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. 第1行:标题行"XXXX台账",需要合并A1:O1单元格
  2. 第2行:15个字段的表头行
  3. 第3行:详细的填写说明
  4. 第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复杂格式导出功能的实现,我们掌握了以下关键技术点:

成功要素

  1. 正确的Handler选择 :使用SheetWriteHandler而非AbstractMergeStrategy避免合并冲突
  2. 样式应用时机 :在afterRowDispose中设置样式确保数据写入完成
  3. 列宽管理策略:预设固定宽度替代autoSizeColumn避免追踪问题
  4. 分批处理机制:避免大数据量导致的内存溢出

技术收益

  1. 专业的Excel输出:实现了完全符合业务要求的复杂格式
  2. 良好的性能表现:支持大数据量导出而不会内存溢出
  3. 高可维护性:通过常量提取和方法拆分提高代码质量
  4. 可扩展性:架构设计支持未来的格式变更需求

注意事项

  1. 版本兼容性:EasyExcel 3.3.4与Apache POI 5.2.4的兼容性良好
  2. 内存管理:大文件导出时建议使用SXSSFWorkbook的流式写入
  3. 样式复用:避免重复创建相同的CellStyle对象,可以提高性能
  4. 异常处理:确保ExcelWriter在finally块中正确关闭

这个实现方案在生产环境中稳定运行,成功解决了复杂Excel格式导出的技术难题,为类似需求提供了可靠的技术参考。

相关推荐
吃我两拳2 小时前
EasyExcel停止当前Sheet的读取,且不影响主线程及其他Sheet读取的方法
excel
qq_393828225 小时前
办公文档批量打印器 Word、PPT、Excel、PDF、图片和文本,它都支持批量打印。
windows·word·powerpoint·excel·软件需求
过期的秋刀鱼!7 小时前
用“做饭”理解数据分析流程(Excel三件套实战)
数据挖掘·数据分析·excel·powerbi·数据分析入门
挑战者6668887 小时前
如何将Excel表的内容转化为json格式呢?
excel
张太行_10 天前
MySQL与Excel比较
数据库·mysql·excel
cwtlw10 天前
Excel学习03
笔记·学习·其他·excel
郭优秀的笔记10 天前
计算本地Excel某两列的差异值
excel
涔溪10 天前
实现 el-table 中键盘方向键导航功能vue2+vue3(类似 Excel)
前端·vue.js·excel