EasyExcel合并单元格

EasyExcel合并单元格:基于 AbstractMergeStrategy 的灵活实现

在生成 Excel 报表时,合并单元格是优化表格可读性的常用手段(如合并相同值的相邻单元格)。EasyExcel 并未直接提供合并单元格的 API,但通过其钩子机制(尤其是 AbstractMergeStrategy 抽象类),可灵活实现自定义合并逻辑。本文将详细讲解如何基于 AbstractMergeStrategy 实现单元格合并,并通过实例演示 “相同值自动合并” 的功能。

合并单元格的核心思路

EasyExcel 合并单元格的核心是通过 单元格钩子(CellWriteHandler) 监听单元格创建过程,在数据写入时动态判断是否需要合并。具体步骤:

  1. 记录当前单元格的值及其所在行索引;
  2. 与上一行同列单元格的值对比,若相同则标记为待合并区域;
  3. 当值发生变化或到达最后一行时,执行合并操作(通过 POI 的 Sheet.addMergedRegion 方法)。

AbstractMergeStrategy 是 EasyExcel 提供的合并策略抽象类,实现了 CellWriteHandler 接口,简化了合并逻辑的开发(只需重写 merge 方法)。

自定义合并策略:相同值自动合并

以下实现一个通用的合并策略:对指定字段(列),自动合并相邻的相同值单元格。

实现自定义合并策略类

import com.alibaba.excel.metadata.Head;  
import com.alibaba.excel.write.merge.AbstractMergeStrategy;  
import org.apache.poi.ss.usermodel.Cell;  
import org.apache.poi.ss.usermodel.CellType;  
import org.apache.poi.ss.usermodel.Sheet;  
import org.apache.poi.ss.util.CellRangeAddress;  
import java.util.HashMap;  
import java.util.List;  
import java.util.Map;  
import java.util.Objects;  

/**  
 * 自定义合并策略:自动合并指定字段的相邻相同值单元格  
 */  
public class SameValueMergeStrategy extends AbstractMergeStrategy {  

    // 需要合并的字段名(与实体类的 @ExcelProperty 字段对应)  
    private final List<String> mergeFieldNames;  
    // 数据总条数(用于判断是否为最后一行)  
    private final int totalRowCount;  

    // 存储上一行的数据:key=字段名,value=Pair(值, 行索引)  
    private final Map<String, RowData> lastRowDataMap = new HashMap<>();  

    // 内部类:存储行数据(值和行索引)  
    private static class RowData {  
        Object value;       // 单元格值  
        int rowIndex;       // 行索引(从 0 开始)  

        RowData(Object value, int rowIndex) {  
            this.value = value;  
            this.rowIndex = rowIndex;  
        }  
    }  

    public SameValueMergeStrategy(List<String> mergeFieldNames, int totalRowCount) {  
        this.mergeFieldNames = mergeFieldNames;  
        this.totalRowCount = totalRowCount;  
    }  

    @Override  
    protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {  
        // relativeRowIndex:相对行索引(0 表示表头行,1 开始为数据行)  
        // 跳过表头行(只处理数据行)  
        if (relativeRowIndex == null || relativeRowIndex < 1) {  
            return;  
        }  

        // 当前数据行的绝对索引(0 开始,表头行占 1 行)  
        int currentRowIndex = relativeRowIndex;  
        // 当前字段名(从 Head 中获取)  
        String currentFieldName = head.getFieldName();  

        // 仅处理需要合并的字段  
        if (mergeFieldNames.contains(currentFieldName)) {  
            // 获取当前单元格的值  
            Object currentValue = getCellValue(cell);  

            // 从缓存中获取上一行同字段的数据  
            RowData lastRowData = lastRowDataMap.get(currentFieldName);  

            if (lastRowData == null) {  
                // 首次处理该字段,直接缓存当前行数据  
                lastRowDataMap.put(currentFieldName, new RowData(currentValue, currentRowIndex));  
            } else {  
                // 对比当前值与上一行值  
                if (!Objects.equals(currentValue, lastRowData.value)) {  
                    // 值不同:合并上一段相同值的单元格(如果行数 >1)  
                    mergeIfNeeded(sheet, lastRowData, currentRowIndex - 1, cell.getColumnIndex());  
                    // 更新缓存为当前行数据  
                    lastRowDataMap.put(currentFieldName, new RowData(currentValue, currentRowIndex));  
                } else if (currentRowIndex == totalRowCount) {  
                    // 值相同且为最后一行:合并到最后一行  
                    mergeIfNeeded(sheet, lastRowData, currentRowIndex, cell.getColumnIndex());  
                }  
            }  
        }  
    }  

    /**  
     * 执行合并操作(如果需要)  
     * @param sheet 工作表  
     * @param lastRowData 上一行数据  
     * @param endRowIndex 结束行索引  
     * @param columnIndex 列索引  
     */  
    private void mergeIfNeeded(Sheet sheet, RowData lastRowData, int endRowIndex, int columnIndex) {  
        int startRowIndex = lastRowData.rowIndex;  
        // 只有当行数差 >0 时才合并(至少 2 行)  
        if (endRowIndex - startRowIndex > 0) {  
            CellRangeAddress mergeRegion = new CellRangeAddress(  
                startRowIndex,  // 起始行  
                endRowIndex,    // 结束行  
                columnIndex,    // 起始列  
                columnIndex     // 结束列(同列)  
            );  
            // 添加合并区域  
            sheet.addMergedRegion(mergeRegion);  
        }  
    }  

    /**  
     * 获取单元格的值(兼容不同数据类型)  
     */  
    private Object getCellValue(Cell cell) {  
        if (cell == null) {  
            return "";  
        }  
        CellType cellType = cell.getCellType();  
        switch (cellType) {  
            case STRING:  
                return cell.getStringCellValue();  
            case NUMERIC:  
                // 处理数字和日期(简化处理,实际可根据需求优化)  
                return cell.getNumericCellValue();  
            case BOOLEAN:  
                return cell.getBooleanCellValue();  
            default:  
                return "";  
        }  
    }  
}

实体类定义

假设需要合并 “部门” 列,实体类如下:

import com.alibaba.excel.annotation.ExcelProperty;  
import lombok.Data;  

@Data  
public class Employee {  
    @ExcelProperty("姓名")  
    private String name;  

    @ExcelProperty("部门")  // 需要合并的字段  
    private String department;  

    @ExcelProperty("职位")  
    private String position;  
}

测试代码

import com.alibaba.excel.EasyExcel;  
import java.util.ArrayList;  
import java.util.Arrays;  
import java.util.List;  

public class MergeCellTest {  
    public static void main(String[] args) {  
        // 1. 准备测试数据(部门相同的相邻行将被合并)  
        List<Employee> dataList = new ArrayList<>();  
        dataList.add(new Employee("张三", "技术部", "开发工程师"));  
        dataList.add(new Employee("李四", "技术部", "测试工程师"));  // 同部门  
        dataList.add(new Employee("王五", "技术部", "架构师"));      // 同部门  
        dataList.add(new Employee("赵六", "市场部", "市场专员"));    // 不同部门  
        dataList.add(new Employee("钱七", "市场部", "市场经理"));    // 同部门  

        // 2. 配置合并策略:合并“department”字段,数据总条数为 5  
        List<String> mergeFields = Arrays.asList("department");  // 要合并的字段名  
        SameValueMergeStrategy mergeStrategy = new SameValueMergeStrategy(mergeFields, dataList.size());  

        // 3. 写入 Excel 并应用合并策略  
        String filePath = "员工信息_合并单元格.xlsx";  
        EasyExcel.write(filePath, Employee.class)  
                .registerWriteHandler(mergeStrategy)  // 注册合并策略  
                .sheet("员工表")  
                .doWrite(dataList);  

        System.out.println("文件生成成功:" + filePath);  
    }  
}

实现说明与扩展

核心逻辑解析

  • 缓存机制:通过 lastRowDataMap 记录上一行的数据,避免重复读取;
  • 合并时机:当当前值与上一行不同,或到达最后一行时,触发合并操作;
  • 字段过滤:仅对 mergeFieldNames 中指定的字段执行合并,不影响其他列。

扩展场景

多列合并

只需在 mergeFieldNames 中添加多个字段名即可,例如同时合并 “部门” 和 “分公司”:

List<String> mergeFields = Arrays.asList("department", "company");
跨列合并

修改 CellRangeAddress 的列索引范围,例如合并 “部门” 和 “职位” 列(需确保两行数据完全相同):

// 在 mergeIfNeeded 方法中调整列范围  
CellRangeAddress mergeRegion = new CellRangeAddress(  
    startRowIndex, endRowIndex,  
    1, 2  // 合并第 1 列(部门)到第 2 列(职位)  
);
条件合并

merge 方法中添加自定义条件,例如仅合并值为 “技术部” 的单元格:

// 在对比值时添加条件  
if (!Objects.equals(currentValue, lastRowData.value) || "技术部".equals(currentValue)) {  
    // 执行合并...  
}

注意事项

  1. 表头行处理relativeRowIndex 从 0 开始,其中 0 代表表头行,需跳过(避免合并表头);
  2. 性能优化:合并操作会触发 Excel 内部重绘,大量合并可能影响性能,建议仅对必要字段合并;
  3. 合并冲突:同一区域多次合并会导致异常,需确保合并区域不重叠;
  4. POI 版本兼容Sheet.addMergedRegion 是 POI 的方法,需与 EasyExcel 依赖的 POI 版本匹配(推荐使用 EasyExcel 3.x 配套的 POI 5.x)。