尧图精选

EasyExcel多级表头与数据自动合并导出实战

🕒 发布时间:2026/10/2 14:31:28 📁 来源:尧图网络
接手过的报表需求里返工率最高的一类就是带多级表头的 Excel表头要分组、要跨列数据区还得按某几列自动合并相同值最后一列往往还要挂合计。我第一次碰这种需求时用的是原生 POI光算每个单元格的 rowspan 和 colspan 就写了两百多行表头结构一改就得重算全部合并区域维护成本高得离谱。后来换成 EasyExcel 导出 Excel 合并表头和数据同样是这个需求核心代码能压到几十行动态表头还能直接用业务数据拼出来。这篇就把我在真实项目里验证过的写法和踩过的坑整理一遍怎么把一棵表头结构翻译成 EasyExcel 认识的ListListString怎么让数据区的相同值自动合并怎么绕开合并区域重叠、样式丢失、版本 API 不兼容这几个高频问题。内容偏实战写过 Java 后端或者需要产出复杂格式报表的都能直接拿去用新手建议先看前两章把表头矩阵这个概念吃透再往下读。1. 先想清楚复杂表头在 EasyExcel 里到底长什么样1.1 多级表头本质是一棵按列切开的树很多人一上来就写代码结果表头错位、合并乱套根因是没搞清楚 EasyExcel 对表头的数据结构约定。你在 Excel 里看到的合并表头视觉上是一个二维网格但 EasyExcel 要求你按列来描述它每个列对应一个从顶层到最底层的文字数组。也就是说一个三层表头用户信息 / 基本信息 / 姓名这一列在代码里就是[用户信息, 基本信息, 姓名]这样一个长度为 3 的列表再来一列用户信息 / 基本信息 / 年龄就是[用户信息, 基本信息, 年龄]。把整个表头写成一棵树理解更直观根节点下面挂若干分组节点分组节点再挂叶子节点而 EasyExcel 要的是从根到叶子的每一条路径一条路径就是一个内层 List。所以你在纸上画表头的时候正确的画法不是横着画格子而是竖着画列。我自己习惯先把表头画成缩进树状图然后一行一行抄成代码这样几乎不会出错。这里有个新手最容易忽略的点所有列的路径长度必须一致。上面例子里两列都是 3 层没问题如果有一列只有 2 层EasyExcel 会按最长的层级去渲染短的那列最底层就会留空视觉上看着像少了一个格子实际是空字符串。解决方式很简单短的那列在末尾补空字符串把长度对齐。别偷懒省略一旦有列长度不齐后面合并的逻辑会跟着一起乱。1.2 注解式表头和数据驱动表头的取舍EasyExcel 提供两套写法。一套是实体类加注解用ExcelProperty(value {用户信息, 基本信息, 姓名})value 传数组就是多级表头字段顺序就是列顺序。另一套是运行时构造ListListString作为 head配合ListListObject作为数据。什么时候用注解表头结构固定、字段不多、不需要按条件增减列的时候注解最省事编译期就能看到结构还能顺手配上ColumnWidth、DateTimeFormat、NumberFormat这些格式注解。我做过一个固定资产台账导出一共 23 列固定表头注解写完就没再动过。什么时候必须用动态 head表头随业务变化的时候。典型场景是按时间区间导出列是动态生成的日期或者多组织对比报表列是选中的组织名称。这种场景注解完全无能为力因为字段是运行时才知道的。还有一种折中情况表头固定但列数很多且不同角色看到的列不一样这时候用注解反而要维护多套实体类不如统一走动态 head用一份配置驱动。我个人的判断标准是只要表头里出现任何一个运行时才能确定的文字就直接上动态 head别想着用注解加反射改 value那条路只会越走越深。动态 head 的代价是所有列宽、样式都要手动管但这部分本来就是一次性的投入封装一次后面复用。1.3 表头自动合并和手动合并是两件事这是我最想强调的一点因为很多人根本没意识到 EasyExcel 会自动帮你合并表头。当你传进去的 head 矩阵里相邻列的同一层级文字相同时EasyExcel 默认就会把这两个单元格合并成一个。上面那个三层表头的例子用户信息只会出现在第一行的第一、二列位置但导出后它是横跨两列的一格不需要你写任何合并代码。这个默认行为在 99% 的场景下是好事但有几种情况会坑到你。第一种是表头文字恰好重复但业务上不该合并比如两列最底层都叫备注只是分组不同这时候 EasyExcel 依然会按相邻且相同去合并。关闭的方式是在 sheet 上调用automaticMergeHead(false)关掉之后所有格子按原样铺开需要合并的地方你自己算。第二种情况是跨行合并。三列表头里如果第一列只有一层文字EasyExcel 会把它在垂直方向上合并成跨三行的一格这是默认行为带来的效果不是 bug。理解了这个机制你就能预判导出结果长什么样而不是导出一次看一眼再改代码。至于数据区的合并EasyExcel 完全不负责必须自己写策略。所以合并表头和数据这句话实际拆成了两个完全不同的问题表头靠数据结构驱动自动合并数据靠 WriteHandler 手动合并。把这两条线分开处理问题就清晰了。2. 表头矩阵怎么拼ListList 的实操写法2.1 三条铁律列优先、层级等长、不留 null先把依赖确定下来。项目里加dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency这个版本对应 POI 5.2.x如果你的项目里已经有 POI 依赖注意排掉冲突否则会出现NoSuchMethodError这种运行期才爆的错排查起来很费时间。回到表头矩阵。三条铁律我在前面提过一部分这里展开说透。第一外层 List 的顺序就是列的顺序内层 List 的顺序是从顶层到最底层。第二所有内层的长度必须相等不够的用空字符串补齐。第三内层 List 里不要放 null。第三点看着像废话但真的踩过用 Map 拼表头的时候某个 key 没取到值map.get(xxx)返回 null直接放进 List 里EasyExcel 渲染时会把这一格留空且不参与合并计算结果就是相邻两列本该合并的单元格裂开了而且不报错纯靠肉眼发现。我现在的写法是拼装环节统一过滤private static String safe(String value) { return value null ? : value.trim(); }所有进入表头矩阵的文字都过一遍这个方法。看起来土但能省掉大量导出来怎么少了一格的排查时间。还有一个细节表头文字里的换行。如果你希望表头显示两行文字可以在字符串里放\n同时给表头样式设置自动换行否则换行符会被吞掉显示成一行。这个不算必须但报表给别人看的时候两行表头往往比一行挤着好看得多。2.2 手写一个能复用的表头构建工具网上很多示例是直接在一个方法里head.add(Arrays.asList(...))十几行这种写法只适合演示真到项目里列一多就变成一坨。我习惯封装成一个小工具用分组 叶子的结构来描述public class HeadBuilder { private final ListListString head new ArrayList(); public HeadBuilder leaf(String... path) { ListString column new ArrayList(path.length); for (String p : path) { column.add(p null ? : p.trim()); } head.add(column); return this; } public ListListString build() { if (head.isEmpty()) { throw new IllegalStateException(表头不能为空); } int depth head.get(0).size(); for (ListString column : head) { if (column.size() ! depth) { throw new IllegalStateException( 表头层级不一致期望 depth 层实际 column.size() 层: column); } } return head; } }用法就很清爽了ListListString head new HeadBuilder() .leaf(用户信息, 基本信息, 姓名) .leaf(用户信息, 基本信息, 年龄) .leaf(用户信息, 联系方式, 手机号) .leaf(订单信息, 订单编号) .leaf(订单信息, 下单时间) .build();注意最后一个.leaf(订单信息, 订单编号)只有两层而前面是三层build()会直接抛异常。这不是多余的限制是逼你在开发阶段就把结构对齐而不是等导出的文件被业务方发现有问题再回头改。这种启动即失败的设计比运行到一半给你一个诡异的文件要友好得多。这个工具类还能继续扩展比如加一个span(columnName, colspan)方法用同一个文字连续添加多列专门处理某一层需要横跨多列但下面没有细分的情况。2.3 动态分组表头从业务数据反推结构真实项目里最麻烦的是分组和列都是动态的。举个我遇到过的场景按机构导出月度指标每个机构下面有 4 个指标列机构数量和名称都由前端传进来。这种表头的构建思路是两层循环外层遍历机构内层遍历固定的指标ListListString head new ArrayList(); head.add(Arrays.asList(统计维度, 机构名称)); head.add(Arrays.asList(统计维度, 统计月份)); for (Institution inst : institutionList) { for (String metric : METRIC_NAMES) { head.add(Arrays.asList(指标数据, inst.getName(), metric)); } }这里有个坑第一列和第二列只有两层后面是三层的所以必须补一个空字符串变成[统计维度, 机构名称, ]否则层级不齐。补空之后EasyExcel 渲染出来就是第一列在垂直方向跨三行合并视觉上完全正确。另一个坑是列数上限。XLSX 单 sheet 的列上限是 16384一般业务到不了但如果机构数量是不可控的比如按门店导出几百家门店乘 4 个指标列数很容易冲到几千列导出文件体积会爆炸打开也卡。这种情况我一般会做两件事一是限制单次导出的机构数量超过阈值就提示分批二是把机构横向铺开改成机构纵向铺开也就是行列转置这样列数固定、行数增长配合后面要讲的流式写出几十万行也不会有问题。数据结构定了之后数据行的填充也要同步对齐。用ListListObject作为数据载体时每一行的元素个数必须等于表头的列数多的会被忽略少的后面留空。我习惯在写入前做一次校验把列数不匹配的行直接拦掉并打日志不然导出文件里出现整列错位业务方第一反应是你系统有 bug但其实是上游数据的问题。3. 数据区合并AbstractMergeStrategy 的正确打开方式3.1 为什么我不推荐相邻两行直接合并数据区合并的官方扩展点是AbstractMergeStrategy它有一个抽象方法protected abstract void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex);翻译一下参数sheet是当前 POI 工作表cell是刚写完的当前单元格head是这一列的表头信息relativeRowIndex是当前行在数据区内的相对行号从 0 开始。也就是说这个方法会在每个数据单元格写完后被回调一次。网上最常见的示例是这样写的拿当前行和上一行比较值相同就sheet.addMergedRegion(new CellRangeAddress(row - 1, row, col, col))。这段代码在小数据量、相同值不连续的时候能跑通但只要出现连续三行相同就会生成(1,2)和(2,3)两个重叠区域。POI 在addMergedRegion时对重叠有校验XSSF 下直接抛IllegalStateException提示overlaps with another merged region。我用这种方式在测试环境踩过一次第一版数据恰好只有两两相同上线后业务方导真实数据连续五行相同直接报错。所以正确思路不是相邻两行合并而是先把相同值的行收敛成一个块再按块一次性合并。这样每次添加的区域天然不重叠也不依赖 POI 的容错行为。3.2 分块提交的合并策略实现按块的思路我需要为每个需要合并的列记住三样东西当前块的起始行、当前块的值、以及这一列最后处理到的行号。值发生变化时就把上一块提交掉数据全部结束时再把最后一块提交掉。代码大致长这样public class ColumnBlockMergeStrategy extends AbstractMergeStrategy { private final SetInteger mergeColumns; private final int headRowCount; private final int totalDataRows; /** 列下标 - 当前块的起始绝对行号 */ private final MapInteger, Integer blockStart new HashMap(); /** 列下标 - 当前块的比较值 */ private final MapInteger, String blockValue new HashMap(); public ColumnBlockMergeStrategy(SetInteger mergeColumns, int headRowCount, int totalDataRows) { this.mergeColumns mergeColumns; this.headRowCount headRowCount; this.totalDataRows totalDataRows; } Override protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) { if (relativeRowIndex null) { return; } int col cell.getColumnIndex(); if (!mergeColumns.contains(col)) { return; } int absRow cell.getRowIndex(); String value readValue(cell); Integer start blockStart.get(col); if (start null) { blockStart.put(col, absRow); blockValue.put(col, value); } else if (!Objects.equals(blockValue.get(col), value)) { commit(sheet, col, start, absRow - 1); blockStart.put(col, absRow); blockValue.put(col, value); } // 数据区最后一行收尾提交 if (relativeRowIndex totalDataRows - 1) { commit(sheet, col, blockStart.get(col), absRow); } } private void commit(Sheet sheet, int col, int from, int to) { if (to from) { sheet.addMergedRegion(new CellRangeAddress(from, to, col, col)); } } private String readValue(Cell cell) { if (cell null) { return ; } return new DataFormatter().formatCellValue(cell); } }这里有三个关键决策我逐个解释为什么这么写。第一收尾判断没有用sheet.getLastRowNum()而是传入totalDataRows从外部算好。原因是 POI 的行对象是创建即存在在流式写出过程中getLastRowNum()的返回值虽然大体正确但如果后面还要追加合计行、备注行判断就会提前触发把不该合并的块提交掉。外部传总行数语义明确也不会被后续写入干扰。第二比较值用的是DataFormatter格式化后的字符串而不是cell.getStringCellValue()。因为单元格可能是数字、日期类型直接调getStringCellValue()会抛IllegalStateException。这个坑非常隐蔽本地测试数据全是文本没问题线上有数字列就崩。用DataFormatter统一转字符串做比较代价是多一次格式化对大文件来说可以接受。第三只在mergeColumns指定的列上做合并。如果放任所有列都参与比较不光性能浪费还容易把不该合并的列比如每行都不同的流水号恰好重复了一次也合并掉视觉上莫名其妙。3.3 版本差异2.x 和 3.x 的回调签名不一样上面这段代码基于 EasyExcel 3.x。如果你项目里锁的是 2.xAbstractMergeStrategy的merge方法签名是一样的所以继承这个抽象类基本不受版本影响这是我一直推荐从AbstractMergeStrategy入手的原因。但如果你想写更通用的CellWriteHandler版本差异就大了。2.x 里afterCellDispose有七个参数包括WriteSheetHolder、WriteTableHolder、ListCellData、Cell、Head、Integer、Boolean3.x 改成了单个CellWriteHandlerContext上下文对象参数都从 context 上取。这个变化没有兼容处理2.x 的实现在 3.x 上直接编译不过。所以升级 EasyExcel 版本时如果你的自定义 handler 直接实现了CellWriteHandler一定要提前把签名对齐。我的习惯是尽量少直接实现底层 handler 接口优先用AbstractMergeStrategy、LongestMatchColumnWidthStyleStrategy这些官方封装或者在 handler 里只做样式、不碰合并逻辑把版本耦合面压到最小。3.4 合并之后样式怎么补合并区域有个必须记住的规则合并后的样式由左上角单元格决定其余单元格的样式被忽略。这句话意味着如果你给所有数据单元格都设了边框合并后中间那些边框会消失只剩下左上角那一格带来的效果。看起来像边框丢了其实是合并区域内部的边框线本来就被 Excel 认为不存在。想让合并后的区域四周有完整边框正确做法是在写数据的时候就用统一的内容样式比如HorizontalCellStyleStrategy配一套带边框的WriteCellStyle然后依赖 Excel 自己的渲染合并区域外框会沿着合并后的边界画。多数情况下这样已经够用。如果你的表头样式和数据样式差别很大记得分别配headCellStyle和contentCellStyle不要用同一个对象否则表头会很丑。另外一个小技巧合并列里的值只需要在首行写后续行可以写空。不过 EasyExcel 的顺序写入要求每行数据长度一致我一般还是每行都写值靠合并策略把重复值盖掉这样数据源那边逻辑更简单。4. 大数据量与导出链路分片、流、响应头4.1 内存估算与流式写出先算一笔账。假设导出 20 列、100 万行那就是 2000 万个单元格。POI 在 XSSF 全内存模式下每个单元格对象加上共享字符串表、样式引用经验值大约 100 到 200 字节2000 万个单元格对应的堆内存需求在 2 GB 到 4 GB 之间再叠加上你的业务对象一次导出把服务打挂是很常见的事。EasyExcel 默认对 XLSX 使用 SXSSF 的流式写出超过一定行数会把数据刷到临时文件里只有窗口内的行常驻内存。窗口大小是可以调的单位是行EasyExcel.write(outputStream, null) .head(head) .excelType(ExcelTypeEnum.XLSX) .autoCloseStream(false) .sheet(明细) .doWrite(dataList);窗口默认是 100 行一般场景够用。如果单行单元格特别多比如 200 列以上可以适当调大减少临时文件的刷写次数如果行数特别多、列数少调小反而更省内存。我的经验值是单元格数超过 100 万以后窗口设 200 到 500 之间比较平衡磁盘临时文件也要确保有足够空间这点很容易被忽略——容器里的临时目录往往是有限额的导出大文件时写满临时目录表现是写出来一个几百 KB 的损坏文件排查方向完全跑偏。4.2 分 Sheet 和分批导出单 sheet 的行数上限是 1048576 行超过会直接报错。如果你的数据可能接近这个量级必须做分片。我的做法是按 50 万行一片超过就新建 sheetExcelWriter writer EasyExcel.write(outputStream) .registerWriteHandler(mergeStrategy) .build(); int pageSize 500_000; int total dataList.size(); int pageCount (total pageSize - 1) / pageSize; for (int i 0; i pageCount; i) { int from i * pageSize; int to Math.min(from pageSize, total); ListListObject part dataList.subList(from, to); WriteSheet sheet EasyExcel.writerSheet(i, 明细 (i 1)) .head(head) .build(); writer.write(part, sheet); } writer.finish();注意finish()必须调用否则最后一批数据不会被刷写到输出流得到的文件是残缺的。这个错误在实际项目里出现的频率比想象中高因为write之后代码看起来已经跑完了很容易忘了收尾。分片的另一个好处是合并策略的作用范围。上面的策略是按 sheet 独立维护块的所以每个 sheet 内部合并互不影响不会出现跨 sheet 的错误合并。4.3 Web 层下载的完整写法导出最后一步是让浏览器下载这里有几个必踩的坑。第一个是响应头文件名编码中文文件名不编码会变成乱码或者被截断兼容写法是同时给filename和filename*String fileName 机构指标明细.xlsx; String encoded URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) .replace(, %20); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-Disposition, attachment; filename\ encoded \; filename*UTF-8 encoded); response.setHeader(Cache-Control, no-store);第二是流关闭。EasyExcel 默认会在写完时关闭输出流而在 Spring MVC 里响应流是由容器管理的重复关闭虽然多数情况下不报错但某些容器会抛IllegalStateException导致明明文件已经写完了前端却收到 500。加.autoCloseStream(false)更稳。第三是异常处理。如果导出过程中抛异常此时响应头已经写出去、流里可能已经有半个文件再想返回 JSON 错误信息就来不及了。实用做法是在写流之前先把所有数据准备好把可能失败的逻辑前置真正写流的那段尽量只做 IO同时在写流前response.reset()清掉之前可能写入的内容。第四是超时。大数据量导出动辄几十秒前端和网关都有超时限制。我的做法是超过某个行数阈值就走异步导出先生成文件落到对象存储再让用户去下载中心取接口层面只返回任务号。这个改造看起来麻烦但比天天处理导出到一半连接断了的客诉要省心得多。5. 常见问题速查与踩坑记录5.1 表头错位和合并报错的排查表下面这张表是我这些年遇到问题后整理的基本覆盖了八成的现场问题建议直接对照排查。现象可能原因处理方式表头少一格或裂开某一列层级数与其它列不一致补齐空字符串统一层级长度表头该合并却没合并矩阵里混入 null或文字有首尾空格统一过滤 null 并 trim导出的列顺序和预期不符外层 List 添加顺序错乱用构建工具按列顺序添加并加校验报 overlaps with another merged region相邻两行直接合并产生重叠区域改成按块收敛后一次性合并数据列整体错位数据行列数与表头列数不一致写入前校验每行元素个数某单元格样式莫名丢失合并区域样式以左上角为准给左上角单元格设置完整样式数字变科学计数法单元格被识别为数值格式设置文本格式的 ContentStyle关于最后一条数字变科学计数法补充一句。像身份证号、订单号这种长数字就算你传的是 String如果样式是常规格式Excel 有时仍会按数值渲染。解决办法是给内容样式显式设置数据格式为文本WriteCellStyle contentStyle new WriteCellStyle(); DataFormat dataFormat new DataFormat(); dataFormat.setIndex((short) 49); // 49 对应内置的文本格式 contentStyle.setDataFormat(dataFormat);数字 49 是 Excel 内置格式里文本的编号直接写死比通过workbook.createDataFormat().getFormat()更省事因为后者需要拿到 workbook 实例在样式注册阶段不太方便。5.2 文件损坏、乱码、打开提示修复导出的文件打不开、提示发现不可读取的内容绝大多数情况是流没有被正确关闭或者被重复关闭。按顺序检查三件事writer.finish()有没有调用autoCloseStream(false)有没有设有没有在写流的过程中用到response.getWriter()一旦拿了字符流再拿字节流就会冲突。这三条基本能覆盖所有文件损坏问题。乱码问题分两种。一种是文件名乱码前面讲的filename*写法能解决另一种是内容乱码通常是你自己往单元格里塞了编码不对的字符串或者表头文字来自某个以错误编码读取的配置文件。后者跟 EasyExcel 无关但排查时很容易冤枉框架我一般的定位方式是先写一个纯英文表头的最小复现如果英文正常、中文异常那问题一定在数据源头。还有一种看起来像损坏的情况用 Excel 打开时提示需要修复点修复后内容是全的。这通常是表头合并区域的尺寸超出了 sheet 边界比如列数算错导致合并区域落到了 16384 列之外。检查一下动态表头的列数计算特别是多个分组相乘的场景。5.3 几个反直觉的细节第一个细节Head参数的判空。在AbstractMergeStrategy#merge里head在某些情况下可能为 null直接head.getHeadNameList()会空指针。虽然数据行回调时通常不为 null但加上判空不亏。第二个细节合并区域的行号是绝对行号不是相对行号。策略里拿到的relativeRowIndex从 0 开始计数而cell.getRowIndex()是包含表头在内的绝对行号两者差值是表头行数。我在ColumnBlockMergeStrategy里统一用cell.getRowIndex()就是为了避免两套坐标混用。如果你的表头是三层数据第一行的绝对行号是 3相对行号是 0弄混了会导致合并区域整体上移或下移一行这种错误肉眼很难发现因为文件看起来差不多是对的。第三个细节单个 sheet 上添加大量合并区域会显著变慢。每个addMergedRegion都会触发一次区域列表的维护如果分块策略收敛得不好比如每两行就有一个块合并区域数量会膨胀到几万个写文件的时间会从几秒涨到几十秒。我在一个 30 万行的报表里遇到过这个情况后来把策略改成只在值真正连续超过两行时才合并单行不成块的直接跳过合并区域数量从 6 万降到 1.2 万导出耗时从 40 秒降到 9 秒。第四个细节合并列里如果第一行的值是空的合并后整个区域都是空的因为样式和值都取自左上角。所以做分块比较时不要用isEmpty去过滤空值跳过合并空的连续块往往也是业务上需要合并的比如备注列连续为空表示属于同一个分组。这一点很多人在写策略时会顺手过滤掉空值结果导出的表格里空单元格散成一片。第五个细节调试阶段建议把automaticMergeHead临时关掉看看原始矩阵铺开是什么样。这样表头错位、层级不齐的问题会一眼暴露出来比对着合并后的结果猜要高效得多。定位完之后再打开保持最终产物的美观。再补一个实际使用中的体会表头结构这类东西最好在项目里沉淀成一份可配置的定义而不是散落在代码里。我现在的做法是把表头按报表编码存到配置表里每一列记录层级路径、字段名、列宽、是否合并、数字格式导出时读取配置拼 head 和数据。这样业务加一列不需要改代码发版测试也不用重新写用例长期看省下的时间远比一开始多花的那几天多。刚从注解式转过来的时候会觉得配置很啰嗦但等第二个、第三个报表需求进来复用同一套引擎边际成本就趋近于零了。如果你的场景里还会用到模板填充比如固定的表头加一行动态数据注意模板填充和 head 驱动的写法不能混用在同一套 API 调用里fill系列方法走的是模板占位符替换合并行为由模板决定和本文讲的策略完全是两条路先确认你的需求属于哪一类再选对应的写法。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →