戰(zhàn):給 Windows 資源管理器加工具條)
簡(jiǎn)介這份資源是一套基于 COM 與 ATL 技術(shù)實(shí)現(xiàn)的 Windows 資源管理器 Shell 擴(kuò)展工程源碼面向具備 C 基礎(chǔ)、希望深入理解 Windows Shell 擴(kuò)展機(jī)制的中高級(jí)開(kāi)發(fā)者。它通過(guò)編寫 COM 組件向資源管理器注入自定義工具欄涉及接口實(shí)現(xiàn)、類型庫(kù)、類工廠與注冊(cè)腳本等核心環(huán)節(jié)可用于學(xué)習(xí) Shell 擴(kuò)展的完整開(kāi)發(fā)流程。壓縮包共 33 個(gè)文件約 67KB以 h 頭文件、cpp 源文件、c 實(shí)現(xiàn)文件為主輔以 def 模塊定義、rgs 注冊(cè)腳本、idl 接口描述、tlb 類型庫(kù)、bmp 工具欄位圖及 dll 成品等覆蓋從接口聲明到編譯注冊(cè)的各類素材。工程按 ShellServer、ViewObj、FolderObj、ShellListView、maindlg 等模塊拆分分別對(duì)應(yīng)主 COM 組件、視圖對(duì)象、文件夾對(duì)象、列表視圖與工具欄界面便于讀者對(duì)照理解各 Shell 對(duì)象的職責(zé)劃分。目前已有 252 人學(xué)習(xí)適合作為 COM 組件開(kāi)發(fā)與 Shell 擴(kuò)展定制的實(shí)戰(zhàn)參考。1. 給 Windows 資源管理器加工具條從 ATL Shell Extension 到可復(fù)現(xiàn)的 COM 組件在 Windows 上做桌面增強(qiáng)繞不開(kāi)資源管理器右鍵菜單和工具條這兩個(gè)入口。標(biāo)題里的com atl shell extension說(shuō)的就是用 ATLActive Template Library寫一個(gè) COM 組件注冊(cè)成 Shell Extension讓資源管理器在加載時(shí)把它掛進(jìn)工具欄或菜單。很多人第一次聽(tīng)到「給資源管理器加工具條」會(huì)覺(jué)得這是系統(tǒng)級(jí)改造實(shí)際上它就是一個(gè)實(shí)現(xiàn)了IObjectWithSite和IOleCommandTarget的 COM 對(duì)象注冊(cè)到HKCR\*\shellex或HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects下面。適合誰(shuí)適合需要把內(nèi)部工具、批量重命名、文件校驗(yàn)、路徑復(fù)制這類高頻操作塞進(jìn)資源管理器的人。難點(diǎn)不在寫業(yè)務(wù)邏輯而在 COM 注冊(cè)、線程模型、資源管理器版本差異和調(diào)試手段——這幾處翻車率最高。2. ATL Shell Extension 的選型與最小工程骨架2.1 為什么用 ATL 而不是手寫 COM手寫一個(gè) COM 組件要實(shí)現(xiàn)IUnknown、IClassFactory、QueryInterface、引用計(jì)數(shù)、注冊(cè)表腳本代碼量輕松上三百行而且每加一個(gè)接口就要重復(fù)一遍。ATL 把這些模板化了CComObjectRootEx管引用計(jì)數(shù)CComCoClass管類工廠IDispatchImpl管自動(dòng)化接口BEGIN_COM_MAP宏把接口映射表展開(kāi)。對(duì) Shell Extension 來(lái)說(shuō)ATL 還提供了IObjectWithSiteImpl省掉自己寫SetSite的麻煩。選 ATL 的另一個(gè)理由是它和 Visual Studio 的集成。新建 ATL 項(xiàng)目后右鍵「添加類」→「ATL 簡(jiǎn)單對(duì)象」向?qū)?huì)生成.h、.cpp、.rgs三個(gè)文件。.rgs是注冊(cè)腳本ATL 的CAtlModule::UpdateRegistryFromResource會(huì)在DllRegisterServer時(shí)解析它。這意味著你不需要手寫.reg文件改注冊(cè)項(xiàng)只改.rgs。但 ATL 不是沒(méi)有代價(jià)。它的模板錯(cuò)誤信息極長(zhǎng)一個(gè)拼寫錯(cuò)誤能報(bào)出兩百行。另外 ATL 默認(rèn)假設(shè)你清楚 COM 的線程模型選錯(cuò)ThreadingModel會(huì)導(dǎo)致資源管理器卡死或工具條不顯示。2.2 工程創(chuàng)建與關(guān)鍵配置在 Visual Studio 里新建項(xiàng)目選「ATL 項(xiàng)目」項(xiàng)目名比如ExplorerToolbar。向?qū)Ю飸?yīng)用類型選「動(dòng)態(tài)鏈接庫(kù) (DLL)」不要勾「允許合并代理/存根代碼」。創(chuàng)建完成后右鍵項(xiàng)目 → 添加 → 新建項(xiàng) → 「ATL 簡(jiǎn)單對(duì)象」短名稱填ToolbarExt。生成的ToolbarExt.h里類聲明大致如下class ATL_NO_VTABLE CToolbarExt : public CComObjectRootExCComSingleThreadModel, public CComCoClassCToolbarExt, CLSID_ToolbarExt, public IObjectWithSiteImplCToolbarExt, public IOleCommandTarget { public: CToolbarExt() {} DECLARE_REGISTRY_RESOURCEID(IDR_TOOLBAREXT) DECLARE_NOT_AGGREGATABLE(CToolbarExt) BEGIN_COM_MAP(CToolbarExt) COM_INTERFACE_ENTRY(IObjectWithSite) COM_INTERFACE_ENTRY(IOleCommandTarget) END_COM_MAP() // IObjectWithSite STDMETHOD(SetSite)(IUnknown* pUnkSite); // IOleCommandTarget STDMETHOD(QueryStatus)(const GUID* pguidCmdGroup, ULONG cCmds, OLECMD prgCmds[], OLECMDTEXT* pCmdText); STDMETHOD(Exec)(const GUID* pguidCmdGroup, DWORD nCmdID, DWORD nCmdfOpt, VARIANT* pvaIn, VARIANT* pvaOut); };這里有幾個(gè)參數(shù)必須解釋。CComSingleThreadModel表示對(duì)象只在單線程套間里使用。資源管理器的主線程是 STAShell Extension 通常也注冊(cè)為Apartment線程模型。如果你改成CComMultiThreadModel而.rgs里寫的是ThreadingModelApartmentCOM 會(huì)在跨套間調(diào)用時(shí)做封送輕則性能下降重則死鎖。我一般保持CComSingleThreadModel加Apartment除非有明確的后臺(tái)線程需求。DECLARE_NOT_AGGREGATABLE表示這個(gè) COM 對(duì)象不支持聚合。Shell Extension 幾乎不需要聚合加上它可以讓CoCreateInstance走更簡(jiǎn)單的路徑。.rgs文件決定注冊(cè)位置。一個(gè)典型的工具條擴(kuò)展注冊(cè)腳本如下HKCR { NoRemove CLSID { ForceRemove {你的CLSID} s ToolbarExt Class { InprocServer32 s %MODULE% { val ThreadingModel s Apartment } TypeLib s {你的TypeLib GUID} Version s 1.0 } } NoRemove * { NoRemove shellex { NoRemove ContextMenuHandlers { ForceRemove ToolbarExt s {你的CLSID} } } } }NoRemove表示卸載時(shí)不刪除該鍵ForceRemove表示注冊(cè)前先刪掉舊鍵再重建。%MODULE%是 ATL 的占位符注冊(cè)時(shí)替換成 DLL 完整路徑。ThreadingModel必須和 C 類里的線程模型匹配。2.3 實(shí)現(xiàn) SetSite 與工具條按鈕SetSite是 Shell Extension 拿到瀏覽器站點(diǎn)的入口。資源管理器會(huì)傳入一個(gè)IUnknown*你可以QueryInterface出IWebBrowser2再通過(guò)它拿到IExplorerToolbar或直接操作IExplorerCommand。下面是一個(gè)最小實(shí)現(xiàn)把站點(diǎn)指針存下來(lái)并在Exec里響應(yīng)按鈕點(diǎn)擊STDMETHODIMP CToolbarExt::SetSite(IUnknown* pUnkSite) { // 先釋放舊站點(diǎn)避免資源管理器刷新時(shí)泄漏 m_spSite.Release(); if (pUnkSite) { HRESULT hr pUnkSite-QueryInterface(IID_PPV_ARGS(m_spSite)); if (FAILED(hr)) return hr; } return S_OK; } STDMETHODIMP CToolbarExt::Exec(const GUID* pguidCmdGroup, DWORD nCmdID, DWORD nCmdfOpt, VARIANT* pvaIn, VARIANT* pvaOut) { if (pguidCmdGroup IsEqualGUID(*pguidCmdGroup, CLSID_ToolbarExt)) { switch (nCmdID) { case 1: // 復(fù)制當(dāng)前路徑 CopyCurrentPathToClipboard(); return S_OK; case 2: // 批量重命名 BatchRenameSelected(); return S_OK; } } return OLECMDERR_E_NOTSUPPORTED; }m_spSite用CComPtrIUnknown聲明。CopyCurrentPathToClipboard里通過(guò)m_spSite查詢IServiceProvider再Q(mào)ueryService(SID_STopLevelBrowser, IID_IShellBrowser)最后拿到IShellView和IFolderView用GetFolder取IShellFolderGetDisplayNameOf得到路徑。這條鏈路每一步都可能返回E_NOINTERFACE所以每個(gè)QueryInterface都要判FAILED并提前返回。QueryStatus決定按鈕是否可用、是否顯示。如果返回OLECMDERR_E_NOTSUPPORTED資源管理器會(huì)隱藏按鈕。如果返回OLECMDF_ENABLED按鈕可點(diǎn)。常見(jiàn)做法是根據(jù)當(dāng)前選中項(xiàng)數(shù)量動(dòng)態(tài)設(shè)置STDMETHODIMP CToolbarExt::QueryStatus(const GUID* pguidCmdGroup, ULONG cCmds, OLECMD prgCmds[], OLECMDTEXT* pCmdText) { if (!pguidCmdGroup || !IsEqualGUID(*pguidCmdGroup, CLSID_ToolbarExt)) return OLECMDERR_E_UNKNOWNGROUP; for (ULONG i 0; i cCmds; i) { prgCmds[i].cmdf OLECMDF_SUPPORTED; if (HasSelection()) prgCmds[i].cmdf | OLECMDF_ENABLED; } return S_OK; }HasSelection通過(guò)IShellView的GetItemObject拿IShellItemArray判斷GetCount是否大于零。注意不要在QueryStatus里做耗時(shí)操作資源管理器會(huì)頻繁調(diào)用它卡住就是整個(gè)窗口無(wú)響應(yīng)。3. 注冊(cè)、加載與調(diào)試讓工具條真正出現(xiàn)在資源管理器里3.1 編譯與注冊(cè)的完整命令編譯出 DLL 后必須用管理員權(quán)限注冊(cè)。32 位和 64 位資源管理器加載的擴(kuò)展不同DLL 位數(shù)必須匹配。在 x64 系統(tǒng)上64 位資源管理器只加載 64 位 DLL32 位程序如某些舊版 Total Commander加載 32 位 DLL。如果你只編譯了 Win32 版本在 64 位資源管理器里看不到任何效果。注冊(cè)命令:: 以管理員身份運(yùn)行 regsvr32 /s C:\Build\ExplorerToolbar\x64\Release\ExplorerToolbar.dll :: 驗(yàn)證 CLSID 是否寫入 reg query HKCR\CLSID\{你的CLSID}\InprocServer32 /ve/s表示靜默不彈成功對(duì)話框。如果注冊(cè)失敗去掉/s看錯(cuò)誤碼。常見(jiàn)錯(cuò)誤0x80070005是權(quán)限不足0x8002801c是 TypeLib 注冊(cè)失敗通常因?yàn)?rgs里的 TypeLib GUID 和IDL文件不一致。注冊(cè)后需要重啟資源管理器才能加載新擴(kuò)展taskkill /f /im explorer.exe start explorer.exe注意這會(huì)關(guān)閉所有資源管理器窗口。更溫和的方式是注銷再登錄或者用Process Explorer找到explorer.exe只重啟 shell 進(jìn)程。3.2 用 DebugView 和 OutputDebugString 排查加載失敗Shell Extension 最頭疼的是「注冊(cè)成功但工具條不出現(xiàn)」。資源管理器不會(huì)彈錯(cuò)誤框只會(huì)靜默忽略。這時(shí)候OutputDebugString加DebugView是唯一能看見(jiàn)內(nèi)部狀態(tài)的手段。在DllMain和SetSite里加日志#include windows.h static void Log(const char* msg) { OutputDebugStringA([ToolbarExt] ); OutputDebugStringA(msg); OutputDebugStringA(\n); } BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call DLL_PROCESS_ATTACH) Log(DLL loaded into explorer.exe); return TRUE; }用管理員權(quán)限運(yùn)行DebugView勾選「Capture Global Win32」。重啟資源管理器后如果DebugView里沒(méi)有DLL loaded說(shuō)明 DLL 根本沒(méi)被加載。原因通常是位數(shù)不匹配、InprocServer32路徑錯(cuò)誤、ThreadingModel缺失、或者 CLSID 沒(méi)注冊(cè)到正確的 shellex 鍵下。如果看到了DLL loaded但沒(méi)有SetSite日志說(shuō)明 COM 對(duì)象創(chuàng)建失敗。用Process Monitor過(guò)濾explorer.exe和RegOpenKey看它到底讀了哪個(gè)注冊(cè)表路徑。資源管理器在 x64 上會(huì)先讀HKCR\CLSID再讀HKLM\SOFTWARE\Classes\CLSID如果 32 位 DLL 注冊(cè)到了Wow6432Node下64 位資源管理器讀不到。3.3 工具條按鈕不顯示時(shí)的檢查順序按下面順序排查能覆蓋九成問(wèn)題檢查項(xiàng)正確狀態(tài)常見(jiàn)錯(cuò)誤DLL 位數(shù)與資源管理器一致x86 DLL 注冊(cè)到 x64 系統(tǒng)ThreadingModelApartment缺失或?qū)懗?BothCLSID 注冊(cè)位置HKCR\CLSID{...}\InprocServer32只注冊(cè)到 HKCU 未注冊(cè) HKLMshellex 鍵HKCR*\shellex\ContextMenuHandlers鍵名拼寫錯(cuò)誤QueryStatus 返回OLECMDF_ENABLED返回 S_FALSE 導(dǎo)致隱藏資源管理器緩存重啟后生效只刷新窗口未重啟進(jìn)程QueryStatus返回S_FALSE是新手最容易踩的坑。S_FALSE表示「命令存在但當(dāng)前不可用」資源管理器會(huì)隱藏按鈕。必須返回S_OK并設(shè)置OLECMDF_ENABLED。4. 避坑與常見(jiàn)問(wèn)題COM 引用計(jì)數(shù)、線程模型和版本差異4.1 資源管理器卡死SetSite 里做了同步 IO現(xiàn)象注冊(cè)后打開(kāi)任意文件夾資源管理器轉(zhuǎn)圈十秒然后崩潰或自動(dòng)重啟。原因SetSite在資源管理器主線程被調(diào)用里面如果調(diào)用了WaitForSingleObject、同步網(wǎng)絡(luò)請(qǐng)求、或者CoCreateInstance一個(gè) STA 對(duì)象并等待就會(huì)阻塞消息循環(huán)。資源管理器有超時(shí)保護(hù)超時(shí)后直接殺掉擴(kuò)展。解決SetSite里只做指針保存和接口查詢所有耗時(shí)操作放到Exec里并且Exec里如果超過(guò) 50ms 也要考慮異步。我一般用CreateThread或線程池跑后臺(tái)任務(wù)完成后PostMessage回主線程更新 UI。4.2 引用計(jì)數(shù)泄漏m_spSite 沒(méi)有在析構(gòu)里釋放現(xiàn)象反復(fù)打開(kāi)關(guān)閉文件夾資源管理器內(nèi)存持續(xù)上漲最終無(wú)響應(yīng)。原因CComPtr會(huì)在析構(gòu)時(shí)自動(dòng)Release但如果把m_spSite聲明成裸IUnknown*或者SetSite里QueryInterface后忘記Release舊指針就會(huì)泄漏。資源管理器會(huì)反復(fù)調(diào)用SetSite每次泄漏一個(gè)站點(diǎn)指針。解決所有 COM 接口指針用CComPtr或CComQIPtr。SetSite開(kāi)頭先m_spSite.Release()。在FinalConstruct和FinalRelease里加日志確認(rèn)對(duì)象被正確銷毀。4.3 32 位與 64 位注冊(cè)表重定向現(xiàn)象在 64 位系統(tǒng)上用 32 位regsvr32注冊(cè)成功但 64 位資源管理器不加載。原因32 位regsvr32會(huì)把注冊(cè)項(xiàng)寫到HKLM\SOFTWARE\Wow6432Node\Classes\CLSID下64 位資源管理器讀的是HKLM\SOFTWARE\Classes\CLSID。兩者互不相通。解決編譯 x64 版本用C:\Windows\System32\regsvr32.exe注冊(cè)。如果必須支持 32 位程序兩個(gè)版本都編譯分別注冊(cè)。不要試圖用Wow6432Node手動(dòng)搬注冊(cè)表TypeLib 和接口封送會(huì)出問(wèn)題。4.4 工具條按鈕點(diǎn)擊無(wú)響應(yīng)Exec 的 nCmdID 對(duì)不上現(xiàn)象按鈕顯示且可點(diǎn)但點(diǎn)擊后沒(méi)有任何反應(yīng)。原因QueryStatus里設(shè)置的cmdf沒(méi)有包含OLECMDF_SUPPORTED或者Exec里判斷的nCmdID和資源管理器傳入的不一致。資源管理器的命令 ID 從 1 開(kāi)始但如果你在.rgs里定義了多個(gè)命令I(lǐng)D 可能被偏移。解決在Exec開(kāi)頭加OutputDebugString打印nCmdID和pguidCmdGroup用DebugView確認(rèn)實(shí)際傳入值。確保QueryStatus對(duì)每個(gè)命令都設(shè)置了OLECMDF_SUPPORTED否則資源管理器不會(huì)把點(diǎn)擊事件轉(zhuǎn)發(fā)給Exec。4.5 卸載后資源管理器仍加載舊 DLL現(xiàn)象regsvr32 /u卸載后重啟資源管理器仍然加載舊擴(kuò)展甚至崩潰。原因資源管理器有 DLL 緩存卸載后文件被占用無(wú)法刪除下次啟動(dòng)又加載了舊文件。另外.rgs里用了NoRemove的鍵在卸載時(shí)不會(huì)被清理。解決卸載前先taskkill /f /im explorer.exe再regsvr32 /u然后刪除 DLL。檢查HKCR\CLSID\{你的CLSID}和HKCR\*\shellex\ContextMenuHandlers\ToolbarExt是否殘留手動(dòng)刪除。開(kāi)發(fā)階段建議用虛擬機(jī)或快照避免污染主機(jī)。5. 進(jìn)階用 IExplorerCommand 替代 IOleCommandTarget 并做版本適配Windows 7 之后微軟推薦用IExplorerCommand替代IOleCommandTarget做資源管理器命令擴(kuò)展。IExplorerCommand支持更豐富的 UI 描述比如圖標(biāo)、工具提示、分組而且能在 Windows 10/11 的現(xiàn)代上下文菜單里工作。但I(xiàn)ExplorerCommand的注冊(cè)方式和IOleCommandTarget不同它需要注冊(cè)到HKCR\*\shellex\ExplorerCommandHandler下并且實(shí)現(xiàn)IExplorerCommand的GetTitle、GetIcon、GetState、Invoke四個(gè)方法。下面是一個(gè)最小IExplorerCommand實(shí)現(xiàn)用來(lái)在右鍵菜單里加「復(fù)制路徑」class ATL_NO_VTABLE CExplorerCommandExt : public CComObjectRootExCComSingleThreadModel, public CComCoClassCExplorerCommandExt, CLSID_ExplorerCommandExt, public IExplorerCommand { public: DECLARE_REGISTRY_RESOURCEID(IDR_EXPLORERCMD) BEGIN_COM_MAP(CExplorerCommandExt) COM_INTERFACE_ENTRY(IExplorerCommand) END_COM_MAP() STDMETHOD(GetTitle)(IShellItemArray* psiItemArray, LPWSTR* ppszName) { return SHStrDupW(L復(fù)制路徑, ppszName); } STDMETHOD(GetIcon)(IShellItemArray* psiItemArray, LPWSTR* ppszIcon) { return SHStrDupW(Lshell32.dll,-16775, ppszIcon); } STDMETHOD(GetState)(IShellItemArray* psiItemArray, BOOL fOkToBeSlow, EXPCMDSTATE* pCmdState) { *pCmdState ECS_ENABLED; return S_OK; } STDMETHOD(Invoke)(IShellItemArray* psiItemArray, IBindCtx* pbc) { // 取第一個(gè)選中項(xiàng)的路徑并寫入剪貼板 DWORD count 0; psiItemArray-GetCount(count); if (count 0) return S_OK; CComPtrIShellItem item; psiItemArray-GetItemAt(0, item); LPWSTR path nullptr; item-GetDisplayName(SIGDN_FILESYSPATH, path); if (path) { OpenClipboard(nullptr); EmptyClipboard(); size_t len wcslen(path) 1; HGLOBAL hMem GlobalAlloc(GMEM_MOVEABLE, len * sizeof(wchar_t)); memcpy(GlobalLock(hMem), path, len * sizeof(wchar_t)); GlobalUnlock(hMem); SetClipboardData(CF_UNICODETEXT, hMem); CloseClipboard(); CoTaskMemFree(path); } return S_OK; } };GetState返回ECS_ENABLED表示命令可用ECS_HIDDEN表示隱藏ECS_DISABLED表示灰顯。fOkToBeSlow為TRUE時(shí)可以做稍慢的檢查但也不要超過(guò) 100ms。Invoke里操作剪貼板要注意OpenClipboard可能失敗失敗時(shí)直接返回S_OK不要崩潰。注冊(cè)腳本要改成HKCR { NoRemove * { NoRemove shellex { NoRemove ExplorerCommandHandler { ForceRemove ExplorerCommandExt s {你的CLSID} } } } }版本適配方面Windows 11 的右鍵菜單默認(rèn)只顯示「顯示更多選項(xiàng)」里的舊菜單。IExplorerCommand注冊(cè)的項(xiàng)會(huì)出現(xiàn)在舊菜單里如果想出現(xiàn)在新菜單需要額外實(shí)現(xiàn)IExplorerCommandProvider并注冊(cè)為IExplorerCommand的包。這部分微軟文檔很少我一般建議先保證舊菜單可用新菜單作為可選優(yōu)化。驗(yàn)證方法注冊(cè)后打開(kāi)任意文件夾右鍵空白處或選中文件看菜單里是否有「復(fù)制路徑」。如果沒(méi)有用DebugView看GetTitle是否被調(diào)用。如果GetTitle被調(diào)用但菜單不顯示檢查GetState返回值ECS_HIDDEN會(huì)導(dǎo)致菜單項(xiàng)消失。我自己的習(xí)慣是每加一個(gè) Shell Extension先在虛擬機(jī)里注冊(cè)用Process Monitor和DebugView雙管齊下確認(rèn)加載鏈路再上主機(jī)。COM 的引用計(jì)數(shù)和線程模型沒(méi)有后悔藥一旦泄漏就是資源管理器反復(fù)崩潰。寫擴(kuò)展之前先把SetSite、QueryStatus、Exec三個(gè)函數(shù)的日志埋好比事后猜快十倍。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取