EasyExcel多级横向合并表头实战:从静态注解到动态SQL
2026/9/20 4:14:09 网站建设 项目流程

1. 多级横向合并表头的真实场景与难点拆解

1.1 它是哪类需求,为什么会难

先明确一下概念。多级横向合并表头,指的是表头部分不是简单的一行“字段名”,而是占据两行甚至更多行,其中某些父级表头跨多列,子级表头再细分。比如财务管理里最常见的月度销售明细表,第一层是“一季度”,下面再拆成“1月、2月、3月”;第一层是“地区”,下面再拆“华东、华北、华南”。这种报表用Excel手工做很容易——选中几个单元格合并一下就结束,但要用代码动态生成,很多人第一次做都会卡住。

卡住的点主要在几个地方:

一是表头层级怎么表达。一行一行的字符串数组,如何映射到Excel里跨行跨列的单元格区域,这需要先设计好表头结构,不能直接上手写代码。

二是合并的范围怎么计算。每个父级表头跨越多少列,取决于它的子级有多少个叶子节点。如果子级下面还有第三层,那跨度需要递归去算,不是简单地数一下有几列就行。

三是合并后的单元格样式。Easyexcel默认合并单元格后,边框、对齐方式、背景色这些样式往往不连续,导出的表格看起来中间有一条断开的线或者颜色断层,体验很差。

四是表头和数据的对应关系。多级表头意味着数据行的每一列是有明确归属的,顺序不能乱。一旦表头是用动态SQL拼出来的,列的顺序、个数、数据类型都可能变化,代码必须跟着动态调整。

这些问题单独拎出来都不算复杂,但叠在一起,再加上“项目明天就要上线”这种压力,就容易让人手忙脚乱。也正因此,每次在技术群里看到有人问Easyexcel多级表头的问题,我都建议他先别急着写代码,先把表头结构梳理清楚,后面自然就顺了。

1.2 Easyexcel解决思路全景

Easyexcel是阿里巴巴开源的一个Java操作Excel工具库,主打的卖点就是内存占用低、API简单、支持复杂表头。它解决多级表头问题,核心思路是把“表头”和“数据”分开处理:

  • 表头部分:允许传入一个List<List<String>>,外层List代表一行表头,内层List代表这一行里的所有单元格,Easyexcel会自动根据内容的长度和层级生成合并效果。
  • 数据部分:可以是实体类集合,也可以是List<Map<String, Object>>,取决于表头是固定的还是动态生成的。
  • 样式部分:支持自定义CellWriteHandler,在单元格写入后干预样式、合并逻辑、列宽等。

整体上,实现多级横向合并表头有三条路,我按照复杂度和适用场景排个序:

第一条,注解方式。在实体类的字段上用@ExcelProperty(value = {"父级表头", "子级表头"})写一个字符串数组,数组长度代表表头层级。这种方式最简单,只适合表头完全固定、列不变化的场景。优点是代码少、可读性高,缺点是表头一变就得改实体类,不够灵活。

第二条,动态构建表头的方式。用List<List<String>>手动构建多级表头,再配合List<List<Object>>写数据。这种方式把表头和数据都从业务层动态生成,适合报表系统里那种“列不固定、表头不固定、今天导出和明天导出长得不一样”的场景。

第三条,自定义策略方式。通过实现SheetWriteHandlerRowWriteHandlerCellWriteHandler来完全控制表头合并、单元格样式、行列尺寸。这种方式最灵活,但代码量也最大,适合要求非常高的定制化场景。

很多人一听到“多级合并表头”就想去写自定义策略,实际上大部分业务需求用第一条或第二条路就够了。我自己踩过的坑是:一开始图省事用注解,结果第二年需求方说“要加一列”,我改了实体类、改了数据映射,还差点改出bug来。后来遇到列可能变化的场景,一律动态构建表头,宁可代码多写几十行,也不愿意在改需求的时候头痛。所以接下来的实战部分,我会把静态和动态两种方式都覆盖到。

2. 核心方案选型与开发前必备准备

2.1 先给你的表头分级

在写任何代码之前,第一步不是引入依赖,而是先画一张表头结构图。拿我之前做过的“各区域季度销售汇总表”举例,需求方要的表格长这样:

  • 表头第一行:区域 | 一季度 | 二季度 | 三季度 | 四季度
  • 表头第二行:区域 | 1月 | 2月 | 3月 | 4月 | 5月 | 6月 | 7月 | 8月 | 9月 | 10月 | 11月 | 12月

也就是说,“区域”这一列是跨两行的,而“一季度”跨3列,对应下面1月、2月、3月;“二季度”跨3列,对应4月、5月、6月,以此类推。

这里我们要做两件事:

第一件,把表头拆成树状结构。根节点是“区域”,一个叶子节点;根节点“一季度”下面挂三个子节点“1月、2月、3月”。为什么要拆成树?因为这样才能用递归方法去计算每个父级节点跨多少列。如果只是固定的四季度,手动数也行,但万一下次变成“上半年、下半年”或“上旬、中旬、下旬”,手动数就容易出错。

第二件,理解“叶子节点数决定总列数”。多级表头的总列数等于最底层子节点的数量之和,也就是树的所有叶子节点数。这个数非常关键,因为它是动态SQL的select列数、表头构建时的列数、数据行每行的cell数三者的交集。任何一个对不上,导出的Excel就会错位。

我的习惯是在代码里先定义一个表头树节点结构,不急着写Excel相关代码:

public class HeadNode { private String name; // 当前表头名称 private List<HeadNode> children; // 子节点列表 public boolean isLeaf() { return children == null || children.isEmpty(); } public int leafCount() { if (isLeaf()) { return 1; } int count = 0; for (HeadNode child : children) { count += child.leafCount(); } return count; } }

leafCount()这个方法后面会反复用到:计算父级节点跨越的列数、判断是否需要合并单元格、动态列排序校验,都靠它。

2.2 两种实现路线的取舍

静态表头用注解,动态表头用List<List<String>>,这个结论本身不复杂,但很多人在实际项目里会犹豫。我直接给一个判断标准:如果表头可以在配置中心存一份配置,或者表头列来自于数据库动态查询结果,那就不要用注解;如果表头在需求文档里定死了,未来一年都不会变化,那用注解最省事。

注解方式的取舍点:

优点是编码简单、代码直观、异常好排查。你给实体类的每个字段写上@ExcelProperty注解,写数据时传List<实体>,Easyexcel自动完成表头渲染和数据填充,基本不会出什么大问题。

缺点是硬编码严重。表头名称写死在Java代码里,产品经理哪天想把“一季度”改成“Q1”,你得改代码重新发版;表头列一旦要动态增减,注解方式就彻底失效。

动态表头方式的取舍点:

优点是灵活,表头层级、列数、列名都可以根据查询结果动态生成,适合经常变化的报表场景。

缺点是代码量多,且需要自己维护表头层级与数据列的对应关系。如果表头有3层,且每层跨多列,写起来需要递归或循环,逻辑复杂度比注解高一个量级。

我给出的组合建议是:

  • 静态列 + 静态表头:用注解
  • 静态列 + 表头名称动态调整:用动态表头,但数据仍可走实体类
  • 动态列 + 动态表头:用动态表头 +List<Map<String, Object>>数据
  • 特殊样式 + 复杂合并:不管上面哪种情况,都搭配一个自定义CellWriteHandler

还要补一个容易忽略的点:Easyexcel的横向合并表头,本质上是根据@ExcelProperty注解里字符串数组的长度,自动决定表头占几行。比如value = {"季度", "Q1"},表头就是两行,第一行“季度”合并了下面“Q1”那一列,如果两个相邻字段的value数组第一个元素相同,则自动横向合并。这个特性知道以后,很多场景其实用注解比自己写合并逻辑要省力得多。

但要注意的是,Easyexcel的默认表头合并逻辑并不支持“多列相同父级名称但不想合并”的情况。比如两个不同分组的子列,恰好父级名称都叫“合计”,它也会自动合并成一个大“合计”。遇到这种需求,就必须关闭自动合并或者自己实现合并逻辑。

3. 实战:用代码实现多级横向合并表头

3.1 静态多级表头:注解方式三步搞定

适合表头完全固定的场景。我拿一个“学生成绩统计表”当例子,表头结构是:

  • 第一行:姓名 | 语文成绩 | 数学成绩 | 英语成绩 | 总分
  • 第二行:姓名 | 平时分 | 期末分 | 平时分 | 期末分 | 平时分 | 期末分 | 总分

对应的实体类长这样:

public class StudentScoreVO { @ExcelProperty(value = "姓名", index = 0) private String name; @ExcelProperty(value = {"语文成绩", "平时分"}, index = 1) private Double chineseUsual; @ExcelProperty(value = {"语文成绩", "期末分"}, index = 2) private Double chineseFinal; @ExcelProperty(value = {"数学成绩", "平时分"}, index = 3) private Double mathUsual; @ExcelProperty(value = {"数学成绩", "期末分"}, index = 4) private Double mathFinal; @ExcelProperty(value = {"英语成绩", "平时分"}, index = 5) private Double englishUsual; @ExcelProperty(value = {"英语成绩", "期末分"}, index = 6) private Double englishFinal; @ExcelProperty(value = "总分", index = 7) private Double total; }

这里的关键点:

value数组的长度就是表头层级。数组元素顺序是从上层到下层。比如{"语文成绩", "平时分"}表示第一层表头是“语文成绩”,第二层是“平时分”。

index用来显式指定该字段对应的列位置。如果实体类里存在多个字段,且你不希望它们按声明顺序导出,用index最稳妥。顺序一旦错位,表头和数据就对不上了。

导出代码反而是最简单的:

String fileName = "成绩表.xlsx"; EasyExcel.write(fileName, StudentScoreVO.class) .sheet("成绩表") .doWrite(dataList);

就这么几行,Easyexcel会自动生成两行表头,自动合并“语文成绩”“数学成绩”“英语成绩”这三个跨列的父级单元格,“姓名”和“总分”自动跨两行居中。

这段代码跑出来的效果,基本能满足90%的静态多级表头需求。如果还想美化一下,加一个EasyExcel.write(...).registerWriteHandler(new HorizontalMergeStrategy())或者自定义样式处理器,后面我会专门讲。

3.2 动态多级表头:结合动态SQL生成列

动态SQL生成列的场景,通常是这样:前端上传一个报表配置,后端根据配置去数据库查不同的列,然后导出。或者数据仓库的表结构经常变,select语句是动态拼接的,对应的列名也在变。

这时就不能用注解写死实体类了,我的方案是三个步骤:

第一步:动态SQL查询出数据

在服务层根据配置拼接SQL,查询结果用List<Map<String, Object>>接收。需要特别注意的是,SQL中每个动态列最好用别名,并且保证别名唯一。比如:

SELECT region AS region, SUM(CASE WHEN month = '2025-01' THEN amount ELSE 0 END) AS m202501, SUM(CASE WHEN month = '2025-02' THEN amount ELSE 0 END) AS m202502 FROM sales GROUP BY region

后端拿到Map的key就是列标识(别名),value就是单元格值。

第二步:动态构建表头

这是核心部分。我封装了一个工具方法,接收表头树,输出Easyexcel需要的List<List<String>>

public static List<List<String>> buildHead(HeadNode root) { List<List<String>> head = new ArrayList<>(); List<HeadNode> leafs = getLeafs(root); for (HeadNode leaf : leafs) { List<String> colHeads = new ArrayList<>(); HeadNode node = leaf; while (node != null) { colHeads.add(0, node.getName()); node = node.getParent(); } head.add(colHeads); } return head; }

这段代码的作用是:找到所有叶子节点,对每个叶子节点向上回溯,把路径上的每个层级名称从上到下放成一个List。比如“1月”这个叶子,向上回溯到“一季度”,那这个列的List就是["一季度", "1月"]。所有叶子节点都处理完,就得到了Easyexcel所需的完整表头结构。

第三步:导出

动态表头要配合List<List<Object>>使用,每行数据是一个List,顺序要和表头leaves的顺序一致:

List<List<String>> head = buildHead(rootNode); List<List<Object>> dataRows = convertDataToList(queryResult, leafAliasList); EasyExcel.write(outputStream) .head(head) .sheet("动态表头报表") .doWrite(dataRows);

convertDataToList这个方法是关键,不能直接遍历Map塞数据。因为SQL查询出来的Map,key顺序是不可靠的,你必须按之前约定的leafAliasList顺序,逐个从Map中取值。否则就会出现表头和数据位置对不上的问题。

我这里用一个LinkedHashMap来保证顺序,并且在构建SQL时就按顺序拼接列,再单独维护一个List<String> columnAliasList,导出时严格按这个List取值。这样即使数据库返回的Map顺序是乱的,导出的表格依然准确。

到这里,动态SQL + 动态表头 + 动态数据的基本链路就通了。再加一个“递增序号”列,只需要在构建表头时,在最前面加一列叫“序号”,在组装每行数据时,排在最前面填上index + 1

3.3 序号列与样式调整

序号列是导出Excel里最基础但也最容易忽略的功能。很多人的第一反应是在实体类里加一个@ExcelProperty("序号")字段,然后在循环里赋值。这个思路没错,但放在动态多级表头场景里,序号列不是实体字段,而是表头里的一棵子树,数据里的一列值。

我的做法是:在构建表头树的入口处,直接往根节点列表里插入一个叶子节点,名叫“序号”。这个节点没有子节点,但在叶子遍历时会排在第一位。数据组装时,每行数据的第一列写成Integer index = 0; for (...) { row.add(++index); }

另外,有些需求要求“序号列跨多行合并”,比如同一个区域有多行数据,序号只想显示一个,那就要加纵向合并逻辑。Easyexcel里可以registerWriteHandler一个自定义的CellWriteHandler,在afterCellDispose方法里判断当前列是序号列,并且下一行的值相同就不重复写入,把单元格向上合并。不过这个功能要注意:大文件导出时频繁合并单元格会降低性能,数据量超过几千行时,要慎重使用。

样式方面,我用一个自定义的HorizontalMergeStrategy来处理表头样式和合并:

public class HorizontalMergeStrategy implements CellWriteHandler { @Override public void afterCellDispose(CellWriteHandlerContext context) { Cell cell = context.getCell(); Sheet sheet = cell.getSheet(); int rowIndex = cell.getRowIndex(); int colIndex = cell.getColumnIndex(); // 只在表头区域处理,前两行 if (rowIndex <= 1) { // 如果当前单元格是第一行,且下一列和当前列的表头内容相同,则合并 if (rowIndex == 0 && colIndex + 1 < sheet.getRow(0).getLastCellNum()) { String currentValue = cell.getStringCellValue(); String nextValue = sheet.getRow(0).getCell(colIndex + 1).getStringCellValue(); if (currentValue.equals(nextValue)) { sheet.addMergedRegion(new CellRangeAddress(0, 0, colIndex, colIndex + 1)); } } } } }

这个类只是示意,真实的合并逻辑更复杂,因为要处理连续多个相同单元格的合并,还要和Easyexcel内置的自动合并逻辑区分开。我实际项目中更推荐直接继承AbstractMergeStrategy或者实现CellWriteHandler,但我会在“常见问题”里再多说一句。

4. 常见问题与排查技巧实录

4.1 合并后出现难看的边框断点

这是多级表头里最经典的问题。合并单元格以后,原本应该有完整边框的地方,中间可能缺了一条线,或者合并不彻底,出现了半个边框。

原因是:Easyexcel写入每个单元格时会分别设置样式,但合并后只会保留左上角那个单元格的样式。如果左上角那个单元格的边框设置不完整,或者你用了默认样式,合并区域就会出现残缺。

解决思路有两个。一个是在合并后手动给整个合并区域重新设置边框,需要遍历合并区域的所有单元格;另一个是在写数据之前,预先定义好一个完整的CellStyle,让所有表头单元格都引用这个样式,这样即使合并,边框也是完整的。我通常用第二种,因为代码改动小,效果最统一。写法上,通过WriteCellStyle配合HorizontalCellStyleStrategy来统一表头样式:

WriteCellStyle headWriteCellStyle = new WriteCellStyle(); headWriteCellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headWriteCellStyle.setBorderTop(BorderStyle.THIN); headWriteCellStyle.setBorderBottom(BorderStyle.THIN); headWriteCellStyle.setBorderLeft(BorderStyle.THIN); headWriteCellStyle.setBorderRight(BorderStyle.THIN); WriteFont headWriteFont = new WriteFont(); headWriteFont.setBold(true); headWriteStyle.setWriteFont(headWriteFont); HorizontalCellStyleStrategy styleStrategy = new HorizontalCellStyleStrategy(headWriteCellStyle, contentWriteCellStyle);

这样,表头区域的所有单元格在写入时都使用同一套样式,合并后边框和底色自然就连续了。

4.2 表头行高与数据行错位

多级表头通常占2到3行,如果表头文字较多或者字体较大,Easyexcel默认的自动行高可能不够,导致文字被截断。更麻烦的是,表头行高变了之后,数据行如果不调整,整体表格看起来就会“上紧下松”,非常不专业。

解决办法是在构建头部时手动设置行高。自定义SheetWriteHandler,在afterSheetCreate事件里设置表头行高:

sheet.setDefaultRowHeight((short) 400); for (int i = 0; i < headRowCount; i++) { sheet.getRow(i).setHeight((short) 600); }

或者直接对每一列设置宽度,避免表头文字换行导致行高异常:

sheet.setColumnWidth(colIndex, 20 * 256);

经验值:中文表头每个汉字宽度大约是2个英文字符宽度,所以列宽至少要 4×最大字段字数+2 个字符,才能保证不换行。数字类列可以窄一点,日期类列建议12到14个字符宽。

4.3 动态列顺序不稳定

这是动态SQL导出里最隐蔽的问题。就算你在SQL里写了select a, b, c,mysql返回的Map顺序也不一定就是a、b、c。一旦顺序乱了,表头和数据就会对不上,而且这种错位往往要等Excel打开以后才能发现,排查成本很高。

我在项目里杀死了这个隐患,方法是:不依赖Map的遍历顺序,而是用有序的List来定义列顺序。具体来说,在构建SQL时就维护一个List<String> columns,查询完之后,按照这个List从Map里取数据,而不是直接遍历Map:

List<Object> row = new ArrayList<>(); for (String column : columns) { row.add(dataMap.get(column)); }

这样哪怕Map底层是HashMap,顺序乱成一锅粥,最终导出的Excel列顺序也不会乱。

4.4 大数据量下的内存开销

Easyexcel本身就主打低内存,但多级表头的场景下,如果动态列非常多(比如几十列),每行又有一个Map,上百万行数据时内存还是很紧张。这里有两个实测有效的建议:

第一,easyexcel.write()时不要把整个dataRows一次性查出来再导出,要用Easyexcel的doWriteSync+ 分批查询或者ExcelWriter配合WriteSheet循环写:

ExcelWriter writer = EasyExcel.write(outputStream).build(); WriteSheet sheet = EasyExcel.writerSheet("数据").build(); for (int page = 0; page < totalPage; page++) { List<Map<String, Object>> pageData = queryPage(page, pageSize); List<List<Object>> rows = convert(pageData); writer.write(rows, sheet); } writer.finish();

第二,动态表头建议只构建一次,不要放在循环里。很多人写着写着,不小心把buildHead放进了分页循环,结果每写一批数据就重复构建一遍表头,白白浪费内存和CPU。这个是性能问题的重灾区。

4.5 复杂表头导入的方向问题

顺带提一个很多人会混淆的点:Easyexcel不只支持导出,也支持导入。热搜里那个“easyexcel复杂的表头导入”,实际上和导出是反过来的——导入时你要告诉Easyexcel,Excel里的多级表头对应实体类的哪个字段。做法是在@ExcelProperty的value数组里,按同样的层级顺序写一遍,Easyexcel读取时会自动匹配。

但复杂表头导入有个隐藏问题:如果Excel文件里的合并单元格不规则,Easyexcel的默认读取可能会漏数据或者多出空值。解决办法是给ReadListener里的invoke方法增加空值处理,或者自己解析合并区域,这会涉及Sheet.getMergedRegions(),属于另一个话题。这里不展开,只是想提醒各位:导出时用多级表头一时爽,导入时如果没做准备,会被自己挖的坑绊倒。

5. 几个实用心得

最后说几个我实际写代码时积累的小经验,不算什么高深技术,但能省不少调试时间。

第一个心得是:多级表头首先要算清楚“叶子节点数”。无论是静态表头还是动态表头,你在设计阶段就把总列数算明白,后面所有代码都围绕这个数字去校验,能避免至少一半的错位问题。

第二个心得是:@ExcelProperty里的index参数,能写就写。它有三个作用:一是显式控制列顺序;二是防止实体类字段增加时打乱原有顺序;三是在动态场景下做表头和数据映射的锚点。我见过太多人在加了一个字段后,Excel导出全乱,最后发现就是没写index。

第三个心得是:如果需要自定义合并逻辑和样式,优先考虑实现CellWriteHandler而不是直接操作Workbook对象。前者是Easyexcel官方推荐的扩展点,能拿到写单元格前后的事件,而且不容易破坏Easyexcel内部的写流程。直接拿到Workbook去手动合并,虽然功能上也行,但如果你后续需要动态表格、自动列宽、数据校验之类的功能,很容易和Easyexcel自身的逻辑冲突。

第四个心得是:多级合并表头的“横向合并”,和“纵向合并”往往是一起出现的。比如父级表头跨两列,而这两列数据在每一行里又属于同一个分组,那纵向也要合并。遇到这种需求,别急着一股脑全写在CellWriteHandler里,先把横向合并的列范围算清楚,再单独处理纵向合并的起始行和结束行,拆开写会比写成一个巨型判断清晰得多。

根据我个人经验,Easyexcel的多级横向合并表头,90%的坑都出在“表头层级设计”和“数据列顺序映射”上,而不是Easyexcel本身的功能缺失。把这两个点拿捏住,多级表头在项目里就是一个写着舒服、维护起来也不痛的普通功能。剩下的10%,靠本文第四节里那些排查思路去挡,基本就够用了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询