ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

EasyExcel多级横向合并表头实战:从静态注解到动态SQL

EasyExcel多级横向合并表头实战:从静态注解到动态SQL 1. 多级横向合并表头的真实场景与难点拆解1.1 它是哪类需求为什么会难先明确一下概念。多级横向合并表头指的是表头部分不是简单的一行“字段名”而是占据两行甚至更多行其中某些父级表头跨多列子级表头再细分。比如财务管理里最常见的月度销售明细表第一层是“一季度”下面再拆成“1月、2月、3月”第一层是“地区”下面再拆“华东、华北、华南”。这种报表用Excel手工做很容易——选中几个单元格合并一下就结束但要用代码动态生成很多人第一次做都会卡住。卡住的点主要在几个地方一是表头层级怎么表达。一行一行的字符串数组如何映射到Excel里跨行跨列的单元格区域这需要先设计好表头结构不能直接上手写代码。二是合并的范围怎么计算。每个父级表头跨越多少列取决于它的子级有多少个叶子节点。如果子级下面还有第三层那跨度需要递归去算不是简单地数一下有几列就行。三是合并后的单元格样式。Easyexcel默认合并单元格后边框、对齐方式、背景色这些样式往往不连续导出的表格看起来中间有一条断开的线或者颜色断层体验很差。四是表头和数据的对应关系。多级表头意味着数据行的每一列是有明确归属的顺序不能乱。一旦表头是用动态SQL拼出来的列的顺序、个数、数据类型都可能变化代码必须跟着动态调整。这些问题单独拎出来都不算复杂但叠在一起再加上“项目明天就要上线”这种压力就容易让人手忙脚乱。也正因此每次在技术群里看到有人问Easyexcel多级表头的问题我都建议他先别急着写代码先把表头结构梳理清楚后面自然就顺了。1.2 Easyexcel解决思路全景Easyexcel是阿里巴巴开源的一个Java操作Excel工具库主打的卖点就是内存占用低、API简单、支持复杂表头。它解决多级表头问题核心思路是把“表头”和“数据”分开处理表头部分允许传入一个ListListString外层List代表一行表头内层List代表这一行里的所有单元格Easyexcel会自动根据内容的长度和层级生成合并效果。数据部分可以是实体类集合也可以是ListMapString, Object取决于表头是固定的还是动态生成的。样式部分支持自定义CellWriteHandler在单元格写入后干预样式、合并逻辑、列宽等。整体上实现多级横向合并表头有三条路我按照复杂度和适用场景排个序第一条注解方式。在实体类的字段上用ExcelProperty(value {父级表头, 子级表头})写一个字符串数组数组长度代表表头层级。这种方式最简单只适合表头完全固定、列不变化的场景。优点是代码少、可读性高缺点是表头一变就得改实体类不够灵活。第二条动态构建表头的方式。用ListListString手动构建多级表头再配合ListListObject写数据。这种方式把表头和数据都从业务层动态生成适合报表系统里那种“列不固定、表头不固定、今天导出和明天导出长得不一样”的场景。第三条自定义策略方式。通过实现SheetWriteHandler、RowWriteHandler、CellWriteHandler来完全控制表头合并、单元格样式、行列尺寸。这种方式最灵活但代码量也最大适合要求非常高的定制化场景。很多人一听到“多级合并表头”就想去写自定义策略实际上大部分业务需求用第一条或第二条路就够了。我自己踩过的坑是一开始图省事用注解结果第二年需求方说“要加一列”我改了实体类、改了数据映射还差点改出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 ListHeadNode 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 两种实现路线的取舍静态表头用注解动态表头用ListListString这个结论本身不复杂但很多人在实际项目里会犹豫。我直接给一个判断标准如果表头可以在配置中心存一份配置或者表头列来自于数据库动态查询结果那就不要用注解如果表头在需求文档里定死了未来一年都不会变化那用注解最省事。注解方式的取舍点优点是编码简单、代码直观、异常好排查。你给实体类的每个字段写上ExcelProperty注解写数据时传List实体Easyexcel自动完成表头渲染和数据填充基本不会出什么大问题。缺点是硬编码严重。表头名称写死在Java代码里产品经理哪天想把“一季度”改成“Q1”你得改代码重新发版表头列一旦要动态增减注解方式就彻底失效。动态表头方式的取舍点优点是灵活表头层级、列数、列名都可以根据查询结果动态生成适合经常变化的报表场景。缺点是代码量多且需要自己维护表头层级与数据列的对应关系。如果表头有3层且每层跨多列写起来需要递归或循环逻辑复杂度比注解高一个量级。我给出的组合建议是静态列 静态表头用注解静态列 表头名称动态调整用动态表头但数据仍可走实体类动态列 动态表头用动态表头 ListMapString, 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查询结果用ListMapString, 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需要的ListListStringpublic static ListListString buildHead(HeadNode root) { ListListString head new ArrayList(); ListHeadNode leafs getLeafs(root); for (HeadNode leaf : leafs) { ListString 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所需的完整表头结构。第三步导出动态表头要配合ListListObject使用每行数据是一个List顺序要和表头leaves的顺序一致ListListString head buildHead(rootNode); ListListObject dataRows convertDataToList(queryResult, leafAliasList); EasyExcel.write(outputStream) .head(head) .sheet(动态表头报表) .doWrite(dataRows);convertDataToList这个方法是关键不能直接遍历Map塞数据。因为SQL查询出来的Mapkey顺序是不可靠的你必须按之前约定的leafAliasList顺序逐个从Map中取值。否则就会出现表头和数据位置对不上的问题。我这里用一个LinkedHashMap来保证顺序并且在构建SQL时就按顺序拼接列再单独维护一个ListString 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, cmysql返回的Map顺序也不一定就是a、b、c。一旦顺序乱了表头和数据就会对不上而且这种错位往往要等Excel打开以后才能发现排查成本很高。我在项目里杀死了这个隐患方法是不依赖Map的遍历顺序而是用有序的List来定义列顺序。具体来说在构建SQL时就维护一个ListString columns查询完之后按照这个List从Map里取数据而不是直接遍历MapListObject 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) { ListMapString, Object pageData queryPage(page, pageSize); ListListObject rows convert(pageData); writer.write(rows, sheet); } writer.finish();第二动态表头建议只构建一次不要放在循环里。很多人写着写着不小心把buildHead放进了分页循环结果每写一批数据就重复构建一遍表头白白浪费内存和CPU。这个是性能问题的重灾区。4.5 复杂表头导入的方向问题顺带提一个很多人会混淆的点Easyexcel不只支持导出也支持导入。热搜里那个“easyexcel复杂的表头导入”实际上和导出是反过来的——导入时你要告诉EasyexcelExcel里的多级表头对应实体类的哪个字段。做法是在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%靠本文第四节里那些排查思路去挡基本就够用了。
返回列表