EasyExcel实现Excel复杂格式导出:合并单元格与样式设置实战
EasyExcel实现Excel复杂格式导出:合并单元格与样式设置实战
背景介绍
项目技术栈
在一次需求的开发过程中,我们需要实现一个具有复杂格式要求的Excel导出功能。项目采用了以下技术栈:
- Spring Boot: 3.2.0
- JDK: 21
- EasyExcel: 3.3.4 (阿里巴巴开源的Excel处理工具)
- Apache POI: 5.2.4 (EasyExcel底层依赖)
- MyBatis-Plus: 3.5.5
- Maven: 3.9.x
业务需求
需要导出的Excel文件具有特定的格式要求:
- 第1行:标题行"XXXX台账",需要合并A1:O1单元格
- 第2行:15个字段的表头行
- 第3行:详细的填写说明
- 第4行及以后:实际数据内容
每一行都需要不同的样式设置:
- 标题行:Calibri字体,16号,加粗,居中对齐
- 表头行:Calibri字体,11号,加粗,居中对齐
- 说明行:Calibri字体,10号,斜体,左对齐,自动换行
- 数据行:Calibri字体,10号,普通,左对齐
技术实现方案
1. 基础架构设计
首先,我们需要禁用EasyExcel的默认表头机制,采用手动控制的方式来精确控制每一行的内容和样式。
@Override
public void exportMarketingPhoneData(MarketingPhoneQueryDTO queryDTO, HttpServletResponse response) throws IOException {log.info("Export marketing phone data with params: {}", queryDTO);// 设置响应头setExcelExportResponseHeaders(response, EXPORT_FILE_NAME_PREFIX);// 初始化Excel写入器,关键:不使用模板类,直接操作工作表ExcelWriter excelWriter = null;try {excelWriter = EasyExcelFactory.write(response.getOutputStream()).excelType(ExcelTypeEnum.XLSX).registerWriteHandler(new TitleMergeStrategy()) // 注册自定义处理器.build();WriteSheet writeSheet = EasyExcelFactory.writerSheet(EXPORT_SHEET_NAME).needHead(false) // 关键:禁用默认表头.build();// 分层写入数据writeTitleRow(excelWriter, writeSheet); // 写入标题行writeHeaderRow(excelWriter, writeSheet); // 写入表头行 writeDescriptionRow(excelWriter, writeSheet); // 写入说明行writeDataRows(excelWriter, writeSheet, queryDTO); // 写入数据行log.info("Export marketing phone data completed");} finally {if (excelWriter != null) {excelWriter.finish();}}
}
2. 数据写入方法实现
/*** 写入标题行(第1行)*/
private void writeTitleRow(ExcelWriter excelWriter, WriteSheet writeSheet) {List<List<String>> titleData = new ArrayList<>();List<String> titleRow = new ArrayList<>();titleRow.add("台账");// 为其他14列填充空字符串,确保合并单元格正确for (int i = 1; i < 15; i++) {titleRow.add("");}titleData.add(titleRow);excelWriter.write(titleData, writeSheet);
}/*** 写入表头行(第2行)*/
private void writeHeaderRow(ExcelWriter excelWriter, WriteSheet writeSheet) {List<List<String>> headerData = new ArrayList<>();List<String> headerRow = Arrays.asList("序号", "省份", "地市", "号码类型", "客户名称", "客户编码", "接入时间","客户资质", "使用期限", "线路资源类型", "实际使用客户", "实际使用客户的接入时间", "场景用途", "号码", "办理渠道");headerData.add(headerRow);excelWriter.write(headerData, writeSheet);
}
核心技术难点与解决方案
问题1:单元格合并冲突
遇到的问题:
Cannot add merged region A1:O1 to sheet because it overlaps with an existing merged region
问题分析:
最初尝试使用AbstractMergeStrategy
抽象类时,EasyExcel在某些情况下会重复尝试合并同一区域的单元格,导致冲突。
解决方案:
改用SheetWriteHandler
接口,在afterSheetCreate
方法中执行一次性合并:
private static class TitleMergeStrategy implements SheetWriteHandler, RowWriteHandler {@Overridepublic void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {Sheet sheet = writeSheetHolder.getSheet();// 合并第一行的A1到O1(行索引0,列索引0-14)CellRangeAddress cellRangeAddress = new CellRangeAddress(0, 0, 0, 14);sheet.addMergedRegion(cellRangeAddress);log.info("Successfully merged title row: A1:O1");}
}
问题2:autoSizeColumn列宽自适应失败
遇到的问题:
IllegalStateException: Could not auto-size column. Make sure the column was tracked prior to auto-sizing the column.
问题分析:
EasyExcel基于Apache POI的SXSSFSheet实现,为了内存效率,默认不跟踪所有列的内容,因此无法自动计算列宽。
解决方案:
放弃autoSizeColumn,在sheet创建时预设固定列宽:
@Override
public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {Sheet sheet = writeSheetHolder.getSheet();// 合并标题行CellRangeAddress cellRangeAddress = new CellRangeAddress(0, 0, 0, 14);sheet.addMergedRegion(cellRangeAddress);// 预设列宽,避免autoSizeColumn的追踪问题int[] columnWidths = {2500, // 序号2500, // 省份 3000, // 地市3500, // 号码类型6000, // 客户名称4000, // 客户编码3500, // 接入时间8000, // 客户资质3500, // 使用期限4000, // 线路资源类型6000, // 实际使用客户4000, // 实际使用客户的接入时间8000, // 场景用途4000, // 号码3000 // 办理渠道};for (int i = 0; i < 15; i++) {sheet.setColumnWidth(i, columnWidths[i]);}
}
问题3:样式设置时机和作用域
遇到的问题:
样式设置不生效,特别是合并单元格的样式无法正确应用。
解决方案:
在afterRowDispose
方法中设置样式,确保在行数据完全写入后再应用样式:
@Override
public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {if (row != null) {Workbook workbook = writeSheetHolder.getParentWriteWorkbookHolder().getWorkbook();int rowIndex = row.getRowNum();if (rowIndex == 0) {// 标题行样式setTitleRowStyle(row, workbook);} else if (rowIndex == 1) {// 表头行样式setHeaderRowStyle(row, workbook);} else if (rowIndex == 2) {// 说明行样式setDescriptionRowStyle(row, workbook);} else {// 数据行样式setDataRowStyle(row, workbook);}}
}
样式设置的最佳实践
1. 样式创建方法
为了确保合并单元格的样式正确应用,需要对所有相关单元格都设置样式:
/*** 设置标题行样式*/
private void setTitleRowStyle(Row row, Workbook workbook) {CellStyle titleStyle = workbook.createCellStyle();Font titleFont = workbook.createFont();// 设置字体titleFont.setFontName("Calibri");titleFont.setFontHeightInPoints((short) 16);titleFont.setBold(true);// 设置样式titleStyle.setFont(titleFont);titleStyle.setAlignment(HorizontalAlignment.CENTER);titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);// 关键:对合并区域的所有单元格都设置样式for (int i = 0; i < 15; i++) {Cell cell = row.getCell(i);if (cell == null) {cell = row.createCell(i);}cell.setCellStyle(titleStyle);}
}/*** 设置说明行样式(支持自动换行)*/
private void setDescriptionRowStyle(Row row, Workbook workbook) {CellStyle descStyle = workbook.createCellStyle();Font descFont = workbook.createFont();// 设置字体descFont.setFontName("Calibri");descFont.setFontHeightInPoints((short) 10);descFont.setItalic(true);// 设置样式descStyle.setFont(descFont);descStyle.setAlignment(HorizontalAlignment.LEFT);descStyle.setVerticalAlignment(VerticalAlignment.CENTER);descStyle.setWrapText(true); // 启用自动换行// 应用到所有说明单元格for (int i = 0; i < 15; i++) {Cell cell = row.getCell(i);if (cell == null) {cell = row.createCell(i);}cell.setCellStyle(descStyle);}
}
2. 行高设置
在afterRowCreate
方法中设置不同行的高度:
@Override
public void afterRowCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {if (row != null) {int rowIndex = row.getRowNum();if (rowIndex == 0) {row.setHeightInPoints(23.2f); // 标题行高度} else if (rowIndex == 1) {row.setHeightInPoints(16.8f); // 表头行高度} else if (rowIndex == 2) {row.setHeightInPoints(107.0f); // 说明行高度(因为内容较多)} else {row.setHeightInPoints(20); // 数据行高度}}
}
代码优化与最佳实践
1. 常量提取,消除魔法值
// Excel样式常量
private static final String EXCEL_FONT_NAME = "Calibri";
private static final short TITLE_FONT_SIZE = 16;
private static final short HEADER_FONT_SIZE = 11;
private static final short DESCRIPTION_FONT_SIZE = 10;
private static final short DATA_FONT_SIZE = 10;// 行高常量
private static final float TITLE_ROW_HEIGHT = 23.2f;
private static final float HEADER_ROW_HEIGHT = 16.8f;
private static final float DESCRIPTION_ROW_HEIGHT = 107.0f;
private static final float DATA_ROW_HEIGHT = 20f;// Excel结构常量
private static final int EXCEL_COLUMN_COUNT = 15;
private static final int TITLE_START_COLUMN = 0;
private static final int TITLE_END_COLUMN = 14;// 行索引常量
private static final class RowIndex {static final int TITLE = 0;static final int HEADER = 1;static final int DESCRIPTION = 2;static final int DATA_START = 3;
}
2. 方法职责单一化
/*** 创建标题行单元格样式*/
private CellStyle createTitleCellStyle(Workbook workbook) {CellStyle titleStyle = workbook.createCellStyle();Font titleFont = workbook.createFont();titleFont.setFontName(EXCEL_FONT_NAME);titleFont.setFontHeightInPoints(TITLE_FONT_SIZE);titleFont.setBold(true);titleStyle.setFont(titleFont);titleStyle.setAlignment(HorizontalAlignment.CENTER);titleStyle.setVerticalAlignment(VerticalAlignment.CENTER);return titleStyle;
}/*** 获取或创建单元格*/
private Cell getOrCreateCell(Row row, int columnIndex) {Cell cell = row.getCell(columnIndex);if (cell == null) {cell = row.createCell(columnIndex);}return cell;
}
性能优化策略
1. 分批查询避免内存溢出
private void writeDataRows(ExcelWriter excelWriter, WriteSheet writeSheet, MarketingPhoneQueryDTO queryDTO) {final int PAGE_SIZE = 1000; // 每批处理1000条记录int currentPage = 1;while (true) {Page<MarketingPhone> page = new Page<>(currentPage, PAGE_SIZE);LambdaQueryWrapper<MarketingPhone> queryWrapper = buildCountWrapper(queryDTO);IPage<MarketingPhone> pageResult = marketingPhoneMapper.selectPage(page, queryWrapper);if (pageResult.getRecords().isEmpty()) {break;}// 转换为导出数据格式List<List<String>> dataRows = convertToDataRows(pageResult.getRecords(), currentPage, PAGE_SIZE);excelWriter.write(dataRows, writeSheet);currentPage++;// 如果当前页记录数小于页大小,说明已经是最后一页if (pageResult.getRecords().size() < PAGE_SIZE) {break;}}
}
2. 使用流式处理转换数据
private List<List<String>> convertToDataRows(List<MarketingPhone> records, int currentPageNum, int pageSize) {if (records.isEmpty()) {return List.of();}final int startIndex = (currentPageNum - 1) * pageSize;return IntStream.range(0, records.size()).mapToObj(i -> {MarketingPhone record = records.get(i);List<String> row = new ArrayList<>(15);row.add(String.valueOf(startIndex + i + 1)); // 序号row.add(MarketingPhoneUtil.getProvinceName()); // 省份row.add(MarketingPhoneUtil.getCityNameById(record.getCity())); // 地市// ... 其他字段return row;}).toList();
}
总结
通过本次Excel复杂格式导出功能的实现,我们掌握了以下关键技术点:
成功要素
- 正确的Handler选择:使用
SheetWriteHandler
而非AbstractMergeStrategy
避免合并冲突 - 样式应用时机:在
afterRowDispose
中设置样式确保数据写入完成 - 列宽管理策略:预设固定宽度替代autoSizeColumn避免追踪问题
- 分批处理机制:避免大数据量导致的内存溢出
技术收益
- 专业的Excel输出:实现了完全符合业务要求的复杂格式
- 良好的性能表现:支持大数据量导出而不会内存溢出
- 高可维护性:通过常量提取和方法拆分提高代码质量
- 可扩展性:架构设计支持未来的格式变更需求
注意事项
- 版本兼容性:EasyExcel 3.3.4与Apache POI 5.2.4的兼容性良好
- 内存管理:大文件导出时建议使用SXSSFWorkbook的流式写入
- 样式复用:避免重复创建相同的CellStyle对象,可以提高性能
- 异常处理:确保ExcelWriter在finally块中正确关闭
这个实现方案在生产环境中稳定运行,成功解决了复杂Excel格式导出的技术难题,为类似需求提供了可靠的技术参考。