用Java导出Word报表,十次里有八次会撞上“表格里相同字段的单元格要合并”这个需求。比如人员名单按部门导出,一个部门下有十个人,“部门”这一列就会重复十次,打印出来又乱又占地方,需求方看到第一眼就会提:把相同部门合并成大单元格。这就是我这次要写透的问题:怎么用Java操作Word表格,按照某个字段的值来合并单元格,同时保证样式、边框、跨页表现都正常。
网上搜这个需求的资料很碎,大多是“用POI可以合并”,但真正能直接复现的完整代码和避坑记录很少。我把自己在实际项目中踩过的坑、总结出的通用方法、和一批能直接落地的Java代码整理成这篇。适合正在做Java后端、尤其是处理办公文档导出的同学;哪怕你之前没碰过POI,照着下面的思路也能把功能写出来。
1. 需求拆解:为什么Word导出总绕不开“按字段合并单元格”
1.1 这个需求到底长什么样
先还原一个真实场景。假设要导出一张员工清单,数据结构大概是这样的:
| 部门 | 姓名 | 工号 | 岗位 |
|---|---|---|---|
| 技术部 | 张三 | 1001 | 后端工程师 |
| 技术部 | 李四 | 1002 | 后端工程师 |
| 技术部 | 王五 | 1003 | 前端工程师 |
| 产品部 | 赵六 | 2001 | 产品经理 |
这张表最直观的问题就是“部门”列在视觉上非常重复。Word表格不像Excel那样自带“合并相同单元格”的菜单,需求方说的“按字段合并”,本质就是:找出某一列中值相同的连续行,把它们的单元格纵向合并成一个大单元格,内容只保留第一个。合并后的效果应该是这样:
| 部门 | 姓名 | 工号 | 岗位 |
|---|---|---|---|
| 技术部(跨3行) | 张三 | 1001 | 后端工程师 |
| 李四 | 1002 | 后端工程师 | |
| 王五 | 1003 | 前端工程师 | |
| 产品部 | 赵六 | 2001 | 产品经理 |
这种需求在月度考勤、商品清单、订单明细、项目排期里非常常见。字段可以是部门、状态、日期、类型,甚至多个字段联合分组。核心逻辑是一样的:先按合并字段排序,再找到连续相同值的区间,最后对区间执行合并动作。
1.2 方案对比:别急着写代码,先想清楚用什么工具
Java生态里生成Word文档的方案不少,我先把我实际对比过的几个说清楚:
- Freemarker + docx模板:适合结构完全固定的合同、证明文件,用占位符往模板里套数据。但遇到“行数不确定”“合并单元格动态变化”这种需求,模板法会非常痛苦。因为你得提前在模板里画好合并区域,数据一变方案就塌了。
- docx4j:功能很强,改XML的能力不逊于POI,但API风格和POI差异大,文档资料相对少。如果你团队没人用过,上手成本不低。
- Apache POI:我用得最多的方案。XWPF模块提供了一套面向对象的模型,能直接创建表格、遍历单元格、修改底层XML,理论上Word表格能做的操作它都能碰。缺点是API偏底层,很多“看起来很简单”的功能其实没有一键方法,比如合并单元格。
- 直接改document.xml:Word文档本质是ZIP包里的XML,理论上可以直接手动生成或改造XML。但几乎没人会这么干,可维护性太差,只适合拿来理解底层原理。
我的选择是Apache POI,理由很简单:资料相对多、社区活跃、能动态处理任意表格,而且合并单元格的底层标记通过它操作最直观。后面所有代码都基于POI。
2. 基础准备:POI操作Word表格的核心模型
2.1 Maven依赖与版本说明
要用POI操作Word表格,核心依赖是两个:
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency>这里有个坑必须先说:poi和poi-ooxml的版本必须一致,不然运行时极容易遇到NoSuchMethodError这类诡异报错。如果你项目里还用了poi-tl、EasyPOI这类封装库,更要注意版本冲突,最好统一用一个版本。
JDK方面,POI 5.x要求JDK 8以上,实际我建议至少JDK 11,因为较新版本的POI在内存管理和处理大文件上更稳。
2.2 表格在POI里到底长什么样
要理解合并单元格,先要明白POI里Word表格的层级模型:
XWPFDocument // 对应整个docx └─ XWPFTable // 对应文档中的一个表格 └─ XWPFTableRow // 表格的一行 └─ XWPFTableCell // 一行中的一个单元格 └─ XWPFParagraph // 单元格里的段落 └─ XWPFRun // 段落里的文本片段合并单元格这件事,POI没有提供merge()这种现成方法,你要操作的是每个单元格底层的XML对象CTTc。Word描述单元格合并的方式并不复杂:第一个单元格标记为合并起点(START),后续单元格标记为合并继续(CONTINUE)。这个标记位于单元格属性CTTcPr下的vMerge节点。
打个比方,Word中的合并单元格很像一个拼图:起点格子是第一块,后面被合并的格子只是“隐藏了自己”,在渲染时被前一个格子“吸”过去。所以合并后你会看到,那几行里只有第一个格子里有内容,其余格子是空的,但边框是连成一块的。
理解这一点后,代码思路就清晰了:给起点格设置vMerge=START,给后续格设置vMerge=CONTINUE并清空内容。
2.3 定位目标表格和动态表头
一个Word文档里可能有很多表格,必须先定位你要操作的那个。最简单的方案是按索引取:
XWPFDocument document = new XWPFDocument(inputStream); List<XWPFTable> tables = document.getTables(); // 例如取文档中的第二个表格,索引是1 XWPFTable targetTable = tables.get(1);如果你想更稳妥,可以遍历表格判断行数和列数是否符合预期,或者给目标表格加一个书签再通过书签定位。实际项目中我更喜欢“按索引+行列数验证”的组合,简单可靠。
说到表头,很多人会忽略一个细节:表头字段名不一定等于数据库字段名,更常见的是“字段注释”。像MySQL里字段有COMMENT注释,这些注释才适合展示给用户。用JDBC可以这样取:
DatabaseMetaData metaData = connection.getMetaData(); ResultSet columns = metaData.getColumns(null, null, tableName, "%"); while (columns.next()) { String columnName = columns.getString("COLUMN_NAME"); String remarks = columns.getString("REMARKS"); System.out.println(columnName + " -> " + remarks); }这里有个隐藏坑:MySQL驱动下,如果连接URL没有加useInformationSchema=true,getColumns()返回的REMARKS很可能为空。我自己就因为这个排查了半天。把这个开关加上之后,字段注释才能正常读出来。顺带说一句,这个思路可以让你的导出工具表头动态生成,而不是写死在代码里。
3. 核心实现:按字段合并单元格的完整代码
3.1 合并前必须先做数据排序
合并的逻辑是基于“连续相同值”的,所以数据必须先排序。如果从数据库查,直接ORDER BY要合并的字段:ORDER BY dept_name, position。如果数据是接口返回的,就在内存里排:
List<Employee> list = getEmployeeList(); list.sort(Comparator .comparing(Employee::getDeptName) .thenComparing(Employee::getPosition, Comparator.nullsLast(String::compareTo)));排序时一定要注意空值的处理。建议把null当成普通值参与排序,并统一转换成空字符串,避免NullPointerException。排序的意义在于让相同字段值的记录扎堆出现,否则后面找“连续区间”的算法就会出错。很多初次做这个功能的人跳过排序,结果合并结果东一块西一块,原因就在这里。
3.2 纵向合并的底层方法
无论按哪个字段合并,最终都会落到同一个动作:把某一列的某几行纵向合并。我封装了一个通用方法:
private void mergeVertical(XWPFTable table, int col, int startRow, int endRow) { if (startRow >= endRow) { return; } // 第一个单元格标记为合并起点 XWPFTableCell startCell = table.getRow(startRow).getCell(col); CTTc startTc = startCell.getCTTc(); CTTcPr startTcPr = startTc.isSetTcPr() ? startTc.getTcPr() : startTc.addNewTcPr(); CTVMerge startMerge = startTcPr.isSetVMerge() ? startTcPr.getVMerge() : startTcPr.addNewVMerge(); startMerge.setVal(STMerge.START); for (int r = startRow + 1; r <= endRow; r++) { XWPFTableCell cell = table.getRow(r).getCell(col); CTTc tc = cell.getCTTc(); CTTcPr tcPr = tc.isSetTcPr() ? tc.getTcPr() : tc.addNewTcPr(); CTVMerge vMerge = tcPr.isSetVMerge() ? tcPr.getVMerge() : tcPr.addNewVMerge(); vMerge.setVal(STMerge.CONTINUE); clearCellContent(cell); } }为什么CONTINUE的单元格必须清空内容?因为Word渲染时,合并区域虽然显示成一个大格子,但每个单元格内部的段落文本仍然存在。如果你不清空,会出现文字堆叠、内容重叠甚至乱码的情况,尤其在转PDF时特别明显。清空内容时我建议保留一个空段落,避免后续操作读取单元格时出现NPE:
private void clearCellContent(XWPFTableCell cell) { for (int i = cell.getParagraphs().size() - 1; i >= 0; i--) { cell.removeParagraph(i); } cell.addParagraph(); }这里要特别注意行索引从0开始。如果第0行是表头,那么数据行是从第1行开始的,合并区间千万不要把表头带进去。
3.3 单字段分组合并的主流程
有了底层合并方法,主流程就清晰了:遍历数据行,比较当前行和上一行在目标列的值,值相同就继续向后,值不同就把已统计的区间执行一次合并。
下面是一段完整示例。我假设表格已经用数据填充好了,表格结构是:第一行表头,后续行为数据行,目标合并列是第0列:
public void mergeByField(XWPFTable table, int dataStartRow, int mergeCol) { int rowCount = table.getNumberOfRows(); if (rowCount <= dataStartRow) { return; } int mergeStart = dataStartRow; String prevValue = getCellText(table.getRow(dataStartRow).getCell(mergeCol)); for (int r = dataStartRow + 1; r < rowCount; r++) { String currentValue = getCellText(table.getRow(r).getCell(mergeCol)); if (!equalsWithNull(prevValue, currentValue)) { mergeVertical(table, mergeCol, mergeStart, r - 1); mergeStart = r; } prevValue = currentValue; } mergeVertical(table, mergeCol, mergeStart, rowCount - 1); }辅助方法:
private String getCellText(XWPFTableCell cell) { if (cell == null) { return ""; } return cell.getText().trim(); } private boolean equalsWithNull(String a, String b) { if (a == null && b == null) return true; if (a == null) return false; return a.equals(b); }这个流程可以用一个例子推演一遍。表格第1行到第3行的“部门”都是技术部,第4行是产品部。遍历时,r从1到3一直相等,到r=4时“产品部”不等于“技术部”,于是对第1到3行执行合并,mergeStart更新为4。循环结束后再对第4到末尾执行合并。逻辑不难,但容易漏掉最后一段区间,所以循环结束后必须再调用一次mergeVertical。
3.4 多字段联合分组:比如“部门+岗位”一起合并
实际需求往往不止按一个字段。常见情况是:一个是部门,一个是岗位,希望部门大合并,岗位在部门内部小合并。比如技术部里“后端工程师”占两行,这两行岗位也要合并。这时候不能简单地把两列分别跑一遍合并,因为第一列的合并已经改变了部分行的结构状态,再对第二列跑一次时区间容易错位。
正确做法是:先定义一个组合分组键,把两列的值拼起来作为“大区间”判断依据。比如:
String key = deptName + "\u0001" + position;用这个key来划分区间,区间内再对第二列(岗位)执行二次合并。实际封装时,我会定义一个配置列表,按优先级排列需要合并的列:
- 第0列:部门,最外层大合并
- 第1列:岗位,在部门合并区间内的小合并
代码大体是这样:
List<Integer> mergeColumns = Arrays.asList(0, 1); // 联合合并列,顺序很重要遍历时以“所有合并列的值组合”作为分组键,分组键变化时,先对当前区间内的所有列从后往前执行合并;同一个分组区间内,再逐列细分。原则是先大后小、从外层到内层。如果先合并内层岗位列,再合并外层部门列,部门的大合并会把岗位合并的边界打乱,渲染上就容易出现奇怪的格子边界。
4. 格式与渲染:合并只是开始,好看才是交付
4.1 合并后边框断裂怎么补
这是合并单元格最典型的翻车现场:合并完之后,用Word预览发现单元格里面的竖线少了,或者横线断成了几截。原因是VMerge标记的单元格本身没有显式设置边框,Word在渲染时可能不会自动补齐。
我的经验是:给整个表格设置统一边框,再给被合并的单元格单独设置边框,双保险。表格级边框可以这样写:
private void setTableBorders(XWPFTable table) { CTTblPr tblPr = table.getCTTbl().isSetTblPr() ? table.getCTTbl().getTblPr() : table.getCTTbl().addNewTblPr(); CTTblBorders borders = tblPr.isSetTblBorders() ? tblPr.getTblBorders() : tblPr.addNewTblBorders(); borders.addNewTop().setVal(STBorder.SINGLE); borders.addNewBottom().setVal(STBorder.SINGLE); borders.addNewLeft().setVal(STBorder.SINGLE); borders.addNewRight().setVal(STBorder.SINGLE); borders.addNewInsideH().setVal(STBorder.SINGLE); borders.addNewInsideV().setVal(STBorder.SINGLE); setBorderSize(borders.getTop(), "000000", 4); setBorderSize(borders.getBottom(), "000000", 4); setBorderSize(borders.getLeft(), "000000", 4); setBorderSize(borders.getRight(), "000000", 4); setBorderSize(borders.getInsideH(), "000000", 4); setBorderSize(borders.getInsideV(), "000000", 4); }同时给每个被合并的单元格也设置tcBorders。这一步在样式敏感的交付场景里几乎必做。如果做完了还是缺线,就检查是不是模板里自带了样式,模板的样式优先级有时会影响最终效果。
4.2 列宽、单元格宽度与自适应
合并单元格之后,列宽是另一个高频问题。尤其是“部门”这种内容短的列合并后,Word可能按内容自适应宽度,导致整张表格排版松散。
正确做法是同时设置表格网格列宽和单元格宽度。Word的宽度单位是DXA,1厘米约等于567 DXAm,1英寸是1440 DXAm。提供一个常用方法:
private void setCellWidth(XWPFTableCell cell, int widthDXA) { CTTcPr tcPr = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTblWidth tcW = tcPr.isSetTcW() ? tcPr.getTcW() : tcPr.addNewTcW(); tcW.setW(BigInteger.valueOf(widthDXA)); tcW.setType(STTblWidth.DXA); }同时还要改tblGrid里的网格列宽:
CTTblGrid tblGrid = table.getCTTbl().getTblGrid(); tblGrid.getGridColArray()[colIndex].setW(BigInteger.valueOf(widthDXA));两个都设,列宽在Word里才能稳定生效。如果只是设tcW,Word有时还是会按内容自动调整,我之前在模板导出时就遇到过“列宽设了但显示不生效”的问题,最后发现是tblGrid没改。
另外,如果你想做“表格宽度占满页面”的自适应效果,可以给表格设置tblW为pct类型,数值设为5000,表示宽度占页面可用宽度的100%。
4.3 跨页断行、表头重复与合并单元格显示
数据量一大,表格必然跨页。这里有两个很烦人的表现:
第一个是整行被整体移到了下一页,导致当前页留白。这是因为表格行默认可能不允许跨页断行。解决方案是给所有行设置允许跨页,对应XML是cantSplit=false。POI里这样处理:
private void allowRowSplit(XWPFTableRow row) { CTTrPr trPr = row.getCtRow().isSetTrPr() ? row.getCtRow().getTrPr() : row.getCtRow().addNewTrPr(); if (trPr.isSetCantSplit()) { trPr.unsetCantSplit(); } }默认情况下POI新建的行,cantSplit通常不会显式设置,问题不大。但如果你是基于模板复制出来的行,模板里可能带上了cantSplit=true,那就必须处理掉,否则跨页时会出现大段空白。
第二个是跨页后表头不见了。这个简单,把第一行标记为标题行,在每页重复出现:
private void setRepeatHeader(XWPFTable table, int headerRowIndex) { XWPFTableRow headerRow = table.getRow(headerRowIndex); CTTrPr trPr = headerRow.getCtRow().isSetTrPr() ? headerRow.getCtRow().getTrPr() : headerRow.getCtRow().addNewTrPr(); trPr.addNewTblHeader(); }这个细节非常影响阅读体验,尤其是月度报表,第一页有表头,第二页只有数据,用户很容易看错列。另外,Excel打印合并单元格跨页出现空白内容的问题在Word里也有类似表现:合并的单元格跨页后,内容可能出现在后半部分,整体观感不连贯。如果合并区间跨页,且内容一定不能横跨两页,就要提前在数据侧或者分页侧做处理,这个没有绝对通用的解法,通常是在数据量上做控制,或在模板里安排合适的分页符位置。
5. 踩坑实录与排查技巧
5.1 合并后文字重叠或显示空白
这是我被问的最多的问题。同一个症状,两种原因。
一是CONTINUE单元格没清空内容。前面已经说过,合并动作会把多个格子在视觉上变成一个,但每个格子的段落文本和样式都还在。不清空的话,内容会在同一块区域里重叠显示。解决方法是清空继续格的内容,只保留起点格文本。
二是起点格内部有多个段落。如果你填充单元格时用了addParagraph()叠加内容,起点格会显示好几段“重复感很强的文字”。解决方法是每次写单元格前,先清空该格现有段落,再写入文本。
5.2 模板中已有表格,合并后格子“乱套”
如果你用的是Word模板,模板里自带表格,要特别小心。为什么?因为模板表格里可能已经有合并过或隐藏过的格子,这些历史属性会在你合并时和新设置冲突。尤其是“复制行”的场景:用已有行当模板,复制出多行,复制时会连同旧的vMerge标记一起复制,导致新行还没合并就已经处于合并状态。
我的建议是:如果模板表格结构简单,直接用POI往现有表格里填数据;如果结构复杂且涉及大量合并,干脆在代码里从零创建表格,虽然代码多一点,但状态完全可控。
5.3 大批量数据导出慢、内存高
XWPFDocument是把整个文档加载到内存的,数据量很大的时候容易触发OOM。我做过的实际项目里,单个表格达到上万行时,性能下降非常明显。这不是POI有bug,而是docx的XML结构本身就是一大坨文本,全量加载的模型自然吃内存。
可行的规避办法有几个:
- 控制单个Word文档里的表格行数,比如一个文档最多几千行;
- 数据量再大就拆分多个文档,或一个文档里分多个表格,表格之间用分页符隔开;
- 如果只是导出数据给用户做二次处理,优先考虑Excel而不是Word,Excel对应的SXSSF是流式写入,内存表现好得多;
- 合并单元格本身也能减少最终XML里的冗余内容,而且合并后格子数更少,文件体积也小一点。
5.4 嵌套表格与循环输出的合并问题
Word表格里还能嵌套表格,POI遇到这种结构时,getTable()拿到的外层表格和嵌套表格是各自独立的对象。如果你在外层表格里做合并,行索引只作用于外层;如果嵌套表格也需要合并,必须单独取出内层表格再走一遍合并逻辑。
还有一个和“循环输出”相关的点:如果你的表格是嵌套循环生成的,比如外层遍历部门,内层遍历员工,每次循环都会新增行。这时候不建议每次循环都即时做合并,因为行还有可能继续增加,合并区间算不准。正确做法是:先完整填充所有数据行,再按字段统一扫描合并。
5.5 中文文件名与模板字体问题
下载文件时,如果文件名是中文,直接在响应头里放原始中文名可能乱码,需要用URLEncoder.encode或filename*=UTF-8''处理:
String fileName = URLEncoder.encode("员工名单", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);字体是另一个隐藏问题。同一个docx在Windows上打开是一个样,在Linux服务器上生成后再打开,字体可能变了。原因是Word文档本身不内嵌字体,只记录字体名称,渲染时依赖阅读端字体。导出时最好显式指定中文字体,比如宋体、微软雅黑,避免默认字体跨平台不稳定。
5.6 字段注释与动态映射的坑
前面提到用getColumns()读字段注释。实际开发中,我建议把“数据库字段名”和“表格列索引”的映射关系显示定义出来,而不是靠硬编码列号。比如:
Map<String, Integer> columnIndexMap = new HashMap<>(); columnIndexMap.put("deptName", 0); columnIndexMap.put("employeeName", 1); columnIndexMap.put("jobNumber", 2);这样做的好处是,后面需求变更调整列顺序,只需要改映射关系,不用改合并逻辑。
6. 进一步封装与建议
6.1 做一个通用的合并配置器
把上面的逻辑收拢成一个工具类并不复杂。核心配置项就三样:表格对象、数据起始行、要合并的列索引集合。
public void mergeTableCells(XWPFTable table, int dataStartRow, List<Integer> mergeColumns) { if (mergeColumns == null || mergeColumns.isEmpty()) { return; } // 按合并列值组合分组 int rowCount = table.getNumberOfRows(); int groupStart = dataStartRow; String previousKey = buildGroupKey(table, dataStartRow, mergeColumns); for (int r = dataStartRow + 1; r < rowCount; r++) { String currentKey = buildGroupKey(table, r, mergeColumns); if (!Objects.equals(previousKey, currentKey)) { mergeGroup(table, groupStart, r - 1, mergeColumns); groupStart = r; } previousKey = currentKey; } mergeGroup(table, groupStart, rowCount - 1, mergeColumns); }buildGroupKey就是把每行的合并列值拼接成字符串,拼接时用不可见分隔符避免字段值本身撞车。mergeGroup从后往前合并列,保证外层列不破坏内层列的合并边界。
这样封装后,你只需要调用一个方法,就能完成任意数量字段的联合合并。
6.2 调试docx的杀手锏:直接看XML
如果你遇到特别诡异的样式或合并问题,有个终极调试手段:把docx文件后缀改成zip并解压,直接打开word/document.xml看XML结构。你可以搜<vMerge>,看看START和CONTINUE标记是否配对;也可以搜<tcBorders>,检查边框节点是否存在。这个方法远比用代码打日志直观。
更高效的是用Microsoft Open XML SDK的Productivity Tool,它可以清晰展示文档的所有XML节点,还能帮你对照文档对象映射。我排查复杂合并问题时,几乎全靠它确认是“代码逻辑错了”还是“XML结构本来就不对”。这一步很多资料不会提,但确实能省大量时间。
6.3 这些需求还能怎么扩展
按字段合并单元格只是办公文档导出的一个基础能力。搞定它之后,顺理成章能扩展的方向其实不少:
- 多级表头:表头不止一行,跨行跨列的表头本身也可以用合并实现;
- 单元格内多段文本:一个格子里既有标题又有说明,涉及段落级别样式控制;
- 生成图表:POI的XWPFChart支持在Word里插入柱状图、饼图,但配置和刷新的复杂度比表格合并高一个量级;
- 批量导出归档:把多个表格模板拼到一个文档里,配合书签跳转和目录生成。
个人在实际操作中的体会是,Word导出这类需求,代码结构本身不难,难的是“格式永远比预期复杂”。合并单元格只是入门,后面还有边框、跨页、字体、页边距等一系列细节等着你。一旦把这次讲的这套底层模型理解透,再遇到类似功能就会顺手很多。最后再提醒一句:任何改动上线前,务必先导一个几行的小样本文档,解压看看XML,再交给用户验收。这一步做好了,能帮你少挨至少十次“这个样式不对”的投诉。