ATL与COM:基于IDeskBand的Windows资源管理器工具条开发实战
简介这是一份用于开发 Windows 资源管理器自定义工具栏的 COM ATL Shell Extension 源码包适合熟悉 C 与 COM 基础、希望扩展 Shell 功能的桌面端开发者。压缩包内含完整 Visual C 工程文件与注册脚本覆盖从接口定义、组件实现到注册卸载的完整流程。资源共 33 个文件以头文件.h、C 源码.cpp、C 源码.c、模块定义文件.def及注册表脚本.rgs为主另有工具栏位图与已编译 DLL大小仅 67KB小巧完整。关键代码分散在 ShellServer、ViewObj、FolderObj、ShellListView 等模块中便于对照学习 COM 接口实现、ATL 类工厂和 Shell 视图对象协作方式。已有 252 人学习下载适合作为理解 Shell Extension 原理与动手实践的入门素材。1. COM ATL 写的这个“工具条”先和右键菜单扩展分清关系给 Windows 资源管理器加一段自己的界面特别是加一条工具条很多人的第一直觉是“注册个 COM 组件弹窗的时候把按钮画出来”。但工具条的加载路径和右键菜单完全不同Explorer 要在一开始就创建这个 COM 对象并把浏览器主窗口的站点句柄交给它之后这条工具条的窗口要一直挂在资源管理器窗口上。COM 和 ATL 分别对应组件对象模型与活动模板库Shell Extension 是以 COM 形式插入资源管理器的扩展程序总称“工具条”落实到接口上就是 IDeskBand。这篇内容适合会用 C 写点 DLL、见过 COM 注册表结构但没碰过 Band 接口的工程师后面每节拆一个接口最后给一条从编译到注册都能跑的路径。2. 工具条的 COM 契约IDeskBand、IOleWindow、IObjectWithSite 各管哪一段在贴代码之前先把 Explorer 与这条工具条之间的“雇佣关系”讲清楚。资源管理器不是靠埋窗口钩子来发现工具条的而是先在注册表 CLSID 下找到你的 COM 类创建对象然后通过一连串 QueryInterface 确认“这个对象是不是我要的 band”。这套问答就是 COM 的接口协商。如果实现的接口不全Explorer 不会给你任何错误提示只是安静地忽略这个类所以理解协议比记住代码更重要。2.1 Explorer 创建工具条对象的调用链资源管理器启动后会枚举自己认识的那几个工具条来源。所谓认识是指注册表 CLSID 下面挂着把 IDeskBand 当作主接口的 COM 类。Explorer 创建这个类实例然后调用 IObjectWithSite::SetSite 把浏览器框架的站点指针递进去。站点在 COM 语境里是“宿主对象”的代称它让一条 band 有机会从纯组件变成贴着浏览器窗口的子控件。真正决定成败的加载顺序是CLSID 定位 DLL → CoCreateInstance → QueryInterface(IID_IObjectWithSite) → SetSite → QueryInterface(IID_IDeskBand) → GetBandInfo → GetWindow → ShowDW。前两步和注册表挂钩后面则是判断你写的扩展能不能显示的关键。很多工具条注册完不显示资源管理器既不会弹 COM 错误也不会写事件日志原因往往是 Explorer 在 GetWindow 之后拿不到合法窗口或者 GetBandInfo 返回的高度是 0。SetSite 最常见的开场白是拿到 IShellBrowser因为只有它才能让你访问资源管理器当前目录、菜单命令和视图状态STDMETHODIMP CExplorerBand::SetSite(IUnknown* pSite) { m_spSite pSite; // CComPtr自动维护引用计数 CComQIPtrIServiceProvider spProvider(m_spSite); if (spProvider) { CComPtrIShellBrowser spBrowser; spProvider-QueryService(SID_STopLevelBrowser, IID_PPV_ARGS(spBrowser)); m_spBrowser spBrowser; } return S_OK; }m_spSite 是 CComPtr 成员CComQIPtr 会替你调用 QueryInterface不需要手动 Release。QueryService 里的 SID_STopLevelBrowser 是服务标识 GUID意思是“把最顶层的浏览器对象给我”拿到的 IShellBrowser 在后面列目录、操作选中文件时都会用到。调用完成之后m_spBrowser 可能为空指针这是允许的因为不是每一次 SetSite 都发生在完整的主浏览器窗口里。2.2 四个接口的分工和一组容易漏掉的约定工具条对象最少要实现四个接口IObjectWithSite、IOleWindow、IDockingWindow、IDeskBand。它们并非各自独立而是有一条继承链IDeskBand 继承自 IDockingWindowIDockingWindow 又继承自 IOleWindow。所以写代码时可以从 IDeskBand 一个类派生但 COM 映射表里必须把三个 IID 分别列出来否则 QueryInterface 查 IOleWindow 时会失败。接口关键方法在工具条生命周期里做的事最容易漏掉的事IObjectWithSiteSetSite / GetSite接收宿主站点查询 IShellBrowser忘记保存 m_spSite或过早释放IOleWindowGetWindow / ContextSensitiveHelp把工具条自己的 HWND 交给 ExplorerGetWindow 返回空句柄或尚未创建的句柄IDockingWindowShowDW / CloseDW / ResizeBorderDW控制显示隐藏、销毁和边框对齐CloseDW 里没有销毁窗口IDeskBandGetBandInfo上报工具条尺寸、模式、背景色没判断 dwMask或返回高度 0这组约定里最容易被忽略的是 GetWindow 与 SetSite 的先后关系。Explorer 不一定先调用 SetSite 再调用 GetWindow所以 GetWindow 里不能假设“站点一定已经设置好了”。通用做法是SetSite 时立刻创建窗口GetWindow 只负责返回这个已存在的句柄如果窗口还没建好返回 E_FAIL而不是返回 nullptr 让宿主拿一个空句柄去创建父窗口。2.3 ATL 类骨架继承列表和 COM 映射怎么写ATL 的好处是把 AddRef、Release、QueryInterface、类厂这些样板都收进了宏里。一个可用的工具条类骨架如下class ATL_NO_VTABLE CExplorerBand : public CComObjectRootExCComSingleThreadModel, public CComCoClassCExplorerBand, CLSID_ExplorerBand, public IDeskBand { public: CExplorerBand() : m_hWnd(nullptr), m_dwBandID(0) {} BEGIN_COM_MAP(CExplorerBand) COM_INTERFACE_ENTRY(IObjectWithSite) COM_INTERFACE_ENTRY(IDeskBand) COM_INTERFACE_ENTRY2(IDockingWindow, IDeskBand) COM_INTERFACE_ENTRY2(IOleWindow, IDeskBand) END_COM_MAP() DECLARE_NOT_AGGREGATABLE(CExplorerBand) DECLARE_REGISTRY_RESOURCEID(IDR_EXPLORERBAND) };COM_INTERFACE_ENTRY2(IOleWindow, IDeskBand) 的意思是当宿主用 IID_IOleWindow 来查询时ATL 通过 IDeskBand 这个继承分支完成指针偏移再把 IOleWindow 指针返回给调用方。三条接口本质上是同一条继承链这样写既避免重载歧义也让 QueryInterface 的偏移计算有明确依据。如果漏掉这行Explorer 在 GetWindow 那一步就会因为拿不到 IOleWindow 而判定“这不是一个合格的工具条”注册表再干净也没用。类创建之后项目里还要有一份 .rgs 注册脚本ATL 向导生成工程时通常自带。核心注册内容一般长这样HKCR { NoRemove CLSID { ForceRemove {你的GUID} s ExplorerBand Toolbar { InprocServer32 s %MODULE% { val ThreadingModel s Apartment } } } }ThreadingModel 用 Apartment 就够因为 Explorer 会在主线程上创建并调用这个对象用 Free 反而可能让 SetSite 收到的站点跨线程后续所有窗口句柄操作都要加临界区得不偿失。3. 用 ATL 实现资源管理器工具条SetSite 建窗口、GetBandInfo 报尺寸接口协议定下来之后剩下的事情就顺理成章了SetSite 里拿到父窗口并创建子窗口GetBandInfo 里告诉宿主这条工具条多宽、多高、能不能拉伸最后在窗口过程里处理按钮点击。3.1 注册窗口类并在 SetSite 里把窗口挂到浏览器上COM 对象本身不负责画界面工具条的“长相”来自一个 Win32 子窗口。窗口类最好注册成全局的避免反复注册同名类导致失败。常见做法是在第一次创建窗口前调用 RegisterClassconst wchar_t kBandWndClass[] LAtlExplorerBandWnd; static bool RegisterBandWindowClass() { WNDCLASS wc {}; wc.lpfnWndProc CExplorerBand::WindowProc; wc.hInstance _AtlBaseModule.GetModuleInstance(); wc.lpszClassName kBandWndClass; return RegisterClass(wc) ! 0; }窗口过程用静态函数而不是成员函数因为 WndProc 只能按 stdcall 调用约定导出。在窗口创建时通过CreateWindowEx(..., this)把对象指针传给 WM_NCCREATE随后在 WM_CREATE 里用CREATESTRUCT.lpCreateParams取回来。SetSite 里挂父窗口的实现如下STDMETHODIMP CExplorerBand::SetSite(IUnknown* pSite) { m_spSite pSite; CComPtrIOleWindow spWindow; HWND hParent nullptr; if (pSite SUCCEEDED(pSite-QueryInterface(IID_PPV_ARGS(spWindow)))) { spWindow-GetWindow(hParent); } if (hParent !m_hWnd) { RegisterBandWindowClass(); m_hWnd CreateWindowEx( 0, // 扩展样式 kBandWndClass, // 窗口类名 LExplorerBand, // 窗口名 WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, 0, 0, 240, 28, // 先给一个初始尺寸 hParent, // 父窗口就是资源管理器 nullptr, // 无菜单 _AtlBaseModule.GetModuleInstance(), this); // 传对象指针 } return S_OK; }这段代码的关键是pSite-QueryInterface。站点指针是 IUnknown不代表不兼容 IOleWindow 时 QueryInterface 就会成功对宿主而言这只是把“可以问问题的人”还给了你。收到父窗口句柄后立即创建子窗口这样后续 GetWindow 永远能返回有效句柄。m_hWnd 是 HWND 成员CreateWindowEx 失败时返回 nullptr不能让 GetWindow 返回一个无效的旧值。3.2 GetBandInfo 返回的尺寸与模式决定工具条能不能被看到宿主会调用 GetBandInfo 询问工具条的行为模式、最小尺寸、最大尺寸和实际尺寸。很多“注册成功但没有显示”的案例就因为 GetBandInfo 没有把高度写进 ptMinSize。Explorer 拿到 0 高度会认为这条 band 没有任何可绘制区域。STDMETHODIMP CExplorerBand::GetBandInfo( DWORD /*dwBandID*/, DWORD /*dwViewMode*/, DESKBANDINFO* pdbi) { if (!pdbi) return E_POINTER; if (pdbi-dwMask DBIM_VISIBLE) { pdbi-dwModeFlags DBIMF_NORMAL | DBIMF_VARIABLEHEIGHT; } if (pdbi-dwMask DBIM_MIN_SIZE) { pdbi-ptMinSize.x 160; pdbi-ptMinSize.y 28; } if (pdbi-dwMask DBIM_MAX_SIZE) { pdbi-ptMaxSize.x -1; // 宽度不限 pdbi-ptMaxSize.y 28; // 高度固定 } if (pdbi-dwMask DBIM_ACTUAL) { pdbi-ptActual.x 240; pdbi-ptActual.y 28; } return S_OK; }DESKBANDINFO 的字段并不是每次都被宿主问到所以先判断 dwMask 再写值这是 COM 回调里最常见的防御姿势。各掩码对应的行为如下dwMask 值要写的字段效果DBIM_VISIBLEdwModeFlags控制是否可变高度、是否带有系统标题条DBIM_MIN_SIZEptMinSize用户把工具条拖到多窄也不会小于这个尺寸DBIM_MAX_SIZEptMaxSize限制最大高度x 设为 -1 表示宽度随父窗口自由伸缩DBIM_ACTUALptActual工具条第一次显示时的初始尺寸高度 28 是资源管理器标准工具栏按钮的常用高度配合 BS_PUSHBUTTON 按钮可以做到 20 像素按钮加上下边距视觉上不突兀。DBIMF_VARIABLEHEIGHT 表示允许宿主根据字体和 DPI 调整高度如果要严格固定高度去掉这个标志即可。3.3 在窗口过程里响应工具条按钮的点击工具条上放按钮不需要任何 COM 接口这就是一个普通 Win32 子控件。在 WM_CREATE 里创建按钮在 WM_COMMAND 里响应它LRESULT CALLBACK CExplorerBand::WindowProc( HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_CREATE: { auto* pcs reinterpret_castCREATESTRUCT*(lParam); HWND hBtn CreateWindowEx( 0, LBUTTON, L打开面板, WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 4, 4, 80, 20, hWnd, reinterpret_castHMENU(IDC_BAND_BUTTON), pcs-hInstance, nullptr); return hBtn ? 0 : -1; } case WM_COMMAND: if (LOWORD(wParam) IDC_BAND_BUTTON) { MessageBox(hWnd, L弹出一个面板, LExplorerBand, MB_OK | MB_ICONINFORMATION); } return 0; case WM_DESTROY: DestroyWindow(hWnd); // 如果是自己管理的窗口确保销毁子控件 return 0; } return DefWindowProc(hWnd, uMsg, wParam, lParam); }按钮消息进入 WindowProc 时wParam 低 16 位是按钮 ID高 16 位是通知码因此用 LOWORD(wParam) 来判断是哪一个按钮。如果工具条需要读取资源管理器当前选中的文件在按钮响应里通过 m_spBrowser 调用 GetControlWindow 或者查询 IShellView 都能拿到视图对象这部分属于业务逻辑不是工具条本身的必需内容。4. 注册与调试regsvr32 通过之后还是看不到工具条该查什么regsvr32 是最直接的部署动作但它只负责调用 DLL 导出的 DllRegisterServer至于写进注册表的 CLSID 是不是真的能创建对象它并不保证。所以要把“注册成功”和“加载成功”分开判断排查方向才不会乱。4.1 编译产物与最小注册命令Visual Studio 里直接生成项目也行命令行编译更利于复现。Release 配置下先用 msbuild 编出 64 位 DLL再注册cd C:\src\ExplorerBand msbuild ExplorerBand.sln /p:ConfigurationRelease /p:Platformx64 regsvr32 C:\src\ExplorerBand\x64\Release\ExplorerBand.dllregsvr32 会调用 DllRegisterServer把 .rgs 脚本里的 CLSID、InprocServer32、ThreadingModel 写进注册表。遇到“拒绝访问”时先确认命令行是否以管理员权限运行因为 HKCR 下的写入最终落到了系统级注册表。注册成功后正常返回码是 0并会弹出“服务注册成功”的提示静默注册可以加 /s 参数但出错时也看不到信息调试期间不建议用。4.2 对照注册表手动验证 CLSID 键值如果怀疑 .rgs 没有生效可以用 reg add 手动写入效果和 regsvr32 写入的系统一致reg add HKCR\CLSID\{11111111-2222-3333-4444-555555555555} /ve /d ExplorerBand Toolbar /f reg add HKCR\CLSID\{11111111-2222-3333-4444-555555555555}\InprocServer32 /ve /d C:\src\ExplorerBand\x64\Release\ExplorerBand.dll /f reg add HKCR\CLSID\{11111111-2222-3333-4444-555555555555}\InprocServer32 /v ThreadingModel /d Apartment /f上面的 GUID 换成项目实际生成的 GUID。手动写注册表和 .rgs 的区别仅在于.rgs 里的 %MODULE% 会被 DllRegisterServer 替换成 DLL 的完整路径而 reg add 必须自己写全路径。写完以后用下面的命令反向检查reg query HKCR\CLSID\{11111111-2222-3333-4444-555555555555}\InprocServer32 /ve如果路径正确输出里能看到 ExplorerBand.dll 的绝对路径。如果注册表里路径正确但资源管理器仍然不显示问题大概率不在注册表而在接口协商。4.3 explorer.exe 崩溃、闪退与“没有反应”的排查顺序“资源管理器已停止工作”“闪退”和“工具栏静默不出现”这三类现象排查顺序不一样。崩溃和闪退先看 SetSite 里做了什么在 COM 回调里创建窗口、显示对话框或调用需要消息循环的 API很容易在主线程上重入资源管理器造成循环崩溃。检查办法是临时注释 SetSite 的所有逻辑只留 return S_OK重新编译注册如果崩溃消失就是 SetSite 的窗口创建代码有问题。静默不显示则优先怀疑接口协商。用 Visual Studio 的“调试→附加到进程”挂到 explorer.exe然后在 DLL 代码里加调试输出OutputDebugStringW(L[ExplorerBand] SetSite called\n); OutputDebugStringW(L[ExplorerBand] GetBandInfo called\n);用 DebugView 这类抓取 OutputDebugString 的工具实时看输出。如果 SetSite 被调用过但 GetBandInfo 没有说明宿主在接口协商阶段就把对象否掉了通常是 COM 映射里的 IOleWindow、IDockingWindow 条目缺失。如果两个都有输出但窗口不出现检查 GetWindow 返回的句柄是不是非空以及 GetBandInfo 里的 ptMinSize.y 是否为 0。最后一个很容易被忽略的因素是位数。64 位资源管理器不会加载 32 位工具条 DLL即便注册成功也会被静默忽略。确认 DLL 的平台版本要看看它在C:\src\ExplorerBand\x64\Release\下还是C:\src\ExplorerBand\Win32\Release\下加载失败时用进程监视工具能看到 Explorer 访问了错误路径的 DLL。5. 打包交付前再检查一遍位数、签名和退出码工具条最终会以一个 zip 包的形式分发给别人。zip 本身只是载体里面真正影响运行的三个东西是 DLL 的位数、签名和注册验证方式。Visual Studio 默认生成的解决方案通常同时有 Win32 和 x64 两个平台。如果只编译了 Win32在绝大多数现代 Windows 上都会出现“注册成功但资源管理器没反应”的情况。打包之前用下面命令确认 DLL 的机器类型dumpbin /headers ExplorerBand.dll | findstr machine输出要是x64才能给 64 位资源管理器用。另外Release 下的 DLL 不要依赖 vcruntime140d.dll 这类调试版运行库否则目标机器上会报 0x8007007E。最省事的做法是在“C/C → 代码生成 → 运行库”里选多线程静态链接。签名不是 COM 加载的硬性条件但工具条 DLL 会被资源管理器注入进程杀毒软件和 SmartScreen 对未签名的 shell 扩展警惕性很高。签名的意义不是让 COM 加载更快而是降低交付时被误报、被拦截的概率。没有代码签名证书的话至少在 zip 里附带 SHA256 校验文件。发布前建议按下面的顺序快速验证一遍先 regsvr32 注册再 reg query 检查注册表路径存在最后重启资源管理器进程。重启时直接用命令行结束 explorer.exe避免在图形界面上手动重启造成花屏taskkill /f /im explorer.exe start explorer.exe新资源管理器起来后在工具栏区域右键勾选 ExplorerBand。如果这一遍没有出现再回到第 4.3 的接口协商检查。一套能跑的 ATL 工具条最终稳定性不取决于代码量而取决于 SetSite 的重入安全、GetWindow 的句柄有效性以及打包位数这三个细节。本文还有配套的精品资源点击获取