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