1. 为什么需要单元格合并功能
在Excel数据处理中,单元格合并是最基础也是最常用的功能之一。当我们需要展示具有层级关系的数据时,合并单元格能够有效提升表格的可读性和美观度。比如在制作财务报表时,相同部门的多个人员信息需要合并显示部门名称;在生成统计报表时,相同类别的数据行需要合并显示类别标题。
传统Apache POI虽然提供了单元格合并的API,但存在几个明显痛点:
- 代码冗长:需要手动计算合并区域
- 内存消耗大:处理大数据量时容易OOM
- 样式控制复杂:合并后样式需要单独处理
EasyExcel作为阿里巴巴开源的Excel处理工具,针对这些问题提供了更优雅的解决方案。它基于事件模型解析Excel文件,内存占用更小,同时提供了简洁的API来实现单元格合并功能。
2. EasyExcel合并单元格的核心实现
2.1 基础合并实现方案
EasyExcel通过CellWriteHandler接口实现单元格合并。这是一个写入处理器,允许我们在写入单元格前后执行自定义逻辑。以下是实现合并的基本步骤:
java复制public class MergeStrategy implements CellWriteHandler {
// 需要合并的列索引
private int[] mergeColumnIndex;
// 从第几行开始合并
private int mergeRowIndex;
public MergeStrategy(int mergeRowIndex, int[] mergeColumnIndex) {
this.mergeRowIndex = mergeRowIndex;
this.mergeColumnIndex = mergeColumnIndex;
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
// 获取当前单元格
Cell cell = context.getCell();
// 获取当前行
int rowIndex = cell.getRowIndex();
// 获取当前列
int columnIndex = cell.getColumnIndex();
// 判断是否需要合并
if (rowIndex > mergeRowIndex) {
for (int i = 0; i < mergeColumnIndex.length; i++) {
if (columnIndex == mergeColumnIndex[i]) {
// 获取当前单元格值
Object currentCellValue = cell.getCellType() == CellType.STRING ?
cell.getStringCellValue() : cell.getNumericCellValue();
// 获取上一行同列单元格值
Object preCellValue = context.getFirstCellData().getCellValue();
// 比较当前单元格和上一个单元格的值
if (currentCellValue.equals(preCellValue)) {
Sheet sheet = context.getWriteSheetHolder().getSheet();
List<CellRangeAddress> mergeRegions = sheet.getMergedRegions();
boolean isMerged = false;
for (CellRangeAddress region : mergeRegions) {
// 检查是否已经合并过
if (region.isInRange(rowIndex - 1, columnIndex)) {
region.setLastRow(rowIndex);
isMerged = true;
break;
}
}
if (!isMerged) {
// 新建合并区域
CellRangeAddress region = new CellRangeAddress(
rowIndex - 1, rowIndex, columnIndex, columnIndex);
sheet.addMergedRegion(region);
}
}
break;
}
}
}
}
}
2.2 动态合并策略优化
上述基础方案存在一个明显问题:需要预先知道要合并哪些列。实际业务中,我们可能需要根据数据内容动态决定合并策略。下面是改进后的动态合并方案:
java复制public class DynamicMergeStrategy implements CellWriteHandler {
// 记录每列的合并信息
private Map<Integer, MergeInfo> mergeInfoMap = new HashMap<>();
private static class MergeInfo {
int startRow;
Object preValue;
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
int rowIndex = cell.getRowIndex();
int columnIndex = cell.getColumnIndex();
// 获取当前单元格值
Object currentValue = getCellValue(cell);
// 获取该列的上次合并信息
MergeInfo mergeInfo = mergeInfoMap.get(columnIndex);
if (mergeInfo == null) {
mergeInfo = new MergeInfo();
mergeInfo.startRow = rowIndex;
mergeInfo.preValue = currentValue;
mergeInfoMap.put(columnIndex, mergeInfo);
return;
}
// 值相同则准备合并
if (currentValue.equals(mergeInfo.preValue)) {
Sheet sheet = context.getWriteSheetHolder().getSheet();
// 检查是否已经是最后一行
if (rowIndex == sheet.getLastRowNum()) {
// 合并到最后一行
sheet.addMergedRegion(new CellRangeAddress(
mergeInfo.startRow, rowIndex, columnIndex, columnIndex));
}
} else {
// 值不同,先合并之前的区域
if (mergeInfo.startRow < rowIndex - 1) {
Sheet sheet = context.getWriteSheetHolder().getSheet();
sheet.addMergedRegion(new CellRangeAddress(
mergeInfo.startRow, rowIndex - 1, columnIndex, columnIndex));
}
// 开始新的合并区域
mergeInfo.startRow = rowIndex;
mergeInfo.preValue = currentValue;
}
}
private Object getCellValue(Cell cell) {
// 根据单元格类型获取值
switch (cell.getCellType()) {
case STRING: return cell.getStringCellValue();
case NUMERIC: return cell.getNumericCellValue();
case BOOLEAN: return cell.getBooleanCellValue();
default: return "";
}
}
}
3. 合并单元格的样式处理
合并单元格后,样式处理是一个容易被忽视但很重要的问题。合并区域的样式默认会继承第一个单元格的样式,但有时我们需要特殊处理:
3.1 边框样式处理
合并单元格后,边框可能会出现断裂或不连续的情况。解决方法是在合并后重新设置边框:
java复制// 在合并策略中添加样式处理
public void afterCellDispose(CellWriteHandlerContext context) {
// ...合并逻辑...
// 处理合并后的样式
CellStyle cellStyle = context.getFirstCellData().getCellStyle();
if (cellStyle == null) {
cellStyle = context.getWriteSheetHolder().getSheet().getWorkbook()
.createCellStyle();
}
// 设置边框
cellStyle.setBorderTop(BorderStyle.THIN);
cellStyle.setBorderBottom(BorderStyle.THIN);
cellStyle.setBorderLeft(BorderStyle.THIN);
cellStyle.setBorderRight(BorderStyle.THIN);
// 应用样式
for (CellRangeAddress region : context.getWriteSheetHolder().getSheet()
.getMergedRegions()) {
for (int i = region.getFirstRow(); i <= region.getLastRow(); i++) {
Row row = context.getWriteSheetHolder().getSheet().getRow(i);
if (row == null) continue;
for (int j = region.getFirstColumn(); j <= region.getLastColumn(); j++) {
Cell cell = row.getCell(j);
if (cell != null) {
cell.setCellStyle(cellStyle);
}
}
}
}
}
3.2 内容对齐处理
合并单元格后,内容默认会显示在合并区域的左上角单元格。如果需要居中显示,需要单独设置:
java复制// 设置垂直居中
cellStyle.setVerticalAlignment(VerticalAlignment.CENTER);
// 设置水平居中
cellStyle.setAlignment(HorizontalAlignment.CENTER);
4. 复杂合并场景实践
4.1 多级表头合并
在制作复杂报表时,经常需要实现多级表头合并。下面是一个三级表头合并的实现示例:
java复制public class MultiLevelHeaderMergeStrategy implements CellWriteHandler {
// 表头层级数
private int headerLevel;
// 每层表头的合并规则
private Map<Integer, List<Integer>> levelMergeRules;
public MultiLevelHeaderMergeStrategy(int headerLevel) {
this.headerLevel = headerLevel;
this.levelMergeRules = new HashMap<>();
}
public void addMergeRule(int level, List<Integer> mergeColumns) {
levelMergeRules.put(level, mergeColumns);
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
int rowIndex = context.getRowIndex();
int columnIndex = context.getColumnIndex();
// 只处理表头行
if (rowIndex >= headerLevel) return;
// 检查当前列是否需要合并
List<Integer> mergeColumns = levelMergeRules.get(rowIndex);
if (mergeColumns == null || !mergeColumns.contains(columnIndex)) return;
// 计算合并区域
int firstRow = rowIndex;
int lastRow = rowIndex;
int firstCol = columnIndex;
int lastCol = columnIndex;
// 向右查找相同内容的单元格
Sheet sheet = context.getWriteSheetHolder().getSheet();
Row row = sheet.getRow(rowIndex);
Object currentValue = getCellValue(row.getCell(columnIndex));
for (int i = columnIndex + 1; i < row.getLastCellNum(); i++) {
Cell nextCell = row.getCell(i);
if (nextCell == null) break;
Object nextValue = getCellValue(nextCell);
if (currentValue.equals(nextValue)) {
lastCol = i;
} else {
break;
}
}
// 向下查找相同内容的单元格
for (int i = rowIndex + 1; i < headerLevel; i++) {
Row nextRow = sheet.getRow(i);
if (nextRow == null) break;
Cell nextCell = nextRow.getCell(columnIndex);
if (nextCell == null) break;
Object nextValue = getCellValue(nextCell);
if (currentValue.equals(nextValue)) {
lastRow = i;
} else {
break;
}
}
// 添加合并区域
if (firstRow < lastRow || firstCol < lastCol) {
sheet.addMergedRegion(new CellRangeAddress(
firstRow, lastRow, firstCol, lastCol));
}
}
}
4.2 分组数据汇总合并
在财务或统计报表中,经常需要对分组数据进行汇总并合并显示。下面是一个带汇总行的合并实现:
java复制public class GroupSummaryMergeStrategy implements CellWriteHandler {
// 分组列索引
private int groupColumn;
// 需要合并的列索引
private List<Integer> mergeColumns;
// 汇总行样式
private CellStyle summaryStyle;
// 记录分组信息
private String currentGroup;
private int groupStartRow = -1;
public GroupSummaryMergeStrategy(int groupColumn, List<Integer> mergeColumns) {
this.groupColumn = groupColumn;
this.mergeColumns = mergeColumns;
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
int rowIndex = context.getRowIndex();
int columnIndex = context.getColumnIndex();
Sheet sheet = context.getWriteSheetHolder().getSheet();
// 处理分组变化
if (columnIndex == groupColumn) {
Object cellValue = getCellValue(context.getCell());
String groupValue = cellValue != null ? cellValue.toString() : "";
if (!groupValue.equals(currentGroup)) {
// 结束上一个分组
if (currentGroup != null && groupStartRow != -1 &&
groupStartRow < rowIndex - 1) {
// 合并分组列
sheet.addMergedRegion(new CellRangeAddress(
groupStartRow, rowIndex - 1, groupColumn, groupColumn));
// 添加汇总行
Row summaryRow = sheet.createRow(rowIndex);
for (int i = 0; i < context.getRow().getLastCellNum(); i++) {
Cell cell = summaryRow.createCell(i);
if (i == groupColumn) {
cell.setCellValue(currentGroup + " 汇总");
if (summaryStyle != null) {
cell.setCellStyle(summaryStyle);
}
} else if (mergeColumns.contains(i)) {
// 合并汇总单元格
sheet.addMergedRegion(new CellRangeAddress(
rowIndex, rowIndex, i, i));
}
}
rowIndex++; // 汇总行占了一行
}
// 开始新分组
currentGroup = groupValue;
groupStartRow = rowIndex + 1; // 跳过标题行
}
}
// 处理普通合并列
if (mergeColumns.contains(columnIndex) && rowIndex >= groupStartRow) {
Object currentValue = getCellValue(context.getCell());
Object preValue = getCellValue(
sheet.getRow(rowIndex - 1).getCell(columnIndex));
if (currentValue.equals(preValue)) {
List<CellRangeAddress> mergedRegions = sheet.getMergedRegions();
boolean isMerged = false;
for (CellRangeAddress region : mergedRegions) {
if (region.isInRange(rowIndex - 1, columnIndex)) {
region.setLastRow(rowIndex);
isMerged = true;
break;
}
}
if (!isMerged) {
sheet.addMergedRegion(new CellRangeAddress(
rowIndex - 1, rowIndex, columnIndex, columnIndex));
}
}
}
}
}
5. 性能优化与常见问题
5.1 大数据量下的性能优化
当处理大量数据时,合并操作可能会影响性能。以下是几个优化建议:
- 批量合并:不要在每行写入时都检查合并,而是先收集需要合并的区域,最后批量合并
java复制public class BatchMergeStrategy implements CellWriteHandler {
private List<CellRangeAddress> mergeRegions = new ArrayList<>();
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
// 识别需要合并的区域,添加到mergeRegions中
// ...
}
@Override
public void afterSheetCreated(WriteSheetHolder writeSheetHolder) {
// 在sheet创建完成后批量合并
Sheet sheet = writeSheetHolder.getSheet();
for (CellRangeAddress region : mergeRegions) {
sheet.addMergedRegion(region);
}
}
}
-
限制合并范围:只对可见区域或关键列进行合并,减少不必要的合并操作
-
使用SXSSF模式:对于非常大的文件,使用流式写入模式
java复制// 创建ExcelWriter时指定使用SXSSF
ExcelWriter excelWriter = EasyExcel.write(fileName)
.registerWriteHandler(new BatchMergeStrategy())
.inMemory(false) // 启用磁盘缓存
.build();
5.2 常见问题与解决方案
问题1:合并后边框不完整
解决方案:在合并完成后重新设置整个区域的边框样式,如第3.1节所示
问题2:合并单元格内容不居中
解决方案:显式设置单元格样式的对齐方式,如第3.2节所示
问题3:合并后公式引用错误
解决方案:在合并前确保公式使用绝对引用,或者在合并后调整公式
java复制// 调整合并区域内的公式
for (CellRangeAddress region : mergedRegions) {
for (int i = region.getFirstRow(); i <= region.getLastRow(); i++) {
Row row = sheet.getRow(i);
if (row == null) continue;
for (int j = region.getFirstColumn(); j <= region.getLastColumn(); j++) {
Cell cell = row.getCell(j);
if (cell != null && cell.getCellType() == CellType.FORMULA) {
String formula = cell.getCellFormula();
// 调整公式中的单元格引用
formula = adjustFormulaReferences(formula, region);
cell.setCellFormula(formula);
}
}
}
}
问题4:合并后排序失效
解决方案:避免对包含合并单元格的区域进行排序,或者先取消合并,排序后再重新合并
问题5:性能下降明显
解决方案:使用第5.1节的优化策略,或者考虑在数据库层面先处理好数据再导出
在实际项目中,我遇到过导出10万行数据时合并操作导致性能下降的问题。最终采用的解决方案是:
- 在SQL查询中使用GROUP BY先对数据进行分组
- 在Java层只对分组边界行进行合并
- 使用SXSSF模式并设置合理的rowAccessWindowSize
这样处理后的导出时间从原来的30秒降低到了5秒左右
