
接手过不少导入导出需求之后我得先撂一句实话用 Apache POI 硬写导入导出尤其是面对几万行数据、复杂报表头、各种合并单元格的时候真的容易写到自己怀疑人生。后来我把项目里的 Excel 处理全部切换到了 EasyExcel才算是彻底解脱了。这篇文章就围绕我实际项目里落地“基于 EasyExcel 实现文件导入导出功能”的完整过程展开把表头解析、样式定制、sheet 锁定、冻结列、序号生成、下拉框处理这些高频坑挨个讲清楚。EasyExcel 是阿里巴巴开源的一款 Excel 处理工具底层还是走的 POI但它把 POI 那套繁琐的 Workbook、Sheet、Cell 操作封装成了注解加监听器的模式。导入的时候只要定义好实体类监听器里收数据就行导出的时候一个EasyExcel.write()链式调用搞定。对于业务系统来说最直观的价值就是代码量砍掉一大半内存占用大幅下降再也不用每导一次就 OOM 一次。这篇文章适合这几类人看正在用 POI 硬写导入导出、想切换 EasyExcel 的后端开发被复杂表头、样式定制折磨过的业务系统开发者以及刚接触 EasyExcel、想找一个完整可落地参考的新手。我会把为什么选它、核心细节怎么处理、实际代码怎么组织、踩过的坑怎么排查全都展开说清楚。1. 为什么最终选了 EasyExcel而不是直接怼 POI1.1 从一次 OOM 事故说起之前有个批处理任务要从一个 Excel 文件里读取 20 万行商品数据同步到数据库。当时用 POI 的WorkbookFactory.create()一次性加载整个文件结果生产环境直接 OOMGC 日志刷了满屏。后来排查发现是XSSFWorkbook会把整个文件结构都加载进堆内存一个 50MB 的 Excel 膨胀到 1GB 以上都是正常现象。EasyExcel 的做法完全不同它底层用的是 SAX 事件驱动解析模式。读文件的时候不是把整个文件一次性加载而是边读边解析一行数据解析完就交给监听器处理处理完就释放。同样的 20 万行数据EasyExcel 的内存占用可以控制在几十 MB 级别。这也是我选它的第一个核心原因内存模型设计得合理能扛住真实业务的大数据量场景。1.2 EasyExcel 的核心原理SAX 模式读写EasyExcel 底层还是 POI但两个 API 的加载方式完全不一样。导出的时候EasyExcel 默认使用SXSSFWorkbook模式也就是流式工作簿。它不是把所有单元格都缓存在内存里而是写一部分、刷一部分只保留一个滑动窗口的数据在内存。这个机制保证了 10 万行、20 万行的导出都稳得住。你可以通过excelWriter分批次写入也可以开EasyExcel.write()直接一把梭。导入的时候EasyExcel 通过继承 POI 的XSSFSheetXMLHandler在自己的 handler 里做SAXParser解析。XML 流一标签一标签地过解析到行数据就回调到你实现的AnalysisEventListener的invoke方法。这个模型注定了它处理大文件的时候“几乎不占内存”。1.3 什么时候选 EasyExcel什么时候别选EasyExcel 不是万能的。我个人的判断标准是常规的 xlsx 导入导出无脑选 EasyExcel。需要操作 xls 老格式EasyExcel 支持有限通常建议先让用户另存为 xlsx。需要生成超级复杂的 Excel 宏、ActiveX 控件、复杂图表EasyExcel 做不到得回到原生 POI。需要服务端生成 Excel 后直接邮件发送、或对接报表平台EasyExcel 够用但样式定制程度不如 POI 自由。一句话总结业务系统里 95% 的“文件导入导出”需求EasyExcel 都是最优解剩下 5% 属于“Excel 深度用户”的定制需求才需要动用原始 POI。2. 导出功能的完整实操从注解到定制化样式2.1 最基础的导出实体类注解一行代码出结果导出最简单的场景就是“把数据库列表放到 Excel 里”。用 EasyExcel 导出一个带表头的 Excel只需要三步Data public class ProductExcelVO { ExcelProperty(value 商品编码, index 0) private String productCode; ExcelProperty(value 商品名称, index 1) private String productName; ExcelProperty(value 分类, index 2) private String categoryName; ExcelProperty(value 售价, index 3) private BigDecimal price; ExcelProperty(value 库存, index 4) private Integer stock; ExcelProperty(value 创建时间, index 5) private Date createTime; }// Service 层 public void exportProductList(HttpServletResponse response) throws IOException { ListProductExcelVO dataList productService.listAllForExport(); String fileName URLEncoder.encode(商品列表, UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName .xlsx); EasyExcel.write(response.getOutputStream(), ProductExcelVO.class) .sheet(商品列表) .doWrite(dataList); }这段代码看起来简单但里面有细节index指定列顺序value指定表头名称。如果没有indexEasyExcel 按字段声明顺序处理但一旦报表头字段变动顺序很容易乱所以我的习惯是显式写index。另外Content-Disposition里文件名一定要做 URL 编码处理不然中文文件名在部分浏览器里会乱码。你可以用URLEncoder.encode也可以直接用new String(fileName.getBytes(UTF-8), ISO8859-1)转码两种方式实测都能解决乱码问题。导出的列顺序、表头文字都按注解来数据库查完直接doWrite性能在 10 万行以内都非常快。我实测过 8 万行、25 列的数据从doWrite到响应流写完大概 2 秒左右这在业务系统里完全能接受。2.2 动态表头和复杂表头导出不靠实体类也能写业务里经常会遇到“表头不是固定的列名要动态生成”的场景。比如导出某个月的销售报表列名是“1号、2号、3号...31号”。你不可能在实体类里预定义 31 个字段。这种场景要用 EasyExcel 的动态表头 APIListListString head new ArrayList(); ListString head0 Arrays.asList(日期, 销售额); ListString head1 Arrays.asList(商品, 名称); // 复杂表头可以这样构建第一行是父表头第二行是子表头不过我说的这个例子比较简单。真正复杂的是多级表头。EasyExcel 的动态表头结构是ListListString外层的每个 List 代表 Excel 中的一列内层的 List 代表这一列的层级路径。比如一个两行表头“销售额”下面是“本月”、“上月”两个子列那就要这样构建ListListString dynamicHead new ArrayList(); dynamicHead.add(Arrays.asList(销售额, 本月)); dynamicHead.add(Arrays.asList(销售额, 上月)); dynamicHead.add(Arrays.asList(销售额, 同比)); ListListObject dynamicData new ArrayList(); for (MapString, Object rowMap : dataList) { ListObject row new ArrayList(); row.add(rowMap.get(currentMonth)); row.add(rowMap.get(lastMonth)); row.add(rowMap.get(yoy)); dynamicData.add(row); } EasyExcel.write(outputStream) .head(dynamicHead) .sheet(销售报表) .doWrite(dynamicData);这里最关键的一点是动态表头和动态数据是分离的head只控制表头结构数据行里的每个 List 元素顺序要和表头列索引严格对齐。一旦数据顺序和表头对不上导出后整个报表就是错位的这种错误在报表类需求中很隐形人工核对也耗时。建议导出后写个简单的行列校验工具脚本自动核对列数和数据数。如果业务中是多级动态表头比如“产品线 - 产品 - 销售数据”我的做法是先递归构建head列表同时记录每层级的合并范围再配合 EasyExcel 的merge策略。但说实话如果表头嵌套层数超过三级建议换个思路用报表工具如积木报表、帆软出模板EasyExcel 只负责数据填充这样维护成本更低。2.3 定制导出细节列宽、冻结列、序号和样式很多基础导出教程不会讲样式定制但真实需求里“表头加粗、隔行变色、冻结前几列”几乎是标配。EasyExcel 提供了WriteHandler接口可以拿到底层的Sheet和CellStyle做任意操作。先看一个典型的自定义场景导出商品列表表头加粗、浅灰背景、冻结前三列、每一行前加序号。这个需求我封装了一个CustomSheetWriteHandlerpublic class CustomSheetWriteHandler implements WriteHandler { Override public void sheet(int sheetNo, WriteSheet writeSheet) { // 拿到底层 Sheet 对象 Sheet sheet writeSheet.getSheet(); // 冻结前 3 列 sheet.createFreezePane(3, 1); } Override public void afterCellDispose(WriteWorkbook writeWorkbook, WriteSheet writeSheet, WriteTable writeTable, WriteCellData? headCellData, Cell cell, CellType cellType, Boolean isHead, Boolean isEmptyCell) { if (isHead) { // 表头样式加粗水平居中背景浅灰 CellStyle style cell.getSheet().getWorkbook().createCellStyle(); style.cloneStyleFrom(cell.getCellStyle()); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); Font font cell.getSheet().getWorkbook().createFont(); font.setBold(true); style.setFont(font); cell.setCellStyle(style); } else { // 内容行给第一列写序号注意这里要拿到行号 int rowNum cell.getRowIndex(); if (cell.getColumnIndex() 0) { cell.setCellValue(rowNum); } } } }使用的时候EasyExcel.write(response.getOutputStream(), ProductExcelVO.class) .registerWriteHandler(new CustomSheetWriteHandler()) .sheet(商品列表) .doWrite(dataList);这里有几个容易踩的细节createFreezePane(3, 1)表示冻结前 3 列和第一行。参数含义是水平冻结列数、垂直冻结行数。如果只冻结第一行表头就是createFreezePane(0, 1)。如果只冻结前两列就是createFreezePane(2, 0)。这个参数很多人记混冻结出来的效果和预期完全对不上。写序号的时候在afterCellDispose里对cell.getColumnIndex() 0的列赋值。但要注意如果也要给这一列加表头“序号”就得在表头分支里也特殊处理或者单独用一个字段ExcelProperty(value 序号, index 0)然后在afterCellDispose里判断isHead如果是表头就不覆盖内容是行才写rowNum。我这个实现里用的是rowNum不等于真实序号因为如果数据分页写多次 doWrite行号会重新从 7 开始所以更稳妥的写法是维护一个自增计数器。2.4 下拉框和字典映射EasyExcel 能做什么不能做什么热搜词里有“easyexcel 支持 下拉框复选吗”这个问题我可以直接回答EasyExcel 本身没有暴露直接设置下拉框的 API要实现下拉框必须走WriteHandler里面拿底层 POI 的DataValidation。而且“下拉复选”单元格内复选框EasyExcel 原生不支持Excel 本身也没有这种开箱即用的单元格类型。但“下拉单选”是完全可以实现的。我写过一个封装给指定列添加下拉选项public class DataValidationWriteHandler implements WriteHandler { private final MapInteger, String[] dataValidationMap; public DataValidationWriteHandler(MapInteger, String[] dataValidationMap) { this.dataValidationMap dataValidationMap; } Override public void afterSheetCreate(WriteWorkbook writeWorkbook, WriteSheet writeSheet) { Sheet sheet writeSheet.getSheet(); DataValidationHelper helper sheet.getDataValidationHelper(); for (Map.EntryInteger, String[] entry : dataValidationMap.entrySet()) { Integer colIndex entry.getKey(); String[] options entry.getValue(); CellRangeAddressList addressList new CellRangeAddressList(1, 9999, colIndex, colIndex); DataValidationConstraint constraint helper.createExplicitListConstraint(options); DataValidation validation helper.createValidation(constraint, addressList); // 允许空格 validation.setSuppressDropDownArrow(true); validation.setShowErrorBox(true); sheet.addValidationData(validation); } } }用的时候MapInteger, String[] validations new HashMap(); validations.put(2, new String[]{数码, 家电, 服饰}); // 给第 3 列添加下拉选项 EasyExcel.write(outputStream) .registerWriteHandler(new DataValidationWriteHandler(validations)) .sheet(模板) .doWrite(dataList);这里要注意CellRangeAddressList的起始行是1因为第 0 行是表头。如果你导出的数据从第 2 行开始模板场景就得改成从 2 开始。而“下拉框复选”怎么做我在项目里的妥协方案是把多个选项用逗号拼接成一个单元格字符串比如“数码,家电,服饰”导入时再按逗号拆分做标签关联。虽然不能像专业表单那样点选复选框但业务上完全够用用户也能接受。如果一定要做真正可勾选的复选框那得用 POI 直接生成XSSFDrawing里的控件对象EasyExcel 没提供这个层面的封装工作量会陡增一般不建议在导入导出功能里折腾这个。3. 导入功能的完整实操监听到数据校验没烦恼3.1 基础导入监听器一次性搞定导入了 EasyExcel 之后导入功能的开发被简化成了两个步骤定义实体类 写一个监听器。实体类定义和导出共用即可只是不用关心表头样式额外加一个ExcelProperty映射就行。基础导入代码public class ProductImportListener extends AnalysisEventListenerProductExcelVO { private final ListProductExcelVO cacheList new ArrayList(); private static final int BATCH_COUNT 1000; Override public void invoke(ProductExcelVO data, AnalysisContext context) { cacheList.add(data); if (cacheList.size() BATCH_COUNT) { saveData(); cacheList.clear(); } } Override public void doAfterAllAnalysed(AnalysisContext context) { saveData(); } private void saveData() { // 批量入库这里可以调用 Mapper 或 Service productService.batchInsert(cacheList); } }实际使用EasyExcel.read(inputStream, ProductExcelVO.class, new ProductImportListener()) .sheet() .doRead();这个模式很舒服但有一段必须注意invoke里每解析一行就会回调一次千万不能在里面做 DB 单条插入否则 10 万行数据会产生 10 万次数据库交互慢到怀疑人生。批量攒到 1000 条再统一入库是标准做法。分批之后数据一致性也要考虑如果中间批次插入失败前面批次已经入库了会留下“半批数据”。我项目的做法是开启事务但 EasyExcel 的doRead是同步的你只要在saveData()里加了Transactional方法必须在事务代理中一旦某批失败整体回滚调用方捕获异常后返回导入失败结果。这点在代码 Review 时经常被忽略。3.2 复杂表头导入这种需求比你想象的更常见先说一个真实场景上游供应商给的 Excel 模板是多级表头比如第一行是“基础信息”合并单元格下面才是“商品编码”“商品名称”。这种文件直接用EasyExcel.read(ProductExcelVO.class)解析数据会全错位因为 EasyExcel 默认把“第一个有数据的行”当表头复杂表头必然导致映射错乱。处理复杂表头我的思路是这样的既然 EasyExcel 提供invokeHeadMap这个回调那就自己接管表头解析。public class ComplexHeaderImportListener extends AnalysisEventListenerMapInteger, String { private MapInteger, String headMap; Override public void invokeHeadMap(MapInteger, String headMap, AnalysisContext context) { this.headMap headMap; // 在这里做表头合法性校验 // 比如必须包含“商品编码”、“商品名称”等关键列 } Override public void invoke(MapInteger, String rowData, AnalysisContext context) { // 此时 rowData 是每个单元格的值key 是列索引 String productCode rowData.get(0); String productName rowData.get(1); // 手动构造业务对象 } Override public void doAfterAllAnalysed(AnalysisContext context) { // 收尾处理 } }读取时不再绑定实体类直接用MapInteger, String接收EasyExcel.read(inputStream) .registerReadListener(new ComplexHeaderImportListener()) .sheet() .doRead();注意这种模式下invokeHeadMap拿到的是 EasyExcel 识别出的表头行。如果文件表头占了两行或三行invokeHeadMap拿到的可能只是最后一行表头你需要结合headRowNumber参数来控制跳过的行数EasyExcel.read(inputStream) .headRowNumber(2) // 跳过前两行表头 .registerReadListener(listener) .sheet() .doRead();headRowNumber(2)的意思是跳过前 2 行真正的数据从第 3 行开始解析。这里有个很隐蔽的坑如果复杂表头里包含合并单元格EasyExcel 对合并单元格的解析结果可能是null或者只会把值赋给合并区域的第一个单元格。比如“基础信息”这个跨列合并的表头在invokeHeadMap里除了第一列其他列的 map value 是 null。你在校验表头时就要对这种情况做容错不要一看到 null 就认为文件格式错误而是去校验二级表头是否完整。处理复杂表头导入的另一个常见方案是先用headRowNumber跳过所有表头行然后直接按列索引映射字段。不要依赖实体类的ExcelProperty匹配因为复杂表头的列名很容易和实体类属性名不一样手动映射最可靠。代码可读性和维护性也比啃注解省心得多。3.3 导入校验错误不能让用户靠肉眼找导入功能的体验好不好集中体现在“出错后的提示”是否友好。最差的做法是导入失败后告诉用户“第 3 行错误”用户得自己在 Excel 里数行号。好一点的做法是在前端把错误行标红或返回错误明细列表。我常用的方案是监听器里逐行校验错误信息全部收集最后统一返回。public class ProductImportListener extends AnalysisEventListenerMapInteger, String { private final ListString errorMessages new ArrayList(); Override public void invoke(MapInteger, String rowData, AnalysisContext context) { Integer rowIndex context.readRowHolder().getRowIndex(); String productCode rowData.get(0); String priceStr rowData.get(3); if (StringUtils.isBlank(productCode)) { errorMessages.add(第 (rowIndex 1) 行商品编码不能为空); } try { BigDecimal price new BigDecimal(priceStr); if (price.compareTo(BigDecimal.ZERO) 0) { errorMessages.add(第 (rowIndex 1) 行售价不能为负数); } } catch (NumberFormatException e) { errorMessages.add(第 (rowIndex 1) 行售价格式不正确); } } public ListString getErrorMessages() { return errorMessages; } }这里一个实用细节是context.readRowHolder().getRowIndex()它拿到的行号是 0 基数的从 0 开始。Excel 里用户看到的第一行数据是第 1 行所以展示给用户时要加 1。更严谨一点如果跳过了headRowNumber行那还要再加上跳过的行数否则行号会和 Excel 里的实际行号对不上。我之前就在这上面翻过车明明用户第 8 行出错我报给前端第 3 行用户反馈“你们这个提示和文件对不上”排查半天才发现是漏加headRowNumber了。校验失败后要终止入库。监听器设计上不用特殊终止你可以在调用方做判断ProductImportListener listener new ProductImportListener(); try { EasyExcel.read(inputStream, listener).sheet().doRead(); } catch (ExcelAnalysisException e) { // 解析过程中的异常 } if (CollectionUtils.isNotEmpty(listener.getErrorMessages())) { return errorResult(listener.getErrorMessages()); } // 无错误才入库 productService.batchInsert(listener.getValidDataList());但上面这种方式内存占用高因为validDataList会把所有数据先放进内存。如果文件行数多正确做法是监听器内部边校验边分批入库有错误就记录错误、跳过这行数据。等全部解析完如果 errorMessages 不为空事务回滚。这里的“回滚”需要额外设计因为监听器如果一拿到错误就抛异常解析会中断。我的经验法是saveData()之前检查 errorMessages 是否为空不为空则抛ExcelAnalysisException中断整体读取异常信息里带上所有错误。这样前端拿到的错误信息就是完整的且不会产生半笔脏数据。更简单粗暴的方式是先全部解析进内存校验完没问题再入库但这只适合小文件大文件老老实实走“分批 事务回滚”方案。4. sheet 保护、冻结列和序号那些看似简单却总被问爆的点4.1 sheet.protectSheet 的坑锁定全表还是锁定部分列热搜词里有“easyexcel sheet.protectsheet();设置了就锁定了全局,style.setlocked(false);”这正好是我在实际项目里碰到过的问题给模板 sheet 设定保护以后发现整个工作表都不能编辑了想设置“部分列可编辑其他列只读”怎么做先解释原因Excel 的单元格有Locked属性默认值就是 true。当你给 sheet 执行protectSheet(password)后所有 Locked 为 true 的单元格都会变成只读。EasyExcel 并不会在写表时自动设置 Locked 为 false所以一旦你调用了 protectSheet整个表就被锁死了。解决办法是先通过WriteHandler把允许编辑的列单元格的 Locked 属性设成 false再去protectSheet。下面这段代码是我封装好的模板生成逻辑public class SheetProtectWriteHandler implements WriteHandler { private final String protectPassword; private final SetInteger editableColumnIndexes; public SheetProtectWriteHandler(String protectPassword, SetInteger editableColumnIndexes) { this.protectPassword protectPassword; this.editableColumnIndexes editableColumnIndexes; } Override public void afterCellDispose(WriteWorkbook writeWorkbook, WriteSheet writeSheet, WriteTable writeTable, WriteCellData? headCellData, Cell cell, CellType cellType, Boolean isHead, Boolean isEmptyValue) { if (!isHead editableColumnIndexes.contains(cell.getColumnIndex())) { CellStyle style cell.getCellStyle(); style.setLocked(false); } } Override public void afterSheetCreate(WriteWorkbook writeWorkbook, WriteSheet writeSheet) { // 这里要在所有单元格都写完再执行才能保证 Locked 设置生效 } Override public void sheet(int sheetNo, WriteSheet writeSheet) { // 注意在这个回调里执行 protectSheet 可能太早 } }这里有一个顺序问题我在 initial 版本里踩过afterCellDispose是在每个单元格写入后回调而sheet回调是在 sheet 创建时触发。如果你在sheet回调里执行protectSheet此时很多单元格还没写入即使后面设置 Locked 为 false也可能被保护逻辑影响。更稳妥的方式是在afterAllSheetsDisposed回调WorkbookWriteHandler里执行 protectSheet但 EasyExcel 的 WriteHandler 和 WorkbookWriteHandler 是两个不同接口。我把 protect 逻辑放在最终回调解法如下public class SheetProtectWorkbookWriteHandler implements WorkbookWriteHandler { private final String password; private final SetInteger unLockedColumnIndexes; Override public void afterWorkbookDispose(WriteWorkbook writeWorkbook) { // do nothing } Override public void afterAllSheetsDisposed(WriteWorkbook writeWorkbook) { for (Sheet sheet : writeWorkbook.getWorkbook().getAllSheets()) { // 先把可编辑列 Locked 设为 false遍历该列所有行 for (Integer col : unLockedColumnIndexes) { for (Row row : sheet) { Cell cell row.getCell(col); if (cell ! null) { cell.getCellStyle().setLocked(false); } } } sheet.protectSheet(password); } } }当然如果你只是生成模板写数据量不大也可以一开始就设置好全表默认 Locked false然后在需要只读的列设置 Locked true。实际操作中我把“可编辑列”和“只读列”作为配置项传入模板生成逻辑就完全可复用了。注意protectSheet的密码参数是明文存储的它只是防止误操作不是安全加密。如果真的担心用户改模板密码的作用只是“增加修改门槛”专业安全还得靠服务端二次校验。4.2 冻结列、冻结行让用户盯着数据不迷路冻结列是表格操作里非常常见的需求。EasyExcel 没有直接提供类似.freeze(3, 1)的 API但用 WriteHandler 可以轻松实现。EasyExcel.write(outputStream, ProductExcelVO.class) .registerWriteHandler(new WriteHandler() { Override public void sheet(int sheetNo, WriteSheet writeSheet) { Sheet sheet writeSheet.getSheet(); // 冻结第 4 列及之前的列 冻结第 1 行 sheet.createFreezePane(3, 1); } }) .sheet(商品) .doWrite(dataList);createFreezePane(3, 1)的含义要理解对第一个参数是 freeze 的列数3 表示前 3 列冻结第二个参数是 freeze 的行数1 表示第 1 行冻结。所以如果要冻结前两列是createFreezePane(2, 0)。如果要表头行冻结且只冻结第一行是createFreezePane(0, 1)。这个接口还有一个重载createFreezePane(int colSplit, int rowSplit, int leftmostColumn, int topRow)后两个参数是指定右边第一个可见单元格的位置。一般情况下用三个参数的版本就够了。我做报表时通常同时冻结表头行 前几列用户横向滚动的时候能看到编号和名称体验提升得非常明显。4.3 序号列别迷信 Excel 行号热搜词里“easyexcel 增加序号”这个需求很典型。很多人会问既然 Excel 本身有行号显示为什么还要在数据里加一列序号答案是Excel 左上角那个行号在打印预览、用户复制到别处、或者数据里有过滤/隐藏行时都不会跟着数据走而业务上经常需要把“序号”作为一个真实可见的列导出。我推荐的实现方式是不要在数据库查询时拼序号也不要直接在实体类加序号字段并循环赋值最优雅的方案是在导出写单元格时按行号自动填充。上面CustomSheetWriteHandler里已经写了思路这里我再给一个更严谨的版本支持分批次写入时序号不断public class RowNumberWriteHandler extends AbstractRowWriteHandler { private int rowNumber 1; Override public void afterRowDispose(WriteWorkbook writeWorkbook, WriteSheet writeSheet, Row row, Integer relativeRowIndex, Boolean isHead) { if (!isHead) { Cell cell row.createCell(0); cell.setCellValue(rowNumber); } } }用AbstractRowWriteHandler的好处是 EasyExcel 3.x 提供了行级回调不用再去判断列索引。但要注意如果你导出的实体类第一列已经有数据比如ExcelProperty(value 序号, index 0)那afterRowDispose里的createCell(0)会覆盖原值。所以要么实体类不要定义“序号”列要么在回调里拿到cell.getCellStyle()后 set 新值。我更推荐用“模板 实体类控制列顺序”的方式在实体类里保留一个空字段专门放序号导入时忽略该列。4.4 模板填充不写代码也能导出复杂模板除了EasyExcel.write()全量生成EasyExcel 还支持fill模板填充这个功能我在做“年度汇总表”“员工入职表”时经常用到。先做一个模板里面写{name}、{date}这类占位符代码里加载模板InputStream templateStream new FileInputStream(template/年度汇总.xlsx); ExcelWriter excelWriter EasyExcel.write(outputStream).withTemplate(templateStream).build(); WriteSheet writeSheet EasyExcel.writerSheet(年度汇总).build(); // 填充单个变量 FillConfig fillConfig FillConfig.builder().forceNewRow(true).build(); MapString, Object headerData new HashMap(); headerData.put(year, 2024); headerData.put(date, 2024-12-31); excelWriter.fill(headerData, fillConfig, writeSheet); // 填充列表每行一组数据 ListSummaryRow rows summaryService.listAnnualData(); excelWriter.fill(rows, fillConfig, writeSheet); excelWriter.finish();模板填充时有个要注意的地方forceNewRow(true)表示每次 fill 都从新的一行开始填充否则列表数据会连续往下写。如果模板中有纵向合并单元格fill 的机制对合并区域支持得不好可能把合并单元格拆开所以模板设计要尽量避免在数据列做纵向合并。这个功能我强烈建议封装复用因为模板 fill 的方式可以把复杂的表头、样式、水印全部放在 Excel 模板里由业务人员维护程序员只负责喂数据两边都轻松。5. 常见问题排查实录我踩过的那些坑和执行细节5.1 高频问题速查表下表是我在项目群里被问得最多的 EasyExcel 问题基本覆盖了大部分使用场景问题现象原因解决方案导入时中文表头匹配不上实体类实体类注解value和 Excel 实际表头不一致可能是空格或全半角差异invokeHeadMap里打印表头 map 对比或直接用列索引取值不依赖注解匹配导出后数字变成科学计数法长数字如身份证、单号被 Excel 解析为数值实体字段用String并设置ExcelProperty(converter ...)或ColumnWidth加宽列必要时用自定义Converter把值写成文本导入后 BigDecimal 精度丢失Excel 单元格数字格式问题用NumberFormat(0.00)或自定义转换器大数据量导出内存溢出自动采用了非流式模式检查代码是否误用EasyExcel.writer()的excelWriter一次性写全部数据用SXSSFWorkbook的安全阈值或手动分批fill模板保护后整表不能编辑protectSheet 会把 Locked 默认 true 的全部锁掉先设置可编辑列setLocked(false)最后调用protectSheet文件下载时中文文件名乱码Content-Disposition 未处理编码用URLEncoder.encode(fileName, UTF-8)并保证 header 里带filename*utf-8读大数据文件很慢监听器里做了耗时操作invoke里不要做远程调用只解析和攒批耗时逻辑放到批量入库时表头有合并单元格导致列错位合并单元格的值只出现在合并区域左上角invokeHeadMap拿到 null 后根据合并区域补充值或直接用列索引取值5.2 易错代码点POI 对象混用EasyExcel 逐步走向 3.x 后内部 API 变化略大。最容易出现的问题是在自定义 Handler 里直接使用 POI 原生对象时方法名对不上。比如setLocked在 POI 的CellStyle里是有的但 EasyExcel 的CellStyle不一定和 POI 的CellStyle完全一致。实际在 Handler 里拿到的Cell、Sheet都是 POI 原生对象所以可以直接调用但要留意 API 版本。另一个细节是ReadListener接口在 3.x 中invokeHeadMap的默认实现是一个空方法。如果你只关心数据不重写该方法也可以。但我每次都会重写它来做表头校验毕竟一个文件传进来连表头都不对后面解析出来的数据也没有意义。5.3 一个真实排查案例导入模板明明有下拉框导入后却丢了有次用户反映他们用我们系统生成的模板填了数据导入时总是报错说“分类列数据不存在”。我们排查了很久发现模板里分类列有下拉框但用户不是从下拉框里选的而是手打了一个“数码产品”。我们在模板里预置的下拉选项是“数码|家电|服饰”用户填了“数码产品”自然匹配不上。这个案例告诉我们两个事第一下拉框只是“软约束”它拦不住用户手输任何值第二导入校验里不能只依赖预设的枚举值做硬校验要考虑“近似值”、“别名”的匹配策略。我在项目中给枚举字段设计了一套“合法值 别名”映射导入校验时优先匹配别名用户填“数码产品”能自动归到“数码”分类。这个策略表面上增加了代码复杂度和维护量但在真实使用场景中帮用户省了大量纠错时间反馈比死校验好得多。这背后其实也说明所有 Excel 数据导入功能本质上都不是“解析 Excel”而是“数据清洗 转换 入库”。把 Excel 解析出来只是第一步更重要的是校验规则的设计和错误反馈机制。另一个真实案例是导出的数字列被用户手动改了格式比如加了千分位分隔符变成文本导入时new BigDecimal()直接 NumberFormatException。我们后来写了一个宽松转换工具类尝试去掉千分位、货币符号、全角数字再做数值转换。这些虽然不属于 EasyExcel 本身的代码问题但却是“文件导入导出”项目里真正影响用户满意度的地方。技术工具只是骨架数据处理细节才是血肉。5.4 性能调优经验导入导出超时怎么办如果你的导入或导出操作经常超时先看是不是犯了这几个错第一导入时在监听器里做了逐行的数据库查询。比如你导入商品数据时要查分类 ID每个商品都去查一次分类表10 万行就是 10 万次查询。正确姿势是先把所有分类查出来放进内存 Map再在解析时做内存映射。对于分类、字典这类“变化率极低”的数据完全可以启动时缓存或批量预加载。第二导出时没有做“分批查询”。如果数据量很大比如百万行建议不要一次性SELECT * FROM xxx到内存而是用LIMIT分批查询每次查询一部分边查边用excelWriter.write()分片写入写完一屏数据就writer.write()一次。这样内存占用低响应时间也能平滑。第三没有合理设置SXSSFWorkbook的滑动窗口大小。EasyExcel 底层默认用到SXSSFWorkbook时你可以通过ExcelWriter的配置调整内存使用阈值。如果一次doWrite的数据量太大比如单 sheet 超过 30 万行建议拆成多个 sheet 或文件单 sheet 太大即使不 OOM打开文件也会卡顿不一定比拆文件更好。我做过的一版导 50 万行数据的方案按 5 万行拆一个 sheet共 10 个 sheet每个 sheet 写完就释放内存整体内存峰值控制在 200MB 以内。代码结构上就是循环构建WriteSheetexcelWriter.write(data, writeSheet)最后finish。这个方案在生产环境跑了两年从没因为导出 OOM。拆 sheet 还有一个好处用户不用打开一个巨大的工作簿加载速度更快。如果你的业务允许导出的数据量比较大时拆 sheet 是优先策略。6. 写在最后的个人经验做了多年文件处理相关的功能我最大的感受是导入导出这种需求看起来“简单”但它永远是一个“细节毁灭”型模块。你解决了 95% 的场景剩下 5% 的复杂表头、编码问题、样式定制、异常处理才是真正区分“能用”和“好用”的地方。EasyExcel 能把那 95% 的活快速干完但剩下 5% 的坑还得靠经验去填。EasyExcel 的学习成本不高官方文档在 Gitee 和 GitHub 上都有但文档更新速度一般部分高级用法例如自定义 Handler需要结合源码排查。我建议在使用高级功能前先在你的项目里引入 EasyExcel 源码调试断点走一遍特别是WriteHandler、ReadListener这两个扩展点的调用时机弄清楚了很多“看不懂为什么这样做”的问题都能迎刃而解。最后再说一个我从项目实战里悟出来的规矩所有导入导出功能上线前一定要拿真实业务量的三倍数据做压力测试测的不仅是内存和耗时更要看文件内容和 Excel 打开效果。格式错乱、行数不对、列宽异常这类问题在自动化测试里很难被发现只能在手工抽验里解决。流程上我会在新增导出功能后固定让产品经理下载一次真实数据导出文件做人工确认这个看起来“很土”的环节实际上帮我堵住了大量返工。