☰
Apache POI 导出 Word 表格单元格合并实战与避坑
2026/10/1 4:36:11 网站建设 项目流程

在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 里有现成 API,createRun()之后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.x
OOXML 支持org.apache.poi:poi-ooxml提供 XWPF/XSSF4.x / 5.x
XML 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处理的是.doc(97-2003 二进制格式),XWPF处理的是.docx(2007 之后基于 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) { List<XWPFParagraph> 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; } List<XWPFTableCell> 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 打不开或者显示成乱码,排查起来很痛苦。

还有一个情况要处理:横向合并要求这几格在合并前必须是“干净”的,也就是没有被其他方向的合并占着。如果它们中间某格已经被标记成vMerge=continue,你再把它删掉,会让上面的纵向合并链断掉。所以矩形的合并顺序很重要,一般先做横向,再做纵向,这个顺序后面会详细说。

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的列表,每个值是一列的基础宽度,单位是 twips(1/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% 写 5000,80% 写 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 至少 512M
3000 - 10000 行分页或多文件按每 1000 行切一个文档,或一次导多份
大于 10000 行改需求或改格式强烈建议引导用户改导 Excel,Word 不是干这个的

最后一条不是偷懒。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 表格合并这件事,技术本身不难,难的是对细节的耐心。把结构看透、把顺序理对、把异常兜住,剩下的就是熟练度问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询