做过报表和文档导出的同学大概都有这种体会:Excel 那一套玩得还算顺,一碰到 Word 就开始别扭。尤其是用 poi 导出 word 并且要合并单元格这件事,第一次做的人几乎都要卡上半天。原因很简单,Word 的表格模型跟 Excel 完全不是一回事,XSSFWorkbook里那套addMergedRegion的思路搬到XWPFDocument上根本用不了,你得直接去操作底层的 XML 结构。偏偏业务上这种需求又特别常见:排班表、值班表、统计报表、合同附件、成绩单,只要是给人看的正式文档,几乎都会出现"同一个部门只显示一次,后面几行合并"、"表头横跨三列"这类版式要求。
这篇内容就是围绕这个场景展开的。我会从 POI 的 Word 对象模型讲起,把水平合并(gridSpan)和垂直合并(vMerge)的底层原理拆开,然后给出一套可以直接抄的 Java 实现,包含列宽的坑、样式统一、大数据量导出的内存问题,以及我自己在项目里踩过的那些让人抓狂的细节。适合已经会用 POI 生成简单 Word、但一遇到合并单元格就头大的后端同学,也适合刚接手文档导出模块、需要快速摸清门道的人。代码基于 POI 5.x 主线版本,4.x 的差异我会单独标出来。
1. 需求拆解:Word 表格合并到底难在哪
先说清楚为什么这事儿比想象中麻烦。Excel 里合并单元格是一个独立的"区域"概念,你告诉 POI"把 A1 到 C1 合并",它就在 sheet 级别记录一条 mergedRegion,单元格本身的数据和样式都还在原来的位置,渲染时再叠加合并逻辑。Word 完全不同,Word 的表格是真正由一个个单元格节点拼起来的,合并意味着你要在单元格自己的属性里做标记,还要决定多余的那些单元格节点是保留还是删掉。这两种思路带来的代码复杂度差了一个量级。
1.1 XWPF 对象模型与 XSSF 的关键差异
在 Excel 那边,你面对的是Sheet -> Row -> Cell三层,合并是额外挂上去的一个区域列表;在 Word 这边,XWPFDocument -> XWPFTable -> XWPFTableRow -> XWPFTableCell是严格嵌套的实体结构,每个XWPFTableCell背后都对应一个<w:tc>节点,节点里带着<w:tcPr>属性块,合并的标记就写在tcPr里。也就是说,合并是单元格自身的属性,而不是表格的一个全局配置。
这个差异直接决定了实现方式。你没法"声明式"地说一句"这一片合并",只能一个单元格一个单元格地去设置属性,并且要保证同一行、同一列上参与合并的单元格属性互相自洽,否则 Word 打开时就会渲染错乱,甚至直接报文档损坏。还有一点容易被忽略:XWPFTable内部维护着行和单元格的缓存列表,你如果绕过 API 直接改底层 CT 对象,缓存和实际 DOM 就可能对不上,导出结果会出现"代码里明明改了,文件里没生效"的诡异现象。
1.2 水平合并和垂直合并是两套逻辑
很多人第一次写会想当然地以为"合并"就是一个通用方法,其实水平和垂直是两条完全独立的路径。水平合并靠的是gridSpan,它表达的是"这一个单元格横向占了几个格子",所以处理时你要把被"吃掉"的那些单元格节点清掉,只在最左边那个格子标记跨度;垂直合并靠的是vMerge,它有两个状态值,起始格标restart,后续格标continue,格子一个都不能删,因为每一行的高度和边框还得靠它们撑着。
注意:把水平合并和垂直合并混着用的时候,属性设置的顺序和参与范围必须仔细核对,一个单元格同时带
gridSpan和vMerge是完全合法的,但数值必须和实际占位一致,否则 Word 会按最小范围渲染,看起来就像合并失效了。
我在项目里的做法是把两者拆成两个独立方法,然后在业务层自己控制调用顺序:先按数据把行生成好,再做垂直合并,最后处理表头的水平合并。这样出问题时定位范围小,改起来也快。
2. 底层原理:合并单元格在 XML 里究竟做了什么
要写得稳,得先看清楚 Word 文档里一个合并单元格长什么样。你把一个做好的 docx 后缀改成 zip 解压,打开word/document.xml,搜索gridSpan或vMerge,就能直接看到答案。理解了这段 XML,后面所有代码都只是把这段结构用 Java 拼出来而已。
2.1 gridSpan:水平合并的标记位
水平合并后,那一行的<w:tr>里单元格数量会变少。假设原本 3 个格子合并成 1 个,XML 里就只剩 1 个<w:tc>,它的tcPr里多出一行<w:gridSpan w:val="3"/>,意思是"我这个格子占 3 列"。同时表格顶部的<w:tblGrid>里定义的列数是不变的,还是 3 个<w:gridCol>,因为表格的物理格子结构没有变,变的是哪几个格子被同一个单元格占用。
这个细节非常关键。tblGrid是表格的"骨架",它决定了表格总共有多少列、每列多宽;gridSpan是在骨架上做占用声明。所以你写代码时,合并前一定要先保证 tblGrid 已经按最终列数建好了,否则跨度值会越界,Word 直接判定文档异常。很多"合并后列宽全乱"的问题,根源都在这里。
2.2 vMerge:垂直合并的 restart 与 continue
垂直合并在 XML 里表现为:同一列的每一行对应单元格的tcPr里都带<w:vMerge/>,其中第一行是<w:vMerge w:val="restart"/>,下面几行是<w:vMerge w:val="continue"/>。渲染时 Word 会把 restart 那个格子的高度拉伸到覆盖下面所有 continue 的行,continue 格子的内容区被隐藏。
这里有个坑必须提前说:continue 的格子虽然不显示内容,但它的段落节点还在。如果你往里面填了数据不管,虽然视觉上被盖住了,可一旦用户在 Word 里把合并拆开,那些数据就会冒出来;更糟的是,某些解析工具(比如把 docx 再读回来的程序)会把隐藏内容也读进去,造成数据重复。所以正确的做法是:标continue之后,把这个单元格里的段落清干净,只留一个空段落撑结构。
2.3 为什么"删除单元格"这条路走不通
有人会想,垂直合并不就是把下面几行的格子删掉吗?不行。Word 的表格是逐行定义的,每行的单元格数量理论上应该和 tblGrid 的列数对齐(考虑 gridSpan 后)。如果你把某一行的某个格子物理删掉,这一行的列数就比别的行少了,Word 会用自己的规则去补,补出来的结果通常是一堆错位的空格子,视觉效果稀碎。
水平合并倒是可以删,因为那些格子确实被"吃掉"了,格子数量减少、gridSpan 补上,账是平的。这就是两者的本质区别:水平合并是"减格子加跨度",垂直合并是"留格子改状态"。记住这一条,实现时思路就不会乱。
3. 手把手实现:一个可动态合并的 Word 导出器
原理讲完了,接下来是能直接用的代码。我把它组织成一个工具类,方法职责清晰,业务层调用的时候只需要关心"哪一行哪一列要合并",不用碰底层 CT 对象。这套结构我在两个报表项目里都用过,稳定性没问题。
3.1 依赖选型与基础骨架
Maven 依赖直接用 POI 5.x 主线,poi-ooxml一个就够,它会把poi和底层 OOXML schema 一起带进来。4.x 的用户注意,XWPFTableCell的部分 setter 签名有变化,我下面标注了。
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>工具类的骨架很简单,核心就是几个静态方法:
public final class WordTableUtils { private WordTableUtils() {} /** 设置表格为固定布局,列宽不再随内容自适应 */ public static void setFixedLayout(XWPFTable table) { CTTblPr tblPr = table.getCTTbl().getTblPr(); CTTblLayoutType layout = tblPr.isSetTblLayout() ? tblPr.getTblLayout() : tblPr.addNewTblLayout(); layout.setType(STTblLayoutType.FIXED); } }先把这个放进来,是因为它直接关系到后面列宽能不能生效,也关系到用户拿到文档后能不能手动拖列宽。
3.2 表格初始化与列宽正确设置方式
列宽是 Word 导出里最容易翻车的点。很多人调table.setWidth()或者cell.setWidth()发现没反应,原因通常有两个:一是只设了单元格宽度但没同步tblGrid,二是表格布局是 autofit,Word 打开时按内容重新算了一遍,把你设的值覆盖了。
正确做法是三步走:先设固定布局,再设tblGrid里每列的宽度,最后给每个单元格设tcW,三者数值保持一致。单位是twips,也就是 1/20 磅,A4 纸去掉页边距后正文宽度大概是 9000 多 twips,你可以按这个来分配。
/** 按比例设置列宽,weights 是各列的相对权重 */ public static void setColumnWidths(XWPFTable table, int[] weights) { setFixedLayout(table); int totalWidth = 9500; // 约等于 A4 正文宽度,单位 twips int sum = 0; for (int w : weights) sum += w; // 1. 重写 tblGrid CTTblGrid grid = table.getCTTbl().getTblGrid(); while (grid.sizeOfGridColArray() > 0) { grid.removeGridCol(0); } int[] colWidths = new int[weights.length]; for (int i = 0; i < weights.length; i++) { colWidths[i] = totalWidth * weights[i] / sum; CTTblGridCol gridCol = grid.addNewGridCol(); gridCol.setW(BigInteger.valueOf(colWidths[i])); } // 2. 同步每个已有单元格的 tcW for (XWPFTableRow row : table.getRows()) { for (int i = 0; i < row.getTableCells().size(); i++) { XWPFTableCell cell = row.getCell(i); if (i < colWidths.length) { setCellWidth(cell, colWidths[i]); } } } } public static void setCellWidth(XWPFTableCell cell, int widthTwips) { CTTcPr tcPr = getOrCreateTcPr(cell); CTTblWidth tcW = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); tcW.setType(STTblWidth.DXA); tcW.setW(BigInteger.valueOf(widthTwips)); } private static CTTcPr getOrCreateTcPr(XWPFTableCell cell) { CTTc ctTc = cell.getCTTc(); return ctTc.isSetTcPr() ? ctTc.getTcPr() : ctTc.addNewTcPr(); }提示:这里的
totalWidth只是经验值,真正精确的值应该由页面宽度减去左右页边距算出来。如果你的文档用了横向页面或者自定义纸张,记得同步调整,不然表格会超出边界。
关于"列宽拖不动"这个问题,从导出侧看,只要你设了STTblLayoutType.FIXED,用户在 Word 里拖动列宽就会受限。如果业务上希望用户拿到后还能自由调整,就把布局设成 autofit,但那样列宽就得靠单元格内容撑,视觉效果不稳定。我的建议是固定的报表就固定布局,需要二次编辑的文档就 autofit,两害相权而已。
3.3 水平合并的完整实现
水平合并的写法:先删掉被合并的格子,再给最左的格子标 gridSpan。删除的时候一定要从右往左删,因为删掉一个格子后,后面格子的索引会整体前移,正序删会删错位置。
/** 水平合并:将 rowIndex 行从 fromCol 到 toCol 的单元格合并 */ public static void mergeHorizontal(XWPFTable table, int rowIndex, int fromCol, int toCol) { if (toCol <= fromCol) return; XWPFTableRow row = table.getRow(rowIndex); // 从右往左删除多余单元格节点 for (int i = toCol; i > fromCol; i--) { row.removeCell(i); } XWPFTableCell merged = row.getCell(fromCol); CTTcPr tcPr = getOrCreateTcPr(merged); CTDecimalNumber span = tcPr.isSetGridSpan() ? tcPr.getGridSpan() : tcPr.addNewGridSpan(); span.setVal(BigInteger.valueOf(toCol - fromCol + 1)); }这里用到的row.removeCell(int)在 POI 4.0 之后是有的,它会同时维护内部的 cells 缓存和底层 CTRow 的节点,所以不需要我们手动清理缓存。如果你用的是更老的版本,就得自己操作row.getCTRow().removeTc(i),并且手动把XWPFTableRow里的缓存也重置掉,否则会读到已经删掉的格子,报索引越界。
3.4 垂直合并的完整实现
垂直合并分两步:第一行标 restart,后续行标 continue 并清空内容。清空内容这步不能省,原因前面讲过。
/** 垂直合并:将 colIndex 列从 fromRow 到 toRow 的单元格合并 */ public static void mergeVertical(XWPFTable table, int colIndex, int fromRow, int toRow) { if (toRow <= fromRow) return; for (int r = fromRow; r <= toRow; r++) { XWPFTableCell cell = table.getRow(r).getCell(colIndex); CTTcPr tcPr = getOrCreateTcPr(cell); CTVMerge vMerge = tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); if (r == fromRow) { vMerge.setVal(STMerge.RESTART); } else { vMerge.setVal(STMerge.CONTINUE); clearCellContent(cell); } } } /** 清空单元格内容,保留一个空段落撑住结构 */ private static void clearCellContent(XWPFTableCell cell) { List<XWPFParagraph> paragraphs = cell.getParagraphs(); for (int i = paragraphs.size() - 1; i >= 0; i--) { cell.removeParagraph(i); } cell.addParagraph(); }写到这里要强调一个业务层的注意点:垂直合并的前提是被合并的这几行在此之前必须已经真实存在。也就是说,你得先把所有数据行都 addRow 出来,填好内容,再回头做合并。如果边填边合,后面 addRow 的时候表格的 gridSpan 状态是混乱的,很容易生成畸形结构。
3.5 样式统一与表头处理
合并之后最容易出现的观感问题是:合并出来的大格子里文字没有垂直居中,或者边框突然断开。垂直居中的处理方式是在 tcPr 里设vAlign:
public static void setVerticalCenter(XWPFTableCell cell) { CTTcPr tcPr = getOrCreateTcPr(cell); CTVerticalJc vAlign = tcPr.isSetVAlign() ? tcPr.getVAlign() : tcPr.addNewVAlign(); vAlign.setVal(STVerticalJc.CENTER); }边框的话,POI 生成的表格默认继承文档默认样式,如果模板里没有定义表格边框,导出来就是无框的。想统一控制,最省事的办法是用一个预置好样式的模板文档,把你的表格插进去,样式自动继承;如果非要纯代码生成,就得逐个单元格设置tcBorders,工作量大且容易漏。我在实际项目里一律走模板方案,模板里把表头、斑马纹、边框都做好,代码只负责填数据和合并,省心太多。
表头横向合并是高频需求,比如第一行是"2024 年度考勤统计"横跨 6 列,第二行才是具体的列名。这时候你要给表格预留出两行表头,第一行先合并成一个格子再写标题,第二行的 6 个格子正常填列名。顺序是:先把表建成本身就有 6 列的骨架,然后对第一行做下次mergeHorizontal(table, 0, 0, 5),这个顺序反了就出问题。
4. 大数据量导出的性能与内存优化
合并逻辑本身不复杂,真正把项目拖垮的往往是大数据量。一份几千行的报表,用XWPFDocument生成,内存曲线能直接给你顶上去,稍不注意就 OOM。这块值得单独拎出来说。
4.1 XWPFDocument 的内存模型与瓶颈定位
XWPFDocument是完整的 DOM 模型,整个文档在内存里是一棵树,所有段落、运行、表格节点都是 Java 对象。一个单元格大概对应十几个对象,几千行的表格就是几万个对象在堆里堆着,再加上样式和属性对象,几百兆内存跑不掉。Excel 那边好歹有SXSSFWorkbook这种流式写盘的方案,Word 这边没有对等的流式 API,这是硬伤。
所以第一步是定位瓶颈:如果是 500 行以内的常规报表,直接生成完全没问题,别过度设计;如果是上万行的统计表,那基本可以判定 POI 全内存这条路走不通,得换思路。我一般会在生成前先估算行数,超过 2000 行就启动降级策略。
注意:
XWPFDocument.write()之后一定要close(),否则底层的 opc 包结构不释放,循环导出的场景下内存只涨不降,这个泄漏非常隐蔽。
4.2 分片导出与临时文件策略
大数据量的处理原则是"分批生成、分批落盘"。Word 文档本身不像 Excel 那样适合分 sheet,但可以按时间区间、按部门维度拆成多个文档,这是最干净的做法。如果业务坚持要一个文件,那就只能加大堆内存,同时把生成逻辑做得尽量轻——比如复用XWPFParagraph的样式对象、避免给每个单元格单独 new 样式、用完的对象及时置 null。
还有一个容易被忽视的点是图片。很多人往 Word 表格里塞公式图片、签名图,每张图都会以二进制形式嵌进文档,几百张图叠加起来体积和内存双爆。热搜里那个"公式图片转 word"的场景就是典型,我的做法是图片统一压缩到合适分辨率再插入,能矢量化的尽量矢量化,绝不让原图直接进文档。
如果你确实需要处理超大规模数据,可以考虑先生成结构简化的文档,再用文档处理工具做二次排版;或者干脆拆成多个文件打包,让用户自己合并。这些方案都比硬扛 OOM 强。
5. 常见问题速查与排查实录
这部分是我这些年攒下来的排查清单,基本覆盖了合并单元格相关的绝大多数翻车场景。
5.1 列宽拖不动、合并后宽度错乱
用户反馈最多的一条。分两种情形看:如果是导出的表格在 Word 里拖动列宽没反应,多半是tblLayout被设成了 fixed;如果想让它能拖,就把布局改成 autofit,同时保证tblGrid的宽度和单元格tcW一致。如果是合并之后宽度整个错乱,那基本是tblGrid没和最终列数对齐,或者合并时格子删多了、删少了,导致某行实际占用的列数和骨架对不上。排查方法很土但有效:把 docx 改名成 zip,解压看document.xml,数tblGrid里的gridCol个数,再逐行数tc的 gridSpan 之和,对不上就是这里的问题。
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 列宽设了不生效 | 表格是 autofit 布局 | 设STTblLayoutType.FIXED |
| 部分列宽对不上 | tblGrid 与 tcW 不一致 | 两者用同一套数值重设 |
| 导出后列宽拖不动 | 固定布局限制 | 业务允许则改 autofit |
| 合并后整表错位 | 骨架列数与实际占用不符 | 校验每行 gridSpan 之和 |
5.2 合并后内容错位或内容丢失
垂直合并后内容"消失",先别慌,检查是不是没标对 restart。如果所有格子都标成了 continue,Word 找不到起点,整个合并块就是空的;反过来,如果多个格子都标了 restart,Word 会当成多段独立合并,视觉上就是中间断了一截。还有就是合并前填了内容、合并后没清 continue 格子,拆开合并的时候数据冒出来,这个属于业务逻辑问题,不是渲染问题。
内容错位还有一种情况是索引算错了。业务层做分组合并时,经常用"相邻行同部门就合并"这样的逻辑,如果边界处理不严谨,很容易把不该合并的也合进去。我一般的做法是先把分组结果算出来,存成{startRow, endRow}的列表,再统一执行合并,这样逻辑和渲染完全解耦,调试起来也只是看列表对不对,一眼就能定位。
5.3 打开文件报错、版本与安全相关
偶尔会遇到"打开文件时遇到错误"这类提示,除了文档结构本身畸形之外,还有两个方向要查。一是 POI 版本问题,老版本在处理某些复杂属性时生成的结构不完全符合 OOXML 规范,升级到 5.x 主线通常能解决。二是安全相关,Apache POI 4.1.0 及更早版本里的XSSFExportToXml组件存在 XML 外部实体处理不当的风险,官方在后续版本做了加固,稳妥做法是直接升到 5.x,别长期停留在老版本上。
提示:如果文档需要和外部系统做 XML 层面的交换,务必确认解析入口关闭了外部实体加载,这属于基础安全习惯,和用哪个库无关。
另外,文档生成后建议用官方的校验工具或者干脆用 Word 打开一遍再交付,别直接扔给用户,很多结构问题在生成阶段是看不出来的。
6. 几条踩坑经验与实用建议
最后聊几点文档里不会写、但每次做这个需求都会用到的东西。
第一,先画骨架再填肉。表格的列结构、表头合并、列宽,这些"骨架"相关的事一定要在填数据之前全部定死,数据填充和内容合并放到最后。我早期是边填边合,结果每次加一个合并就翻一次车,后来改成两阶段,稳定性直接上来了。这个顺序看着简单,但真到赶工期的时候,最容易被忽略的就是它。
第二,模板优先,代码兜底。能用模板文档解决的事情不要用代码硬写,尤其是样式、字体、页边距、页眉页脚这些。代码里堆几百行设置样式的逻辑,维护成本极高,改一个字号要翻半天。模板方案下,样式问题交给设计,代码只负责数据,职责清晰。
第三,合并逻辑抽成独立方法并且写单元测试。合并单元格这种逻辑,靠肉眼检查导出的文档来判断对错非常低效。我的做法是把"分组结果计算"这部分纯逻辑抽出来,用普通单元测试覆盖各种边界(全合并、不合并、单行、单列、空数据),根本不用生成文档就能验证正确性,剩下的渲染部分出错概率就小多了。
第四,版本别乱升也别不升。POI 的各个大版本之间 API 有变动,尤其 4.x 到 5.x,一些 setter 的签名改了。项目里定好一个版本就别乱动,要升就整体升并且回归测试一遍导出功能。同时尽量跟着主线走,别长期用有已知问题的老版本。
第五,导出的东西自己先打开看一遍。哪怕逻辑再自信,也要生成一份真实数据跑一次,用 Word 打开确认版式、合并、列宽、字体都正常。我遇到过好几次代码逻辑完全正确、但就是某个边界数据触发结构畸形的情况,不实际打开根本发现不了。
关于后续还能怎么扩展,如果你手上同时有 Excel 和 Word 两类导出需求,其实可以把共性的"数据分组"逻辑抽出来复用,两边只是渲染层不同。再往上一层,如果文档有很多固定版式,可以往模板引擎的方向走,让业务配置模板、代码只灌数据,这才是长期收益最高的一条路。