ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC中C++操作Word文档:COM自动化完整实战指南

MFC中C++操作Word文档:COM自动化完整实战指南 做MFC桌面程序的兄弟迟早会碰上一个需求把界面里的数据整理成一份漂亮的Word报告。这需求看着不复杂做起来全是坑。我当年接手的第一个MFC项目就是生产报表工具界面用C搭好了客户一句话把我整懵了“一键导出Word格式不能乱还要带图表。”那时候我对Word自动化一无所知踩了整整一周的坑才把路趟平。现在回头看MFC下用C操作Word文档这条路其实非常有规律核心就是COM自动化。今天我把完整的选型思路、环境搭建、核心编码、避坑经验全部整理出来希望对正在做同样需求的兄弟有帮助。1. Word自动化方案选型为什么我最终选择了COM1.1 摆在桌面上的三条技术路线当时我调研了一圈发现MFC程序里操作Word文档基本只有三条路可以走。第一条路直接生成docx文件OpenXML。把docx当成一个zip压缩包用C去拼接里面的document.xml、styles.xml。这条路听起来很“底层”也确实能实现无Word环境生成文档。但问题在于OpenXML的Schema非常复杂一个最简单的段落都要写一堆标签更别说表格、图片、页眉页脚、样式继承这些高级功能。真要把格式做到像客户要求的那样“精致”光研究XML结构就得耗掉好几个星期。第二条路使用C调用Word的COM接口OLE自动化。Word本身就是COM服务器安装Office时会注册一个叫Word.Application的全局类标识CLSID。MFC程序可以通过COM接口去启动Word进程然后像操作自己的控件一样操作Word的文档对象。这是微软官方支持的自动化方式功能覆盖了用户在Word里能做的所有操作包括插入表格、设置样式、生成目录、导出PDF。第三条路直接生成RTF文本文件。RTF格式比OpenXML简单得多记事本都能打开。但RTF在Word里打开时兼容性一般稍微复杂一点的排版就会乱掉而且如果用户用的是WPS或者其他办公软件渲染效果差异更大。只适合生成极简单的纯文本表格我之前做过一次就果断放弃了。三者对比如下方案开发成本功能覆盖环境依赖排版效果直接生成docx高低无一般COM自动化中高需装Office最好生成RTF低极低无较差1.2 COM自动化的底层逻辑选COM之后心里得先有个概念MFC程序是COM客户端Word是COM服务器。程序每次要操作Word实际上是跨进程调用了Word内部的接口。Word对象模型的核心结构其实就是一个大树。最顶层是Application对象代表Word程序本身往下是Documents文档集合再往下是Document单篇文档然后是Range文本区域、Tables表格集合、InlineShapes嵌入式图片集合、Bookmarks书签集合等等。理解这个树形结构特别重要因为所有操作本质上都是“沿着这棵树找对象然后调对象的方法”。比如要插入一张图片路径就是Application - Documents - Document - InlineShapes - Add()。只要记住了这条主干后面遇到具体功能时查官方文档就能举一反三。2. 环境准备与类型库导入这一步决定你后面少踩多少坑2.1 初始化COM库的时机MFC工程使用COM的前提是初始化COM库。如果你用的是MFC对话框程序直接在CWinApp::InitInstance()里加一行AfxOleInit()就行。BOOL CMyApp::InitInstance() { // 其他初始化代码... if (!AfxOleInit()) { AfxMessageBox(_T(COM库初始化失败)); return FALSE; } // 继续你的初始化... }AfxOleInit()内部会调用OleInitialize()这个函数比裸的CoInitializeEx多做了一件事它同时初始化了OLE剪贴板、OLE拖放等环境。MFC程序里用COM操作Word用AfxOleInit()是最省心的选择。有件小事要提醒这个操作只对当前线程生效。MFC程序默认的操作都是在主UI线程按钮事件里调用Word自动化没问题。如果哪一天你决定把导出操作放进工作线程那必须在线程函数开头重新调用CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)否则碰到Registration incoming这种错误会非常痛苦。2.2 #import引入Word类型库的正确姿势MFC程序操作Word最常用的方式是#import指令导入Word的类型库这样编译器会自动生成C可以识别的智能指针封装类和接口定义。#include stdafx.h #pragma warning(push) #pragma warning(disable:4146 4191 4244) #import C:\\Program Files\\Microsoft Office\\root\\Office16\\MSWORD.OLB \ rename(ExitWindows, ExitWindowsEx) \ rename(FindText, FindTextEx) \ rename(Open, OpenEx) \ rename(Rectangle, RectangleEx) #pragma warning(pop) using namespace Word;这里有几个点必须注意。第一MSWORD.OLB的路径。不同Office版本路径不一样Office 2010在Office142013在Office152016及以后在root\\Office16。最保险的方式是去注册表里查HKEY_CLASSES_ROOT\\Word.Application\\CLSID拿到CLSID之后再定位类型库所在路径。嫌麻烦的话直接写死当前客户环境里有的版本也行但换机器时容易出问题。第二rename那几行。Word类型库里有些枚举名和方法名和Windows SDK里重名比如FindText会和MFC的CFindText冲突ExitWindows和Win32 API冲突。用rename把它们换个名字是为了让编译器不报重定义错误。这个细节在你第一次编译报错时你就知道有多救命了。第三#pragma warning(push/pop)的作用。COM智能指针生成的代码会触发一堆编译警告比如C4146、C4191、C4244看着非常吓人但都是无害的。在#import周围把警告级别压掉保证编译输出干净可控。2.3 编译环境要不要动“字符集”还有一个高频问题很多MFC工程为了兼容老代码字符集设置的是“使用多字节字符集”。但Word的类型库走的是Unicode路线生成的代码大量使用BSTR和wchar_t。我建议直接改成Unicode字符集。如果项目实在改不了也可以在操作Word的地方单独使用CStringW配合_bstr_t做转换。但经验之谈是用Word自动化这种纯Unicode场景项目整体切换到Unicode一劳永逸不然一天到晚处理宽窄字符转换容易写出内存越界。3. 核心链路实战从启动Word到保存文档的完整编码3.1 创建Word.Application实例并控制可见性这个步骤是整个操作链路的起点相当于把Word程序“拉起来”。WORDApp::WORDApp() { m_pApp nullptr; m_pDoc nullptr; } bool WORDApp::StartWord(bool bVisible) { if (m_pApp ! nullptr) return true; HRESULT hr m_pApp.CreateInstance(__uuidof(Application)); if (FAILED(hr)) { AfxMessageBox(_T(无法创建Word.Application对象请确认已安装Office)); return false; } m_pApp-PutVisible(bVisible ? VARIANT_TRUE : VARIANT_FALSE); // 关闭Word自带的提示对话框 m_pApp-PutDisplayAlerts(0); // wdAlertsNone return true; }用CreateInstance(__uuidof(Application))创建对象而不是new一个对象是因为Application是COM组件必须通过COM运行时实例化。CreateInstance(__uuidof(Application))的__uuidof会从导入类型库时生成的LIBID里找到正确的CLSID自动完成CoCreateInstance的封装。PutVisible(FALSE)这个操作很反直觉但非常重要。如果设为TRUE每次导出报告用户都能看到Word窗口一闪而过体验很差而且用户可能会手贱去点文档内容导致程序崩溃。设置成FALSE可以让Word进程在后台默默工作。还有个细节PutDisplayAlerts(0)。如果不关掉这个提示当代码里保存文件时一旦有重名或者格式冲突Word会弹出一个模态对话框程序就会卡住等用户去点。这是自动化程序里的致命坑。3.2 打开已有文档还是新建文档有两种常见场景一是生成新报告二是往现成的模板里填数据。模板填充场景更常见也更实用毕竟格式往往都需要预置好。bool WORDApp::OpenDocument(const CString strFilePath) { if (m_pApp nullptr) return false; DocumentsPtr pDocs m_pApp-GetDocuments(); if (pDocs nullptr) return false; // 打开文档注意这里参数众多需要按序号传 m_pDoc pDocs-Open( COleVariant(strFilePath), // FileName COleVariant(false), // ConfirmConversions COleVariant(false), // ReadOnly COleVariant(true), // AddToRecentFiles COleVariant(L), // PasswordDocument COleVariant(L), // PasswordTemplate COleVariant(false), // Revert COleVariant(L), // WritePasswordDocument COleVariant(L), // WritePasswordTemplate COleVariant(0), // Format COleVariant(false), // OpenAndRepair COleVariant(false), // DocumentDirection COleVariant(false), // NoEncodingDialog COleVariant(false) // Visible ); return (m_pDoc ! nullptr); }Open方法有十几个参数实际用得上的就FileName那一两个但C必须按顺序全写。用COleVariant包一层是为了把各种类型转换成COM统一要求的VARIANT。这里解释下发什么要这么麻烦COM自动化方法并不支持C原生类型所有参数统一规定为VARIANT。COleVariant是MFC提供的VARIANT包装类能自动完成字符串、整型、布尔值到VARIANT的转换还能自动管理内存。写代码时宁可多用几个COleVariant也千万别自己去手工填VARIANT结构体那样内存释放起来很容易出错。新建文档相对简单用的是Documents-Add()bool WORDApp::CreateDocument() { if (m_pApp nullptr) return false; DocumentsPtr pDocs m_pApp-GetDocuments(); m_pDoc pDocs-Add(COleVariant(L), COleVariant(false)); return (m_pDoc ! nullptr); }3.3 Range对象操作文档内容的核心抓手拿到文档对象之后往里面写内容全靠Range对象。Range可以理解成“文档里选定的一块连续区域”。它可以是整个文档也可以是光标位置开始的几十个字甚至可以是一个表格单元格里的内容。最基础的操作是把整篇文档的内容替换成新文本bool WORDApp::SetContentText(const CString strText) { if (m_pDoc nullptr) return false; RangePtr pRange m_pDoc-GetContent(); if (pRange nullptr) return false; pRange-SetText(COleVariant(strText)); return true; }GetContent()返回文档主体的全部内容范围SetText会清空里面的原有内容再写入新文本。注意SetText的参数也是COleVariant。中文编码问题在这里默认是安全的因为COleVariant(CString)在Unicode字符集下面自动走宽字节。往文档末尾添加内容需要先定位到文档末尾的Rangebool WORDApp::AppendText(const CString strText) { if (m_pDoc nullptr) return false; RangePtr pRange m_pDoc-GetContent(); pRange-Collapse(0); // wdCollapseEnd把范围收缩到文档末尾的光标位置 // 先移动到文章末尾 pRange-InsertAfter(COleVariant(strText)); return true; }Collapse(0)是Range对象里非常实用的一个方法它能把原本覆盖整片内容的Range收缩成一个点。传入0表示收缩到末尾wdCollapseEnd于是后续的InsertAfter就在文档末尾追加文字了。如果还需要调整字体、大小、对齐方式Range对象逐层往下挂pRange-SetText(COleVariant(_T(这是标题))); pRange-GetFont()-SetSize(16); // 字号16 pRange-GetFont()-SetBold(VARIANT_TRUE); // 加粗 pRange-GetFont()-SetName(COleVariant(_T(微软雅黑))); pRange-GetParagraphFormat()-SetAlignment(1); // wdAlignParagraphCenter 居中一套组合拳下来文字排版基本就实现了。这种“拿对象、调属性、往下层钻”的模式在Word自动化里会反复出现。3.4 插入图片、表格和书签内容图片插入实际开发中需求特别多比如报告里需要夹带统计图表。Word自动化插入图片是通过InlineShapes嵌入式图形集合的AddPicture方法实现的bool WORDApp::InsertImage(const CString strImagePath, int nWidthTwips, int nHeightTwips) { if (m_pDoc nullptr) return false; InlineShapesPtr pShapes m_pDoc-GetInlineShapes(); RangePtr pRange m_pDoc-GetContent(); pRange-Collapse(0); InlineShapePtr pShape pShapes-AddPicture( COleVariant(strImagePath), // FileName COleVariant(false), // LinkToFile COleVariant(true), // SaveWithDocument COleVariant(pRange.GetInterfacePtr()) // Range ); if (pShape ! nullptr) { pShape-SetWidth(nWidthTwips); pShape-SetHeight(nHeightTwips); } return true; }这里要注意图片的长宽单位不是像素而是磅Point。1磅约等于1/72英寸Word内部很多时候也接受缇Twips1磅20缇。如果直接往SetWidth里塞像素数字图片会变得小得离谱。我当时踩这个坑时一张600像素宽的截图在Word里变成指甲盖大小最后才想起来是单位对不上。表格操作稍微复杂一点但也很模式化bool WORDApp::InsertTable(int nRows, int nCols) { if (m_pDoc nullptr) return false; TablesPtr pTables m_pDoc-GetTables(); RangePtr pRange m_pDoc-GetContent(); pRange-Collapse(0); TablePtr pTable pTables-Add( pRange.GetInterfacePtr(), // Range nRows, nCols, COleVariant(1), // wdTableFormatGrid1 COleVariant(false) ); for (int i 1; i nRows; i) { for (int j 1; j nCols; j) { CellPtr pCell pTable-GetCell(i, j); pCell-GetRange()-SetText(COleVariant(_T(单元格内容))); } } return true; }表格的行列索引从1开始不是0这是Word对象模型和C/C数组一个非常大的区别。第一次写循环时我习惯性从0开始结果直接抛异常。这一点要多提一句非常容易踩坑。书签填充是模板操作最常见的功能。预先在Word模板里插入几个书签位置代码只需要替换书签里的内容bool WORDApp::FillBookmark(const CString strBookmarkName, const CString strValue) { if (m_pDoc nullptr) return false; BookmarksPtr pBookmarks m_pDoc-GetBookmarks(); BookmarkPtr pBookmark pBookmarks-Item(COleVariant(strBookmarkName)); if (pBookmark ! nullptr) { RangePtr pRange pBookmark-GetRange(); pRange-SetText(COleVariant(strValue)); return true; } return false; }这种操作方式在批量生成合同、报价单、简历模板时非常好用——你只需要维护一个模板文档程序负责往书签里填不同的值格式永远不变。3.5 保存、关闭与退出操作结束之后保存、关闭、退出这三步缺一不可顺序也不能错bool WORDApp::SaveDocument(const CString strSavePath) { if (m_pDoc nullptr) return false; // 参数1保存格式为docx0是doc格式 m_pDoc-SaveAs2( COleVariant(strSavePath), COleVariant(16) // wdFormatDocumentDefault 16, docx ); return true; } void WORDApp::CloseDocument(bool bSaveChanges) { if (m_pDoc ! nullptr) { m_pDoc-Close( COleVariant(bSaveChanges ? -1 : 0), // wdSaveChanges-1, wdDoNotSaveChanges0 COleVariant(0), COleVariant(false) ); m_pDoc nullptr; } } void WORDApp::Quit() { if (m_pApp ! nullptr) { m_pApp-Quit( COleVariant(0), // wdDoNotSaveChanges COleVariant(0), COleVariant(false) ); m_pApp nullptr; } }SaveAs2是2010之后Word版本的方法比老的SaveAs多了更多参数和格式支持。保存格式传16表示docxwdFormatDocumentDefault。如果客户要的是老版doc文件传0就行。Close和Quit的第一个参数控制是否保存修改。这里我统一传了0表示不保存因为调用CloseDocument之前已经显式保存过了。万一Word里有未保存的修改不传这个参数的话会在退出时弹出一个保存提示对话框在后台运行模式下对话框无人点击程序永远卡死。4. 基础代码跑通之后你绝对会遇到的几个拦路虎4.1 Office版本不同导致类型库接口不一致这就是MSWORD.OLB路径问题的延伸。Office 2010、2013、2016、2019、365其类型库版本号一直在变。2010导入生成的是Word10命名空间下的类2016是Word16。如果你在装了Office 2016的机器上开发代码里用的Word::ApplicationPtr拿到只有Office 2010的老机器上编译类型库版本对不上直接编译失败。解决办法一般有两种。要么发布时按客户实际用的Office版本来进行条件编译要么开发时就直接把类型库路径写成一个可以动态检测的值。实际操作中大部分项目都是绑定固定版本开发毕竟给企业客户做的项目通常Office版本是统一的。关键是小规模交付时写文档时一定要标注清楚“需要Office 2016及以上版本”。其实还有一种更正规的做法那就是绕开#import直接用IDispatch写通用代码。这种方案完全不依赖类型库版本但代码量爆炸GetIDsOfNames加Invoke的调用方式可读性极差只适合那种“一次写代码通吃所有版本”的基础库普通业务项目不值得投入。4.2 _variant_t与COleVariant搞清楚你手上到底哪个是哪个开发过程中最让人崩溃的就是类型混淆。_variant_t是C标准为COM提供的VARIANT封装类它不属于MFCCOleVariant是MFC自己提供的。两者内部都包着一个VARIANT结构体理论上可以混用但混用场景里很考验技巧。比如上述代码里Open方法接受VARIANT类型的参数但我在代码里传的是COleVariant。这是合法的因为COleVariant有到VARIANT的隐式转换运算符。而如果函数需要的是_bstr_t类型比如Word::Range::SetText在某些版本的类型库里签名其实是BSTR此时直接用L...也能通过因为编译器会自动转成BSTR。但保险起见我习惯显式用COleVariant包一层避免编译器因为类型推导产生模糊重载。还有一个小坑在#import生成的智能指针中如果一个方法的重载版本返回值类型是IDispatch*而不是DocumentPtr就必须用.GetInterfacePtr()方法把智能指针转出来否则编译不过。我在写Tables-Add和InlineShapes-AddPicture时都刻意加了.GetInterfacePtr()就是为了应对这种情况。4.3 Word进程残留最隐蔽的内存泄漏程序运行完任务管理器里躺着好几个WINWORD.EXE看不见摸不着时间久了内存越吃越多。这个问题几乎所有写过Word自动化的开发都遇到过。残留的根因大致有三个。第一Quit()没有调用或Application智能指针没有释放。如果只是把pApp离开作用域实际上只是COM引用计数减一但Word进程不一定退出。只有显式Quit()才会让进程主动关闭。第二文档对象或子对象还被局部智能指针引用着。比如循环里反复新建的RangePtr、CellPtr如果它们还在作用域内没有释放Word进程就会觉得“还有人在用我的文档”拒绝退出。所以在循环体内尽量用代码块把它们限制在局部作用域。第三打开文档后程序异常崩溃Quit()根本没机会执行。处理方式有一个口诀谁创建谁释放释放完再退出。void WORDApp::ExitWord() { // 先释放文档对象 if (m_pDoc ! nullptr) { m_pDoc.Release(); m_pDoc nullptr; } // 再通知Word进程退出 if (m_pApp ! nullptr) { m_pApp-Quit(COleVariant(0), COleVariant(0), COleVariant(false)); m_pApp.Release(); m_pApp nullptr; } }如果程序中途崩溃过残留的进程可以用任务管理器手动清。也有开发在程序启动时用EnumProcesses按进程名查找并干掉残留的WINWORD.EXE但这个操作有可能会导致用户自己正在编辑的Word文档异常关闭我建议只在开发调试阶段用正式产品不要这样做。4.4 用模板文档填充书签时内容格式容易“跑掉”用书签填充内容看起来很方便但它有一个致命特性书签会随着内容被替换而消失。当时我做了一份报价单模板书签位置后面本来跟着一个表格填了一次数据之后后续再要填充同一位置就找不到书签了。解决方案是在替换书签文本时把书签对象重新加回来pRange-SetText(COleVariant(strValue)); // 替换完成后重新创建同名书签以便后续再次替换 BookmarksPtr pBookmarks m_pDoc-GetBookmarks(); pBookmarks-Add(COleVariant(strBookmarkName), pRange.GetInterfacePtr());这样每次填完内容书签会重新被定义在同样的位置区域上下次还能继续替换。这个技巧对批量生成多份合同特别重要否则第二份文档就会直接报错“书签不存在”。另外模板里书签的位置如果跨越多个段落SetText有可能会把段落标记一起干掉进而导致后续内容排版混乱。经验是设计Word模板时书签尽量只覆盖文本内容本身不要跨段落或跨表格。5. 实战工程化封装一个可复用的CWordOperator类5.1 类的生命周期设计思路开发到后期我发现把Word操作直接堆在对话框代码里完全不可持续CtrlC、CtrlV都救不了。于是抽了一个专门负责Word自动化的类CWordOperator把打开、填充、保存、退出的流程全部封装进去对话框里只需要几行代码CWordOperator word; if (word.StartWord(false) word.OpenDocument(templatePath)) { word.FillBookmark(_T(CompanyName), _T(某某科技有限公司)); word.FillBookmark(_T(ProjectName), strProjectName); word.InsertImage(chartPath, 400, 300); word.SaveDocument(outputPath); word.CloseDocument(true); word.ExitWord(); }生命周期管理遵循三条原则构造时不启动Word只有需要时才创建析构时如果还持有Word引用就强制关闭所有Open/Close/Exit操作必须成对调用。这样的话异常路径上不至于把Word进程留在后台。5.2 异常处理与错误信息定位Word COM调用失败时返回HRESULT错误码比较隐蔽的是很多方法内部会抛_com_error如果我们不捕获程序可能直接崩溃。所以CWordOperator每个公开方法都套了一层try/catchtry { m_pDoc-SaveAs2(...); } catch (const _com_error e) { CString strError; strError.Format(_T(保存文档失败错误码: 0x%08X), e.Error()); AfxMessageBox(strError); return false; }错误码本身看不出太多问题但配合e.Description()往往能拿到更详细的错误描述。调试时还有个技巧在catch里把当前执行的步骤编号记录下来比如统一返回false前用TRACE输出日志能更快定位是哪一步抛的异常。有一点要特别提醒COM异常发生后Word当初的状态不一定还在。像OpenDocument失败后Word可能处于一个半初始化状态。这个时候最稳妥的做法是直接m_pApp.Release()把整个Word进程干掉重建而不是尝试继续操作。5.3 尽量扩展一个批量导出PDFWord自动化顺带还能干一件高频需求把文档导出成PDF。Office 2010以后Word自带PDF导出能力不需要额外装打印机。bool WORDApp::ExportToPdf(const CString strPdfPath) { if (m_pDoc nullptr) return false; m_pDoc-ExportAsFixedFormat( COleVariant(strPdfPath), COleVariant(0), // wdExportFormatPDF COleVariant(false), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(0), COleVariant(false) ); return true; }这段代码实际用起来效果很稳。客户看到“一键导出PDF版报告”这个功能时满意度直接提升一个档次。做项目的兄弟可以把这个功能顺手做进工具里价值感拉满。6. 再补几个很细节但很要命的经验6.1 32位/64位环境的兼容问题MFC程序如果是32位的操作64位Office的COM对象是没问题的因为COM跨进程调用本身支持位数不同的进程互相访问。但#import导入类型库时要确保拿到的是64位Office的类型库文件路径别用32位的去代替。反过来如果MFC程序是64位的操作32位Office同样可行。真正的墙在于Hot修类型的DLL不能直接加载进不同位数的进程但COM自动化走系统服务不受此限。开发时还有一个常见的报错“此项目需要MFC库”。这种报错一般出现在别人的工程拷贝到你机器上编译时解决方案是在项目属性里把“使用MFC”改成“在共享DLL中使用MFC”或者在链接器输入里加上uafxcw.lib和mfc90.lib等对应版本的库。这个问题虽然不是Word自动化特有的但在MFC工程配置时特别容易遇到顺便提一嘴。6.2 模板文档的路径处理很多团队会把Word模板文件放在程序的安装目录下但程序可能安装在C:\Program Files里普通用户没有写入权限。导出时又需要往模板里填充内容再保存到别处如果直接在模板文件上修改再另存模板源文件很容易被改坏。我的做法是程序第一次启动时把模板复制到%APPDATA%\你的程序名\Template\目录下然后所有操作都在副本上进行。这样哪怕脚本跑挂了原始模板始终完好无损下一次运行还能继续干活。6.3 大批量导出时逐篇打开Word窗口不可行我接过一个需求一键导出几百份合同。如果每份合同都启动一个新Word进程那机器直接卡到死。当时的优化做法是整个导出过程只启动一次Word循环里反复使用同一个Application对象——打开模板、填充书签、另存新文件、关闭文档然后下一个。实测下来几百份文档也就多花几分钟而且内存占用稳定。for (int i 0; i nCount; i) { word.OpenDocument(strTemplatePath); word.FillBookmark(_T(Index), strIndex); // 填充更多业务字段... word.SaveDocument(strOutputDir \\ strFileName); word.CloseDocument(false); } // 循环结束后再退出 word.ExitWord();这个思路用在任何“批量生成Word”的场景中都成立核心就是启动一次反复利用。最后再分享一个小技巧调Word自动化代码时不要用断点去单步跟踪逻辑那样容易导致Word对象状态错乱尤其不要中途把鼠标点进Word窗口里。正确做法是打日志、跑流程、看输出结果。我后来养成习惯在每个方法入口出口都留一个TRACE日志定位问题时能少掉不少头发。
返回列表