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的默认表头机制,采用手动控制的方式来精确控制每一行的内容和样式。
java
@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. 数据写入方法实现
java
/**
* 写入标题行(第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
方法中执行一次性合并:
java
private static class TitleMergeStrategy implements SheetWriteHandler, RowWriteHandler {
@Override
public 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创建时预设固定列宽:
java
@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
方法中设置样式,确保在行数据完全写入后再应用样式:
java
@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. 样式创建方法
为了确保合并单元格的样式正确应用,需要对所有相关单元格都设置样式:
java
/**
* 设置标题行样式
*/
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
方法中设置不同行的高度:
java
@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. 常量提取,消除魔法值
java
// 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. 方法职责单一化
java
/**
* 创建标题行单元格样式
*/
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. 分批查询避免内存溢出
java
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. 使用流式处理转换数据
java
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格式导出的技术难题,为类似需求提供了可靠的技术参考。