ARTICLE DETAIL

资讯详情

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

EasyExcel自定义样式策略:逐列定制不同样式的完整实现

EasyExcel自定义样式策略:逐列定制不同样式的完整实现 项目标题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.00F完成率右对齐百分比低于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()判断当前写入的是表头层级还是数据内容相比注解和 HorizontalCellStyleStrategyHandler 不依赖实体类字段所以动态表头也能用。而动态表头在复杂报表里是非常常见的表头可能来自数据库配置列顺序可能是用户自定义的这时候根本没有办法在编译期用注解写死。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和WriteCellStylepublic class StatusColorConverter implements ConverterInteger { Override public Class? supportJavaTypeKey() { return Integer.class; } Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.STRING; } Override public WriteCellData? convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { WriteCellDataString 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 构建动态表头与列类型映射动态表头用ListListString表示外层 List 是列内层 List 是该列的表头层级。单层表头就是每个内层 List 只有一个元素。ListString titles Arrays.asList(序号, 部门, 负责人, 订单数, 销售额, 完成率, 备注); ListListString head new ArrayList(); for (String title : titles) { head.add(Collections.singletonList(title)); } // 构建列索引 - 列类型 的映射策略里靠这个查表 MapInteger, 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 内部维护一个MapString, CellStyle做样式缓存所有CellStyle按 key 复用。buildStyle方法负责真正创建工作簿样式不同的列类型走不同的构建逻辑。public class SalesReportStyleHandler implements CellWriteHandler { /** 样式缓存样式类型 - 样式对象 */ private final MapString, CellStyle styleCache new ConcurrentHashMap(); /** 列索引 - 列类型 */ private final MapInteger, String columnTypeMap; /** 合计行行号-1 表示没有合计行 */ private final int totalRowIndex; public SalesReportStyleHandler(MapInteger, 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) { ListListString head buildHead(); ListSalesRow dataList buildData(); MapInteger, 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只是示意真实项目里一定要根据你自己的数据组装方式来定别直接抄。如果你的合计行不在数据列表里而是在导出前单独构造一行更推荐这样ListSalesRow detailRows buildDetailData(); SalesRow totalRow buildTotalRow(); ListSalesRow 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之类的报错。原因就是每个单元格一个样式工作簿的样式表爆炸了。这也是为什么我在上面的代码里坚持用MapString, 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)新开一个 WorkbookbuildStyle用当前 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 去按列、按行、按内容查表。带着这个思路来做不管换成库存报表还是考勤导出你都能很快落地而且不容易出问题。
返回列表