
简介一份使用Visual C、COM和ATL为Microsoft Office Excel编写插件的完整工程源码包定位为ATL/COM入门到进阶的学习参考适合已掌握C基本语法、想实际体验COM组件开发流程的开发者。压缩包共23个文件体积仅20KB以h头文件、c/cpp源文件、idl接口定义和def模块定义为主并携带tlb/tlh类型库、rgs注册脚本、rc资源、dsp/dsw工程文件以及bmp位图清晰展示了从接口设计、ATL类实现到组件注册的完整骨架适合分析ATL宏封装原理和IDL声明方式。已有529人学习下载代码精简便于快速定位关键逻辑。对照描述中的开发步骤可梳理出Excel插件的基本工程结构为自研Excel功能扩展或学习COM互操作提供直接参考。1. 为什么还在用VC给Excel写COM加载项当今天Office开发的主流叙事已经被C# VSTO和JavaScript Office Add-ins主导时COM/ATL路线看起来像一个古董。但真正处理过超大行列数据、需要在Excel内部做实时计算管道、或者要在没有.NET运行时和WebView2环境的机器上部署插件的工程师大概率会遇到这么一个问题VSTO加载项在启动速度上被原生COM碾压而一个ATL COM加载项本身只有几十KB不依赖任何托管运行时在Excel进程内以最直接的方式调用对象模型。这份ExcelAddin源码包是典型的ATL COM加载项工程包含IDL接口定义、RGS注册脚本、类型库和完整的VC工程文件可以编译出真正的Excel进程内COM组件。适合三类人一是需要对Excel二次开发做性能压测的桌面端工程师二是仍在维护老系统里Excel加载项的运维开发三是想彻底搞懂COM注册机制和Office加载项生命周期的C开发者。接下来直接从工程文件拆这个加载项。2. 从IDL到RGSATL加载项的工程骨架与注册原理2.1 工程文件里哪些是编译必须的解压开ExcelAddin.zip文件看起来多实际按编译依赖分成四组。第一组是源码核心ExcelAddin.idl定义COM接口和coclassExcelAddin.cpp是DLL模块入口和对象映射表Excel2000Addin.h和ExcelAddin.h是类声明第二组是资源Excel2000Addin.rgs是ATL注册脚本Button1.bmp是按钮位图Resource.h管理资源ID第三组是编译生成物ExcelAddin.tlb、ExcelAddin_i.c、dlldatax.c这些都可以由midl从IDL重新生成不需要手改第四组是导入头文件MSADDNDR.tlh它从Office安装目录的msaddndr.dll类型库生成里面定义了IDTExtensibility2接口。MSADDNDR.tlh值得停下来细看。msaddndr.dll是微软自带的“Add-In Designer”类型库Office安装时会注册到系统。ATL工程用#import指令导入这个类型库编译器会生成.tlh和.tli两个文件.tlh是声明.tli是内联实现。如果这个文件缺失说明你的Office没有安装对应的设计器组件或者路径没有加入include目录这是新手编译最常见的第一个报错。工程文件ExcelAddin.dsp和ExcelAddin.dsw是VC6格式VS2008之后的IDE打开时提示升级升级后要注意平台字符集设置改成“未设置”否则CString和_bstr_t的互转会多一步转换。ExcelAddin.def和ExcelAddinps.def分别导出主DLL和代理DLL的函数主DLL必须导出DllGetClassObject、DllCanUnloadNow、DllRegisterServer、DllUnregisterServer这四件套。汇总下来文件作用编译必需ExcelAddin.idl定义接口、coclass、GUID是由midl生成.h/.cExcelAddin.cpp模块入口与对象映射是Excel2000Addin.h / ExcelAddin.h类声明是Excel2000Addin.rgs注册表脚本是编译进资源MSADDNDR.tlhIDTExtensibility2声明是作为依赖头Button1.bmp命令栏按钮图标是编译进资源ExcelAddinps.mk / dlldatax.c代理/占位工程视目标而定ExcelAddin.tlb / *_i.cmidl生成物可重新生成2.2 RGS脚本里Excel加载项注册的特殊之处普通COM组件在RGS里只需要声明CLSID下的InprocServer32、ProgID和AppID但Office加载项不是这样。Excel的“COM加载项”对话框扫描的不是CLSID分支而是专门的Addins键。ATL工程里DECLARE_REGISTRY_RESOURCEID宏会在DllRegisterServer被调用时自动执行RGS脚本不需要自己写reg文件HKLM { NoRemove SOFTWARE { NoRemove Microsoft { NoRemove Office { NoRemove Excel { NoRemove Addins { ForceRemove Excel2000Addin.Connect { val Programmable s 1 val Description s Excel Addin val FriendlyName s Excel2000Addin } } } } } } }这里ForceRemove子键的键名就是ProgIDExcel用它在加载项列表里显示FriendlyName。Programmable必须存在Excel只有确认这个值后才允许该组件进入加载项管理。注册位置有两处HKLM代表为当前机器所有用户加载HKCU只对当前用户加载。企业部署通常用HKLM个人调试用HKCU因为HKCU不需要管理员权限。2.3 IDL中coclass与已实现接口的关系IDL文件里定义的是给外部使用者和类型库查看的“官方接口”但Excel加载项实际实例化时只看IDTExtensibility2。所以IDL里声明的coclass和类实现的接口往往是两条线。常见做法是import oaidl.idl; import ocidl.idl; importlib(msaddndr.tlb); [ uuid(9E3B3F00-1837-4E3A-9B31-7A4E246A2D00), version(1.0), helpstring(ExcelAddin 1.0 Type Library) ] library EXCELADDINLib { importlib(stdole2.tlb); [ uuid(9E3B3F01-1837-4E3A-9B31-7A4E246A2D00), helpstring(Excel2000Addin Class) ] coclass Excel2000Addin { [default] interface IExcel2000Addin; } };在C类里通过COM_MAP同时导出IExcel2000Addin和IDTExtensibility2class ATL_NO_VTABLE CExcel2000Addin : public CComObjectRootExCComSingleThreadModel, public CComCoClassCExcel2000Addin, CLSID_Excel2000Addin, public IDispatchImplIExcel2000Addin, IID_IExcel2000Addin, LIBID_EXCELADDINLib, public IDTExtensibility2ImplCExcel2000Addin { public: BEGIN_COM_MAP(CExcel2000Addin) COM_INTERFACE_ENTRY(IExcel2000Addin) COM_INTERFACE_ENTRY(IDTExtensibility2) COM_INTERFACE_ENTRY2(IDispatch, IExcel2000Addin) END_COM_MAP() };关键在COM_INTERFACE_ENTRY2(IDispatch, IExcel2000Addin)这一行。因为IExcel2000Addin继承自IDispatchIDTExtensibility2也继承自IDispatch直接QI(IDispatch)会歧义。ATL的COM_INTERFACE_ENTRY2显式指定了从哪条路径返回IDispatch否则IDispatch指针指向错误接口运行时可能崩溃或行为异常。IDTExtensibility2Impl不是ATL原生提供的需要自己补一个模板类把五个纯虚方法全部实现为空插件类只需要覆盖自己关心的回调template class T class IDTExtensibility2Impl : public IDTExtensibility2 { public: STDMETHOD(OnConnection)(IDispatch* /*pApplication*/, AddInConnectMode /*ConnectMode*/, IDispatch* /*pAddInInst*/, SAFEARRAY** /*ppCustom*/) { return S_OK; } STDMETHOD(OnDisconnection)(AddInDisconnectMode /*RemoveMode*/, SAFEARRAY** /*ppCustom*/) { return S_OK; } STDMETHOD(OnAddInsUpdate)(SAFEARRAY** /*ppCustom*/) { return S_OK; } STDMETHOD(OnStartupComplete)(SAFEARRAY** /*ppCustom*/) { return S_OK; } STDMETHOD(OnBeginShutdown)(SAFEARRAY** /*ppCustom*/) { return S_OK; } };这个模板类的好处是继承了它的插件类在代码里非常干净只重写需要的回调另外几个不碰就是空实现。编译链接时整个DLL只要几十KB比同等功能的VSTO加载项轻一个量级。3. IDTExtensibility2生命周期与Excel对象模型交互3.1 五个回调的调用时机和顺序理解IDTExtensibility2这五个方法的时序是写加载项的基本功。OnConnection在加载项被加载时触发第一次拿到Application指针时Excel的启动可能还没全部完成。OnStartupComplete在所有加载项都完成连接之后调用此时挂事件、构造UI最安全。OnAddInsUpdate在任意加载项被勾选或取消勾选时统一触发不是当前加载项自己的状态变化才触发。OnBeginShutdown在Excel开始退出流程时触发此时Application对象仍然可用适合做清理。OnDisconnection是最后一步Application对象可能已经不可信任何反注册连接点的代码必须放在OnBeginShutdown里。回调方法调用时机Application可用性OnConnection加载项被加载可用但Excel未完全启动OnStartupComplete所有加载项连接完毕完全可用OnAddInsUpdate任一加载项状态变化可能处于切换中OnBeginShutdownExcel启动关闭流程可用OnDisconnection加载项被释放不可靠注意OnConnection里不要做阻塞式弹窗也不要在这里解析大文件。Excel还没有走完初始化流程弹窗会让整个进程看起来像假死。COM加载项的线程模型也需要确认。ATL的CComSingleThreadModel表示对象生命周期和接口调用都在创建它的线程上执行也就是Excel主线程。不要为了并行计算把线程模型改成FreeIDTExtensibility2本身没有MTA能力Free模型下事件回调会进入不同线程上下文操作Excel对象模型时大概率崩溃。3.2 OnConnection里拿Application并保存先看最直接的实现STDMETHODIMP CExcel2000Addin::OnConnection( IDispatch* pApplication, AddInConnectMode ConnectMode, IDispatch* pAddInInst, SAFEARRAY** /*ppCustom*/) { m_connectMode ConnectMode; CComPtrExcel::_Application spExcelApp; HRESULT hr pApplication-QueryInterface( __uuidof(Excel::_Application), (void**)spExcelApp); if (FAILED(hr)) return hr; m_spExcelApp spExcelApp; // 取主版本号运行时判断Excel版本用于分支 CComBSTR bstrVersion; spExcelApp-get_Version(bstrVersion); _bstr_t version(bstrVersion); m_majorVersion _wtoi((const wchar_t*)version); return S_OK; }pApplication实际上是IDispatch指针直接QI到Excel::_Application的GUID。Excel::_Application是Excel类型库中的主接口接口布局在不同Office版本间基本稳定因此这种方式可以兼容较大范围的Excel版本。ConnectMode有三种取值ext_cm_AfterStartup、ext_cm_Startup、ext_cm_CommandLine分别对应手动加载、开机自动加载和命令行启动加载。get_Version返回的是“16.0”这种字符串取整数位是为了在后续代码里判断当前环境。成员变量m_spExcelApp用CComPtr声明析构时自动Release。如果你在插件类里同时保存了Application对象和Worksheet对象的CComPtr要注意循环引用问题——Excel对象模型是倒挂的树子对象持有父对象不一定形成COM引用环但保存太多顶层对象会拖慢Excel退出速度不需要时最好手动置空。3.3 用IDispEventImpl订阅Application级事件Excel的Application对象暴露了一组事件比如SheetChange、WorkbookBeforeSave、WorkbookOpen每个事件都有固定DISPID。IDispEventImpl通过连接点接口IConnectionPointContainer去订阅这些事件不需要自己写连接点枚举代码。实际接入步骤分三步。第一步在类继承列表中加入IDispEventImplclass ATL_NO_VTABLE CExcel2000Addin : public CComObjectRootExCComSingleThreadModel, public CComCoClassCExcel2000Addin, CLSID_Excel2000Addin, public IDispatchImplIExcel2000Addin, IID_IExcel2000Addin, LIBID_EXCELADDINLib, public IDTExtensibility2ImplCExcel2000Addin, public IDispEventImpl1, CExcel2000Addin, __uuidof(Excel::AppEvents) { public: BEGIN_SINK_MAP(CExcel2000Addin) SINK_ENTRY_EX(1, __uuidof(Excel::AppEvents), 0x0702, OnSheetChange) END_SINK_MAP() void __stdcall OnSheetChange(IDispatch* Sh, Excel::Range* Target); };第二步实现事件处理函数void __stdcall CExcel2000Addin::OnSheetChange( IDispatch* /*Sh*/, Excel::Range* Target) { // 打印改动单元格地址验证事件是否生效 CComBSTR bstrAddr; Target-get_Address(CComVariant(), CComVariant(), xlA1, CComVariant(), bstrAddr); OutputDebugString(bstrAddr); }第三步在OnStartupComplete里完成连接在OnBeginShutdown里断开STDMETHODIMP CExcel2000Addin::OnStartupComplete(SAFEARRAY** /*ppCustom*/) { if (m_spExcelApp) return DispEventAdvise(m_spExcelApp); return S_OK; } STDMETHODIMP CExcel2000Addin::OnBeginShutdown(SAFEARRAY** /*ppCustom*/) { DispEventUnadvise(m_spExcelApp); return S_OK; }SINK_ENTRY_EX的第二个参数是接口GUID第三个是DISPID第四个是处理函数。DISPID不是随手填的我例子里的0x0702对应的是我环境里Excel的SheetChange事件换一个Excel版本最好从类型库导出文件里确认一次。DISPID填错不会编译报错但事件永远不会触发这种问题排查起来很费时间。另外注意OnSheetChange的参数顺序第一个是工作表对象第二个是改变的Range对象这个顺序必须和类型库里的声明一致。4. 命令栏与Ribbon两代UI实现路线的差异4.1 CommandBar方案里按钮的OnAction指向哪里从包里的Button1.bmp可以推断出这个工程走的是CommandBar路线这是Excel 2000到2003时代最主流的插件UI方式。在OnConnection里拿到Application后通过CommandBars集合添加一个按钮并且必须给按钮指定OnAction处理函数CComVariant vtBarName(LWorksheet Menu Bar); CComPtrOffice::CommandBars spCmdBars; HRESULT hr m_spExcelApp-get_CommandBars(spCmdBars); if (FAILED(hr)) return hr; CComPtrOffice::CommandBar spMenuBar; hr spCmdBars-Item(vtBarName, spMenuBar); if (FAILED(hr)) return hr; CComPtrOffice::CommandBarControls spCtrls; spMenuBar-get_Controls(spCtrls); CComVariant vtType(1L); // msoControlButton CComVariant vtId(1L); // 使用第一个可用按钮ID CComPtrOffice::CommandBarControl spControl; hr spCtrls-Add(vtType, vtId, CComVariant(), VARIANT_TRUE, spControl); if (SUCCEEDED(hr)) { CComQIPtrOffice::CommandBarButton spBtn(spControl); if (spBtn) { spBtn-put_Caption(L执行插件动作); spBtn-put_TooltipText(L调用ATL COM插件); spBtn-put_OnAction(L!Excel2000Addin.Connect.OnAction); } }按钮只有被赋予OnAction后才会工作。OnAction字符串必须以“!ProgID.方法名”的形式填写Excel解析这个字符串后通过IDispatch去调用加载项对象的方法。这个方法需要预先在IDL里声明成可调度的方法并在类里实现[ object, uuid(9E3B3F10-1837-4E3A-9B31-7A4E246A2D00), dual, oleautomation ] interface IExcel2000Addin : IDispatch { [id(1)] HRESULT OnAction(); };注意IDL里你声明什么名字OnAction里就写什么名字完全对应。实际开发里很多人把方法名写成Action、ExecuteButton、ClickHandler都无所谓但格式必须严格是“!ProgID.方法名”。我见过最典型的失败案例是少写感叹号或者把ProgID写错成CLSID字符串Excel会直接忽略这个按钮的点击事件。4.2 Ribbon XML与IRibbonExtensibility接口2007年之后的Excel主推Ribbon命令栏在部分安全策略下会被禁用。Ribbon方案不是ATL工程里加对话框那么简单而是要实现IRibbonExtensibility接口。如果工程还没这个接口需要在COM映射里增加一行BEGIN_COM_MAP(CExcel2000Addin) COM_INTERFACE_ENTRY(IExcel2000Addin) COM_INTERFACE_ENTRY(IDTExtensibility2) COM_INTERFACE_ENTRY(IRibbonExtensibility) COM_INTERFACE_ENTRY2(IDispatch, IExcel2000Addin) END_COM_MAP()然后实现GetCustomUI方法返回描述Ribbon结构的XMLSTDMETHODIMP CExcel2000Addin::GetCustomUI( BSTR RibbonID, BSTR* RibbonXml) { if (RibbonID NULL || RibbonXml NULL) return E_POINTER; const wchar_t* xml LcustomUI xmlns\http://schemas.microsoft.com/office/2006/01/customui\ Lribbontabs Ltab id\MyTab\ label\我的插件\ Lgroup id\MyGroup\ label\工具\ Lbutton id\myButton\ label\执行\ LimageMso\HappyFace\ size\large\ LonAction\OnRibbonButton\ / L/group/tab L/tabs/ribbon L/customUI; *RibbonXml ::SysAllocString(xml); return S_OK; }XML里的onAction属性回调到IDispatch方法Ribbon通过IDispatch::Invoke调用类里实现的OnRibbonButtonSTDMETHODIMP CExcel2000Addin::OnRibbonButton( IDispatch* /*control*/) { if (!m_spExcelApp) return S_OK; CComPtrExcel::Range spSel; m_spExcelApp-get_Selection(spSel); if (spSel) { CComBSTR bstrVal; spSel-get_Text(bstrVal); MessageBox(NULL, bstrVal, L当前选中, MB_OK); } return S_OK; }Ribbon XML缓存机制有一个需要注意的点GetCustomUI只在加载项首次被加载时调用一次如果改了XML想重新测试必须退出Excel再重启。编辑加载项期间频繁重启Excel是正常的别指望这个函数每次重新激活加载项时都会刷新。4.3 UI方案的选型边界维度命令栏CommandBarRibbon XML兼容范围Excel 2000/2003完整支持2007兼容性不稳定Excel 2007UI能力菜单、工具栏按钮标签、组、按钮、下拉、库运行时动态修改直接操作Controls的Caption/Enabled调用Invalidate后回调刷新图标资源BMP位图imageMso或图片Mso如果插件必须覆盖Excel 2003的老环境只能走CommandBar。反过来在2010以上的环境里走CommandBar不仅风格难看部分机器上会被策略禁用且命令栏API在后续版本里被标记为deprecated。Ribbon XML的代价是所有回调都通过IDispatch走一遍普通按钮点击的调用频率不高影响可以忽略但如果你要在Ribbon里放实时更新的状态标签每次Invalidate都触发完整回调链就需要考虑缓存了一些数据而不是每次都查Excel对象模型。5. 版本兼容性排错x86/x64与注册失效问题5.1 位数匹配32位Excel必须配32位插件Office 2013之后的默认安装是32位即便系统是x64 Windows。COM加载项和Excel必须同为32位或同为64位否则加载时直接报“无法加载”。判断方法很简单任务管理器里Excel进程名带“*32”就是32位。确认位数后把ATL工程的平台设置为对应架构如果你的工程还没有x64平台在VS配置管理器里新增x64平台重新编译。注意32位DLL注册到64位系统时注册表会写到HKCR\Wow6432Node\CLSID下用64位regsvr32去注册32位DLL会导致路径错位。5.2 注册失败先查这几个键regsvr32显示成功但Excel加载项列表里没有先检查三个位置。第一个HKCR\CLSID\{你的CLSID}\InprocServer32默认值是否指向DLL完整路径。第二个HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Office\Excel\Addins\Excel2000Addin.Connect下面是否有Programmable和FriendlyName。第三个是COM加载项对话框里的“加载行为”字段。前两个都正常但不加载重新注册一次regsvr32 /u Excel2000Addin.dll regsvr32 Excel2000Addin.dllregsvr32必须以管理员权限运行。如果Excel是32位而插件是32位不要用64位的regsvr32注册反过来同理。5.3 Excel的加载项阻断与信任中心设置Office 2013以后有“受信任的加载项”机制。如果Office检测到加载项崩溃过会把CLSID记进阻断列表被阻断的加载项不再出现在COM加载项列表里且不提示原因。处理方法是在注册表里找到HKEY_CURRENT_USER\Software\Microsoft\Office\16.0\Excel\Resiliency\DisabledItems删除这个键下的对应项重启Excel。Office版本号要替换成实际的16.0对应2016/2019/2021/36515.0对应2013。另外Excel信任中心“加载项”页签里的“禁用所有应用程序加载项”选项勾选后COM加载项全部失效开发调试阶段建议取消这个勾选。5.4 调试时让断点命中加载项是DLL在Excel进程里跑直接用F5启动调试是不行的。正确做法是Visual Studio里打开加载项工程菜单“调试-附加到进程”选择EXCEL.EXE代码类型选“本机”然后回到Excel里触发加载项逻辑。如果OnConnection断点根本不命中说明Excel还没实例化你的组件问题大概率在注册表。如果断点命中但事件回调不进来检查DISPID和连接点是否成功建立在DispEventAdvise的返回值上加一个FAILED检查能少走很多弯路。本文还有配套的精品资源点击获取