EasyExcel - 行合并策略(二级列表)
😼前言:博主在工作中又遇到了新的excel导出挑战:需要导出多条文章及其下联合作者的信息,简单的来说是一个二级列表的数据结构。
🕵️♂️思路:excel导出实际上是一行一行的记录,再根据条件对其进行合并。
目录
- 最终效果图📌
- 一、数据格式及处理📚
- 1.文章对象(处理前)
- 2.文章及作者对象(处理后)
- 3.未合并的效果图
- 二、通用行合并策略🔍
- 1.源码学习
- 2.通用行合并后的效果图
- 三、二级通用行合并策略✍
- 1.源码改造
- 2.设置excel输出策略
- 3.延伸
- 四、问题☔
- 1.问题描述
- 2.问题原因
- 3.解决办法
- 参考文章📒
最终效果图📌
一、数据格式及处理📚
首先,需要先将一条文章按联合作者数量,拆分为指定数量的文章及作者导出记录的集合。
以文章《牧区歌与马》为例,一篇文章有三名联合作者,生成三条导出记录。
1.文章对象(处理前)
// 文章记录对象 Acticle.class
[{
"contentId": "1",
"contentTitle": "牧区歌与马",
"contentCount": 940,
"releaseTime": "2025-01-09 11:21:16",
"readNum": 1,
"auditor": "小李",
"orgName": "办公室",
"authorList": [{
"userName": "小A ",
"orgName": "单位A"
},
{
"userName": "小B ",
"orgName": "/"
},
{
"userName": "小C",
"orgName": "单位C"
}
]
}]
2.文章及作者对象(处理后)
可以看到记录由一条变为三条,除了作者名称和单位,其余字段内容均一致。
// 文章处理后记录对象 ActicleAuthor.class
[{
"contentId": "1",
"contentTitle": "牧区歌与马",
"contentCount": 940,
"releaseTime": "2025-01-09 11:21:16",
"readNum": 1,
"auditor": "小李",
"orgName": "办公室",
"author":"小A",
"authorUnit":"单位A"
},{
"contentId": "1",
"contentTitle": "牧区歌与马",
"contentCount": 940,
"releaseTime": "2025-01-09 11:21:16",
"readNum": 1,
"auditor": "小李",
"orgName": "办公室",
"author":"小B",
"authorUnit":"/"
},{
"contentId": "1",
"contentTitle": "牧区歌与马",
"contentCount": 940,
"releaseTime": "2025-01-09 11:21:16",
"readNum": 1,
"auditor": "小李",
"orgName": "办公室",
"author":"小C",
"authorUnit":"单位C"
}]
3.未合并的效果图
未设置行合并策略直接导出时,表格的格式内容如下:
👆图中的E、F列示例错误,应分别为6行记录
二、通用行合并策略🔍
此处学习了csdn博主xiao谢同学分享的通用行合并策略源码
链接:EasyExcel 通用行合并策略实现
1.源码学习
🐱👓该策略以列的行数作为主键,每次遍历记录列的最新合并区域信息。将同列且相邻行的单元格内容进行比较:
(1)一致:则仅更新endRow和endCell,继续遍历;
(2)不一致:则将已有区域进行合并,再将MergeRange所有字段进行更新。
MergeRange.class
- startRow :合并开始行
- endRow:合并结束行
- startCell:合并开始单元格
- endCell:合并结束单元格
- lastValue:列最新单元格内容
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.write.merge.AbstractMergeStrategy;
import org.apache.commons.collections.map.HashedMap;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
import java.util.*;
public class MergeStrategy extends AbstractMergeStrategy {
// 合并的列编号,从0开始,指定的index或自己按字段顺序数
private Set<Integer> mergeCellIndex = new HashSet<>();
// 数据集大小,用于区别结束行位置
private Integer maxRow = 0;
// 禁止无参声明
private MergeStrategy() {
}
public MergeStrategy(Integer maxRow, int... mergeCellIndex) {
Arrays.stream(mergeCellIndex).forEach(item -> {
this.mergeCellIndex.add(item);
});
this.maxRow = maxRow;
}
// 记录上一次合并的信息
private Map<Integer, MergeRange> lastRow = new HashedMap();
// 每行每列都会进入,绝对不要在这写循环
@Override
protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
int currentCellIndex = cell.getColumnIndex();
// 判断该列是否需要合并
if (mergeCellIndex.contains(currentCellIndex)) {
String currentCellValue = cell.getStringCellValue();
int currentRowIndex = cell.getRowIndex();
if (!lastRow.containsKey(currentCellIndex)) {
// 记录首行起始位置
lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex));
return;
}
//有上行这列的值了,拿来对比.
MergeRange mergeRange = lastRow.get(currentCellIndex);
if (!(mergeRange.lastValue != null && mergeRange.lastValue.equals(currentCellValue))) {
// 结束的位置触发下合并.
// 同行同列不能合并,会抛异常
if (mergeRange.startRow != mergeRange.endRow || mergeRange.startCell != mergeRange.endCell) {
sheet.addMergedRegionUnsafe(new CellRangeAddress(mergeRange.startRow, mergeRange.endRow, mergeRange.startCell, mergeRange.endCell));
}
// 更新当前列起始位置
lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex));
}
// 合并行 + 1
mergeRange.endRow += 1;
// 结束的位置触发下最后一次没完成的合并
if (relativeRowIndex.equals(maxRow - 1)) {
MergeRange lastMergeRange = lastRow.get(currentCellIndex);
// 同行同列不能合并,会抛异常
if (lastMergeRange.startRow != lastMergeRange.endRow || lastMergeRange.startCell != lastMergeRange.endCell) {
sheet.addMergedRegionUnsafe(new CellRangeAddress(lastMergeRange.startRow, lastMergeRange.endRow, lastMergeRange.startCell, lastMergeRange.endCell));
}
}
}
}
}
class MergeRange {
public int startRow;
public int endRow;
public int startCell;
public int endCell;
public String lastValue;
public MergeRange(String lastValue, int startRow, int endRow, int startCell, int endCell) {
this.startRow = startRow;
this.endRow = endRow;
this.startCell = startCell;
this.endCell = endCell;
this.lastValue = lastValue;
}
}
2.通用行合并后的效果图
可以发现,这种仅根据相邻行单元格内容进行合并的方式,还未能完全满足博主想要二级列表的效果。
👆黄色代表非理想合并的区域
三、二级通用行合并策略✍
🐱💻改造思路:列A是文章标题,以列A的内容作为第一层级的标识(tip:不如contentId准确)。即使相邻行单元格内容相等,对应行的A列内容不相等也不能合并。
处理步骤:
(1)在合并区域对象类MergeRange中,增加A列内容的值字段lastValueRowa。
(2)遍历单元格构造合并区域对象时,记录A列内容值以此来作为附加的合并条件。
(3)当同一列字段相邻行内容相等且A列内容值相等时,再进行合并。
1.源码改造
import cn.hutool.json.JSONUtil;
import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.write.merge.AbstractMergeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.map.HashedMap;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;
import java.util.*;
/**
* 行合并策略
*/
@Slf4j
public class MergeStrategy extends AbstractMergeStrategy {
// 合并的列编号,从0开始,指定的index或自己按字段顺序数
private Set<Integer> mergeCellIndex = new HashSet<>();
// 数据集大小,用于区别结束行位置
private Integer maxRow = 0;
// 禁止无参声明
private MergeStrategy() {
}
public MergeStrategy(Integer maxRow, int... mergeCellIndex) {
Arrays.stream(mergeCellIndex).forEach(item -> {
this.mergeCellIndex.add(item);
});
this.maxRow = maxRow;
}
// 记录上一次合并的信息
private Map<Integer, MergeRange> lastRow = new HashedMap();
// 每行每列都会进入,绝对不要在这写循环
@Override
protected void merge(Sheet sheet, Cell cell, Head head, Integer relativeRowIndex) {
// 获取单元格当前列
int currentCellIndex = cell.getColumnIndex();
log.info("遍历单元格:{}行,{}列 >>>>>>>>>>>>>>>>>", cell.getRowIndex(), currentCellIndex);
// 判断该列是否需要合并
if (mergeCellIndex.contains(currentCellIndex)) {
// 获取当前单元格内容值
String currentCellValue = new DataFormatter().formatCellValue(cell);
// 获取当前单元格的行row
int currentRowIndex = cell.getRowIndex();
// 获取当前行A列单元格内容 new
String currentValueRowa = "";
// 如果最后合并行的map,不包括当前列
if (!lastRow.containsKey(currentCellIndex)) {
log.info("lastRow添加第{}列【前】,当前lastRow={}", currentCellIndex, JSONUtil.toJsonStr(lastRow));
// 获取当前行的列A内容 new
if (currentCellIndex == 0) {
currentValueRowa = currentCellValue;
} else {
currentValueRowa = getRowaValue();
}
// 记录首行起始位置? 记录当前列及合并范围
lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex, currentValueRowa));
log.info("lastRow添加第{}列【后】,当前行列A内容={},新lastRow={}", currentCellIndex, currentValueRowa, JSONUtil.toJsonStr(lastRow));
return;
} else {
// 该列已存在lastRow中,则取最新的列A内容值 new
currentValueRowa = getRowaValue();
}
//有上行这列的值了,拿来对比.
MergeRange mergeRange = lastRow.get(currentCellIndex);
// 判断条件:增加A列内容判断 new
log.info("合并比对1>>>>>>>>>:第{}列最新内容lastValue = {},当前内容currentCellValue={},"
, currentCellIndex, mergeRange.lastValue, currentCellValue);
log.info("合并比对2>>>>>>>>>第{}列最新列A内容lastValueRowa={}, 当前列A内容currentValueRowa={}"
, currentCellIndex, mergeRange.lastValueRowa, currentValueRowa);
if (!(mergeRange.lastValue != null
&& mergeRange.lastValue.equals(currentCellValue) && mergeRange.lastValueRowa.equals(currentValueRowa))) {
// 结束的位置触发下合并.
// 同行同列不能合并,会抛异常
if (mergeRange.startRow != mergeRange.endRow || mergeRange.startCell != mergeRange.endCell) {
sheet.addMergedRegionUnsafe(new CellRangeAddress(mergeRange.startRow, mergeRange.endRow, mergeRange.startCell, mergeRange.endCell));
}
// 更新当前列起始位置
lastRow.put(currentCellIndex, new MergeRange(currentCellValue, currentRowIndex, currentRowIndex, currentCellIndex, currentCellIndex, currentValueRowa));
log.info("比对不一致,确认合并!!>>>>>>>>>:第{}列最新列A内容={},最新lastRow = {}"
, currentCellIndex, currentValueRowa, JSONUtil.toJsonStr(lastRow));
}
// 合并行 + 1
mergeRange.endRow += 1;
// 结束的位置触发下最后一次没完成的合并
if (relativeRowIndex.equals(maxRow - 1)) {
MergeRange lastMergeRange = lastRow.get(currentCellIndex);
// 同行同列不能合并,会抛异常
if (lastMergeRange.startRow != lastMergeRange.endRow || lastMergeRange.startCell != lastMergeRange.endCell) {
sheet.addMergedRegionUnsafe(new CellRangeAddress(lastMergeRange.startRow, lastMergeRange.endRow, lastMergeRange.startCell, lastMergeRange.endCell));
}
}
}
}
/**
* 获取列A最新一行的内容值 new
*/
private String getRowaValue() {
// 获取当前行A列单元格内容
String currentValueRowa = "";
if (lastRow.get(0) != null) {
currentValueRowa = lastRow.get(0).lastValue;
}
return currentValueRowa;
}
}
class MergeRange {
public int startRow;
public int endRow;
public int startCell;
public int endCell;
public String lastValue;
// 最后一个合并值得A列值 new
public String lastValueRowa;
public MergeRange(String lastValue, int startRow, int endRow, int startCell, int endCell, String lastValueRowa) {
this.startRow = startRow;
this.endRow = endRow;
this.startCell = startCell;
this.endCell = endCell;
this.lastValue = lastValue;
this.lastValueRowa = lastValueRowa;
}
}
2.设置excel输出策略
// 设置excel输出策略
EasyExcel.write(fileName, ActicleAuthor.class)
// 0,1 表示 对1,2列启用合并策略
.registerWriteHandler(new MergeStrategy(dataList.size(),0,1))
.sheet(0)
.doWrite(dataList);
3.延伸
🐱🚀如果需要做三级、四级等列表,可以将指定多个字段的拼接值当作列A来处理。可修改getRowaValue()方法实现逻辑。
四、问题☔
在开发的过程中,不可避免地碰到了一些问题……
1.问题描述
💁♀️在获取列A内容值时,曾尝试从Sheet对象中获取。
因为存在Sheet.getRow(0)获取第一行的row对象是null的问题,所以用从lastRow中获取列A内容的方法进行替代。
/**
* 从sheet.getRow(0)中获取列A内容值
*/
private String getRowaValue(Sheet sheet, int rowId) {
// 获取当前行A列单元格内容
String currentValueRowa = "";
Row row = sheet.getRow(rowId);
if (row != null) {
Cell cell = row.getCell(0);
if (cell != null) {
currentValueRowa = cell.getStringCellValue();
}
}
return currentValueRowa;
}
2.问题原因
👩💻经过面向百度查询,从csdn博主吾乃南华老仙分享的文章sheet.getRow(0)获取的row为null?中得知:
new SXSSFWorkbook(new XSSFWorkbook(inputStream)) 创建Workbook的时候,
SXSSFWorkbook对象内部会维护一个HashMap(反编译后的名称为_xFromSxHash)。
而当使用workBook.getSheetAt(0)的时候,其实是从_xFromSxHash中获取新创建的Sheet对象,从而导致sheet.getRow(0)获取的row为null。
😸文中提供的解决方法:
将获取首行代码:
Workbook workBook = new SXSSFWorkbook(new XSSFWorkbook(inputStream));
Sheet sheet = workBook.getSheetAt(0);
Row row = sheet.getRow(0);
修改为👇:
Workbook workBook = new SXSSFWorkbook(new XSSFWorkbook(inputStream));
Sheet sheet;
if (workBook instanceof SXSSFWorkbook) {
SXSSFWorkbook sxssfWorkbook = (SXSSFWorkbook) workBook;
sheet = sxssfWorkbook.getXSSFWorkbook().getSheetAt(sheetIndex);
} else {
sheet = workBook.getSheetAt(sheetIndex);
}
Row row = sheet.getRow(0);
😧而我们使用的导出是基于EasyExcel的,并没有单独的使用流去创建对象,应该怎么办呢?
👇可以看到debug过程中,显示sheet对象类型是SXSSFSheet。
3.解决办法
😾再次经过一番查询,在EasyExcel语雀文档的QA:EasyExcel 我想在导出excel文件的时候添加水印,要怎么做,请给出代码示例和解释中找到了解释:
- inMemory(true):
EasyExcel默认使用SXSSFWorkbook以减少内存消耗,但它不支持复杂的样式设置(如水印)。通过设置inMemory(true),我们改用XSSFWorkbook,它提供了更全面的样式支持。
在设置excel输出策略时加上inMemory(true)设置:
// 设置excel输出策略
EasyExcel.write(fileName, ActicleAuthor.class)
// 必须设置,以便使用XSSFWorkbook而非SXSSFWorkbook new
.inMemory(true)
// 0,1 表示 对1,2列启用合并策略
.registerWriteHandler(new MergeStrategy(dataList.size(),0,1))
.sheet(0)
.doWrite(dataList);
🙆♀️执行debug后,发现可以获取到sheet.getRow(0)的第一行对象了,切换类型的问题成功解决。
参考文章📒
EasyExcel 通用行合并策略实现-xiao谢同学
sheet.getRow(0)获取的row为null?
EasyExcel 我想在导出excel文件的时候添加水印,要怎么做,请给出代码示例和解释