ARTICLE DETAIL

资讯详情

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

Apache POI 导出 Word 表格单元格合并实战与避坑

Apache POI 导出 Word 表格单元格合并实战与避坑 在Java后端做文档导出的圈子里Apache POI 几乎是绕不开的选择。业务系统里只要出现“把数据库里的数据生成一份带格式的 Word 报表”这类需求最后十有八九会落到 POI 头上。而这个需求里最容易让人卡住的恰恰不是写文字、插图片而是表格里的单元格合并。POI 导出 Word、合并 Word 单元格这两个词放在一起听起来像是个小功能真上手你会发现它牵扯到 WordprocessingML 的底层结构、表格宽度计算、合并后的样式继承、大文档的内存占用等一连串问题。这篇文章想聊的就是这套东西。适合谁看如果你正被“导出的 Word 表格合并错位”“列宽拖不动”“数据一多就内存溢出”这类问题折磨那这篇就是写给你的。如果你还没写过 POI 导出只是想把这块知识补上也能从中拿到可以直接抄的代码和踩坑清单。我会先把 Word 表格在 XML 层面的真实样子讲清楚再给出一套能跑的工具类最后把这几年在项目里踩过的坑、修过的 bug 整理成速查表。1. 需求拆解POI 导出 Word 到底难在哪1.1 从“导出”两个字开始拆先把需求翻译成人话。所谓“POI 导出 Word”本质是用 Java 代码在内存里构建一个.docx文档对象然后把用户想看到的内容——标题、段落、表格、图片——按照约定的格式塞进去最后通过流写到磁盘或者浏览器响应里。.docx从 2007 版开始就不是二进制格式了它是一个 ZIP 包里面装着若干 XML 文件正文在word/document.xml里样式在word/styles.xml里。POI 的XWPF组件就是这套 XML 的 Java 对象映射。这意味着什么意味着你在 Word 里看到的“表格”“合并单元格”在 POI 眼里就是一层层嵌套的 XML 节点。你没法像在 Word 界面上那样框选几个格子右键合并只能手工去改这些节点的属性。这是所有麻烦的起点也是理解后面所有代码的前提。1.2 合并单元格为什么是分水岭写文字、加粗、设字号这些操作在 POI 里有现成 APIcreateRun()之后setBold(true)就完事属于“查文档就能干”的活。但合并单元格不一样POI 从头到尾没有提供一个mergeCells()这样的方法。你得自己去操作底层的CTTcPr单元格属性设置gridSpan和vMerge。更麻烦的是横向合并还要求你把被合并掉的单元格从行里删掉纵向合并反而要求把它们留着占位——两个方向的规则完全相反很多人第一次写就写反导出的文档要么少了一列要么整个表格塌掉。我见过不少项目在这块翻车最常见的表现是横向合并代码写对了纵向合并也写对了但两个方向叠加的时候就乱套。比如“第一行跨三列第二三行第一列纵向合并”这种 L 形或者矩形区域的合并靠单点操作是拼不出来的必须有一个统一的算法来兜底。1.3 一个真实场景的完整需求拿一个我实际做过的报表来说。需求是这样导出一份设备巡检记录单表头是两行合并的——“设备信息”跨 3 列“巡检结果”跨 4 列下面第二行再分“编号/名称/型号”和“电压/温度/状态/备注”。数据区里同一台设备连续几行巡检设备信息那一列要纵向合并成一个格子。这种表头加纵向合并的组合就是最典型的实战场景。所以说这个需求的核心不是“会调用 POI”而是能把二维数据表格里的合并区域准确地翻译成 gridSpan 和 vMerge 的组合。下面我们就从最底层的结构开始一层层把它拆开。2. 环境与依赖版本选型这件事比你想的重要2.1 依赖清单与版本对照先说依赖。只要用到 Word 的 XWPF至少需要这几个包Maven 里配三行就够了POI 会自动带上poi-ooxml-schemas和xmlbeans。但版本千万不要随手写个“最新”因为 POI 在 3.x 到 5.x 之间有过一次比较大的包结构调整很多老代码在新版本上编译不过。组件坐标作用常用版本区间POI 核心org.apache.poi:poi提供 HWPF/HSSF 等4.x / 5.xOOXML 支持org.apache.poi:poi-ooxml提供 XWPF/XSSF4.x / 5.xXML Schemaorg.apache.poi:poi-ooxml-schemas底层 XML 节点类4.x 随包XMLBeansorg.apache.xmlbeans:xmlbeansXML 绑定引擎5.x 需显式引入我个人的选择是新项目直接上 POI 5.2.x老项目如果还在 3.17 或者 4.1.0建议尽快升级。原因不只是功能还有安全。早期版本在处理 XML 时对 DTD 和外部实体的限制不够严格如果你导出的文档内容部分来源不可控或者系统里还有解析外部 docx 的入口比如某些导出转 XML 的工具链就存在被恶意 XML 影响的风险。升级到新版本之后还要养成习惯凡是解析外部传入的 Office 文件都自己包一层禁用了外部实体的解析工厂不要图省事直接用默认配置。2.2 XWPF 与 HWPF 的选择新手最容易在这里绕晕HWPF和XWPF到底用哪个一句话区分——HWPF处理的是.doc97-2003 二进制格式XWPF处理的是.docx2007 之后基于 XML 的格式。现在新做的系统导出的目标格式基本都是.docx所以你需要的只有 XWPF。偶尔会遇到甲方要求必须给.doc那也只能用 HWPF但 HWPF 对表格合并的支持相当弱很多时候建议直接生成 docx 再让用户另存别硬啃。还有一个坑XWPF 里的类名都以XWPF开头比如XWPFDocument、XWPFTable、XWPFTableRow、XWPFTableCell。而操作底层 XML 的类名是CT开头比如CTTc、CTTcPr、CTTblPr。你写代码时会在两者之间来回跳XWPFTableCell.getCTTc()就是拿到它对应的底层节点。理解这个“高层对象 底层节点”的双层结构是写好 POI 的关键。2.3 中文字体与样式前置准备在动手合并之前先把字体样式这块的坑填了否则后面调试合并效果时你会分不清是合并写错了还是字体没生效。POI 里设置字体有个经典陷阱run.setFontFamily(微软雅黑)只设置了ascii和hAnsi两种字体中文默认走的是eastAsia字体不设置的话在中文 Word 里可能显示成默认宋体。XWPFRun run paragraph.createRun(); run.setText(设备巡检记录); run.setFontFamily(微软雅黑); run.setFontSize(10.5); // 关键一步补上 eastAsia 字体 CTRPr rpr run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); CTFonts fonts rpr.isSetRFonts() ? rpr.getRFonts() : rpr.addNewRFonts(); fonts.setAscii(Calibri); fonts.setHAnsi(Calibri); fonts.setEastAsia(微软雅黑);字号这里也有个换算Word 里说的“五号字”是 10.5 磅setFontSize传的就是磅值不是半点所以直接写10.5。这个值别写错很多人从 Excel 那边带过来的习惯会传21结果导出就是你想要的 10.5 的两倍大。3. Word 表格在底层长什么样3.1 w:tbl / w:tr / w:tc 三层结构要合并单元格必须先看懂这张结构图。一个 Word 表格在document.xml里是这样嵌套的最外层是w:tbl代表整张表里面每一行是一个w:tr行里每一格是一个w:tc。你所有关于合并的操作最终都是在改w:tc里的w:tcPr节点。除了这三层还有两个重要的“配置节点”经常被忽略。一个是w:tblPr描述整张表的属性比如表格宽度、边框、布局算法另一个是w:tblGrid它用一个w:gridCol列表声明每一列的基准宽度。这个 tblGrid 极其重要后面讲列宽拖不动的时候罪魁祸首基本都在这里。如果把表格想象成一个 Excel 网格w:tc是实际存在的格子而w:tblGrid是这张网格的“标尺”。Word 渲染时会拿w:tc的数量和w:tblGrid的列数去对对不上就出问题。很多人写合并代码时只改w:tc不动w:tblGrid结果就是列宽莫名其妙、拖动无反应。3.2 gridSpan横向合并的真相横向合并同一行里把几格拼成一格在底层是靠gridSpan实现的。它的语义是“我这个单元格横向上占据了 N 列”。所以横向合并的正确做法是在目标行的起始单元格上把gridSpan设成要跨越的列数然后把后面被吃掉的那几个w:tc节点从行里删掉。这里有个反直觉的点合并后行里w:tc的数量是减少的。比如一行本来 5 个格你把第 2 到第 4 格合并那么这行只剩下 3 个w:tc第 1 格、跨 3 列的第 2 格、第 5 格。Word 靠gridSpan的值来还原“这行实际占了几列”从而和其他行对齐。所以同表不同行的w:tc数量可以不一样只要w:tc数量与gridSpan之和相等表格就不会错位。3.3 vMerge纵向合并的真相纵向合并同一列里把几行拼成一格用的是vMerge。它有两个取值restart和continue。起始行那个单元格设成restart下面被合并的每一行对应的单元格设成continue。注意纵向合并的时候不能删除单元格被合并的行里那个w:tc必须留着只是把它标记成continue内容清空。为什么不能删因为行是横向排列的你把某一行里的一格删掉这行就少了一格整个表格的列对应关系就崩了。纵向合并只允许“在视觉上把格子的内容合并到起始格里”物理上它仍然分层存在于每一行。这个规则和横向合并正好相反也正是很多人写错的地方——他们以为纵向合并也是删格子。3.4 为什么没有现成的 merge 方法理解了上面两点你大概就明白 POI 为什么不提供mergeCells()了。因为“合并”这个动作在底层是两种完全不同的操作横向是删节点 加跨度纵向是保留节点 打标记。而且真实需求里的合并区域往往是矩形要同时涉及两个方向接口设计会非常复杂干脆不提供把自由度交给开发者。这个设计哲学在 POI 里很常见它给你 XWPF 这层“好用但有限”的 API同时也把getCTxxx()这层“难用但万能”的底层节点暴露给你。你要做的就是在这两层之间找到平衡——能用高层就用高层高层搞不定就下沉到底层。4. 动手实现从工具类到合并算法4.1 工具类骨架先给一个稳定的起点——创建文档、表格和基础工具方法。我把它们都放在一个WordTableUtils类里后面所有方法都往里加。public class WordTableUtils { private WordTableUtils() {} /** 创建文档统一页边距 */ public static XWPFDocument createDocument() { XWPFDocument doc new XWPFDocument(); CTSectPr sectPr doc.getDocument().getBody().isSetSectPr() ? doc.getDocument().getBody().getSectPr() : doc.getDocument().getBody().addNewSectPr(); CTPageMar mar sectPr.isSetPgMar() ? sectPr.getPgMar() : sectPr.addNewPgMar(); mar.setTop(BigInteger.valueOf(720)); mar.setBottom(BigInteger.valueOf(720)); mar.setLeft(BigInteger.valueOf(1080)); mar.setRight(BigInteger.valueOf(1080)); return doc; } /** 清空单元格内容但至少保留一个空段落 */ public static void clearCell(XWPFTableCell cell) { ListXWPFParagraph ps cell.getParagraphs(); while (ps.size() 1) { cell.removeParagraph(ps.size() - 1); } XWPFParagraph p cell.getParagraphs().get(0); for (int i p.getRuns().size() - 1; i 0; i--) { p.removeRun(i); } } /** 向单元格写入文本沿用第一段的样式 */ public static void writeCell(XWPFTableCell cell, String text) { XWPFParagraph p cell.getParagraphs().get(0); XWPFRun run p.createRun(); run.setText(text null ? : text); run.setFontFamily(微软雅黑); run.setFontSize(10.5); cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER); } }这个骨架里clearCell的写法值得说一句。POI 的单元格必须至少有一个段落这是它的硬性约束所以你删段落时要保证留一个。很多新手直接while(true) removeParagraph(0)要么抛异常要么后面写入时报空指针。另外垂直居中要显式设置否则内容默认贴顶看起来很难看。4.2 横向合并的实现与坑横向合并的核心逻辑很简单找到起始格设置gridSpan删掉后面的格子。但有一个细节要注意——如果起始格已经有 gridSpan比如前面已经合并过你要做的是累加而不是覆盖。public static void mergeHorizontal(XWPFTableRow row, int fromCol, int toCol) { if (fromCol toCol) { return; } ListXWPFTableCell cells row.getTableCells(); XWPFTableCell first cells.get(fromCol); CTTc tc first.getCTTc(); CTTcPr tcPr tc.isSetTcPr() ? tc.getTcPr() : tc.addNewTcPr(); CTDecimalNumber span tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); int current span.getVal() null ? 1 : span.getVal().intValue(); int extra toCol - fromCol 1 - current; span.setVal(BigInteger.valueOf((long) current extra)); // 从右往左删避免索引错位 for (int i toCol; i fromCol; i--) { row.removeCell(i); } }这段代码里有个很隐蔽的坑删除时要从右往左删。如果你从左往右删每次删除后后面单元格的索引都会前移第二次删的就不是你想删的那个了。这是用List做删除时的通病但在 POI 这里格外致命因为表格结构一旦错位Word 打不开或者显示成乱码排查起来很痛苦。还有一个情况要处理横向合并要求这几格在合并前必须是“干净”的也就是没有被其他方向的合并占着。如果它们中间某格已经被标记成vMergecontinue你再把它删掉会让上面的纵向合并链断掉。所以矩形的合并顺序很重要一般先做横向再做纵向这个顺序后面会详细说。4.3 纵向合并的实现与坑纵向合并和横向相反它保留所有单元格只打标记。起始行设restart其余行设continue。public static void mergeVertical(XWPFTable table, int col, int fromRow, int toRow) { for (int r fromRow; r toRow; r) { XWPFTableRow row table.getRow(r); XWPFTableCell cell row.getCell(col); if (cell null) { continue; } CTTc tc cell.getCTTc(); CTTcPr tcPr tc.isSetTcPr() ? tc.getTcPr() : tc.addNewTcPr(); CTVMerge vm tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); if (r fromRow) { vm.setVal(STMerge.RESTART); } else { vm.setVal(STMerge.CONTINUE); clearCell(cell); // 清掉多余内容避免内容重复显示 } } }这里的坑有两个。第一STMerge这个类是底层 schema 里的枚举导入路径是org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge不是 POI 主包下的很多人 import 不到就乱猜类名。第二被合并的后续单元格内容要不要清空取决于业务。如果上面的起始格里已经有内容下面这些continue格的内容不会显示但会留在 XML 里一旦有人取消合并就会冒出来。所以我建议统一清掉保持数据结构干净。4.4 矩形区域合并的一体化算法有了横向和纵向两个基础动作就可以拼出矩形合并。思路是先对每一行在列区间做横向合并再对合并后的那一格做纵向合并。/** * 合并 [startRow, endRow] x [startCol, endCol] 的矩形区域 * 说明先横向后纵向横向合并后每行在 startCol 位置只剩一格 */ public static void mergeRegion(XWPFTable table, int startRow, int startCol, int endRow, int endCol) { // 1. 逐行横向合并 for (int r startRow; r endRow; r) { XWPFTableRow row table.getRow(r); // 用 getTableCells 的绝对列位置定位 mergeHorizontal(row, startCol, endCol); } // 2. 纵向合并已合并出的那一格 for (int r startRow; r endRow; r) { XWPFTableCell cell table.getRow(r).getCell(startCol); CTTc tc cell.getCTTc(); CTTcPr tcPr tc.isSetTcPr() ? tc.getTcPr() : tc.addNewTcPr(); CTVMerge vm tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); if (r startRow) { vm.setVal(STMerge.RESTART); } else { vm.setVal(STMerge.CONTINUE); clearCell(cell); } } }这段算法的关键假设是横向合并之后索引 startCol 处的单元格就是合并出来的主格。这个假设在“从干净表格开始、按顺序合并”的前提下是成立的。但如果你先做了别的合并导致行内w:tc数量变化索引就不再对应逻辑列了。这是 POI 表格操作里最容易出的错——getCell(i)拿的是“第 i 个物理格子”不是“第 i 逻辑列”。所以我给一条硬经验先把所有合并操作在内存里排好序从左上到右下依次执行中间不要混入插格删格的操作。如果你的业务里有“先合并再填数据”的需求更稳妥的做法是先把数据全部写好最后统一做合并这样索引不会因为中途的合并而漂移。4.5 合并后的填充与边框处理合并完了不代表结束。合并后的单元格经常出现边框缺失或者边框重叠的问题。Word 里边框是画在tcPr的tcBorders上的横向合并会删掉中间的竖线因为格子没了但格子删除时它的边框设置也跟着丢了左右两端的框线可能不完整。解决方式是合并后给主格统一补一遍边框public static void setCellBorders(XWPFTableCell cell) { CTTcPr tcPr cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTcBorders borders tcPr.isSetTcBorders() ? tcPr.getTcBorders() : tcPr.addNewTcBorders(); borders.addNewTop().setVal(STBorder.SINGLE); borders.addNewBottom().setVal(STBorder.SINGLE); borders.addNewLeft().setVal(STBorder.SINGLE); borders.addNewRight().setVal(STBorder.SINGLE); }注意addNew会重复添加节点所以一定要先isSet判断。边框宽度和外层表格的边框是两套设置如果只设了单元格没设表格有时会出现“外层没有框、内层有框”的诡异效果。稳妥做法是表格级别设一遍整体边框关键格子再单独覆盖。内容填充的时机我建议放在合并之前。因为合并会删格或清格先填后并逻辑上更顺先把每个逻辑位置的数据写进去再根据合并规则把这个区域拼起来。这样你就不需要在合并之后再回头找“数据写到哪个格去了”。5. 列宽这个老大难问题5.1 tblW、tblGrid、tcW 三者的关系列宽相关问题占了我遇到的 POI 表格问题里差不多一半。先把三个概念捋清楚tblW整张表的宽度定义在tblPr里。tblGrid列宽标尺gridCol的列表每个值是一列的基础宽度单位是 twips1/20 磅。tcW单个单元格的宽度定义在tcPr里。Word 渲染时的优先级大致是先看tblW决定整张表多宽再看tblGrid决定每一列多宽最后tcW作为单元格的个体声明参与微调。三者一致时最稳任何两者打架就会出现怪异现象。最常见的错误是只设了单元格宽度没有对应更新 tblGrid结果 Word 打开后按 gridCol 的老值渲染列宽和你预期完全对不上。5.2 为什么导出的表格列宽拖不动“列宽无法拖动”是个高频投诉原因通常有这么几类我按出现概率排一下现象根本原因处理方式完全拖不动文档被设置了编辑限制/保护检查是否误加了保护取消文档保护拖动后自动弹回tblLayout为 fixed 且 tblGrid 与实际列数不符同步修正 tblGrid 的 gridCol 列表只能拖一点点表格总宽已到页宽上限没有余量调整页边距或缩小某列部分列拖不动该列所有单元格都固定了 tcW 且值相同减少硬编码宽度改用相对宽度最典型的是第二种。很多人导出后打开文档想调整列宽一松鼠标就弹回去怎么拖都没用。原因就是tblGrid里的列数和表格实际列数不一致——比如你合并了表头导致某一行物理格子变少但tblGrid还是原始的列数Word 在 fixed 布局下按 gridCol 硬渲染自然拖不动。解决办法是在生成表格时一次性把 tblGrid 的列数设对并且保证它等于“表格逻辑列数”。合并操作不会改变逻辑列数所以 tblGrid 应该始终是原始列数。public static void initGrid(XWPFTable table, int logicCols, int totalWidthTwips) { CTTbl ctTbl table.getCTTbl(); // 清掉自动生成的和已有的 grid while (ctTbl.sizeOfTblGridArray() 0) { ctTbl.removeTblGrid(0); } CTTblGrid grid ctTbl.addNewTblGrid(); int each totalWidthTwips / logicCols; for (int i 0; i logicCols; i) { grid.addNewGridCol().setW(BigInteger.valueOf(each)); } // 表格总宽度 CTTblPr tblPr ctTbl.isSetTblPr() ? ctTbl.getTblPr() : ctTbl.addNewTblPr(); CTTblWidth w tblPr.isSetTblW() ? tblPr.getTblW() : tblPr.addNewTblW(); w.setW(BigInteger.valueOf(totalWidthTwips)); w.setType(STTblWidth.DXA); }这里STTblWidth.DXA表示单位是 twips。如果想用百分比就换成STTblWidth.PCT但要注意百分比的值是“百分数 × 50”比如 100% 写 500080% 写 4000。这个 50 倍的关系坑过太多人我第一次写的时候传了 100 进去结果表格宽度只有 2%还以为 POI 有 bug。5.3 fixed 布局与百分比宽度的配合tblLayout有两种取值fixed和autofit。fixed 是固定布局严格按 tblGrid 的宽度渲染可预测性强autofit 是自动布局Word 会根据内容自动调整列宽导出的效果经常和你设计的不一样。我的建议是固定报表一律用 fixed这样每次导出的列宽都是稳定的。public static void setFixedLayout(XWPFTable table) { CTTblPr tblPr table.getCTTbl().isSetTblPr() ? table.getCTTbl().getTblPr() : table.getCTTbl().addNewTblPr(); CTTblLayoutType layout tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); }用 fixed 布局配合 tblGrid列宽就完全由你掌控。但也要注意fixed 布局下如果某列内容特别长会直接溢出显示不全。所以固定布局适合列宽需求明确、内容长度可控的报表如果内容长度差异巨大可以退而求其次用 autofit把控制权交还 Word。另外即便用固定布局也建议给单元格设一个合理的最小宽度避免出现“一列只有几个像素宽”的情况。还有个实用技巧如果 A4 纵向排不下可以横向。A4 纵向正文宽度大概 9026 twips横向能到 13000 左右。列数多的时候横排往往比硬压列宽明智得多。6. 大文件导出性能与稳定性6.1 XWPFDocument 的内存模型这里必须泼一盆冷水XWPFDocument 是全内存模型没有类似 Excel 那边 SXSSF 的流式写入方案。也就是说你生成的每一个段落、每一个单元格、每一次样式设置都会变成内存里的 XML 对象。表格行数一旦上万内存占用会非常可观我实测过一个 5000 行 × 15 列的表格堆内存峰值能到七八百 MB稍微不注意就 OOM。这个限制没法绕过只能通过降低单次生成量来缓解。所以设计导出功能时我一般先问清楚用户要导出的数据量级大概多少如果超过几千行我就会推动“分页导出”或者“按条件分批导出多个文档”而不是硬塞进一个文件。这不光是内存问题也是体验问题——几万行的 Word 表格用户打开都要转半天圈。6.2 样式复用的正确姿势既然是全内存模型那每一次创建对象都要精打细算。最典型的浪费是每个单元格都新建一套字体设置。run.setFontFamily()、setFontSize()每次调用都会操作底层 XML 节点成千上万次重复调用内存和时间都吃不消。更优的做法是使用样式Style。在styles.xml里预先定义一个“表格正文”样式里面写好字体、字号、段落对齐然后单元格写入时只引用样式名XWPFParagraph p cell.getParagraphs().get(0); p.setStyle(TableBody); // 引用 styles.xml 中定义好的样式 XWPFRun run p.createRun(); run.setText(text);不过 XWPF 对自定义样式的支持没有 XSSF 那么完善有时候你得手工往styles.xml里塞 XML 节点。如果嫌麻烦至少也要做到“同一份文档里复用同一个XWPFRun的样式配置逻辑”把它封成一个方法虽然没省内存但至少代码好维护。我实测下来的经验是能少一次 set 就少一次 set。比如整张表都是同一种字号那就统一设置一遍不要每个格子都设。另外导出完成后要及时document.close()并让对象尽快被回收长驻应用里尤其要注意这点否则内存只会一路涨上去。6.3 数据量分级处理策略结合经验我给一套分级策略你可以直接套用数据量建议方案说明小于 500 行单文档直接生成性能无压力怎么方便怎么来500 - 3000 行单文档 样式复用注意 JVM 堆设置建议 -Xmx 至少 512M3000 - 10000 行分页或多文件按每 1000 行切一个文档或一次导多份大于 10000 行改需求或改格式强烈建议引导用户改导 ExcelWord 不是干这个的最后一条不是偷懒。Word 本质上是个排版工具不是数据容器。几万行的数据塞进去用户体验极差打开慢、滚动卡、还容易损坏。这时候和产品说清楚把导出目标换成 Excel 或者让对方自己选是个技术判断不是甩锅。还有一点容易被忽略导出时的临时文件。如果你用了模板文件比如从resources读取一个 pre-made docx 当模板要注意流关闭。曾经有个项目因为没关模板输入流跑一段时间句柄耗尽整个服务起不来。任何InputStream都要放进 try-with-resources。7. 常见问题排查速查7.1 打开报错与文件损坏“Word 在试图打开文件时遇到错误请尝试下列方法”——这是最让人抓狂的一类问题因为提示极其笼统。实践中我总结出几个高频原因一是 XML 结构不合法比如删了格子却没有同步 tblGrid或者段落被删空二是命名空间写错手工拼 XML 时最容易犯三是文件写到一半被关流导致 ZIP 不完整。排查思路很固定把导出的 docx 后缀改成 zip解压看word/document.xml能不能被 XML 解析器正常解析。如果解析报错错误行号和消息会直接告诉你哪个节点坏了。这招比盯着 Java 代码猜快得多我每次都是先解压看 XML。7.2 Word 关闭卡顿“Word 关闭时卡顿”在导出的文档里也时有出现原因之一是大表格的渲染负担。如果表格行数多、合并关系复杂Word 在关闭时要重新计算和保存渲染信息就会卡。另一个原因是文档里的图形对象或者域代码过多。缓解办法是精简表格结构、避免不必要的嵌套表格、减少重复的样式声明。还有一种情况容易被误判文档本身没问题是打开它的机器配置低或者同时开着很多别的文档。所以遇到“关闭卡顿”别急着改代码先在一台干净的机器上验证一遍确认是文档问题还是环境问题。7.3 合并后内容错位或丢失这是合并操作本身的 bug症状是合并后某些格子里的文字跑到了别的格或者干脆不见了。原因基本就两个一是索引错位前面说过的物理格子和逻辑列不一致二是纵向合并的continue格内容没清导致 Word 显示时取了错误的内容。诊断方法很简单把合并代码注释掉先看没合并时的表格是否正常。如果正常问题就在合并逻辑如果本来就不正常那是填数据阶段就错了。二分定位永远是最快的排查手段。7.4 样式不生效比如设了字体没变化、设了边框看不见、设了行高没反应。最常见的原因是设置的对象不对。在 POI 里段落级样式和字符级样式是两层对齐、行距、段落间距属于段落XWPFParagraph字体、加粗、颜色属于字符XWPFRun。很多人把字体设到段落上当然不生效。另一个原因是设置时机——有些属性必须在内容写入前设写入后再设可能被覆盖。还有一类“不生效”其实是生效了但看起来没变比如设了 0.5 磅的细边框在低分辨率屏幕上几乎看不见。所以调试样式时先把效果放大验证比如用红色 3 磅边框确认通路是通的再调回正常值。7.5 我个人的几条经验收尾最后分享几个用血换来的小习惯。第一任何涉及索引的表格操作都先在纸上画一遍标出每一步之后物理格子和逻辑列的对应关系画完再写代码能省掉大半调试时间。第二合并逻辑一定要有单元测试用一小段代码生成文档然后用 XML 解析器断言gridSpan和vMerge的值比肉眼打开 Word 看可靠得多。第三不要迷信“能打开就行”有些结构错误的文档 Word 能容错打开但 WPS 或者别的阅读器就打不开或者导出成 PDF 时格式乱掉所以测试时最好用两三种阅读器都验一遍。第四导出功能上线后要盯着日志收集真实的失败样本POI 的问题很多都是“数据一极端就暴露”靠想象测不全。这几条听起来朴素但每一条背后都是一个加班夜。写 POI 表格合并这件事技术本身不难难的是对细节的耐心。把结构看透、把顺序理对、把异常兜住剩下的就是熟练度问题。
返回列表