EasyExcel - 行合并策略(二级列表)

😼前言 :博主在工作中又遇到了新的excel导出挑战:需要导出多条文章及其下联合作者的信息,简单的来说是一个二级列表 的数据结构。

🕵️‍♂️思路:excel导出实际上是一行一行的记录,再根据条件对其进行合并。

目录

最终效果图📌

一、数据格式及处理📚

首先,需要先将一条文章 按联合作者数量,拆分为指定数量的文章及作者导出记录的集合。

以文章《牧区歌与马》为例,一篇文章有三名联合作者,生成三条导出记录。

1.文章对象(处理前)

javascript 复制代码
// 文章记录对象 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.文章及作者对象(处理后)

可以看到记录由一条变为三条,除了作者名称和单位,其余字段内容均一致。

javascript 复制代码
// 文章处理后记录对象 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:列最新单元格内容
javascript 复制代码
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.源码改造

javascript 复制代码
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输出策略

javascript 复制代码
  // 设置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内容的方法进行替代。

javascript 复制代码
/**
	* 从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。

😸文中提供的解决方法:

javascript 复制代码
将获取首行代码:
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)设置:

javascript 复制代码
  // 设置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文件的时候添加水印,要怎么做,请给出代码示例和解释

相关推荐
猷咪10 分钟前
C++基础
开发语言·c++
IT·小灰灰11 分钟前
30行PHP,利用硅基流动API,网页客服瞬间上线
开发语言·人工智能·aigc·php
快点好好学习吧13 分钟前
phpize 依赖 php-config 获取 PHP 信息的庖丁解牛
android·开发语言·php
秦老师Q13 分钟前
php入门教程(超详细,一篇就够了!!!)
开发语言·mysql·php·db
烟锁池塘柳013 分钟前
解决Google Scholar “We‘re sorry... but your computer or network may be sending automated queries.”的问题
开发语言
是誰萆微了承諾14 分钟前
php 对接deepseek
android·开发语言·php
vx_BS8133017 分钟前
【直接可用源码免费送】计算机毕业设计精选项目03574基于Python的网上商城管理系统设计与实现:Java/PHP/Python/C#小程序、单片机、成品+文档源码支持定制
java·python·课程设计
2601_9498683617 分钟前
Flutter for OpenHarmony 电子合同签署App实战 - 已签合同实现
java·开发语言·flutter
星火开发设计31 分钟前
类型别名 typedef:让复杂类型更简洁
开发语言·c++·学习·算法·函数·知识
qq_1777673743 分钟前
React Native鸿蒙跨平台数据使用监控应用技术,通过setInterval每5秒更新一次数据使用情况和套餐使用情况,模拟了真实应用中的数据监控场景
开发语言·前端·javascript·react native·react.js·ecmascript·harmonyos