接了个内部工具需求,用户要在不装Office的Windows机器上,用Unity客户端打开一份Word模板,把模板里的【项目名称】【客户单位】【编制人】【日期】这类占位符替换成实际内容,然后另存为新文档,供后续打印和归档。起初我想当然地写了个File.ReadAllText然后Replace的脚本,跑完生成的docx用Word打开直接提示文件损坏,这才意识到docx根本不是纯文本文件,不能按txt那样直接碰。后来转用NPOI插件,在保留模板字体、表格框线、页眉页脚的前提下,把关键字替换这条链路彻底做通了。
这篇文章就把我从选型对比、Unity环境集成、替换算法到发布踩坑的完整过程写下来。需要说明的是,这里讨论的不只是Unity,任何基于.NET的程序需要做Word模板占位符替换,都可以照着这套思路走。适合的人群大概两类:一类是在Unity客户端里做报告生成功能的开发,另一类是给业务部门做批量文档处理小工具的同学。
1. 为什么选NPOI:四个候选方案的实测对比
1.1 需求被三条硬约束卡死
动手之前,我先花了半天把需求约束列清楚,这比直接写代码有价值得多。
第一条硬约束:目标机器不能预装Office。部署环境里有不少机器除了系统自带的东西外什么办公软件都没有,更别提Office组件了。这一条直接宣判了"调用Word COM组件"方案的死刑。虽然用Word COM写字符串替换代码几天就能跑通,但Office版本、激活状态、权限、进程残留、平台限制这些坑,会在你交付后一个个找上门。
第二条硬约束:输出文档必须保留模板原有的字体、字号、加粗、居中对齐、表格框线这些视觉要素。所以"把docx转成HTML再读进来改写"这条路也走不通,一旦经过中间格式转换,表格结构和样式基本就废了,还原效果没法看。
第三条硬约束:程序要考虑Windows、Mac、Android三个平台,不能绑定某个操作系统特有的能力。这又排除了P/Invoke调用系统API的思路。
三条约束列完,能选的只剩"直接解析并修改docx文件"这个方向,候选方案就是Open XML SDK、DocX、NPOI这几类开源库。
1.2 候选方案对比与最终选型
我实际把四个方向都试了一遍,整理出来的对比结果是这样:
| 方案 | 实现原理 | 优点 | 缺点 |
|---|---|---|---|
| Open XML SDK | 官方维护的强类型API,直接操作docx规范 | 功能最完整,对OpenXML标准支持最透彻 | DLL依赖多,Unity里Mono/IL2CPP切换时容易出兼容问题,包体偏大 |
| DocX | 封装OpenXML的第三方库 | API风格接近C#习惯,生成新文档很方便 | 新版本已经商业化,集成到Unity麻烦;改旧模板的能力不如生成新文档 |
| Word COM | 通过Interop调用本机Office组件 | 替换逻辑最简单,Word原生功能都能调 | 需要装Office,跨平台差,进程和权限问题多 |
| NPOI | 纯托管库解析OpenXML/OLE复合文档 | 无Office依赖,DLL数量少,Unity里做Excel导入导出的社区基础成熟 | Word模块API没有Excel方向完善,个别场景需要自己绕路 |
最终选了NPOI。理由很实际:NPOI在Unity里的使用基础非常好,美术、策划、程序整理Excel配置都会用到它,DLL可以直接丢进Assets/Plugins目录,不用处理复杂依赖;它的Word模块入口在NPOI.XWPF.UserModel命名空间,很多人只知道NPOI能写Excel,不知道它也能读写docx,这个信息差让NPOI在Word场景里被严重低估。选型阶段我用一份带表格和页眉的真实模板分别跑了三个库的Demo,NPOI和Open XML SDK都能完整保留样式,但NPOI集成成本低一个量级,所以它就是那个"够用且最不折腾"的选择。
2. 集成到Unity工程:DLL版本、依赖与link.xml
2.1 拿到正确版本的DLL并放进Plugins
NPOI的Word能力不在主命名空间,很多人不知道这一步,导致集成时就找错了门。我以2.5.5版本为准来讲,这是目前综合兼容性最好、页眉页脚API也比较完整的一个版本。
操作步骤:
- 到NuGet页面搜NPOI,下载2.5.5版本的.nupkg文件。拿到文件后把后缀名改成.zip,直接解压。
- 解压后进入
lib目录,找到netstandard2.0或net40文件夹,这两个目标框架下的DLL对Unity最友好。不要用net6.0以上版本目录里的文件,Unity的编译链暂时没法很好适配那种依赖。 - 把下面这几个文件复制到
Assets/Plugins/NPOI目录:- NPOI.dll(核心基础库)
- NPOI.OOXML.dll(OOXML格式解析)
- NPOI.OpenXml4Net.dll(OpenXML容器读写,本质是处理docx的zip包层级)
- NPOI.OpenXmlFormats.dll(OpenXML结构对象模型)
- ICSharpCode.SharpZipLib.dll(压缩解压依赖,NPOI的zip操作全靠它)
- 在
Project Settings > Player > Other Settings里,把Api Compatibility Level设置为.NET Framework(Unity 2021之后有些版本显示为.NET Standard 2.1,实测NPOI 2.5.x也能跑。但最稳妥的组合还是.NET Framework 4.x加Mono)。
一个容易踩的细节:DLL放进Unity后,选中它们检查Inspector里的Auto Reference是否勾选,平台相关设置不要误关闭。如果这几个DLL没有正常被引用,编译阶段会报一堆命名空间不存在的错,排查起来非常莫名其妙。
版本选择上我建议直接用2.5.x,不要学网上老教程用2.3.0。NPOI的页眉页脚支持是后来才补全的,早期版本里doc.Headers这种属性根本不存在,你照着新代码写会直接编译失败。同时我也没建议用2.6以上版本,那些版本对.NET Standard的依赖升级过,Unity侧的表现需要重新验证,没必要追新。
2.2 API兼容级别与link.xml裁剪配置
如果你只在Windows编辑器里跑,不配link.xml问题不大。但只要用IL2CPP方式打包,比如Android、iOS、WebGL或者Windows IL2CPP模式,NPOI几乎必然在运行时报FileNotFound或TypeLoadException。原因是IL2CPP的代码裁剪会把通过反射加载的类型删掉,而NPOI内部大量使用反射来加载XWPFRun、XWPFParagraph这些类型,一裁就完。
解决办法是在Assets/link.xml里加一段保留配置:
<linker> <assembly fullname="NPOI" preserve="all" /> <assembly fullname="NPOI.OOXML" preserve="all" /> <assembly fullname="NPOI.OpenXml4Net" preserve="all" /> <assembly fullname="NPOI.OpenXmlFormats" preserve="all" /> <assembly fullname="ICSharpCode.SharpZipLib" preserve="all" /> </linker>配好之后,一定要用IL2CPP打一个Android包,在真机上跑一遍完整流程再收工。只测编辑器远远不够,我见过太多项目在编辑器里一切正常、上真机就崩,最后查半天全是link.xml的问题。
3. 关键字替换的核心算法:Run内定位与跨Run合并
3.1 docx内部结构:XWPFDocument、段落与Run的关系
要写出稳妥的替换逻辑,需要先搞清楚docx的底层组织方式。
docx文件本身是个zip压缩包,里面最关键的是word/document.xml,正文内容全在这个XML文件里。一段文字对应一个<w:p>节点,也就是NPOI里的XWPFParagraph;段落内部一个"格式连续的文本片段"对应一个<w:r>节点,也就是NPOI里的XWPFRun。Run是携带字体、字号、颜色、加粗这些格式属性的最小单元。
可以这样理解:Word里的Run像一串珍珠项链的珠子,每颗珠子有自己的颜色和大小,整段文字就是由这些珠子穿成的。正常人在Word里输入"【项目名称】"时,视觉上它是连续一串字,但在XML存储层面,Word完全可能把它拆成多个Run。原因很多:中途切换输入法、局部设置过颜色、顺手改过一个字的字体、从别的文档复制粘贴历史遗留的样式冲突。
所以替换策略千万不能只写"遍历每个Run,在单个Run内部找关键字",否则漏替换的概率很高。正确思路是:段落级检测 → 处理Run内替换 → 处理跨Run合并。
3.2 Run内替换:最简单也最安全的一种
先看基础实现,处理单Run内完整包含关键字的情况:
private static bool ReplaceInSingleRun(XWPFParagraph para, Dictionary<string, string> replacements) { bool changed = false; foreach (XWPFRun run in para.Runs) { string text = run.GetText(0); if (string.IsNullOrEmpty(text)) continue; string newText = text; foreach (var kvp in replacements) { if (newText.Contains(kvp.Key)) { newText = newText.Replace(kvp.Key, kvp.Value); } } if (newText != text) { run.SetText(newText, 0); changed = true; } } return changed; }这里用GetText(0)而不是直接读run.Text是有讲究的。NPOI的XWPFRun.Text在某些版本里会受文档其他位置影响,GetText(0)才是拿到当前Run自身文本的稳定方式;写入时用SetText(processedText, 0)也是同样道理,控制更精确。
这个方案的优点是格式零损失,因为每个Run都保持自己的样式属性。缺点是它处理不了跨Run拆分的占位符,而现实恰恰是跨Run拆分经常发生。
3.3 跨Run合并:处理Word自动拆分的占位符
当段落文本被拆进多个Run时,需要把段落级文本拼接起来,在完整字符串里做替换,再把结果写回。我的处理策略是:先遍历段落所有Run,拼接全文;如果发现替换项后,说明这些Run共同组成的关键字需要整体重写;此时采用"保留第一个Run样式、删除其余Run"的简化方案。
public static void ReplaceInParagraph(XWPFParagraph para, Dictionary<string, string> replacements) { List<XWPFRun> runs = para.Runs; if (runs.Count == 0) return; // 把段落所有Run文本拼成完整字符串 StringBuilder sb = new StringBuilder(); foreach (XWPFRun run in runs) { sb.Append(run.GetText(0)); } string fullText = sb.ToString(); string result = fullText; // 按字典依次替换 foreach (var kvp in replacements) { if (result.Contains(kvp.Key)) { result = result.Replace(kvp.Key, kvp.Value); } } if (result == fullText) return; // 没有需要处理的替换项 // 保留第一个Run的样式,其余清空 while (para.Runs.Count > 1) { para.RemoveRun(para.Runs.Count - 1); } runs[0].SetText(result, 0); }你一定注意到了,这个简化方案有个代价:段落里如果本来混合了多种样式,比如一句里有加粗和正常文字、有黑体和宋体,重建后整个段落都会变成第一个Run的样式,混排信息丢失。
但在实际项目里,这个代价大多数时候可以接受。原因很简单:模板占位符通常单独占一个段落,比如【项目名称】、【编制日期】这类,本身就是一行独立文字,不存在混排需求。如果你的模板必须保留段内多样式,那就需要更精细的做法——先找出关键字在完整文本中的起止下标,映射回对应的Run索引区间,只清理这个区间内的Run,把替换结果写进起始Run,区间外的Run保持原样。这个延伸实现留给读者按需补全,核心API就是runStartIndex和runEndIndex的映射,实现难度不高,但代码量会明显增加。
3.4 正文之外的表格与页眉页脚遍历
模板不只是正文段落,我处理的模板里:第一页有标题段落,中间有一张参数表,表内单元格也有占位符,页眉还挂了项目编号。所以替换逻辑必须覆盖三个维度:正文段落、正文表格、页眉页脚。
完整入口函数:
public static void ReplaceAll(XWPFDocument doc, Dictionary<string, string> replacements) { // 正文段落 foreach (XWPFParagraph para in doc.Paragraphs) { ReplaceInParagraph(para, replacements); } // 正文表格 foreach (XWPFTable table in doc.Tables) { ReplaceInTable(table, replacements); } // 页眉 foreach (XWPFHeader header in doc.Headers) { foreach (XWPFParagraph para in header.Paragraphs) { ReplaceInParagraph(para, replacements); } foreach (XWPFTable table in header.Tables) { ReplaceInTable(table, replacements); } } // 页脚 foreach (XWPFFooter footer in doc.Footers) { foreach (XWPFParagraph para in footer.Paragraphs) { ReplaceInParagraph(para, replacements); } foreach (XWPFTable table in footer.Tables) { ReplaceInTable(table, replacements); } } } private static void ReplaceInTable(XWPFTable table, Dictionary<string, string> replacements) { foreach (XWPFTableRow row in table.Rows) { foreach (XWPFTableCell cell in row.GetTableCells()) { foreach (XWPFParagraph para in cell.Paragraphs) { ReplaceInParagraph(para, replacements); } // 处理嵌套表格 foreach (XWPFTable innerTable in cell.GetTables()) { ReplaceInTable(innerTable, replacements); } } } }如果你用的NPOI版本较老,没有doc.Headers/doc.Footers属性,那得升到2.5以上,或者绕道解压包内word/header1.xml手动处理。我的建议是直接升版本,别在这个地方浪费时间。
3.5 输出保存与换行、特殊字符处理
替换完成后把文档写出去就简单了:
using (FileStream output = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) { doc.Write(output); }但有两个细节得提醒。
第一个是换行符。如果替换后的内容里包含\n,直接写进同一个Run,Word打开后经常表现得很诡异,有的版本显示成空格,有的版本显示成小方块。我的做法是:替换值里发现换行,就把文本按换行拆成多个部分,每个部分分别SetText,行与行之间调用run.AddCarriageReturn()。这样Word才能正确识别为段落内换行或非正式换行。
第二个是XML特殊字符。关键字或替换值里如果包含&、<、>这类字符,NPOI的SetText内部会做XML转义,一般不用你操心。但有一种情况会翻车:从外部配置读取替换值时,如果文件编码不是UTF-8,比如读GB2312编码的CSV,中文会直接变成乱码再写进docx。所以读取外部数据源时,要么统一转成UTF-8,要么显式指定编码方式读取。
4. 实测踩坑记录:三类真实故障的完整排查
4.1 替换后无效果的排查:占位符被拆散成多个Run
现象很简单:替换程序跑完没有任何报错,新文档打开一看,【项目名称】还是原样躺在那里,日志里甚至搜不到"关键字未找到"的输出——因为它压根没觉得需要找。
排查过程走了弯路,我一开始怀疑是模板里的占位符名称写错了,于是打开word/document.xml直接搜"【项目名称",发现它确实存在。但注意看XML结构时发现,这个占位符被格式拆分成了两个Run,中间还夹着一个样式变更节点。
为了彻底看透这个问题,我写了个辅助函数,把段落里每个Run的文本带索引打出来:
private static void DumpParagraphRuns(XWPFParagraph para) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < para.Runs.Count; i++) { sb.Append(i) .Append(": [") .Append(para.Runs[i].GetText(0)) .Append("]\n"); } Debug.Log(sb.ToString()); }打印结果一目了然:第0个Run文本是"【项目",第1个Run文本是"名称】,字体从宋体变成了黑体"。也就是说,用户编辑模板时把占位符分成了两段格式不同的文本,Word老实巴交地存成了两个Run。
这之后我把替换逻辑全部升级为"段落级判定",也就是第3章的方案。从那以后不管模板来源多复杂,只要视觉上是连续的文本,都能稳定替换。
4.2 文件损坏的排查:Word域与普通占位符混用
这个坑比较深,排查过程也更曲折。现象是替换完成后生成的docx,偶发性地用Word打开提示"文件损坏,是否尝试恢复"。
第一步我先去掉了所有含百分号的替换内容,故障消失,于是怀疑和%相关。但后来又试出只含百分号不含其他内容时故障也不一定会触发,这才意识到问题不在字符本身,而在模板里占位符周边的结构。
进一步定位发现,模板里很多东西是用Word的"域"功能做的。域功能生成的是一组结构化XML节点:fldChar、instrText、separate、t等,它们共同组成一个域对象。在外观上,用户看到的就是"【项目名称】"这一行字,但内部结构完全不同。NPOI的Run遍历能看到其中的文本片段,可当你直接用SetText修改某个Run的内容,很可能把域的结构语义破坏掉,Word打开时就认为文件结构不合法,要求恢复。
排查清楚之后,修复方案很直接:模板里禁止用Word的"域"来当占位符。如果业务方拿来的模板已经用了域,先在Word里全选占位符区域,把它们转为纯文本再进Unity流程。从这以后,"模板里不准出现域"直接写进了模板制作规范。
4.3 平台与文件锁问题:IL2CPP裁剪、流未释放、中文路径
这一节攒了三个小坑,每个都不大,但遇到了都很磨人。
第一个是Android真机替换失效。Windows编辑器模式跑得无比顺畅,打Android包后一跑就抛异常,报的是MissingMethodException或TypeLoadException。排查栈信息后发现指向NPOI内部类型加载失败,最后锁定IL2CPP裁剪。解决办法就是第2.2节的link.xml,配置保留后重打包验证通过。
第二个是连续生成第二次报"文件正在被另一个进程使用"。原因是模板读取的FileStream没有及时释放,第一次用完还占着句柄。修复方式是所有流操作都用using包裹,输出文件名也尽量用时间戳或GUID保证唯一。
第三个是中文路径问题。输出目录里带中文,在某些非中文系统区域设置的机器上,FileStream创建会失败。我的处理是:程序内所有中间路径统一用英文,最终文件放到用户选择的英文目录;如果业务必须中文文件名,用Path.Combine拼接并且确保目录提前存在,避免"目录不存在"这种隐性问题。
5. 模板规范化与批量生成的项目经验
5.1 好模板的三个标准
踩过这些坑之后,我总结出一套"好模板"的标准,直接写进了模板交付规范。
第一,占位符尽量单独成段,不要和常规文字混排。因为跨Run合并的简化方案会重建整个段落,如果该段落只有占位符,重建后格式丢失在视觉上几乎没有影响;如果占位符混在一句话中间,重建后整句格式被统一,用户一眼就能看出不对劲。
第二,占位符标志选择要足够"安全"。我推荐【】或者{{ }}这种组合,不要用单个#或@,避免和正文里的普通符号冲突导致误替换。替换字典的Key也要避免正则特殊字符,不然在文本内容里匹配时会出人意料的事故。
第三,模板里所有占位符尽量统一字体、字号和颜色,不要一个占位符内局部加粗、局部变色。这会让Word自动拆分的Run数量达到最小,也在很大程度上减少了跨Run问题的发生频率。
5.2 批量生成时的性能与并发注意点
做批量报告生成时,性能瓶颈不在替换逻辑本身,而在反复加载模板。如果你要基于同一个模板生成几千份文件,每次new XWPFDocument(FileStream)再跑替换,光解析模板的时间就会积累成明显开销。
我的做法是:把模板解析和替换拆成两步。第一步加载模板得到XWPFDocument对象,做完所有替换,立即写入输出流,然后释放;下一份文档重新加载模板。看起来简单,但能避免一个隐性问题——直接复用同一个XWPFDocument对象连续Write到多个输出,NPOI内部的一些索引状态可能残留,偶发出现内容串文档的情况。
并发方面,实测下来不同输出文件的处理可以丢给多个线程并行跑,但要注意替换字典在并发读时不要被修改。Dictionary<string, string>在只读场景下多线程访问是安全的,我可以放心地把处理循环丢进线程池。单份几十KB的模板,NPOI单文档替换耗时在几十毫秒到两百毫秒之间,1000份文档几分钟跑完,完全能接受。
5.3 发布前验证清单与工具化思路
项目交付前,我把验证项整理成了一张清单,建议你也照这个思路做一遍:
| 检查项 | 具体操作 | 期望结果 |
|---|---|---|
| 正文占位符 | 用NPOI读回输出文件并搜索关键字 | 搜不到任何占位符 |
| 表格占位符 | 用Word打开生成的docx检查表格 | 单元格内容已替换,表格框线完整 |
| 页眉页脚 | 用Word打开检查每一节页眉 | 页眉内容替换正确 |
| Android真机 | IL2CPP打包后跑完整流程 | 无异常且文件可正常打开 |
| 中文文件名 | 输出路径含中文 | 打开保存正常,无路径报错 |
除了清单,我还习惯性地做一个小工具函数:处理模板之前,先打印整个文档的段落、Run、表格层级结构。不要小看这一步,它能在替换逻辑真正跑起来之前,就把模板里的域、拆分Run、文本框等问题全部暴露出来,省掉大量排错时间。
我在实际项目里用这套流程跑了几千份模板,核心功能一直很稳定。唯一一次线上翻车,是业务同事把占位符做进了Word自动域里,从那以后模板入库之前必然先跑结构检查工具。如果你准备在Unity里用NPOI做Word关键字替换,我的建议是:先别急着写替换脚本,先写个函数把模板的段落、Run、表格层级打一遍,看清结构再动手,后面所有问题都会好解决很多。希望这篇踩坑记录能让你少走点弯路。