EasyExcel动态合并单元格:基于CellWriteHandler的解决方案 导出Excel时遇到“同一列大量重复值希望相同值的单元格合并成一个”的需求恐怕不少人都被折腾过。尤其是需求还加一句“动态判断不能写死合并行数”那才是真正让普通easyexcel写法没法继续下去的地方。我最近刚好在处理一个报表导出功能时被这个卡了一次网上翻到的资料要么只讲固定合并要么版本很旧跑不通。把踩过的坑和最终能用的方案整理出来直接给你能抄作业的代码。1. 这个需求为什么让人头疼1.1 场景还原重复列值让表格没法看先看一个实际场景。你要导出一份客户订单明细列大概有订单编号、客户名称、商品名称、数量、金额。正常导出的效果是每行一条明细但同一个订单编号下面会有多行不同商品客户名称也重复出现在很多行里。你拿着一份几十行的Excel看着还好但如果导出的是几百行、上千行呢同一个订单号在一个单元格里反复出现视觉上非常分散领导看了第一句话就是“能不能把相同订单号合并起来看着清爽点”。这就是“按条件动态合并单元格的重复数据”这个需求的真实来源不是你想不想合并而是数据呈现需要你合并。如果只是少数几行Excel本身手动合并几下也就搞定了。可一旦数据是系统动态生成的行数不定、重复次数不定就必须在代码层面写一套通用逻辑让导出模块自己识别“哪些相邻单元格的值相同”然后自动合并。1.2 EasyExcel 自带的合并策略为什么不够用用过 EasyExcel 的人可能第一时间想到它本身不是有OnceAbsoluteMergeStrategy或者LoopMergeStrategy之类的东西吗确实EasyExcel 官方提供过一些简单的合并策略但它们基本都是“告诉我要合并哪几行到哪几行”也就是死了的区间。比如LoopMergeStrategy最多可以做到从第几行开始、每隔多少行合并一次这种固定节奏完全没法应对“相同值连在一起但次数不固定”的情况。你要让程序自己去看当前单元格的内容和上一行单元格的内容是否相同相同就继续往下合并不同则断开这就得走 EasyExcel 提供的事件回调机制自己在回调里实现“动态判断”逻辑。1.3 常见“土办法”的坑有的项目里开发会先用 EasyExcel 把数据写出来然后用 Apache POI 二次打开文件、扫描所有行、再调用sheet.addMergedRegion()合并。这种办法能做但问题也实在不少需要把整个文件读写两遍数据量一大内存和时间成本都翻倍。再写回文件时容易把 EasyExcel 生成的一些样式弄乱尤其是边框、字体。如果文件被其他工具打开过POI 读取时偶尔会碰到潜在格式损坏。相比之下直接在 EasyExcel 的写入过程中处理合并区间是最干净的数据没落盘之前就把合并区域算好一次写出内存和性能都可控。2. 思路拆解与核心原理2.1 CellWriteHandler 机制是怎么回事EasyExcel 之所以灵活在于它几乎每一步都留了事件回调。你可以在写入单元格之前、写入单元格之后、一行写完之后等节点插入自己的逻辑。这个回调接口在不同版本里的名字不完全一样但核心统一叫CellWriteHandler。我们动态合并依赖的是“单元格写入之后”的事件因为只有单元格已经被写入、值已经确定你才知道这个格子里到底存的是什么内容才能拿它跟上一行同列的值比较。用通俗的话说EasyExcel 是一边写数据一边通知你“我刚写完一个格子你要不要干点啥”你在这个通知里拿到当前 sheet、当前行号、当前列号、当前单元格对象想让哪些格子合并直接调用sheet.addMergedRegion()就行。2.2 afterCellDispose 的执行时机真正要重写的方法是afterCellDispose。这个方法的执行时机很有讲究它在一个单元格写完内容之后触发此时单元格已经有值并且 sheet 中还没有开始写下一个单元格。在这里你可以拿到context.getCell()当前单元格对象可以拿到值。context.getRowIndex()当前行号从 0 开始。context.getColumnIndex()当前列号从 0 开始。context.getWriteSheetHolder().getSheet()当前工作表对象用于添加合并区域。因为在每个单元格写完后都会回调一次所以你只需要对“需要合并的列”做判断。判断逻辑也很简单如果当前单元格的值和上一个被记录的单元格的值相等就认为这里是同一个合并区间的延续一旦不相等就把之前攒下来的区间正式合并。2.3 动态合并的判定逻辑动态合并最核心的判定是什么是“相邻行的值相同”。一般我建议先在 SQL 或者内存里对要导出的数据做一次排序让相同值天然连续。比如要按订单号合并就先ORDER BY 订单号这样订单号相同的明细会连续排在一起程序扫描时也容易判断。判定流程大致如下缓存当前列的“上一次的值”。缓存当前列的“合并起始行号”。遇到相同值只更新合并结束行号。遇到不同值把上一个区间addMergedRegion()然后重置开始行、结束行、当前值。这个过程就像你在纸上数一排圆圈看到颜色一样的连在一起就继续数颜色变了就把刚才数过的圈画个框包起来然后重新开始数。3. 完整实现一个可复用的动态合并策略3.1 定义合并规则接口为了不把代码写死我先把“值相同”这个条件抽象出来。后面想升级成“日期年份相同”“订单号前缀相同”之类的规则就只需要改一个实现类。/** * 合并匹配规则 */ public interface MergeRule { /** * 判断两个单元格的值是否属于同一个合并区间 * * param preValue 上一行的值 * param currentValue 当前行的值 * param preRow 上一行行号 * param currentRow 当前行行号 */ boolean isSameGroup(Object preValue, Object currentValue, int preRow, int currentRow); }默认规则实现就是比较值相等我习惯再加一个空值处理null和null可以合并但null和空字符串不合并。public class DefaultMergeRule implements MergeRule { Override public boolean isSameGroup(Object preValue, Object currentValue, int preRow, int currentRow) { if (preValue null currentValue null) { return true; } if (preValue null || currentValue null) { return false; } return Objects.equals(preValue, currentValue); } }3.2 策略类核心代码下面这个类就是核心了。你可以把它直接复制到项目里改一改构造参数就能用。import com.alibaba.excel.write.handler.CellWriteHandler; import com.alibaba.excel.write.handler.context.CellWriteHandlerContext; import com.alibaba.excel.metadata.Head; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.util.CellRangeAddress; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 动态合并单元格策略 * 根据指定列将相邻行中值相同的单元格纵向合并。 */ public class DynamicMergeCellWriteHandler implements CellWriteHandler { /** * 需要参与合并的列索引从 0 开始 */ private final ListInteger mergeColumnIndexes; /** * 表头占用的行数默认 1 */ private final int headRowNumber; /** * 合并规则 */ private final MergeRule mergeRule; /** * 每个列当前合并区间的缓存key 为列索引value 为当前闭区间 */ private final MapInteger, int[] rangeCache new HashMap(); /** * 每个列上一次的值 */ private final MapInteger, Object lastValueCache new HashMap(); private final DataFormatter dataFormatter new DataFormatter(); public DynamicMergeCellWriteHandler(ListInteger mergeColumnIndexes, MergeRule mergeRule) { this(mergeColumnIndexes, 1, mergeRule); } public DynamicMergeCellWriteHandler(ListInteger mergeColumnIndexes, int headRowNumber, MergeRule mergeRule) { this.mergeColumnIndexes new ArrayList(mergeColumnIndexes); this.headRowNumber headRowNumber; this.mergeRule mergeRule null ? new DefaultMergeRule() : mergeRule; } Override public void afterCellDispose(CellWriteHandlerContext context) { if (Boolean.TRUE.equals(context.getHead())) { return; } int rowIndex context.getRowIndex(); int colIndex context.getColumnIndex(); if (rowIndex headRowNumber) { return; } if (!mergeColumnIndexes.contains(colIndex)) { return; } Object currentValue getCellValue(context.getCell()); Sheet sheet context.getWriteSheetHolder().getSheet(); if (!rangeCache.containsKey(colIndex)) { // 当前列第一行初始化区间 rangeCache.put(colIndex, new int[]{rowIndex, rowIndex}); lastValueCache.put(colIndex, currentValue); return; } int[] range rangeCache.get(colIndex); Object lastValue lastValueCache.get(colIndex); if (mergeRule.isSameGroup(lastValue, currentValue, range[1], rowIndex)) { // 值相同延长合并区间 range[1] rowIndex; } else { // 值不同先把之前的区间合并掉再开启新区间 if (range[1] range[0]) { sheet.addMergedRegion(new CellRangeAddress(range[0], range[1], colIndex, colIndex)); } range[0] rowIndex; range[1] rowIndex; lastValueCache.put(colIndex, currentValue); } } /** * 在数据全部写完以后调用处理最后一个合并区间 */ public void mergeLast(Sheet sheet) { for (Map.EntryInteger, int[] entry : rangeCache.entrySet()) { int col entry.getKey(); int[] range entry.getValue(); if (range[1] range[0]) { sheet.addMergedRegion(new CellRangeAddress(range[0], range[1], col, col)); } } rangeCache.clear(); lastValueCache.clear(); } private Object getCellValue(Cell cell) { if (cell null) return null; CellType cellType cell.getCellType(); if (cellType CellType.STRING) { return cell.getStringCellValue(); } else if (cellType CellType.NUMERIC) { return dataFormatter.formatCellValue(cell); } else if (cellType CellType.BOOLEAN) { return cell.getBooleanCellValue(); } else if (cellType CellType.FORMULA) { return cell.getCellFormula(); } return null; } }关于getCellValue这里提一句EasyExcel 写入单元格后的 Cell 类型不一定是你想的字符串数值列可能会变成NUMERIC。如果你直接用getStringCellValue()去取直接会抛异常。我这边统一用 POI 的DataFormatter把数字格式化成字符串这样比较值时不会出现 1000 和 1000.0 对不上的问题。3.3 在 EasyExcel 中挂载使用调用方式不复杂直接把策略注册到 WriteHandler 里就行。ListInteger mergeCols Arrays.asList(0, 2); // 合并第1列和第3列 DynamicMergeCellWriteHandler mergeHandler new DynamicMergeCellWriteHandler(mergeCols, new DefaultMergeRule()); ListRowData dataList queryData(); // 记得按合并列排序 EasyExcel.write(outputStream) .sheet(订单明细) .registerWriteHandler(mergeHandler) .doWrite(dataList); // 关键一步合并最后一个未处理的区间 Sheet sheet getSheetFromWorkbook(); // 这里需要拿到写入后的 sheet见下文说明 mergeHandler.mergeLast(sheet);写完后需要调用一次mergeLast()正是我在这里要强调的因为程序是在“值发生变化”的时候才合并上一段数据全部写完之后如果没有新的值变化最后一段合并区间的range[1]一直停留在最后一行但永远不会被主动合并。所以必须手动触发一次完成收尾。如果你不想传 sheet 进来也可以在策略构造时接收“数据总行数”并在afterCellDispose里判断当rowIndex lastDataRowIndex且当前值已经是最后一行时主动 flush。我实际项目中用的是后一种因为更省事但暴露给外部调用mergeLast更直观。3.4 如何让合并后的单元格保持边框这里有个特别容易忽略的坑POI 的addMergedRegion只是把几何区域合并了但合并后这个区域只剩下左上角单元格的样式其他单元格原来的边框会消失。你导出的表格可能出现“合并后中间没有横线、边界框线缺失”的情况。解决方案是在合并前把要合并区域内的每个单元格都设置一遍完整样式或者合并后遍历区域重新设置BorderStyle。我一般写一个工具方法private void mergeWithStyle(Sheet sheet, CellRangeAddress region) { RegionUtil.setBorderBottom(BorderStyle.THIN, region, sheet); RegionUtil.setBorderTop(BorderStyle.THIN, region, sheet); RegionUtil.setBorderLeft(BorderStyle.THIN, region, sheet); RegionUtil.setBorderRight(BorderStyle.THIN, region, sheet); sheet.addMergedRegion(region); }RegionUtil是 POI 提供的工具类专门处理合并区域的边框比手动遍历单元格要省事很多。在afterCellDispose里把sheet.addMergedRegion替换成这个工具方法基本就不会遇到边框丢失的问题了。4. 进阶多列联动与自定义合并条件4.1 按订单号商品名称组合合并上面策略已经支持传入多个列索引它会针对每一列独立判断。常见场景是“订单号相同且商品名称相同才合并”这其实需要把这两列当做一个组合键来处理。最直接的做法是在导出前给数据里加一个隐藏字段mergeKey由“订单号 分隔符 商品名称”拼接而成然后把该字段放在合并列里只对该列执行合并。合并完成后再把隐藏列宽度设为 0 或者干脆不写入这一列。如果你不想加字段也可以自定义一个MergeRule在规则里根据当前行的 rowIndex 去查原始数据再判断两行的组合值是否一致。这要求策略类里能拿到数据列表稍微多写一点代码常见模板如下public class CompositeMergeRule implements MergeRule { private final ListRowData dataList; public CompositeMergeRule(ListRowData dataList) { this.dataList dataList; } Override public boolean isSameGroup(Object preValue, Object currentValue, int preRow, int currentRow) { if (preRow dataList.size() currentRow dataList.size()) { RowData pre dataList.get(preRow - 1); // 减去表头行 RowData cur dataList.get(currentRow - 1); return Objects.equals(pre.getOrderNo(), cur.getOrderNo()) Objects.equals(pre.getGoodsName(), cur.getGoodsName()); } return false; } }这种方式的好处是规则完全由你掌控不仅同一列可以合并还能跨列判断。4.2 自定义合并判断的几种玩法实际业务里“合并”的判断标准不一定是精确相等。我举几个真实遇到的例子日期相同希望按“年-月”分组把同月的日期单元格合并。你可以在规则里把值先转成日期再截断到月份比较。金额区间金额在同一个区间内就算同一组比如 100 到 200 合并成一段。状态变化某个字段变了就要断开比如订单状态从“待审核”变成“已审核”中间断开。这些统统通过实现MergeRule接口就能完成策略类完全不用改。4.3 横向合并且“按条件动态合并”怎么处理上面一直在讲纵向合并也就是不同行之间的重复值合并。有时候需求是横向合并同一行里后面几列内容和前面一样希望它们合并成一个单元格。横向合并其实也可以复用这个思路不过判断的方向要从“上一行同一列”改成“当前行同一行前一个单元格”。你需要自己维护一个横向的缓存在afterCellDispose里判断当前行号和上一个参与合并的单元格所在列号逻辑相似只是把“行维度”换成“列维度”。我一般不建议在同一个 Handler 里又处理横向又处理纵向容易把自己绕晕。横向单独写一个 Handler纵向用一个 Handler两个都注册到 writer 上互不干扰。5. 踩坑实录与排查手记5.1 版本差异导致的方法签名对不上这是最多人问的问题。EasyExcel 3.x 的CellWriteHandler接口方法签名是void afterCellDispose(CellWriteHandlerContext context);而 2.x 的签名是void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead);我一开始拿网上 2.x 的代码放进 3.x 项目里编译直接报错折腾了好一阵。你如果用的是新版直接实现带有CellWriteHandlerContext的方法就行。老项目如果必须用 2.x那参数很多但判断逻辑是一样的取rowIndex用relativeRowIndex判断表头用isHead。顺便提醒一句context.getHead()在有些版本返回的是Boolean而不是boolean所以判断时用Boolean.TRUE.equals(context.getHead())最安全别直接写if (!context.getHead())拆箱时万一拿到 null 会 NPE。5.2 合并区域重叠报错POI 对合并区域有严格限制两个区域如果重叠会抛出异常异常信息通常是IllegalStateException或直接提示区域相交。重叠常见原因有两种。第一种是同一个 Handler 里既合并且列又合并同一行其他列两个区域有交集。比如第一列合并了 1-3 行同时又对每一行做了 2-3 列横向合并横向区域和纵向区域就交叉了。这种只能调整需求或者只保留一种合并。第二种是多个 Handler 之间互相不知道对方已经合过哪些区域。你注册了两个独立的合并 Handler第一个 Handler 把 1-3 行第一列合了第二个 Handler 又把 1-4 行第三列合了POI 检查时有时候不会直接报错但生成的文件打开时会提示“损坏”或者无法正常合并。解决方法是尽量把“要合并哪些区域”的逻辑收敛到一个 Handler 里或者在你添加新区域前遍历sheet.getMergedRegions()判断是否与已有区域交叉。5.3 样式丢失与边框线全没了这个问题在前面提到过但很多项目真到上线才暴露。因为合并之后除左上角的单元格其他单元格的边框样式会被保留但你在 Excel 里看到的却是没有边线尤其是原来设置过thin border的表格。强烈建议合并动作别直接调addMergedRegion包装一层专门设置边框的工具方法。如果你还要保留背景色、字体、对齐方式那就在合并前对CellRangeAddress区域内的每一个Cell都setCellStyle。最简单不容易出错的做法是先用左上角单元格的样式作为样板复制一份区域里的其他单元格都设置成这个样式再合并。5.4 大数据量导出卡顿如果你一次性导几万行还每一行都参与合并判断性能压力主要在两个方面一是addMergedRegion次数太多导致 sheet 的合并区域列表非常长二是每次比较都从缓存拿值逻辑本身没问题但 POI 写大 Excel 本来就吃内存。我的建议是如果数据超过两万行尽量用SXSSF或者 EasyExcel 默认的流式写模式别一次性把所有数据加载进内存。合并区域数量控制在几千以内如果太多说明数据本身不该合并或者需要换一种展示方式。如果每次导出前都要排序排序尽量在 SQL 层完成不要在内存里用 Comparator 硬排几万条。6. 最终整理与个人心得代码写到最后一个容易漏掉的点还是那句话最后一组重复值不会在“值变化”时被触发合并必须手动调用一次mergeLast。我在第一次写完这个功能时自测数据里刚好最后一行是单独的所以问题没暴露等上生产后用户导出的数据最后一批正好是连续相同的才发现最后一段没合并。所以如果你在自己的项目里调试建议专门构造一个“最后几行值相同”的用例来验证。另一个建议是把“动态合并”这个能力封装成一个独立的工具类入参只要“合并列索引”和“合并规则”内部自己管理缓存和 flush。这样不管你是 EasyExcel 导出订单、报表、台账还是别的什么数据一行代码就能复用。千万不要把这个逻辑写到业务 Service 里否则后面想调整合并条件还得去翻业务代码。这个功能虽然看着小但真做完之后对 EasyExcel 的事件机制、POI 的合并区域操作、样式处理都会有更深的体会。如果你的数据结果更复杂比如需要跨行跨列动态合并也可以继续在这个基础上扩展。先把这个纵向的动态合并吃透后面无论来什么变体需求你都能快速应对。