1. 先说结论:EasyExcel 2.2.10 的样式机制,为什么单独的行和列这么难搞
用 EasyExcel 2.2.10 做过导出的人应该都有这个感受:普通导出太简单了,实体类加几个注解,一行doWrite就把本地文件写出来了。但一旦涉及到"给指定行、指定列添加样式",你就发现官方文档能给的东西非常有限。它默认只给你一个水平方向上的整体样式策略,也就是所有表头统一一套样式,所有数据区统一一套样式。你没法直接说"第 3 行给我标黄一下""金额这一列给我染个浅蓝色"。
先说清楚,EasyExcel 底层的样式机制其实并不复杂。整个写入过程本质上是把数据封装成 POI 的 Workbook,再落盘到本地文件。而样式处理,EasyExcel 留了一个处理器(handler)的口子。你在写出的时候不断registerWriteHandler,框架就会按照注册顺序,在创建单元格、写入单元格、处理完单元格等节点回调你的处理器。掌握了这套回调机制,"指定行""指定列"就只是你在回调里判断行号、列号的问题,根本不需要去改 EasyExcel 源码,也不用绕道去用 POI 重写文件。
这篇博客的目标读者很明确:已经在用 EasyExcel 2.2.x 做本地文件的读写、现在被"某一行、某一列单独上色"这类需求卡住的人。我会直接给出完整代码和参数说明,顺带把我踩过的坑一起讲了。
2. 环境准备:依赖、实体类和一个最小可运行的本地文件写出
2.1 Maven 依赖和 POI 版本问题
项目里引入 EasyExcel 2.2.10,只需要一个依赖:
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>2.2.10</version> </dependency>这里有个隐藏信息容易忽略:easyexcel 2.2.10 会传递依赖 POI 4.1.2。为什么要强调这个?因为下面写自定义样式的时候,我们用的是 POI 底层的CellStyle、Workbook、FillPatternType这些类,不同 POI 大版本之间的 API 有差异。尤其注意 4.x 里CellStyle.cloneStyleFrom()已经标记为过时,推荐用copyFrom()。你用 2.2.10 自带的 POI 4.1.2,代码里就该写copyFrom,而不是去网上抄一个基于 POI 3.x 的老写法。
建议动手前先看一眼本地依赖树,确认 POI 版本没被项目里其他依赖顶掉。那种"样式明明写了却不生效"的诡异问题,大概率就是 POI 版本被覆盖后 API 行为变了。
2.2 实体类设计
样式的核心载体是单元格,单元格由实体类字段映射生成。所以先定义好实体:
@Data public class OrderExcel { @ExcelProperty("订单号") private String orderNo; @ExcelProperty("客户名称") private String customerName; @ExcelProperty("商品名") private String product; @ExcelProperty("数量") private Integer quantity; @ExcelProperty("金额") private BigDecimal amount; @ExcelProperty("状态") private String status; }这里我故意没加@ColumnWidth、@ContentStyle这些注解,因为本次需求是动态的"指定行、指定列",注解方案是静态的、列级别的,两者配合起来再看情况。有一点可以提前说:@ContentStyle、@HeadStyle这类注解在 2.2.10 里已经存在,如果你的样式是固定配好的,用注解是最省事的;但注解管不了行,顶多对某一列生效,所以千万别指望靠注解完成所有花活。
2.3 最小的本地文件写出代码
后面的所有样式方案,都基于这段基准代码扩展:
String targetPath = "D:/reports/order_report.xlsx"; List<OrderExcel> orderList = buildOrders(); EasyExcel.write(targetPath, OrderExcel.class) .sheet("订单明细") .doWrite(orderList);这个targetPath就是"本地文件"的落点。EasyExcel 会自己创建FileOutputStream去写这个路径,目录必须存在且可写,不然运行时会直接报FileNotFoundException。如果你要覆盖已有文件,它会直接重建,不会像 Word 那样弹警告,这点在报表场景里往往是个坑,后面讲常见问题再细说。
3. 入门方案:HorizontalCellStyleStrategy 全局设置表头和数据区样式
3.1 为什么先做全局样式
很多人的需求其实是两层:先要一个整体上说得过去的基础样式,再对个别行或列做重点突出。全局样式用HorizontalCellStyleStrategy就够了,它也是 EasyExcel 官方文档里唯一写得比较详细的样式方案。
它做的事情本质上是对"表头区域"和"数据区域"各套一个WriteCellStyle。这个策略本身就是通过registerWriteHandler注册进去的一个特殊 WriterHandler,所以理解它,也就理解了整个 handler 机制的入口。
3.2 WriteCellStyle 和 WriteFont 的配置
看代码:
WriteCellStyle headStyle = new WriteCellStyle(); headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND); WriteFont headFont = new WriteFont(); headFont.setBold(true); headFont.setFontHeightInPoints((short) 12); headStyle.setWriteFont(headFont); WriteCellStyle contentStyle = new WriteCellStyle(); contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); contentStyle.setVerticalAlignment(VerticalAlignment.CENTER); HorizontalCellStyleStrategy styleStrategy = new HorizontalCellStyleStrategy(headStyle, contentStyle); EasyExcel.write(targetPath, OrderExcel.class) .registerWriteHandler(styleStrategy) .sheet("订单明细") .doWrite(orderList);几个容易记混的细节:
setFillForegroundColor接收的是IndexedColors枚举的索引值,颜色深浅和打印效果要看实际情况,报表场景我一般推荐浅色系,比如LIGHT_YELLOW、LIGHT_BLUE、GREY_25_PERCENT,深色背景一旦打印或者转 PDF,很容易把字吃掉。setFillPatternType(FillPatternType.SOLID_FOREGROUND)必须配上前景色的设置,否则颜色设置不生效。这是 POI 历史上最容易踩的空手坑。- 边框、数据格式、自动换行也都挂在
WriteCellStyle上,比如金额列要显示千分位,可以配合实体字段的@NumberFormat("#,##0.00")或者直接给整列数据区域加数据格式。
3.3 全局方案的局限
HorizontalCellStyleStrategy的定位是"横向一致性"。它让所有表头长得一样、所有数据行长得一样,但无法做到"指定行""指定列"更细的差异化。比如你想在数据区的第一行前面插一行合计,或者把状态这一列里值为"异常"的单元格标红,这个策略就帮不上忙了。
从整个需求来看,全局策略应该当成"地基"来用,指定行、指定列是盖在上面的"装修"。正确的组合方式后面第 6 章会讲。
4. 核心方案一:自定义 CellWriteHandler 给指定行加样式
4.1 CellWriteHandler 回调参数全解析
在 2.2.10 里,官方主推的新版写入处理器接口是com.alibaba.excel.write.handler.CellWriteHandler,它一共有四个默认方法,做样式最常用的是afterCellDispose,也就是"单元格内容处理完毕"之后回调:
default void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) {}这个方法里的参数,是把样式写到指定行、指定列的关键:
| 参数 | 含义 | 典型用法 |
|---|---|---|
cell | POI 的Cell对象,能拿到行号、列号、值 | cell.getRowIndex()、cell.getColumnIndex() |
head | 当前列的Head元信息,能拿到表头名 | head.getColumnName() |
relativeRowIndex | 数据区域内的相对行号,从 0 开始 | 判断"第几条数据" |
isHead | 当前单元格是否属于表头区域 | 排除表头,或专门处理表头 |
这里必须分清两个行号:cell.getRowIndex()是整个 Sheet 的绝对行号,从第 0 行开始;relativeRowIndex是数据区域里的相对行号。假如表头占了 1 行,那么第一条数据的绝对行号是 1,相对行号是 0。表头占了 2 行,第一个数据行的绝对行号就变成 2,相对行号还是 0。
用哪个,取决于你的需求描述。你想说"Excel 里的第 3 行",那就用绝对行号;你想说"数据区域里的第 1 条记录",就用相对行号。
4.2 指定行样式的完整实现
直接上代码。这个处理器接收一个"需要高亮的行号集合",以及一个颜色索引,会把匹配到的整行所有单元格统一设置背景色:
public class SpecifiedRowStyleHandler extends AbstractCellWriteHandler { private final Set<Integer> rowIndexSet; private final short fillColor; private CellStyle cacheStyle; public SpecifiedRowStyleHandler(Set<Integer> rowIndexSet, short fillColor) { this.rowIndexSet = rowIndexSet == null ? new HashSet<>() : rowIndexSet; this.fillColor = fillColor; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (Boolean.TRUE.equals(isHead)) { return; } if (!rowIndexSet.contains(cell.getRowIndex())) { return; } Workbook workbook = cell.getSheet().getWorkbook(); if (cacheStyle == null) { cacheStyle = workbook.createCellStyle(); cacheStyle.copyFrom(cell.getCellStyle()); cacheStyle.setFillForegroundColor(fillColor); cacheStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); } cell.setCellStyle(cacheStyle); } }然后在使用时注册进去:
Set<Integer> highlightRows = new HashSet<>(); highlightRows.add(1); // 表头占1行时,这是数据区第1行 highlightRows.add(12); // 第13行,比如合计行 EasyExcel.write(targetPath, OrderExcel.class) .registerWriteHandler(styleStrategy) .registerWriteHandler(new SpecifiedRowStyleHandler(highlightRows, IndexedColors.LIGHT_YELLOW.getIndex())) .sheet("订单明细") .doWrite(orderList);这段代码里有几个地方要展开讲:
第一,为什么用cacheStyle缓存,而不是每个单元格都新建CellStyle?因为 POI 的CellStyle对象和Workbook强关联,创建太多会占用较多内存。如果数据量几万行,你又高亮了几千行,逐格创建是一笔不小的开销。缓存同一个CellStyle直接套上去,性能上安全很多。
第二,copyFrom(cell.getCellStyle())的作用是把当前单元格的原有样式(字体、边框、对齐方式等)复制到新样式,再叠加颜色。如果直接workbook.createCellStyle()然后只设前景色,这个单元格的字体、边框全部会丢失。这个细节很容易被忽略,我见过不少人样式一改,整张表的边框全没了。
第三,cacheStyle的基准样式来自第一个被匹配到的单元格。如果几个被高亮的行之间原本样式就不同,统一套同一个cacheStyle会导致它们之间的差异被抹平。报表场景里这通常无所谓,但如果你要求每一行保持各自原有的边框、字体,那就要改成逐格复制样式,放弃缓存,数据量大时需要用其他方式做性能补偿。
4.3 行高、行内所有单元格都要照顾到
指定行加样式,除了背景色,经常还涉及到行高。比如标题行要加高,合计行要突出。行高可以用RowWriteHandler来做,它专门在"行处理完"的时候回调:
public class SpecifiedRowHeightHandler extends AbstractRowWriteHandler { private final Map<Integer, Float> rowHeightMap; public SpecifiedRowHeightHandler(Map<Integer, Float> rowHeightMap) { this.rowHeightMap = rowHeightMap; } @Override public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) { Float height = rowHeightMap.get(row.getRowNum()); if (height != null) { row.setHeightInPoints(height); } } }行高和背景色分开做的好处是职责单一。特别是当你只想调行高、不想动颜色,或者只想调颜色、不想动行高的时候,不会互相干扰。
还有一个容易忽略的点:一个指定行如果数据本身只有 5 列,但表格有 6 列,第 6 列没有值时,afterCellDispose未必会对不存在的空单元格回调。也就是说你可能只给前 5 个单元格上了色,第 6 列没有背景色,看起来像一块补丁。这种场景需要你在afterRowDispose里手动补建空单元格再赋样式,或者干脆接受现状。我这边的建议是:做报表时先确认每行数据的字段完整性,要么把所有列都填值,要么通过afterRowDispose统一补齐样式。
5. 核心方案二:自定义 CellWriteHandler 给指定列加样式
5.1 按列下标直接染列
给指定列加样式,最粗暴的方式是判断cell.getColumnIndex()。列下标从 0 开始,第 5 列就是下标 4。
public class SpecifiedColumnStyleHandler extends AbstractCellWriteHandler { private final Set<Integer> columnIndexSet; private final short fillColor; private CellStyle cacheStyle; public SpecifiedColumnStyleHandler(Set<Integer> columnIndexSet, short fillColor) { this.columnIndexSet = columnIndexSet == null ? new HashSet<>() : columnIndexSet; this.fillColor = fillColor; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (Boolean.TRUE.equals(isHead)) { return; } if (!columnIndexSet.contains(cell.getColumnIndex())) { return; } Workbook workbook = cell.getSheet().getWorkbook(); if (cacheStyle == null) { cacheStyle = workbook.createCellStyle(); cacheStyle.copyFrom(cell.getCellStyle()); cacheStyle.setFillForegroundColor(fillColor); cacheStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); } cell.setCellStyle(cacheStyle); } }用的时候:
Set<Integer> highlightColumns = new HashSet<>(); highlightColumns.add(4); // 金额列,下标从0开始 EasyExcel.write(targetPath, OrderExcel.class) .registerWriteHandler(styleStrategy) .registerWriteHandler(new SpecifiedColumnStyleHandler(highlightColumns, IndexedColors.LIGHT_BLUE.getIndex())) .sheet("订单明细") .doWrite(orderList);按下标实现很简单,但有个致命问题:一旦实体类字段顺序调整,或者表结构里私下多塞了一列,高亮列就错位了。所以它只适合字段结构极其稳定的内部系统。
5.2 按表头名动态定位列
更稳妥的做法是根据head.getColumnName()来定位列。这样哪怕列的位置换了,只要表头名还叫"金额",渲染就不会错。
public class SpecifiedColumnByNameStyleHandler extends AbstractCellWriteHandler { private final List<String> columnNameList; private final short fillColor; private CellStyle cacheStyle; public SpecifiedColumnByNameStyleHandler(List<String> columnNameList, short fillColor) { this.columnNameList = columnNameList == null ? Collections.emptyList() : columnNameList; this.fillColor = fillColor; } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (Boolean.TRUE.equals(isHead) || head == null) { return; } if (!columnNameList.contains(head.getColumnName())) { return; } Workbook workbook = cell.getSheet().getWorkbook(); if (cacheStyle == null) { cacheStyle = workbook.createCellStyle(); cacheStyle.copyFrom(cell.getCellStyle()); cacheStyle.setFillForegroundColor(fillColor); cacheStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); } cell.setCellStyle(cacheStyle); } }注意head可能为 null,尤其是当你表里某列根本没有映射到实体字段的时候,不判空直接调head.getColumnName()会空指针。另外getColumnName()返回的是复杂表头里最底层那一个表头名,这个行为在多层表头时很重要,后面第 6 章会细说。
建议日常都用按表头名的方式,维护成本低,逻辑也更贴近业务语义。
5.3 按单元格值做条件样式
指定列的另一个常见玩法是"这个列里满足条件的单元格才变色"。比如金额超过 1000 的标红,状态为"异常"的标红。这个其实也是在列判断基础上,再加一层值判断:
public class AmountConditionStyleHandler extends AbstractCellWriteHandler { @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (Boolean.TRUE.equals(isHead) || head == null) { return; } if (!"金额".equals(head.getColumnName())) { return; } double value = extractDoubleValue(cell); if (value <= 1000) { return; } Workbook workbook = cell.getSheet().getWorkbook(); CellStyle cellStyle = workbook.createCellStyle(); cellStyle.copyFrom(cell.getCellStyle()); cellStyle.setFillForegroundColor(IndexedColors.RED.getIndex()); cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); cell.setCellStyle(cellStyle); } private double extractDoubleValue(Cell cell) { if (cell.getCellType() == CellType.NUMERIC) { return cell.getNumericCellValue(); } if (cell.getCellType() == CellType.STRING) { try { return Double.parseDouble(cell.getStringCellValue()); } catch (NumberFormatException e) { return 0.0; } } return 0.0; } }这里提醒一句:afterCellDispose阶段单元格里已经是写入后的值,EasyExcel 默认会把 BigDecimal 转成数字写入,所以CellType.NUMERIC是最常见的。但如果你在实体字段上挂了@NumberFormat,写出来的单元格可能是字符串,所以判断类型时要考虑两种情况,别一上来就getNumericCellValue()。
条件样式本质上是在"定位"之后再加一个"判断",从这往后你可以任意扩展逻辑:根据业务字段判断、根据相邻单元格判断、甚至根据当前行的其他列数据联动判断。这已经是写报表样式时最灵活的一套玩法了。
6. 组合玩法与常见坑:注册顺序、合并单元格、大数据量
6.1 多个 handler 注册的顺序决定了样式的覆盖关系
registerWriteHandler注册的处理器会按注册顺序依次执行。所以如果你把HorizontalCellStyleStrategy放在自定义 handler 后面,全局策略设定的样式就会覆盖掉你刚刚给指定行列设的颜色。反过来,HorizontalCellStyleStrategy放在前面,等于先打好地基,再让自定义 handler 去做重点装修,这才是正确顺序。
我见过有人搞反了,排查半天,最后把注册顺序换一下就解决了。建议把全局策略固定放在最前面,所有的精细化 handler 往后排:
EasyExcel.write(targetPath, OrderExcel.class) .registerWriteHandler(styleStrategy) // 先全局 .registerWriteHandler(new SpecifiedRowStyleHandler(...)) // 再行 .registerWriteHandler(new SpecifiedColumnByNameStyleHandler(...)) // 后列 .sheet("订单明细") .doWrite(orderList);行和列处理器如果都命中了同一个单元格,谁后注册谁生效。比如某一行和第 5 列交叉的那个格子,你既在行集合里又在列集合里,最后显示的颜色由后一个 handler 决定。
6.2 复杂表头场景下的注意点
"easyexcel 复杂的表头导入"这件事,本身就和样式有强关联。复杂表头通常用嵌套@ExcelProperty实现:
@ExcelProperty(value = {"基本信息", "订单号"}) private String orderNo; @ExcelProperty(value = {"基本信息", "客户名称"}) private String customerName;这种写法下,表头区域会产生横向或纵向的合并单元格。head.getColumnName()返回的是最底层的表头名,比如"订单号""客户名称",这一点和普通表头一致,所以按表头名定位列的 handler 在复杂表头下仍然能正常工作。
但要注意,合并表头里的"中间层"单元格,不一定会在afterCellDispose回调里按你的直觉逐一触发。EasyExcel 内部对合并区域的处理和普通单元格不同,合并区域的样式经常只体现在区域的左上角单元格上。所以处理复杂表头时,建议先用一个两条数据的样例文件跑一遍,打开生成的 Excel 看看哪些单元格回调了、哪些没回调,再决定你的样式策略。别一上来就在复杂表头上写几百行的样式逻辑,踩了合并单元格的坑会很痛。
6.3 数据量变大之后的性能问题
本地文件动辄几万行时,样式处理就成了导出性能的一个包袱。主要瓶颈有两个:
一个是CellStyle对象的创建,这也是全文中反复强调缓存的原因。一个Workbook里CellStyle数量很多的话,文件体积会明显膨胀,而且 Excel 的样式上限是 65430 个左右,超过会直接报错。所以高亮规则最好收敛到有限的几种组合,把样式实例提前缓存好。
另一个是我前面提到的情况:如果要求每个单元格都保留自身原有边框、字体再叠加颜色,你就必须逐格copyFrom,这会放大成本。折中的办法是先判断"这个格子原本的样式是不是已经在缓存里有对应版本",有就直接复用,没有再新建。实际项目里,我们可以做一个以"原始样式指纹"为 key 的Map来做样式复用,思路就是典型的空间换时间。
6.4 本地文件相关的几个隐藏问题
文件路径和文件类型要一起说。EasyExcel 写本地文件时,文件扩展名和ExcelTypeEnum要匹配。默认EasyExcel.write(path)会根据扩展名推断,.xlsx走 XLSX,.xls走 XLS。但样式 API 有些在 XLS 老格式下支持不完整,报表类需求我建议统一输出.xlsx,省得给自己找事。
还有一个容易被业务方投诉的点:EasyExcel.write(targetPath)在文件已存在时会直接覆盖,不会报错。有时候定时任务跑挂了,残留了一个只有半截数据的文件,下次任务启动直接覆盖是没事的,但怕的是任务挂了之后文件没写完,你拿半截文件去交付。我现在做这类需求,习惯都是先写临时文件,比如xxx_tmp.xlsx,全部完成后用Files.move原子替换正式文件,这样至少不会把坏文件留给下游。
6.5 从本地文件先读后写的典型场景
标题提到"本地文件",有一种很常见的完整链路是:先读一个本地存量文件,加工数据后,写成另一个带样式的新文件。这在报表整理场景里太常用了,给你一个可直接抄的骨架:
// Step1 读取本地文件 List<OrderExcel> existList = EasyExcel.read("D:/data/orders_old.xlsx") .head(OrderExcel.class) .sheet(0) .headRowNumber(1) .doReadSync(); // Step2 业务加工:比如筛出待处理订单 List<OrderExcel> needList = existList.stream() .filter(o -> o.getStatus() != null && o.getStatus().contains("待处理")) .collect(Collectors.toList()); // Step3 写到新本地文件,并给指定行指定列上样式 EasyExcel.write("D:/output/orders_highlight.xlsx", OrderExcel.class) .registerWriteHandler(styleStrategy) .registerWriteHandler(new SpecifiedRowStyleHandler( new HashSet<>(Arrays.asList(1, needList.size())), IndexedColors.LIGHT_YELLOW.getIndex())) .registerWriteHandler(new SpecifiedColumnByNameStyleHandler( Collections.singletonList("金额"), IndexedColors.LIGHT_BLUE.getIndex())) .sheet("处理结果") .doWrite(needList);这里有一个很容易被忽略的细节:doReadSync()读取时,如果原有文件里不仅有数据,还带了格式、合并单元格之类的东西,实体映射会按行数据读,样式是读不进来的。所以"保留原文件样式再加工"这件事,EasyExcel 本身做不到,只能做"读取数据,重新生成,再套新样式"。这算是它的边界,知道就好,别在这里浪费太多时间。
7. 一点个人经验总结
最后分享几个我做完这个需求后最想说的感受。
样式处理这件事,最忌一上来就写一个巨型 handler,把行、列、条件、字体、边框全揉在一起。我一开始就是图省事,写了一个几百行的SuperStyleHandler,结果改一个颜色的需求要动半天,还容易影响其他逻辑。后来拆成SpecifiedRowStyleHandler、SpecifiedColumnStyleHandler、SpecifiedRowHeightHandler这种单一职责的小类,每次需求变了,只改一个类就够了,排查问题也快得多。
另外建议工作里任何时候都先跑一个最小样例,数据只用两三条,样式只覆盖你要验证的场景,打开生成的xlsx肉眼确认一遍,再往上堆复杂度。特别是涉及合并单元格、复杂表头的时候,这个习惯能帮你节省大量排查时间。
还有一点是关于颜色的选择。浅黄、浅蓝、浅绿这类浅色系,打印和电脑上看都不容易刺眼;红色我一般留给"异常""失败"这种强警示场景,用过一次之后你就会懂为什么深色那么坑。
如果你后续还要做更复杂的报表导出,可以在这个 handler 机制基础上继续扩展,比如根据登录用户动态决定高亮规则、根据导出参数控制是否输出样式等。EasyExcel 2.2.10 虽然是个老版本,但这次需求中表现出的稳定性确实没让我失望。