EasyExcel 合并单元格最佳实践:基于注解的自动合并与样式控制
前言
在日常开发中,我们经常需要导出 Excel 报表,而合并单元格是提升报表可读性的常见需求。本文将介绍如何基于 EasyExcel 实现智能的单元格合并功能,通过自定义注解 @ExcelMerge
标记需要合并的字段,并确保合并后的内容完美居中对齐。
核心功能
- 注解驱动 :通过
@ExcelMerge
注解标记需要合并的字段 - 自动合并:相邻行相同值的单元格自动合并
- 样式控制:合并后的单元格内容水平和垂直居中
- 兼容性:支持 EasyExcel 原生功能(自动列宽、下拉框等)
实现代码
1. 定义合并注解
java
import java.lang.annotation.*;
/**
* 标记需要合并的 Excel 列
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExcelMerge {
/**
* 是否启用合并(默认 true)
*/
boolean enable() default true;
}
2. Excel 合并工具类
java
package cn.iocoder.yudao.framework.excel.core.util;
import cn.iocoder.yudao.framework.excel.core.handler.SelectSheetWriteHandler;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.converters.longconverter.LongStringConverter;
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.write.merge.AbstractMergeStrategy;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Excel 合并单元格工具类(支持注解驱动)
*/
public class ExcelMergeUtils {
/**
* 导出 Excel 并自动合并标记字段
*
* @param outputStream 响应
* @param filename 文件名
* @param sheetName Sheet 名称
* @param head 表头类
* @param data 数据列表
* @param <T> 数据类型
* @throws IOException 写入异常
*/
public static <T> void write(OutputStream outputStream, String filename, String sheetName,
Class<T> head, List<T> data) throws IOException {
// 内容样式:水平 + 垂直居中
WriteCellStyle contentStyle = new WriteCellStyle();
contentStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
contentStyle.setVerticalAlignment(VerticalAlignment.CENTER);
// 注册样式策略
HorizontalCellStyleStrategy styleStrategy = new HorizontalCellStyleStrategy(null, contentStyle);
// 自动合并策略(基于注解)
AbstractMergeStrategy mergeStrategy = new AnnotationBasedMergeStrategy<>(data, head);
// 输出 Excel
EasyExcel.write(outputStream, head)
.autoCloseStream(false)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 自动列宽
.registerWriteHandler(new SelectSheetWriteHandler(head)) // 下拉框支持
.registerWriteHandler(mergeStrategy) // 自动合并
.registerWriteHandler(styleStrategy) // 居中对齐
.registerConverter(new LongStringConverter()) // Long 转 String
.sheet(sheetName)
.doWrite(data);
}
public static <T> void write(HttpServletResponse response, String filename, String sheetName,
Class<T> head, List<T> data) throws IOException {
write(response.getOutputStream(), filename, sheetName, head, data);
// 设置响应头
response.addHeader("Content-Disposition", "attachment;filename=" +
URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
response.setContentType("application/vnd.ms-excel;charset=UTF-8");
}
/**
* 基于注解的合并策略
*/
private static class AnnotationBasedMergeStrategy<T> extends AbstractMergeStrategy {
private final List<T> dataList;
private final Class<T> clazz;
public AnnotationBasedMergeStrategy(List<T> dataList, Class<T> clazz) {
this.dataList = dataList != null ? dataList : new ArrayList<>();
this.clazz = clazz;
}
@Override
protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
if (relativeRowIndex != 0) return; // 只在第一行处理
int columnIndex = cell.getColumnIndex();
Field field = clazz.getDeclaredFields()[columnIndex];
if (field.isAnnotationPresent(ExcelMerge.class)) {
mergeColumn(sheet, columnIndex);
}
}
private void mergeColumn(Sheet sheet, int columnIndex) {
List<CellRangeAddress> ranges = new ArrayList<>();
if (dataList.isEmpty()) return;
try {
Field field = clazz.getDeclaredFields()[columnIndex];
field.setAccessible(true);
Object currentValue = field.get(dataList.get(0));
int startRow = 1; // 从第2行开始(第1行是标题)
for (int i = 1; i < dataList.size(); i++) {
Object value = field.get(dataList.get(i));
if (!value.equals(currentValue)) {
if (startRow < i) {
ranges.add(new CellRangeAddress(startRow, i, columnIndex, columnIndex));
}
currentValue = value;
startRow = i + 1;
}
}
// 处理最后一段
if (startRow < dataList.size()) {
ranges.add(new CellRangeAddress(startRow, dataList.size(), columnIndex, columnIndex));
}
// 应用合并
for (CellRangeAddress range : ranges) {
sheet.addMergedRegion(range);
}
} catch (IllegalAccessException e) {
throw new RuntimeException("反射获取字段值失败", e);
}
}
}
}
使用示例
1. 定义实体类
java
public class UserVO {
@ExcelMerge // 此字段相同值会自动合并
private String username;
@ExcelMerge(enable = false) // 不合并
private Integer age;
@ExcelMerge // 此字段相同值会自动合并
private String department;
// 省略构造方法、getter/setter
}
2. 导出 Excel
java
List<UserVO> users = Arrays.asList(
new UserVO("张三", 25, "研发部"),
new UserVO("张三", 30, "研发部"), // username 和 department 相同,会自动合并
new UserVO("李四", 28, "市场部")
);
// HTTP 响应方式
ExcelMergeUtils.write(response, "users.xlsx", "用户列表", UserVO.class, users);
// 或者输出流方式
try (OutputStream out = new FileOutputStream("users.xlsx")) {
ExcelMergeUtils.write(out, "users.xlsx", "用户列表", UserVO.class, users);
}
技术要点解析
-
合并策略实现:
- 继承
AbstractMergeStrategy
实现自定义合并逻辑 - 通过反射获取标记了
@ExcelMerge
的字段值 - 计算需要合并的单元格区域(
CellRangeAddress
)
- 继承
-
样式控制:
- 使用
HorizontalCellStyleStrategy
设置内容居中对齐 - 表头使用默认样式,内容使用自定义样式
- 使用
-
性能优化:
- 只在第一行数据时执行合并操作(
relativeRowIndex == 0
) - 按列处理,避免重复计算
- 只在第一行数据时执行合并操作(
常见问题解决
1. 合并区域重叠问题
错误信息:
Cannot add merged region A2:A6 to sheet because it overlaps with an existing merged region
解决方案:
- 确保每个合并操作只执行一次
- 可以使用
Set
记录已处理的列,避免重复合并
2. 字段顺序问题
确保实体类字段顺序与 Excel 列顺序一致:
- 保持字段声明顺序
- 或使用
@ExcelProperty
注解指定顺序
3. 大数据量性能优化
当数据量较大时:
- 考虑分批处理
- 缓存字段信息,减少反射调用
总结
本文实现的 Excel 合并工具具有以下优势:
- 简单易用:通过注解标记即可实现自动合并
- 灵活可控:可以单独控制每个字段是否合并
- 样式美观:合并后的单元格自动居中对齐
- 功能完善:兼容 EasyExcel 的各项特性
通过这种方式,我们可以轻松实现专业级的 Excel 导出功能,提升报表的可读性和美观度。