做 Java 后端的兄弟,十有八九都遇到过导出 Excel 被业务方挑刺的情况:数据没问题,但表格一点样式都没有,白底黑字、无边框、无对齐,打印出来像一摞草稿纸。我第一次用 EasyExcel 的时候也纳闷,明明这个库把 POI 封装得那么顺手,为什么默认导出的文件这么素?后来查源码才想明白,样式本来就不是 EasyExcel 的默认行为,写不写样式全看你有没有注册对应的样式策略。这篇我就拿实际项目里"不同列要不同样式"的需求来拆,讲清楚自定义样式策略怎么设计、怎么写、有哪些坑,以及 EasyExcel 一些高频问题(复杂表头、模板填充、单元格换行、版本冲突)该怎么排查。看完你至少能写出一个属于自己的通用样式处理器,放进项目里直接复用。
1. 需求解析:你到底需要什么样的样式效果
1.1 先别写代码,把样式需求拆成清单
很多人在 EasyExcel 里写样式翻车,不是因为 API 不会用,而是需求根本没想透。"给表格加样式"这句话太模糊了,落到具体 Excel 文件里,至少包含这些维度:字体、字号、颜色、背景色、对齐方式、是否自动换行、边框、行高列宽、数字格式。再加上作用范围,又分为表头行、数据行、整表、某一列、某个单元格。
所以我接到需求的第一件事,是先把"要什么样式"翻译成一张清单。比如我们最近导出的一份订单报表,需求大概是这样的:
| 位置 | 样式要求 |
|---|---|
| 表头整行 | 深蓝背景、白色加粗字体、居中、带细边框 |
| 序号列(第1列) | 数据居中、不加粗 |
| 订单金额列(第4列) | 数据右对齐、带千分位格式、字体红色 |
| 状态列(第5列) | 根据值变背景色:成功绿、失败红、处理中黄 |
| 备注列(第6列) | 自动换行、左对齐、上下居中 |
| 其他数据列 | 默认样式加细边框、垂直居中 |
这张表看起来不难,但用 EasyExcel 默认能力去套就会发现:内置的HorizontalCellStyleStrategy只能做到"表头一套样式、数据区一套样式",根本做不到不同列不同规则,更别提"某一列根据单元格值动态变色"。这就是我们非要自定义样式策略的根本原因。
1.2 选型思路:先看自己属于哪种场景
我总结下来,EasyExcel 样式需求一般就三种量级,不同量级方案完全不同:
- 全表统一:只要表头加个粗、数据加个边框,那用
HorizontalCellStyleStrategy就够了,不需要自己动脑。 - 按固定规则区分列:比如某几列右对齐、某几列加背景色、某列自动换行,这些可以用
CellWriteHandler在单元格写入后动态处理。 - 完全动态:列数不固定、样式根据数据值变化、甚至导出格式都由用户配置,这种最复杂,基本就得自己封装一套"规则引擎",本质还是围绕
CellWriteHandler和WriteCellData做扩展。
其实日常 90% 的需求都属于第二种,也就是 "固定列 + 差异化样式"。这篇文章重点讲的也是这种,因为它是性价比最高的做法:代码量不大,又能覆盖绝大多数业务场景。
2. 核心机制:EasyExcel 样式扩展的运行原理
2.1 从 POI 到 EasyExcel:样式到底是在哪个环节写进去的
要搞懂自定义样式策略,得先理解 EasyExcel 写出 Excel 的过程。底层还是 Apache POI 在干活,EasyExcel 做的事情是在 POI 之上加了一层事件回调机制,你写的各种 Handler 就像是生产线上的质检工,在单元格创建、写入、样式处理等节点被回调。
其中最关键的一个接口是CellWriteHandler。它在写单元格的整个生命周期里提供了几个钩子方法:beforeCellCreate(单元格创建前)、afterCellCreate(单元格创建后)、afterCellDispose(单元格数据处理完毕)等。我们要改样式,通常在afterCellDispose里动手,因为到这个阶段,单元格的值已经写进去了,样式也处于"马上要落盘"的状态,你这时候改,就是最终效果。接口签名大致长这样:
public interface CellWriteHandler extends WriteHandler { default void afterCellDispose(WriteCellData<?> writeCellData, WriteSheetHolder writeSheetHolder, WriteRowHolder writeRowHolder, WriteCellHolder writeCellHolder) { } }注意不同版本签名可能有差异,新版还引入了带WriteHandlerContext的重载方法,但基本逻辑一致,我用的是 3.x 里最常见的五参写法,如果你的 IDE 提示过时或者找不到,看下依赖版本里的默认方法签名就行。我们实现的 handler 注册到EasyExcel.write()的链路上,每一行每一个单元格处理后都会被回调。
2.2 回调里能拿到什么?这和样式有什么关系
afterCellDispose这五个参数,每个都有用:writeCellData是当前单元格的数据和样式载体,writeSheetHolder可以拿到当前 Sheet,writeRowHolder能拿到当前行的信息,writeCellHolder能拿到单元格的行号列号、是否为表头等元信息。
这时候就可以做判断了。我想给不同的列设置不同的样式,核心就是通过writeCellHolder.getColumnIndex()拿到列索引,然后针对性地往writeCellData.getOrCreateStyle()返回的WriteCellStyle里塞样式属性。比如我要把某一列背景色设成浅绿,代码是这样:
WriteCellStyle cellStyle = writeCellData.getOrCreateStyle(); cellStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex()); cellStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND);WriteCellStyle里能设置的东西很多:字体用setWriteFont()传入WriteFont对象,边框用setBorderLeft()、setBorderBottom()等方法,对齐用setHorizontalAlignment()、setVerticalAlignment(),换行用setWrapped(true),数字格式用setDataFormat()。这些属性写起来和 POI 很像,但它是 EasyExcel 自己的元数据对象,最终由 EasyExcel 在写文件时才真正转成 POI 的CellStyle。
2.3 一个容易被忽略的重点:别直接去 new POI 的 CellStyle
这里有个性能大坑。很多新手在 handler 里图省事,直接拿原生 POI 的方式搞:
Cell cell = writeCellHolder.getCell(); CellStyle style = writeSheetHolder.getSheet().getWorkbook().createCellStyle(); style.set...; cell.setCellStyle(style);问题是,一个 Excel 文件里的 CellStyle 是有数量上限的,老的.xls(HSSFWorkbook)上限是 4000 个左右,.xlsx(XSSFWorkbook)虽然高一些,但也架不住你在回调里几十万行每行都 new 一个。跑大数据量导出的时候,要么内存爆了,要么文件打不开,要么导出速度慢到怀疑人生。EasyExcel 提供的WriteCellStyle+getOrCreateStyle()是经过复用的设计,真正落到文件时 EasyExcel 会帮你做样式的合并和复用,所以能用它的方式就别自己造轮子。这也是自定义样式策略里最值得记住的一条原则。
3. 实操篇:不同列设置不同样式的三种实现方式
3.1 方式一:直接实现 CellWriteHandler,按列索引分发样式
这是最直观、最灵活的方式,也是我项目里一直在用的方式。核心思路很朴素:在afterCellDispose里拿列号,然后走一个 switch 或者 Map 分发到不同的样式方法里。我先把一个通用一点的 handler 骨架写出来:
import com.alibaba.excel.write.handler.CellWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteCellHolder; import com.alibaba.excel.write.metadata.holder.WriteRowHolder; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.style.WriteCellStyle; import com.alibaba.excel.write.metadata.style.WriteFont; import org.apache.poi.ss.usermodel.IndexedColors; import org.apache.poi.ss.usermodel.BorderStyle; import org.apache.poi.ss.usermodel.FillPatternType; import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.VerticalAlignment; public class MultiColumnStyleHandler implements CellWriteHandler { @Override public void afterCellDispose(WriteCellData<?> writeCellData, WriteSheetHolder writeSheetHolder, WriteRowHolder writeRowHolder, WriteCellHolder writeCellHolder) { Integer rowIndex = writeCellHolder.getRowIndex(); Integer columnIndex = writeCellHolder.getColumnIndex(); if (rowIndex == null || columnIndex == null) { return; } // 表头单独处理,这里假设只有一行表头 if (rowIndex == 0) { applyHeadStyle(writeCellData); return; } switch (columnIndex) { case 0: applySeqStyle(writeCellData); break; case 3: applyAmountStyle(writeCellData); break; case 4: applyStatusStyle(writeCellData); break; case 5: applyRemarkStyle(writeCellData); break; default: applyDefaultStyle(writeCellData); break; } } private void applyHeadStyle(WriteCellData<?> writeCellData) { WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex()); style.setFillPatternType(FillPatternType.SOLID_FOREGROUND); style.setHorizontalAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); WriteFont font = new WriteFont(); font.setBold(true); font.setColor(IndexedColors.WHITE.getIndex()); font.setFontHeightInPoints((short) 12); style.setWriteFont(font); setBorder(style); } private void applySeqStyle(WriteCellData<?> writeCellData) { WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setHorizontalAlignment(HorizontalAlignment.CENTER); style.setVerticalAlignment(VerticalAlignment.CENTER); } private void applyAmountStyle(WriteCellData<?> writeCellData) { WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setHorizontalAlignment(HorizontalAlignment.RIGHT); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setDataFormat((short) 3); // 0.00_ 千分位保留两位小数 WriteFont font = new WriteFont(); font.setColor(IndexedColors.RED.getIndex()); style.setWriteFont(font); } private void applyStatusStyle(WriteCellData<?> writeCellData) { // 根据值动态设置背景色 String status = writeCellData.getStringValue(); WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setHorizontalAlignment(HorizontalAlignment.CENTER); if (status == null) { return; } switch (status.trim()) { case "成功": style.setFillForegroundColor(IndexedColors.SEA_GREEN.getIndex()); style.setFillPatternType(FillPatternType.SOLID_FOREGROUND); break; case "失败": style.setFillForegroundColor(IndexedColors.ROSE.getIndex()); style.setFillPatternType(FillPatternType.SOLID_FOREGROUND); break; case "处理中": style.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); style.setFillPatternType(FillPatternType.SOLID_FOREGROUND); break; default: break; } } private void applyRemarkStyle(WriteCellData<?> writeCellData) { WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setWrapped(true); style.setHorizontalAlignment(HorizontalAlignment.LEFT); style.setVerticalAlignment(VerticalAlignment.CENTER); } private void applyDefaultStyle(WriteCellData<?> writeCellData) { WriteCellStyle style = writeCellData.getOrCreateStyle(); style.setVerticalAlignment(VerticalAlignment.CENTER); setBorder(style); } private void setBorder(WriteCellStyle style) { style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); style.setBorderTop(BorderStyle.THIN); style.setBorderBottom(BorderStyle.THIN); } }使用的时候只要在导出链路上注册它:
EasyExcel.write(response.getOutputStream(), OrderExportBO.class) .registerWriteHandler(new MultiColumnStyleHandler()) .sheet("订单报表") .doWrite(dataList);这种方式的好处是直白,你完全掌控每个单元格的样式逻辑。缺点是如果列特别多、规则特别复杂,这个类会膨胀得很厉害。但没关系,至少我们先跑通了,后面再优化也不迟。
3.2 方式二:继承 AbstractCellStyleStrategy,做一个真正的"策略"
EasyExcel 在com.alibaba.excel.write.style包下提供了一个抽象类AbstractCellStyleStrategy。看名字就知道,这其实就是官方为"策略模式"准备的基类。它内部会在写表头和数据行时,分别调用两个抽象方法:setHeadCellStyle和setContentCellStyle。
public abstract class AbstractCellStyleStrategy implements WriteHandler { protected abstract void setHeadCellStyle(CellStyle headCellStyle, List<WriteCellData<?>> headCellDataList, WriteSheetHolder writeSheetHolder, WriteSheet writeSheet); protected abstract void setContentCellStyle(CellStyle contentCellStyle, WriteCellData<?> cellData, WriteSheetHolder writeSheetHolder); }这里有个关键差异:它的setContentCellStyle拿到的contentCellStyle是整个数据行共用的一个样式对象,不是每个单元格独立。所以按列区分不同样式,要么你在方法里根据cellData的列信息动态 clone 出多个样式,要么就只在"全数据区统一一种样式"的场景用。我个人的经验是:这个类适合做"所有数据行同一套基础样式 + 少量特例"的场景,比如统一加边框、统一行高、统一对齐方式。真到了按列花式变样式,还是直接实现CellWriteHandler更顺手,因为它给你的是完整的回调上下文,不用在共用样式对象上绕弯子。
3.3 方式三:注解驱动的轻量方案
EasyExcel 还提供了一些注解,可以声明式地给某个字段设置样式,比如@ContentStyle、@ContentFontStyle、@HeadStyle、@HeadFontStyle、@ColumnWidth、@ContentRowHeight等。用法就是在导出实体类的字段上加注解:
@Data public class ProductBO { @ExcelProperty("商品名称") @ColumnWidth(20) private String name; @ExcelProperty("价格") @ContentStyle(horizontalAlignment = HorizontalAlignment.RIGHT) @ContentFontStyle(color = 10) private BigDecimal price; }这种方式最快,适合样式规则固定、不需要动态判断的简单场景。但它的缺点也很明显:每个字段的注解是写死的,你想根据值变色做不到,想表头和数据行用不同规则也比较繁琐,而且注解多了以后实体类会显得非常臃肿。所以我一般只在内部快速演示、或者模板相对固定的导出场景用。生产环境里复杂报表,我还是推荐方案一。
4. 实战案例:完整导出"订单报表"并应用自定义样式策略
4.1 需求场景与数据结构
纸上谈兵没意思,我拿一个真实项目里的订单报表导出场景来完整走一遍。需求就是上面提到的:
- 表头:深蓝背景、白色加粗、居中、细边框。
- 序号列:居中。
- 订单金额列:右对齐、千分位格式、红色字体。
- 状态列:成功显示绿色背景、失败显示红色背景、处理中显示黄色背景。
- 备注列:自动换行、左对齐。
- 其余数据列:加细边框、垂直居中。
实体类我定义为OrderExportBO,导出字段包括序号、订单号、客户名称、订单金额、状态、备注六个字段。注意,金额列我期望输出千分位格式,所以我直接在实体类上用@NumberFormat注解来格式化数据,样式上再配合右对齐,避免在样式里处理数字格式。
@Data public class OrderExportBO { @ExcelProperty(value = "序号") private Integer seq; @ExcelProperty(value = "订单号") private String orderNo; @ExcelProperty(value = "客户名称") private String customerName; @ExcelProperty(value = "订单金额") @NumberFormat("0.00") private BigDecimal amount; @ExcelProperty(value = "状态") private String status; @ExcelProperty(value = "备注") private String remark; }注意我这里故意没有在实体类上堆样式注解,因为样式规则比较多,用注解会让实体类很难看,而且状态列需要动态变色,注解做不了。全部交给自定义 handler 统一处理,逻辑集中,后面也好维护。
4.2 导出服务与 Controller 的完整代码
有了实体类,我把导出的 Service 方法写出来:
@Service public class OrderExportService { public void exportOrderReport(LocalDate startDate, LocalDate endDate, HttpServletResponse response) throws IOException { // 实际业务中这里从数据库分页查询,这里用示例数据代替 List<OrderExportBO> dataList = buildDemoData(); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("订单报表", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream(), OrderExportBO.class) .registerWriteHandler(new MultiColumnStyleHandler()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .build()) { WriteSheet writeSheet = EasyExcel.writerSheet("订单报表").build(); excelWriter.write(dataList, writeSheet); } } private List<OrderExportBO> buildDemoData() { List<OrderExportBO> list = new ArrayList<>(); for (int i = 1; i <= 20; i++) { OrderExportBO bo = new OrderExportBO(); bo.setSeq(i); bo.setOrderNo("SO20250101" + String.format("%04d", i)); bo.setCustomerName("客户" + i); bo.setAmount(new BigDecimal("100.00").multiply(BigDecimal.valueOf(i))); if (i % 3 == 0) { bo.setStatus("失败"); } else if (i % 2 == 0) { bo.setStatus("处理中"); } else { bo.setStatus("成功"); } bo.setRemark(i % 4 == 0 ? "订单备注较长,需要自动换行显示完整信息" : ""); list.add(bo); } return list; } }Controller 层很简单,就是接收查询参数,调用 Service,把响应对象传进去:
@RestController @RequestMapping("/order") public class OrderExportController { @Resource private OrderExportService orderExportService; @GetMapping("/export") public void export(@RequestParam(required = false) LocalDate startDate, @RequestParam(required = false) LocalDate endDate, HttpServletResponse response) throws IOException { orderExportService.exportOrderReport(startDate, endDate, response); } }这里有几个细节值得说:LongestMatchColumnWidthStrategy是 EasyExcel 内置的自动列宽策略,能让列宽根据内容长短自适应,算是一个低成本提升导出效果的小技巧,搭配我们的自定义样式 handler 用很舒服。另外,导出文件名的编码处理我用了URLEncoder,是为了防止中文文件名在浏览器里乱码。如果你们公司有统一的下载网关,这段可以省略。
4.3 效果验证与检查点
代码写完,启动服务访问导出接口,打开生成的 Excel 文件,挨个检查清单里的样式是否生效:
- 表头第一行是不是深蓝底、白字加粗?如果不是,先检查
rowIndex == 0的判断是否被多行表头影响。 - 金额列是不是右对齐、有没有千分位?注意我这里用
@NumberFormat("0.00")格式化数据,样式里的setDataFormat就没有和它冲突,否则两者会打架。 - 状态列背景色是否按值变化?比如"失败"的行,状态单元格应该是红色背景。如果所有状态都是默认背景,多半是
writeCellData.getStringValue()取值没取到,可以改用writeCellHolder.getCell()读取。 - 备注列超长文本是否换行?换行除了样式设置
setWrapped(true),还有个前提是行高足够,否则内容被截断看不到完整文字。所以如果换行没生效,可以顺便设置一个合适的行高,见后面的问题排查。
我实测下来,这套代码导出 20 行、几万行的数据都能稳定跑,样式表现一致,也不存在样式互相串的问题。
5. 高频问题与排查实录
5.1 复杂表头时列索引对不上
自定义样式策略最常见的一个坑,就是表头不是简单的一行。比如表头带合并单元格、多级表头(动态表头由 List 拼接),这时rowIndex不再只是 0,而可能是 0、1、2 多行;更坑的是,如果使用了某些表头合并策略,列索引columnIndex不一定和你数据库字段顺序一一对应,容易把样式加到错误的列上。
我的排查思路是:先别急着写样式,在 handler 里临时打印rowIndex和columnIndex,和实际文件里的单元格位置对照一遍。如果是动态表头,最好先把表头文本读出来,拿表头文本做映射,而不是硬编码列索引。比如在afterCellDispose里,如果判断当前是表头行,就通过writeSheetHolder.getSheet().getRow(rowIndex).getCell(columnIndex).getStringCellValue()把表头文本取出来,存成一个Map<String, Integer>,后续数据行再根据表头文本查列号,这样就算前端动态加列、调整顺序,样式策略也不会乱。
5.2 模板填充模式(fill)下样式策略不生效
很多人在用EasyExcel.fill()做模板填充时发现,自己注册的CellWriteHandler根本没被调用,或者调了也没效果。这不是代码写错了,而是 fill 模式的写入逻辑和普通 write 模式不一样:fill 模式下 EasyExcel 只是把数据塞进模板里预先留好的单元格,单元格的样式早就在模板文件里定好了,EasyExcel 不会去重建。换句话说,这个模式下的"样式"是模板负责的,不是代码负责的。
解决办法有两个:第一,模板文件里把要填充的单元格样式提前设置到位,这是最推荐的做法;第二,如果你确实需要代码动态控制样式,就别用 fill 模式,改成普通 write 模式,自己拼表头。我之前有次接手一个别人的导出接口,他用 fill 模式填数据,又想给状态列变色,折腾了半天才发现方向错了——模板里根本没有给状态列预留任何可变的样式扩展点。
5.3 单元格自动换行不生效
单元格换行是导出需求里的高频点,尤其是备注、地址这类长文本。样式层面要做的就一件事:给对应列设置setWrapped(true)。但在实际文件里,光有换行还不够,你还得让这一行有足够的高度,不然内容会被遮住。EasyExcel 里可以在实体类上用@ContentRowHeight注解,也可以在我们的 handler 里通过writeRowHolder.getRow()动态设置行高:
writeRowHolder.getRow().setHeightInPoints(30f);如果你在备注里写了\n,但 Excel 里还是显示一行,先排查是不是setWrapped(true)没生效,再看是不是\n被别的逻辑吞掉了。注意,Java 字符串里的\n写到 Excel 就是换行符,不需要换成别的字符。
5.4 版本与依赖问题:NoSuchFieldError factory、libfreetype6
热词里提到的NoSuchFieldError: factory,十有八九是 EasyExcel 和 POI 的版本不匹配导致的。EasyExcel 3.x 内部依赖了特定版本的 POI,如果你为了用某个功能手动加了更高版本或更低版本的 POI 依赖,就可能出现类冲突、字段找不到的情况。我的建议是别自己手工去调 POI 版本,直接用 EasyExcel 传递依赖的那一套,除非你有非常明确的兼容性要求。如果项目里其他地方必须用别的 POI 版本,就用 Maven 的 dependencyManagement 把版本对齐到 EasyExcel 官方推荐的版本。
libfreetype6这个问题实际上和 EasyExcel 本身没关系,是 Linux 服务器上缺了图片字体相关的系统库,一般出现在导出带图片的 Excel 时。报错信息里会提示缺少libfreetype.so.6,在 Debian/Ubuntu 上apt-get install libfreetype6装一下就好。这种环境问题虽然不难解决,但在面试或群里讨论时经常和 EasyExcel 混在一起,容易误导新人。
5.5 嵌套 List 数据如何渲染并应用样式
"如何渲染嵌套 list"一般有两种情况。第一种是实体类里有个字段本身是 List,比如"订单下面有多条商品明细",EasyExcel 默认不帮你展开,你需要在导出前把数据结构打平,一条订单展开成多条商品行,再用分组样式(比如把订单号列只显示第一行)来实现。第二种是完全没有实体类,数据本身就是List<List<String>>,这种情况直接用EasyExcel.write(outputStream).head(headList).sheet().doWrite(dataListList)就能写出去,样式策略依然可以用,因为afterCellDispose回调跟数据源类型无关,只要列号判断对了,照样能加样式。
如果是复杂嵌套模型想做类似"导出一张子表"的效果,我通常建议在数据处理层做转换,而不是硬靠 EasyExcel 的 API 去渲染深层次对象,因为那样代码可读性和维护成本都很难受。
6. 性能优化与最佳实践
6.1 样式对象一定要复用,别在回调里 new 个没完
前面已经说过,POI 的 CellStyle 在单个 workbook 里是有数量上限的。在自定义样式策略里,WriteCellStyle本身可以通过getOrCreateStyle()复用,但如果你硬要在afterCellDispose里创建多个WriteCellStyle实例(比如每种颜色都 new 一个),虽然最终 EasyExcel 会做一些合并,但对象创建开销依然存在。更稳妥的做法是:把所有的样式对象提前初始化好,放到一个 Map 或者数组里缓存,回调里只负责取用,不再重复创建。下面这个思路可以参考:
private final Map<String, WriteCellStyle> styleCache = new HashMap<>(); private WriteCellStyle getCachedStyle(String key, Supplier<WriteCellStyle> initializer) { return styleCache.computeIfAbsent(key, k -> initializer.get()); }状态列根据值变色这种场景,总共就那么几种状态,完全可以把"成功样式""失败样式""处理中样式"都提前建好,用状态值当 key 直接查。这样导出一百万行数据,样式对象也就那几个,性能稳稳的。
6.2 大数据量导出时的几个配套手段
样式策略只是导出性能的一部分。数据量大时,我一般还会注意这几件事:分页查询,不要一次性把全表数据 load 进内存;能用ExcelWriter边查边写就不要用doWrite一把梭;写完后记得关闭流,最好用 try-with-resources;如果文件要传到对象存储,写完本地临时文件再上传,避免响应超时。这些是老生常谈,但很多时候性能问题不是样式策略造成的,而是数据加载方式太粗暴。
6.3 把样式策略封装成可配置的组件
最后说一个工程化建议。如果你所在的项目里到处都在导出 Excel,样式需求五花八门,我建议把样式策略从"一个 handler 写死"升级成"一个规则引擎"。具体做法是:定义一个样式规则的抽象,比如接口ExcelColumnStyleRule,里面包含列号匹配条件、样式应用方法,然后让 handler 持有List<ExcelColumnStyleRule>,遍历匹配到就应用。这样新增一种列样式,只需要新增一个规则实现类,不需要去改 handler 的 switch 分支,既符合开闭原则,也更好测试。
我在实际项目中踩过最大的坑,就是样式对象没有缓存,导致几十万行数据导出时内存暴涨、文件损坏。后来把样式缓存和规则分发做了重构,导出耗时从二十多秒降到八秒以内。所以方案不在多,把一套自定义样式策略的思路用透,EasyExcel 导出这块基本就能应对绝大多数业务场景了。