项目标题:"EasyExcel设置不同列不同样式,自定义样式策略"
做报表导出,最烦的就是"整表一套样式"。我接过一个销售报表导出需求,第一列浅灰底、部门左对齐、负责人居中、销售额右对齐千分位、完成率按百分比显示且进度不同颜色不同、备注自动换行,最后还要来一行加粗的合计行。用 EasyExcel 的默认注解和 HorizontalCellStyleStrategy 硬套,很快就发现这是"整表一套样式"的思维,根本扛不住"一列一个样"的诉求。真正要解决这个问题,得从 EasyExcel 的自定义样式策略入手,也就是 Wood 到 CellWriteHandler 这一层。这篇文章我会从底层机制讲清楚样式策略为什么能自定义,给出三条实现路线的对比,再用一个完整可跑的销售报表导出案例,带你看清从动态表头到逐列样式、再到合计行特殊处理的落地全过程。适合正在用 EasyExcel 做复杂报表导出、被"不同列不同样式"卡住的 Java 开发者。
1. 报表样式需求分析:当默认策略撑不住时发生了什么
1.1 默认注解和 HorizontalCellStyleStrategy 能搞定什么
EasyExcel 默认给了两套能力。第一套是注解式样式,在实体类的字段上打@ContentStyle、@HeadStyle、@ColumnWidth这类注解,比如统一设置表头字体加粗、内容居中。优点是写起来极快,缺点也明显:注解是作用在字段上的,样式跟着字段走,字段多了之后代码里全是注解,想要做"某一列特殊处理"就很难受。
第二套是HorizontalCellStyleStrategy,从名字就能看出来,这是"横向单元格样式策略",它做的事情是把表头样式和内容样式分别统一。核心用法是先构造两个WriteCellStyle,一个给表头,一个给内容,然后注册进去:
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.setVerticalAlignment(VerticalAlignment.CENTER); contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); HorizontalCellStyleStrategy strategy = new HorizontalCellStyleStrategy(headStyle, contentStyle); EasyExcel.write(outputStream) .registerWriteHandler(strategy) .sheet("销售报表") .doWrite(dataList);这套组合适合什么场景呢?适合"整体观感统一"的报表,比如所有内容列统一左对齐、统一加边框、统一字体大小,表头统一灰底黑字加粗。但如果你的需求是"第一列浅灰、第三列红色、第五列百分比",它就直接歇菜了,因为它只能给所有内容列套同一个样式。
1.2 销售报表的需求拆解:6种列6种样式
我接到的那张销售报表,最终的样式需求是这样一张表:
| 列 | 列头 | 样式要求 |
|---|---|---|
| A | 序号 | 内容居中,浅灰底 |
| B | 部门 | 左对齐,常规字体 |
| C | 负责人 | 居中,常规字体 |
| D | 订单数 | 居中,数字不设特殊格式 |
| E | 销售额 | 右对齐,千分位 #,##0.00 |
| F | 完成率 | 右对齐,百分比,低于100%红字,高于等于100%绿字 |
| G | 备注 | 左对齐,自动换行 |
| 最后一列外 | 合计 | 整行加粗、浅黄底、右对齐 |
拆完之后你会发现,这里至少出现了三类不同维度的需求。
第一类是"按列类型给固定样式",比如序号列、部门列、销售额列,样式是确定的,跟单元格内容没有关系。第二类是"按单元格内容动态变更样式",比如完成率列,到底是红字还是绿字,得看这个单元格的值。第三类是"按行维度做特殊样式",比如合计行,它的判断维度不是某一列,而是"这是不是最后一行数据"。
这三类需求用注解都搞不定,尤其是动态表头场景下字段模型都不存在,更不可能打注解。所以第一步要明确:做列级样式,必须走 WriteHandler 体系。
2. 样式策略的工作原理:CellWriteHandler 如何在写入链路中生效
2.1 一次 doWrite 的写入链路
要理解自定义样式策略,先要知道 EasyExcel 写数据时内部经历了什么。调用doWrite之后,会把数据列表交给 ExcelWriter,内部按"工作簿 → Sheet → 行 → 单元格"的层次逐个写入。
在这一条写入链路上,EasyExcel 设计了几个扩展点:SheetWriteHandler负责 Sheet 创建前后的回调,RowWriteHandler负责行创建前后的回调,CellWriteHandler负责单元格创建前后的回调。你不需要继承某个复杂的父类,只需要实现接口里的方法,然后通过registerWriteHandler注册进去。
真正和样式强相关的是CellWriteHandler,它有两个核心回调:
default void beforeCellCreate(CellWriteHandlerContext context) {} default void afterCellDispose(CellWriteHandlerContext context) {}beforeCellCreate是在单元格对象创建之前触发,这个时候你拿不到 Cell 对象,通常用来干预"这个单元格要不要创建"。而afterCellDispose是在单元格值写入完成后触发,这时候可以拿到context.getCell(),Cell 对象已经有值了,但样式还没被固化到工作簿里,所以你在这里调用cell.setCellStyle(...)是安全的,最终会生效。
需要特别说明的是:EasyExcel 3.x 之后,老版本的afterCellDispose(Cell cell, Head head, Integer relativeRowIndex, Boolean isHead)方法已经废弃了,虽然还能用,但新项目不建议再写,直接实现带CellWriteHandlerContext的默认方法即可。
2.2 为什么自定义 Handler 是通用解
理解了回调机制,就能明白为什么说 Handler 是"不同列不同样式"的通用解。
它的判断逻辑非常自由。你可以拿到单元格的列索引cell.getColumnIndex(),也可以拿到行索引cell.getRowIndex(),可以判断当前是不是表头行,甚至可以读取单元格里的值做动态逻辑。这意味着:
- 按列固定样式:通过列索引查样式表
- 按行固定样式:通过行索引或行号判断(比如合计行)
- 按单元格内容动态样式:读取值,判断大小,再决定用哪个样式
- 多层表头场景:通过
context.getHead()判断当前写入的是表头层级还是数据内容
相比注解和 HorizontalCellStyleStrategy,Handler 不依赖实体类字段,所以动态表头也能用。而动态表头在复杂报表里是非常常见的,表头可能来自数据库配置,列顺序可能是用户自定义的,这时候根本没有办法在编译期用注解写死。
2.3 为什么不能改完共享对象再反复用
这是我在实战中踩过的一个坑。CellStyle是 Workbook 级别的共享对象,在工作簿里是唯一索引的。如果你把一个CellStyle设置到多个单元格,那么它们引用的是同一个对象。
很多人做完成率按值变色的时候,第一反应是"先取当前单元格的 CellStyle,然后改字体颜色"。代码如下:
CellStyle style = cell.getCellStyle(); Font font = workbook.createFont(); font.setColor(IndexedColors.RED.getIndex()); style.setFont(font);这段代码的问题非常大。你拿到的是该单元格引用的既有CellStyle,而这个样式可能同时被之前成千上万个单元格引用。你一改,前面那些完成率正常的单元格字体全变红了。这就是共享对象带来的"牵一发而动全身"。
正确做法是:提前把"红字完成率样式"和"绿字完成率样式"都创建好,然后按值选择其中一个设置到单元格上,而不是改共享对象。这一点在后文的实战代码里会完整体现。
3. 三条实现路线对比:注解、Handler、Converter 各自的适用场景
你可能会问:那到底用注解、Handler 还是 Converter?我分别说一下它们的定位和取舍。
3.1 HorizontalCellStyleStrategy:适合整体统一风格
这条路线在上面已经讲过了,核心是一个全局表头样式加一个全局内容样式。优点是代码量少,缺点是只能做"列平行"的样式,没法做列差异化。
什么时候用它?我一般只用在两种场景:一是简单导出,客户没提特殊样式要求;二是在使用自定义 Handler 的基础上,用它来兜底表头样式。比如表头统一灰底加粗,这个用策略做非常省事,然后再注册一个 Handler 专门管内容列。
3.2 CellWriteHandler:动态表头下的主力
动态表头下没有实体类,字段注解完全失效,Handler 是唯一能兼顾"动态列顺序 + 列级样式 + 内容判断"的方案。它的写法是先根据列索引或表头名确定这一列是什么业务含义,再查样式表拿到对应样式设置到 Cell 上。
afterCellDispose里可以拿到值和 CellData,所以也能做内容级别的动态判断。性能上只要注意样式缓存,基本没有瓶颈。
3.3 Converter:字段模型下的按值变样式
如果你的导出还是用实体类字段映射,而且只有某几个列需要"按业务值动态样式",那我建议用 Converter 配合注解,代码会更内聚。
自定义 Converter 的核心是重写convertToExcelData,在返回值的时候带上WriteCellData和WriteCellStyle:
public class StatusColorConverter implements Converter<Integer> { @Override public Class<?> supportJavaTypeKey() { return Integer.class; } @Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; } @Override public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { WriteCellData<String> cellData = new WriteCellData<>(value == 1 ? "正常" : "异常"); WriteCellStyle style = new WriteCellStyle(); WriteFont font = new WriteFont(); font.setColor(value == 1 ? IndexedColors.GREEN.getIndex() : IndexedColors.RED.getIndex()); style.setWriteFont(font); cellData.setWriteCellStyle(style); return cellData; } }然后在实体字段上:
@ExcelProperty(value = "状态", converter = StatusColorConverter.class) private Integer status;这个方案有个天然局限:一旦你的表头是动态拼出来的,连实体类都没有,Converter 就无处安放。所以我的建议是"看有没有字段模型":有字段模型,少数特殊列用 Converter;没有字段模型,全部走 CellWriteHandler。
4. 实战代码:销售报表逐列定制样式的完整代码
4.1 报表需求和样式映射表
这一节直接上可以抄的代码。我用动态表头的方式实现,因为这样对两种场景都通用:就算你有实体类,也可以把实体类头换成动态表头,逻辑完全不受影响。
最终导出的样式映射如下:
| 列类型 | 对齐 | 背景 | 数字格式 | 字体 |
|---|---|---|---|---|
| index | 居中 | 浅灰 | 无 | 常规 |
| text | 左对齐 | 无 | 无 | 常规 |
| center | 居中 | 无 | 无 | 常规 |
| amount | 右对齐 | 无 | #,##0.00 | 常规 |
| rate_red | 右对齐 | 无 | 0.00% | 红色 |
| rate_green | 右对齐 | 无 | 0.00% | 绿色 |
| remark | 左对齐 | 无 | 无 | 常规,自动换行 |
| total | 右对齐 | 浅黄 | 千分位 | 加粗 |
其中完成率列我会在 Handler 里读取单元格的实际值,再决定使用rate_red还是rate_green。合计行则根据传入的totalRowIndex判断,命中后整行不管哪一列都用total样式。
4.2 构建动态表头与列类型映射
动态表头用List<List<String>>表示,外层 List 是列,内层 List 是该列的表头层级。单层表头就是每个内层 List 只有一个元素。
List<String> titles = Arrays.asList("序号", "部门", "负责人", "订单数", "销售额", "完成率", "备注"); List<List<String>> head = new ArrayList<>(); for (String title : titles) { head.add(Collections.singletonList(title)); } // 构建列索引 -> 列类型 的映射,策略里靠这个查表 Map<Integer, String> columnTypeMap = new HashMap<>(); for (int i = 0; i < titles.size(); i++) { switch (titles.get(i)) { case "序号": columnTypeMap.put(i, "index"); break; case "部门": case "备注": columnTypeMap.put(i, "remark"); break; case "负责人": case "订单数": columnTypeMap.put(i, "center"); break; case "销售额": columnTypeMap.put(i, "amount"); break; case "完成率": columnTypeMap.put(i, "rate"); break; default: columnTypeMap.put(i, "text"); break; } }这里的关键点是:不要在 Handler 里硬编码列索引,而是先把"业务列类型"和"实际列索引"做一次映射。因为动态报表的列顺序随时可能调整,今天排在第三列的是负责人,明天可能变成第五列。列类型映射写死了,后面调整列顺序只需要改表头配置的组装逻辑,Handler 代码一行都不用动。
4.3 自定义 CellWriteHandler 完整实现
接下来是重头戏。Handler 内部维护一个Map<String, CellStyle>做样式缓存,所有CellStyle按 key 复用。buildStyle方法负责真正创建工作簿样式,不同的列类型走不同的构建逻辑。
public class SalesReportStyleHandler implements CellWriteHandler { /** 样式缓存:样式类型 -> 样式对象 */ private final Map<String, CellStyle> styleCache = new ConcurrentHashMap<>(); /** 列索引 -> 列类型 */ private final Map<Integer, String> columnTypeMap; /** 合计行行号,-1 表示没有合计行 */ private final int totalRowIndex; public SalesReportStyleHandler(Map<Integer, String> columnTypeMap, int totalRowIndex) { this.columnTypeMap = columnTypeMap; this.totalRowIndex = totalRowIndex; } @Override public void afterCellDispose(CellWriteHandlerContext context) { // 表头行不处理,表头样式交给 HorizontalCellStyleStrategy 或其他策略 if (Boolean.TRUE.equals(context.getHead())) { return; } Cell cell = context.getCell(); int rowIndex = cell.getRowIndex(); int colIndex = cell.getColumnIndex(); Workbook workbook = cell.getSheet().getWorkbook(); // 合计行单独处理,整行统一样式 if (rowIndex == totalRowIndex) { cell.setCellStyle(getStyle(workbook, "total")); return; } String type = columnTypeMap.get(colIndex); if (type == null) { cell.setCellStyle(getStyle(workbook, "text")); return; } // 完成率列需要根据单元格值动态决定红字还是绿字 if ("rate".equals(type)) { double rate = cell.getNumericCellValue(); if (rate < 1.0) { cell.setCellStyle(getStyle(workbook, "rate_red")); } else { cell.setCellStyle(getStyle(workbook, "rate_green")); } return; } cell.setCellStyle(getStyle(workbook, type)); } private CellStyle getStyle(Workbook workbook, String key) { return styleCache.computeIfAbsent(key, k -> buildStyle(workbook, k)); } private CellStyle buildStyle(Workbook workbook, String key) { CellStyle style = workbook.createCellStyle(); Font font = workbook.createFont(); // 通用设置:垂直居中 + 细边框 style.setVerticalAlignment(VerticalAlignment.CENTER); style.setBorderBottom(BorderStyle.THIN); style.setBorderLeft(BorderStyle.THIN); style.setBorderRight(BorderStyle.THIN); style.setBorderTop(BorderStyle.THIN); switch (key) { case "index": style.setAlignment(HorizontalAlignment.CENTER); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); break; case "center": style.setAlignment(HorizontalAlignment.CENTER); break; case "amount": style.setAlignment(HorizontalAlignment.RIGHT); style.setDataFormat(workbook.createDataFormat().getFormat("#,##0.00")); break; case "rate_red": font.setColor(IndexedColors.RED.getIndex()); style.setFont(font); style.setAlignment(HorizontalAlignment.RIGHT); style.setDataFormat(workbook.createDataFormat().getFormat("0.00%")); break; case "rate_green": font.setColor(IndexedColors.GREEN.getIndex()); style.setFont(font); style.setAlignment(HorizontalAlignment.RIGHT); style.setDataFormat(workbook.createDataFormat().getFormat("0.00%")); break; case "remark": style.setAlignment(HorizontalAlignment.LEFT); style.setWrapText(true); break; case "total": font.setBold(true); style.setFont(font); style.setFillForegroundColor(IndexedColors.LIGHT_YELLOW.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); style.setAlignment(HorizontalAlignment.RIGHT); break; case "text": default: style.setAlignment(HorizontalAlignment.LEFT); break; } return style; } }有几点值得说明。
第一,styleCache用的是ConcurrentHashMap。虽然单线程导出时普通 HashMap 也没问题,但实际项目里一个导出服务往往是多线程共享同一个 Handler 实例的,为了避免并发下重复创建样式,直接用并发 Map 更稳妥。
第二,字体对象Font也需要复用的思路。我在buildStyle里每次创建了一个新 Font,在你样式类型有限的情况下问题不大,但如果你的样式类型膨胀到几十种,建议把 Font 也做成缓存。Font 同样是 Workbook 级别的资源,创建太多同样浪费。
第三,完成率列的判断我直接用了cell.getNumericCellValue()。这里隐含一个前提:写入完成率字段时用的是 Double 类型,EasyExcel 默认把 Double 写成数值单元格。如果你的完成率字段是字符串"85%",这里的强转会报错。所以,做这种动态样式判断,一定要确保单元格里存的是你预期的数据类型。
4.4 注册 Handler 并导出
下面是把上述 Handler 接入导出流程的完整入口方法。
public void exportSalesReport(OutputStream outputStream) { List<List<String>> head = buildHead(); List<SalesRow> dataList = buildData(); Map<Integer, String> columnTypeMap = buildColumnTypeMap(head); // 数据从第0行表头、第1行开始写;dataList 包含合计行时,合计行行号就是 dataList.size() int totalRowIndex = dataList.size() - 1; SalesReportStyleHandler styleHandler = new SalesReportStyleHandler(columnTypeMap, totalRowIndex); EasyExcel.write(outputStream) .head(head) .registerWriteHandler(styleHandler) .sheet("销售报表") .doWrite(dataList); }注意这里totalRowIndex的计算。EasyExcel 的 Sheet 第一行是表头,也就是行号 0,数据从行号 1 开始。如果dataList有 n 个元素,第 n 个元素写在行号 n 上。如果最后一个元素恰好是合计行,那么合计行行号就是 n,也就是dataList.size(),而不是size() - 1。
我上面代码写的是dataList.size() - 1,前提是dataList不包含合计行,合计行由其他逻辑追加。为了避免混淆,更稳妥的做法是:把合计行单独传入 Handler,或者在构造数据时把"合计行行号"和"数据列表"分开。我这里写dataList.size() - 1只是示意,真实项目里一定要根据你自己的数据组装方式来定,别直接抄。
如果你的合计行不在数据列表里,而是在导出前单独构造一行,更推荐这样:
List<SalesRow> detailRows = buildDetailData(); SalesRow totalRow = buildTotalRow(); List<SalesRow> allRows = new ArrayList<>(detailRows); allRows.add(totalRow); int totalRowIndex = detailRows.size(); // 因为表头占第0行,数据从1开始,合计行行号等于明细行数这个计算逻辑很容易错,建议在代码里写清楚注释。
5. 必须绕开的坑:样式爆量、并发冲突与换行行高
5.1 样式数量上限:CellStyle 必须缓存复用
Excel 工作簿里的样式不是无限创建的。以常见的 xlsx 格式为例,Workbook 里能容纳的 CellStyle 数量有上限,而旧版 xls 格式上限更低,POI 内部默认是 4090 左右。如果你在每个单元格的afterCellDispose里都workbook.createCellStyle()一下,导 5000 行 10 列的数据,一瞬间就会创建 50000 个样式,先别说出错,内存和耗时已经炸了。
我见过一个真实案例,同事在 Handler 里每次创建一个新 CellStyle,导出 8000 行数据后直接抛异常,日志里是Too many cell styles之类的报错。原因就是每个单元格一个样式,工作簿的样式表爆炸了。
这也是为什么我在上面的代码里坚持用Map<String, CellStyle>缓存。所有同类列共享同一个CellStyle对象,不要担心"共享"会导致样式互相影响,因为setCellStyle本来就是让多个单元格引用同一个样式对象,Excel 的底层样式表也是这么设计的。你要避免的是"创建之后还在修改共享对象",而不是避免共享。
5.2 并发导出:Font/CellStyle 为什么不能跨 Workbook 复用
项目里导出接口往往要应对并发请求,于是有人会想:我把样式做成静态常量,所有请求共用,是不是性能更好?这个想法很危险。
CellStyle、Font、DataFormat这些对象都绑定在具体的Workbook实例上。你在 A 请求的 Workbook 里创建了一个CellStyle,然后在 B 请求的 Workbook 里把它设给单元格,一定出问题。反过来,如果多个请求共用一个 Handler 实例,但是每个请求传入的Workbook不同,只要你在getStyle方法里使用当前请求的workbook.createCellStyle(),就没有跨 Workbook 复用的问题。
所以我建议的并发处理方式是:Handler 实例可以共享,但样式缓存不要做成静态的,而是在 Handler 内部维护。每个请求执行导出时,通过EasyExcel.write(outputStream)新开一个 Workbook,buildStyle用当前 Workbook 创建样式并存入缓存。这样不同请求的 Workbook 互不干扰,同一个请求内的同类型单元格又能复用样式。
5.3 自动换行与行高:POI 不算,自己估
备注列要做自动换行,样式上设置setWrapText(true)只是第一步。真正麻烦的是行高:POI 和 EasyExcel 都不会因为你开启自动换行就自动计算行高,结果就是备注内容很长,但行高固定不变,文字被裁掉或者和下一行叠在一起。
解决办法有两个方向。一是固定行高并预估换行行数,在afterCellDispose里根据字符串长度和列宽估算需要几行,然后设置行高。这是一个很粗糙的估算,因为中英文混排宽度不一样,全角半角也不一样。
二是用模板填充的方式,在 Excel 模板里把备注列行高设置为"自动调整",然后让 Excel 打开时自动撑开。但这个特性在 EasyExcel 写文件时同样不受支持,因为 POI 底层不会调用 Excel 的自动换行计算引擎。
我的建议是:如果备注内容不可控,就不要期待完全自适应,直接给一个足够高的默认行高,或者限制备注列最大字数。如果非要精确换行,只能自己在代码里按字符宽度估算行数,再用row.setHeight去设置。这一点很多教程没提,等你真正跑起来才会发现备注列永远只显示一行。
5.4 判断表头与行号偏移
在afterCellDispose里判断表头,标准写法是Boolean.TRUE.equals(context.getHead()),这个没问题。但要注意,context.getHead()返回的是Boolean,如果某些版本或某些场景下没设置,它会返回null,所以要用Boolean.TRUE.equals()而不是context.getHead() == true。
行号偏移是另一个高频问题。只要你的表头变成两层,比如"2024年度"下面挂"一二季度"这种合并单元格,数据起始行就不再是 1 了。此时不能再用一个固定的totalRowIndex去判断合计行,而应该通过context.getWriteSheetHolder().getSheet().getLastRowNum()动态获取最后一行行号,再配合"最后一行就是合计行"的业务约定来判断。
不要小看这个偏移,我在三层表头场景里就吃过亏,表头多了两行之后,合计行判断全部错位,样式打在普通数据行上,导出结果惨不忍睹。
6. 扩展:按内容变色、合并单元格与模板填充
6.1 按内容变色的完整思路已经在你手上
完成率列的按值变色,本质上只是"读取单元格内容 → 选择预置样式 → 设置到单元格"的流程。把这个流程抽象出来,你可以做很多事情:订单金额超过一万标红,库存低于安全库存标黄,状态为停用的行整行置灰。这些需求都可以在 Handler 里通过cell.getRowIndex()、cell.getColumnIndex()、cell.getCellType()的组合判断实现,核心原则只有一个:不要修改共享样式,提前把所有分支样式建好。
6.2 合并单元格与复杂表头
动态表头做复杂表头,合并策略是绕不开的。EasyExcel 内置了一个LoopMergeStrategy,它做的事情是"每几行多少列合并",适合合并相同内容的行:
// 从第2列开始,每隔3行合并一次,用于纵向合并相同部门 registerWriteHandler(new LoopMergeStrategy(3, 1));但对于复杂表头那种"表头跨列合并"的需求,LoopMergeStrategy 解决不了。你需要的是在写表头时手动控制合并区域,这通常要结合SheetWriteHandler或者使用 Excel 模板来固定表头结构。我的经验是:表头结构一旦复杂到超过两层,直接放弃动态表头,改用模板是最省力的方案。模板里把表头、样式、合并单元格都做好,EasyExcel 只负责填充数据。
6.3 模板填充与嵌套列表
既然提到模板,顺便说一下模板填充场景下怎么处理样式。用EasyExcel.fill()填充模板时,模板里已经写好的样式会保留,数据会填进指定位置。如果你的模板里需要按列表数据动态生成多行,在模板中用{.field}这种语法即可。嵌套列表比如"一个订单对应多个商品明细",模板里用{.goodsList.goodsName}来循环填充。
模板填充的好处是样式问题被极大简化,因为模板本身就是 Excel 文件,样式在 Excel 里编辑时就已经确定。如果你的报表样式需求非常复杂,强烈建议优先考虑模板方案,而不是在代码里写大量样式策略。模板方案也有代价:动态列、列数量不固定就很痛苦,因为模板的列是固定的。所以我的判断标准是:列结构固定用模板,列结构动态用自定义 Handler,两者互为补充。
最后说一个我自己的经验。自定义样式策略的核心,是把样式当成"资源"来管理,而不是当成"属性"来随手设置。新手最容易犯的错就是每个单元格都 new 一个 CellStyle,导致性能越来越差、最后报错还不明所以。你先设计好整个报表需要哪几种样式,把它们建成一张"样式表",再通过 Handler 去按列、按行、按内容查表。带着这个思路来做,不管换成库存报表还是考勤导出,你都能很快落地,而且不容易出问题。