1. 为什么需要动态合并单元格策略第一次用EasyExcel导出报表时我对着满屏重复的订单号直挠头。比如导出100条订单明细相同订单号的商品会重复显示订单信息这种表格拿给业务部门看绝对会被吐槽。后来发现合并单元格能解决这个问题但官方示例只展示了固定列合并而实际业务中经常需要根据数据特征动态决定合并规则。举个真实案例上个月做供应链系统时采购部门需要导出带合并效果的订单汇总表。他们要求相同订单号合并第一列相同商品分类合并第三列每个订单的金额合计列需要跨行合并不同分公司的数据要保留分隔行这种复杂需求用传统固定列合并根本无法实现。于是我开始研究如何基于CellWriteHandler接口封装支持动态规则的通用合并策略。经过两周的踩坑实践最终实现了可配置化的合并方案现在同样的功能只需5分钟就能配置完成。2. 核心设计思路解析2.1 理解合并单元格的本质合并单元格本质上是确定一个矩形区域起始行(startRow)结束行(endRow)起始列(startCol)结束列(endCol)在POI中通过CellRangeAddress类实现EasyExcel底层同样使用这个机制。关键是要准确计算出需要合并的区域坐标。2.2 动态合并的三大难点在实际编码中发现三个技术痛点值比较策略不能简单用equals比较要考虑null值、数字精度等问题区域计算算法向上/向左查找相邻相同值时要处理边界条件多规则叠加当多个合并规则同时作用时要处理交叉区域的优先级我的解决方案是抽象出MergeStrategy基类通过模板方法模式让子类专注规则实现。下面是核心代码框架public abstract class AbstractMergeStrategy implements CellWriteHandler { // 模板方法 public final void afterCellDispose(...) { if (shouldMerge(cell)) { doMerge(sheet, cell); } } protected abstract boolean shouldMerge(Cell cell); protected abstract void doMerge(Sheet sheet, Cell cell); }3. 完整实现方案3.1 基础合并策略实现先实现最简单的列合并策略。以订单号合并为例public class ColumnMergeStrategy extends AbstractMergeStrategy { private final SetInteger mergeColumns; // 要合并的列索引 Override protected void doMerge(Sheet sheet, Cell cell) { int colIdx cell.getColumnIndex(); if (!mergeColumns.contains(colIdx)) return; Object currentValue getCellValue(cell); int firstRow findFirstSameRow(sheet, cell, currentValue); if (firstRow ! cell.getRowIndex()) { sheet.addMergedRegion(new CellRangeAddress( firstRow, cell.getRowIndex(), colIdx, colIdx )); } } // 向上查找第一个值相同的行 private int findFirstSameRow(Sheet sheet, Cell cell, Object value) { // 实现省略... } }使用时只需指定要合并的列索引// 合并第0列(订单号) new ColumnMergeStrategy(Collections.singletonList(0))3.2 支持主从合并规则更复杂的场景需要主从合并逻辑。比如先按订单号合并再按商品分类合并public class MasterSlaveMergeStrategy extends AbstractMergeStrategy { private final ListInteger masterColumns; // 主合并列 private final ListInteger slaveColumns; // 从合并列 Override protected void doMerge(Sheet sheet, Cell cell) { int colIdx cell.getColumnIndex(); boolean isMaster masterColumns.contains(colIdx); boolean isSlave slaveColumns.contains(colIdx); if (!isMaster !isSlave) return; // 主列直接合并 if (isMaster) { doColumnMerge(sheet, cell); return; } // 从列需要检查主列是否一致 if (checkMasterColumnsSame(sheet, cell)) { doColumnMerge(sheet, cell); } } }3.3 性能优化技巧在大数据量导出时10万行合并算法可能成为性能瓶颈。通过以下优化使导出时间减少70%缓存单元格值使用WeakHashMap缓存已解析的单元格值批量合并积累连续相同值的区域减少合并操作次数并行处理对非相邻列采用多线程合并优化后的核心逻辑// 使用缓存加速值读取 private final MapCell, Object valueCache new WeakHashMap(); protected Object getCellValue(Cell cell) { return valueCache.computeIfAbsent(cell, c - { // 实际解析逻辑... }); }4. 实战应用案例4.1 电商订单导出典型的多级合并需求一级合并订单编号二级合并商品类目特殊合并金额合计列ListInteger masterCols Arrays.asList(0); // 订单号 ListInteger slaveCols Arrays.asList(2, 8); // 类目和金额 EasyExcel.write(file) .registerWriteHandler(new MasterSlaveMergeStrategy(masterCols, slaveCols)) .sheet().doWrite(data);4.2 财务报表合并财务部门特别要求的合并规则相同科目合并相同部门合并保留空行分隔不同期间// 自定义策略处理空行分隔 public class FinanceMergeStrategy extends MasterSlaveMergeStrategy { Override protected boolean shouldMerge(Cell cell) { // 空行不参与合并 return StringUtils.isNotBlank(cell.getStringCellValue()); } }4.3 动态规则配置通过yaml配置定义合并规则实现完全动态化merge-rules: - name: 订单合并 master: [0] slave: [2,5] startRow: 1对应的策略工厂类public class MergeStrategyFactory { public static CellWriteHandler create(MergeRule rule) { return new MasterSlaveMergeStrategy( rule.getMasterColumns(), rule.getSlaveColumns() ).setStartRow(rule.getStartRow()); } }5. 常见问题解决方案5.1 合并后边框丢失问题这是POI的常见问题解决方法是在合并后重绘边框// 在合并完成后调用 private void redrawBorders(Sheet sheet, CellRangeAddress region) { RegionUtil.setBorderTop(BorderStyle.THIN, region, sheet); RegionUtil.setBorderLeft(BorderStyle.THIN, region, sheet); RegionUtil.setBorderRight(BorderStyle.THIN, region, sheet); RegionUtil.setBorderBottom(BorderStyle.THIN, region, sheet); }5.2 内容居中显示合并单元格默认继承第一个单元格的样式建议全局设置Bean public CellWriteHandler centerStyleHandler() { return new CellWriteHandler() { Override public void afterCellDispose(...) { CellStyle style cell.getCellStyle(); style.setAlignment(HorizontalAlignment.CENTER); cell.setCellStyle(style); } }; }5.3 大数据量内存溢出采用分批次处理临时文件方式// 分页查询数据 PageHelper.startPage(page, size); ListData pageData mapper.selectAll(); // 使用临时文件 String tempFile /tmp/export_ UUID.randomUUID(); try (ExcelWriter writer EasyExcel.write(tempFile).build()) { for (ListData batch : pageData) { writer.write(batch, buildSheet(page)); } } // 最终合并文件 Files.move(Paths.get(tempFile), targetPath);6. 高级技巧自定义合并策略6.1 交叉表合并针对矩阵式报表的特殊合并逻辑public class CrossTableMergeStrategy extends AbstractMergeStrategy { Override protected void doMerge(Sheet sheet, Cell cell) { // 检查行标题和列标题 if (isRowHeader(cell) || isColumnHeader(cell)) { mergeHeaders(sheet, cell); } else { mergeDataCells(sheet, cell); } } }6.2 条件格式合并根据单元格值动态决定是否合并public class ConditionalMergeStrategy extends AbstractMergeStrategy { Override protected boolean shouldMerge(Cell cell) { Object value getCellValue(cell); return value ! null !N/A.equals(value.toString()); } }6.3 多sheet合并处理跨sheet的相同结构报表public class MultiSheetMergeStrategy implements SheetWriteHandler { private final MapString, MergeStrategy strategies new HashMap(); public void addStrategy(String sheetName, MergeStrategy strategy) { strategies.put(sheetName, strategy); } Override public void afterSheetCreate(...) { String sheetName writeSheetHolder.getSheetName(); MergeStrategy strategy strategies.get(sheetName); if (strategy ! null) { strategy.apply(writeSheetHolder.getSheet()); } } }在最近的项目中这套动态合并策略已经稳定处理了超过200万行数据的导出需求。最复杂的报表包含5级合并规则通过良好的抽象设计新增一种合并类型只需实现一个简单的策略类即可。