文章目录前言一、思路二、使用步骤1.模板2.service方法3.策略4.效果总结前言easy-excel 导出excel时,遇到需要保留模板内的格式和表头等,在使用模板fill模式填充数据的情况下,单元格合并比较麻烦 在easy-excel版本比较老(2.x),升级牵扯到poi升级又涉及到poi-tl等组件也要升级的情况下,使用本文方法可实现单元格合并,如果有更好的方案欢迎交流 AI辅助开发的情况下可以把本问题喂给ai提供思路一、思路先使用fill导出数据到内存,在内存中使用poi对需要合并的单元格进行操作二、使用步骤1.模板第5行为动态数据行,前后表头表尾都是保留的,为了实现导出所有数据后,A5相同的值合并单元格,// 两次填充,对固定单元格和动态行list行进行添值处理// 填充单个数据元数据excelWriter.fill(batchMetaMap,writeSheet);// 填充列表数据FillConfig fillConfigFillConfig.builder().forceNewRow(Boolean.TRUE).build();excelWriter.fill(rowList,fillConfig,writeSheet);2.service方法代码如下示例Override publicvoidexportYearPlan(Long masterId,HttpServletResponse response,OpsPlanMaster opsPlanMaster){try{// 1. 查询该主表下的所有年计划子表记录ListOpsPlanYearplanListthis.listByMasterId(masterId);if(opsPlanMasternull||planListnull||planList.isEmpty()){return;}// 2. 设置响应头response.setContentType(application/vnd.ms-excel);response.setCharacterEncoding(utf-8);String fileNameURLEncoder.encode(年度检查巡视计划,UTF-8);response.setHeader(Content-disposition,attachment;filenamefileName.xlsx);// 3. 加载模板文件String templatePathtemplate/excel/OpsPlanYearFillTemplate.xlsx;try(InputStream templateStreamResourceLoaderUtil.getResourceAsStream(templatePath)){if(templateStreamnull){throw newRuntimeException(模板文件不存在templatePath);}// 4. 准备动态行数据ListMapString,ObjectrowListnew ArrayList();for(OpsPlanYear plan:planList){MapString,Objectrownew HashMap();row.put(categoryName,plan.getCategoryName());row.put(subCategoryName,plan.getSubCategoryName());row.put(month01,plan.getMonth01()!null?plan.getMonth01():);row.put(month02,plan.getMonth02()!null?plan.getMonth02():);row.put(month03,plan.getMonth03()!null?plan.getMonth03():);row.put(month04,plan.getMonth04()!null?plan.getMonth04():);row.put(month05,plan.getMonth05()!null?plan.getMonth05():);row.put(month06,plan.getMonth06()!null?plan.getMonth06():);row.put(month07,plan.getMonth07()!null?plan.getMonth07():);row.put(month08,plan.getMonth08()!null?plan.getMonth08():);row.put(month09,plan.getMonth09()!null?plan.getMonth09():);row.put(month10,plan.getMonth10()!null?plan.getMonth10():);row.put(month11,plan.getMonth11()!null?plan.getMonth11():);row.put(month12,plan.getMonth12()!null?plan.getMonth12():);row.put(ownerName,plan.getOwnerName()!null?plan.getOwnerName():);row.put(remark,plan.getRemark()!null?plan.getRemark():);rowList.add(row);}// 填充批次元数据年月信息、部门、审核人、制表人等MapString,ObjectbatchMetaMapnew HashMap();batchMetaMap.put(orgName,opsPlanMaster.getOrgName());// 部门名称batchMetaMap.put(planDate,opsPlanMaster.getPlanDate().format(DateTimeFormatter.ofPattern(yyyy年MM月dd日)));// 标题中的年月batchMetaMap.put(approvedBy,opsPlanMaster.getApprovedBy());// 审核人batchMetaMap.put(reviewedBy,opsPlanMaster.getReviewedBy());batchMetaMap.put(preparedBy,opsPlanMaster.getPreparedBy());// 使用自定义合并策略合并A列大类名称从第5行索引4开始FillMergeStrategy mergeStrategynewFillMergeStrategy(4,0);// 先将Excel写入内存ByteArrayOutputStream byteArrayOutputStreamnewByteArrayOutputStream();ExcelWriter excelWriterEasyExcel.write(byteArrayOutputStream).withTemplate(templateStream).build();WriteSheet writeSheetEasyExcel.writerSheet(0).build();// 填充单个数据元数据excelWriter.fill(batchMetaMap,writeSheet);// 填充列表数据FillConfig fillConfigFillConfig.builder().forceNewRow(Boolean.TRUE).build();excelWriter.fill(rowList,fillConfig,writeSheet);excelWriter.finish();// 用POI打开内存中的Excel执行合并try(Workbook workbookWorkbookFactory.create(newByteArrayInputStream(byteArrayOutputStream.toByteArray()))){org.apache.poi.ss.usermodel.Sheet sheetworkbook.getSheetAt(0);mergeStrategy.doMergeWithPoi(sheet);// 将合并后的Excel写入响应workbook.write(response.getOutputStream());}catch(Exception e){throw newRuntimeException(合并单元格失败e.getMessage(),e);}}}catch(IOException e){throw newRuntimeException(导出失败e.getMessage(),e);}}3.策略代码如下示例package com.bjhz.microservice.assets.server.full.listener;import com.alibaba.excel.ExcelWriter;import com.alibaba.excel.write.metadata.WriteSheet;import lombok.extern.slf4j.Slf4j;import org.apache.poi.ss.usermodel.Cell;import org.apache.poi.ss.usermodel.DateUtil;import org.apache.poi.ss.usermodel.Row;import org.apache.poi.ss.usermodel.Sheet;import org.apache.poi.ss.util.CellRangeAddress;/** * EasyExcel Fill模式动态单元格合并策略 * * p功能说明/p * ul * li支持多列独立合并每列根据单元格内容自动判断是否合并/li * li仅当相邻行的单元格内容完全相同时才执行合并/li * li在ExcelWriter.finish()之前手动调用doMerge执行合并/li * /ul * * p使用示例/p * pre * // 创建合并策略 * FillMergeStrategy strategy new FillMergeStrategy(4, 0); * * // 在所有fill操作完成后finish之前调用 * strategy.doMerge(excelWriter, writeSheet); * * // 合并A列和B列索引0和1从第5行开始 * FillMergeStrategy strategy new FillMergeStrategy(4, 0, 1); * /pre * * author * date 2026-04-26 */Slf4j public class FillMergeStrategy{/** 需要合并的列索引数组从0开始A列 0 */private finalint[]mergeColumnIndexes;/** 数据开始的行索引从0开始计数例如第5行传入4 */private finalintstartRowIndex;/** * 构造函数 * * param startRowIndex 数据开始的行索引从0开始例如第5行传入4 * param columnIndexes 需要合并的列索引数组可变参数 */publicFillMergeStrategy(intstartRowIndex,int...columnIndexes){this.startRowIndexstartRowIndex;this.mergeColumnIndexescolumnIndexes;}/** * 执行单元格合并 * * p调用时机/p * ul * li在所有fill操作完成后/li * li在excelWriter.finish()之前/li * /ul * * param excelWriter EasyExcel写入器 * param writeSheet 写入的Sheet */publicvoiddoMerge(ExcelWriter excelWriter,WriteSheet writeSheet){Sheet sheetexcelWriter.writeContext().writeSheetHolder().getSheet();log.info(开始执行单元格合并startRowIndex{}, mergeColumnIndexes{},startRowIndex,mergeColumnIndexes);log.info(Sheet总行数: {},sheet.getLastRowNum());// 遍历每一列需要合并的列for(intcolumnIndex:mergeColumnIndexes){mergeColumnByContent(sheet,columnIndex);}log.info(单元格合并完成);}/** * 使用POI Sheet执行单元格合并 * * p调用时机/p * ul * liExcelWriter.finish()之后/li * li使用POI打开Excel后/li * /ul * * param sheet POI的Sheet对象 */publicvoiddoMergeWithPoi(Sheet sheet){log.info(开始执行单元格合并startRowIndex{}, mergeColumnIndexes{},startRowIndex,mergeColumnIndexes);log.info(Sheet总行数: {},sheet.getLastRowNum());// 遍历每一列需要合并的列for(intcolumnIndex:mergeColumnIndexes){mergeColumnByContent(sheet,columnIndex);}log.info(单元格合并完成);}/** * 对指定列执行内容相同的单元格合并 * * param sheet Excel Sheet对象 * param columnIndex 列索引从0开始 */privatevoidmergeColumnByContent(Sheet sheet,intcolumnIndex){intlastRowNumsheet.getLastRowNum();if(lastRowNumstartRowIndex){log.info(数据行数不足第{}列无需合并,columnIndex);return;}String lastValuenull;intmergeStartRow-1;intmergeCount0;// 从指定行开始遍历for(introwIndexstartRowIndex;rowIndexlastRowNum;rowIndex){Row rowsheet.getRow(rowIndex);if(rownull){continue;}Cell cellrow.getCell(columnIndex);String currentValuegetCellValue(cell);// 当前值与上一行值相同记录合并起始位置if(currentValue!nullcurrentValue.equals(lastValue)){if(mergeStartRow-1){mergeStartRowrowIndex-1;}}else{// 当前值与上一行值不同执行上一段合并if(mergeStartRow!-1){intmergeEndRowrowIndex-1;log.info(合并第{}列从第{}行到第{}行值{},columnIndex,mergeStartRow,mergeEndRow,lastValue);CellRangeAddress regionnewCellRangeAddress(mergeStartRow,mergeEndRow,columnIndex,columnIndex);try{sheet.addMergedRegion(region);clearMergedCells(sheet,region);mergeCount;}catch(Exception e){log.warn(合并失败: {},e.getMessage());}mergeStartRow-1;}}lastValuecurrentValue;}// 处理最后一段合并if(mergeStartRow!-1lastRowNummergeStartRow){log.info(合并第{}列从第{}行到第{}行最后一行值{},columnIndex,mergeStartRow,lastRowNum,lastValue);CellRangeAddress regionnewCellRangeAddress(mergeStartRow,lastRowNum,columnIndex,columnIndex);try{sheet.addMergedRegion(region);clearMergedCells(sheet,region);mergeCount;}catch(Exception e){log.warn(合并失败: {},e.getMessage());}}log.info(第{}列合并完成共合并{}个区域,columnIndex,mergeCount);}/** * 清空合并区域中除第一个单元格外的其他单元格的值 * * param sheet Excel Sheet对象 * param region 合并区域 */privatevoidclearMergedCells(Sheet sheet,CellRangeAddress region){for(introwIndexregion.getFirstRow()1;rowIndexregion.getLastRow();rowIndex){Row rowsheet.getRow(rowIndex);if(row!null){Cell cellrow.getCell(region.getFirstColumn());if(cell!null){// POI 3.16兼容设置单元格类型为空来清空值cell.setCellType(Cell.CELL_TYPE_BLANK);}}}}/** * 安全获取单元格的字符串值 * * param cell Excel单元格对象 * return 单元格的字符串值如果单元格为null则返回null */private StringgetCellValue(Cell cell){if(cellnull){returnnull;}intcellTypecell.getCellType();if(cellTypeCell.CELL_TYPE_STRING){returncell.getStringCellValue();}elseif(cellTypeCell.CELL_TYPE_NUMERIC){if(DateUtil.isCellDateFormatted(cell)){returnString.valueOf(cell.getDateCellValue());}else{returnString.valueOf(cell.getNumericCellValue());}}elseif(cellTypeCell.CELL_TYPE_BOOLEAN){returnString.valueOf(cell.getBooleanCellValue());}elseif(cellTypeCell.CELL_TYPE_FORMULA){returncell.getCellFormula();}returnnull;}}示例代码,思路是这样,代码随便看看clearMergedCells必须要有,否则虽然看着单元格合并了,但是下面的单元格每个还有自己的值,不像在excel里直接执行合并,只保留上面的值,这样的话在导入的情况下,会导致你按空处理合并单元格的第二行会莫名奇妙读到值,具体解释看最后4.效果导出效果示例总结先使用fill导出数据到内存,在内存中使用poi对需要合并的单元格进行操作踩坑问题:导出策略中,如果只合并单元格,不进行置空处理,这样导出的excel,合并的单元格并不像我们在excel中合并单元格一样只保留左上角的值,下面单元格也是有值的,如果是导出的时候当成空置判断就会有问题在 Excel 中合并单元格后只有左上角的单元格保留值其他被合并的单元格应该被清空。但 POI 的 addMergedRegion() 只是设置合并区域不会自动清除其他单元格的值。解决方案:需要修改 FillMergeStrategy 在执行合并后清空被合并区域中除了第一个单元格之外的其他单元格的值代码已经修复