1. 为什么需要动态表格合并在日常开发中我们经常遇到需要导出Word报表的需求。比如人力资源系统要导出部门人员清单财务系统要导出分类账目库存系统要导出商品分类表。这些报表通常都有一个共同特点需要按照某个字段如部门、类别进行分组并且相同分组的行需要合并单元格显示。举个实际例子假设我们要导出一个公司各部门员工清单理想的效果应该是这样的| 部门 | 姓名 | 职位 | |------|------|------| | 研发部 | 张三 | 工程师 | | | 李四 | 架构师 | | 市场部 | 王五 | 经理 | | | 赵六 | 专员 |传统做法是先在代码中计算好合并逻辑然后使用Apache POI的API逐个单元格操作。这种方式不仅代码量大而且维护困难。而poi-tl提供的DynamicTableRenderPolicy策略可以让我们专注于业务逻辑将复杂的表格合并操作封装成可复用的策略类。我在最近的一个电商后台项目中就遇到了类似需求。客户需要导出商品分类报表要求相同类别的商品行要合并显示。最初尝试用原生POI实现写了200多行合并逻辑代码后来改用poi-tl的策略模式代码量减少了60%而且后期需求变更时修改起来特别方便。2. poi-tl基础准备2.1 环境搭建首先需要在项目中引入poi-tl的依赖。建议使用最新稳定版本目前是1.12.0。在Maven项目中添加以下依赖dependency groupIdcom.deepoove/groupId artifactIdpoi-tl/artifactId version1.12.0/version /dependency注意版本冲突问题。poi-tl底层依赖Apache POI如果你的项目已经使用了POI要确保版本兼容。我遇到过POI 4.x和poi-tl不兼容的情况最终统一使用poi-tl内置的POI版本解决了问题。2.2 基础模板制作poi-tl采用模板驱动的方式工作。我们先创建一个Word模板文件template.docx放在resources/word目录下。模板中可以使用{{}}语法定义变量比如{{title}} {{table}}更复杂的表格模板可以这样设计{{?tables}} {{name}} {{table}} {{/tables}}在实际项目中我建议把模板文件和代码分离管理。我们团队的做法是由产品经理或UI设计师提供Word模板样稿开发人员在此基础上添加模板标签将模板文件放入版本控制系统统一管理3. 动态表格合并实现3.1 数据结构设计要实现分组合并首先需要设计合适的数据结构。通常需要两类数据表格实际数据行数据分组信息哪些行属于同一组我通常定义一个专门的DTO来封装这些数据Data public class GroupTableData { // 表格行数据 private ListRowRenderData rowData; // 分组信息组名 - 行数 private MapString, Integer groupInfo; // 需要合并的列索引从0开始 private int mergeColumnIndex; }在实际项目中这个结构可以根据需求扩展。比如添加分组合计行、组标题样式等。我曾经在一个财务系统中实现过带多级分组的表格就是在基础结构上增加了level字段和parentGroup字段。3.2 自定义渲染策略poi-tl的核心扩展点是通过继承DynamicTableRenderPolicy来实现自定义渲染逻辑。下面是一个典型的实现public class GroupMergePolicy extends DynamicTableRenderPolicy { Override public void render(XWPFTable table, Object data) throws Exception { GroupTableData tableData (GroupTableData) data; // 清空模板中的示例行 table.removeRow(1); // 插入实际数据行 insertDataRows(table, tableData.getRowData()); // 执行合并操作 mergeCells(table, tableData); } private void insertDataRows(XWPFTable table, ListRowRenderData rows) { // 倒序插入保持顺序正确 for (int i rows.size() - 1; i 0; i--) { XWPFTableRow newRow table.insertNewTableRow(1); // 根据实际列数创建单元格 for (int j 0; j rows.get(i).getCells().size(); j) { newRow.createCell(); } TableRenderPolicy.Helper.renderRow(newRow, rows.get(i)); } } private void mergeCells(XWPFTable table, GroupTableData data) { int currentRow 1; // 从第2行开始跳过表头 for (Map.EntryString, Integer entry : data.getGroupInfo().entrySet()) { int groupSize entry.getValue(); if (groupSize 1) { TableTools.mergeCellsVertically( table, data.getMergeColumnIndex(), currentRow, currentRow groupSize - 1 ); } currentRow groupSize; } } }这个策略类做了三件事清空模板中的示例行插入实际数据行根据分组信息合并单元格3.3 实际应用示例下面看一个完整的业务场景实现。假设我们要导出部门员工列表GetMapping(/export/department) public void exportDepartmentReport(HttpServletResponse response) throws IOException { // 1. 准备模板 InputStream templateStream getClass().getResourceAsStream(/word/department.docx); // 2. 准备数据 ListEmployee employees employeeService.listAll(); GroupTableData tableData buildDepartmentTable(employees); // 3. 配置渲染策略 Configure config Configure.builder() .bind(departmentTable, new GroupMergePolicy()) .build(); // 4. 渲染文档 MapString, Object context new HashMap(); context.put(title, 部门员工清单); context.put(departmentTable, tableData); XWPFTemplate template XWPFTemplate.compile(templateStream, config) .render(context); // 5. 输出到响应流 response.setContentType(application/octet-stream); response.setHeader(Content-Disposition, attachment;filenamedepartment_report.docx); template.write(response.getOutputStream()); template.close(); } private GroupTableData buildDepartmentTable(ListEmployee employees) { // 按部门分组 MapString, ListEmployee byDept employees.stream() .collect(Collectors.groupingBy(Employee::getDepartment)); // 构建行数据 ListRowRenderData rows new ArrayList(); MapString, Integer groupInfo new LinkedHashMap(); byDept.forEach((dept, list) - { groupInfo.put(dept, list.size()); for (Employee emp : list) { RowRenderData row Rows.of( dept, emp.getName(), emp.getPosition() ).center().create(); rows.add(row); } }); GroupTableData data new GroupTableData(); data.setRowData(rows); data.setGroupInfo(groupInfo); data.setMergeColumnIndex(0); // 合并第一列部门列 return data; }这个例子展示了完整的业务流程从数据库获取员工数据按部门分组并构建表格数据结构配置合并策略渲染并输出Word文档4. 高级应用技巧4.1 多级分组合并有时候我们需要多级分组比如先按大区合并再按省份合并。这时可以扩展我们的策略类public class MultiLevelMergePolicy extends GroupMergePolicy { Override protected void mergeCells(XWPFTable table, GroupTableData data) { // 第一级合并大区 super.mergeCells(table, data); // 第二级合并省份 if (data instanceof MultiLevelGroupTableData) { MultiLevelGroupTableData mlData (MultiLevelGroupTableData) data; int currentRow 1; for (Map.EntryString, ListGroupInfo entry : mlData.getLevelGroups().entrySet()) { for (GroupInfo group : entry.getValue()) { if (group.getSize() 1) { TableTools.mergeCellsVertically( table, mlData.getSecondMergeColumn(), currentRow, currentRow group.getSize() - 1 ); } currentRow group.getSize(); } } } } }对应的数据结构也需要扩展Data public class MultiLevelGroupTableData extends GroupTableData { // 第二级合并列 private int secondMergeColumn; // 多级分组信息 private MapString, ListGroupInfo levelGroups; } Data class GroupInfo { private String name; private int size; }4.2 带样式的分组标题有时我们需要为每个分组添加特殊样式比如加粗的背景色。可以在插入行时添加样式判断private void insertDataRows(XWPFTable table, GroupTableData data) { int currentGroupRow 0; String currentGroup null; for (int i 0; i data.getRowData().size(); i) { RowRenderData rowData data.getRowData().get(i); String groupName rowData.getCells().get(data.getMergeColumnIndex()) .getParagraphs().get(0).getText(); // 新分组开始 if (!groupName.equals(currentGroup)) { if (currentGroup ! null) { // 为上一组的最后一行添加底边框 styleLastRow(table, currentGroupRow, i - 1); } currentGroup groupName; currentGroupRow i 1; // 1因为表头占一行 } // 插入行... } } private void styleLastRow(XWPFTable table, int start, int end) { XWPFTableRow lastRow table.getRow(end 1); // 1因为插入位置偏移 for (XWPFTableCell cell : lastRow.getTableCells()) { cell.setColor(D9D9D9); // 设置灰色背景 } }4.3 性能优化建议处理大型表格时超过1000行需要注意性能问题批量操作尽量减少单个单元格操作优先使用批量插入和合并缓存样式重复使用的单元格样式应该缓存起来流式处理对于超大表格考虑分批处理数据模板优化简化模板中的复杂格式我在处理一个万行级别的报表时通过以下优化将导出时间从30秒降到了5秒预计算所有合并区域使用批量插入代替逐行插入禁用自动调整列宽复用单元格样式对象5. 常见问题排查5.1 合并位置不正确这是最常见的问题通常是因为行索引计算错误注意表头行和示例行分组信息与实际数据不匹配合并列索引指定错误调试建议先打印出分组信息和数据行检查模板中的行数使用TableTools.debugTable(table)输出表格结构5.2 样式丢失有时候合并后的单元格会丢失样式解决方法在合并前先设置好基础样式合并后重新应用样式使用poi-tl的Style工具类统一管理样式5.3 版本兼容问题不同版本的poi-tl和POI可能有行为差异建议锁定稳定版本查看变更日志在测试环境充分验证记得有一次升级后合并单元格的边框样式出现了问题最后是通过显式设置边框属性解决的// 合并后设置边框 for (int i startRow; i endRow; i) { XWPFTableRow row table.getRow(i); XWPFTableCell cell row.getCell(mergeCol); cell.setBorderTop(BorderStyle.SINGLE); cell.setBorderBottom(BorderStyle.SINGLE); }6. 最佳实践总结经过多个项目的实践我总结了以下几点经验模板设计原则保持模板简洁只包含必要的示例行使用明确的标签命名如{{employeeTable}}在模板中添加注释说明代码组织建议将表格策略类按功能分类使用工厂模式管理策略实例封装通用的表格工具方法测试方案单元测试验证数据准备逻辑集成测试验证文档生成结果自动化对比测试确保格式一致性能监控记录文档生成时间监控内存使用情况设置超时保护机制在实际项目中我们建立了一个文档生成服务专门处理各种报表导出需求。通过poi-tl的策略模式我们实现了高度的可配置化新产品只需要设计Word模板定义数据DTO配置策略绑定 就可以快速接入新的报表类型大大提高了开发效率。