2026/11/3 16:30:31

EasyPOI合并单元格:mergeVertical与needMerge实战

EasyPOI合并单元格:mergeVertical与needMerge实战 1. 合并单元格在 EasyPOI 里到底合的是什么做 Java 导出这行的人多半都经历过这种场面Excel 文件是导出来了字段没错、数据也对业务方打开看了三秒钟回一句这个部门名怎么每行都在重复能不能合并成一块。这时候你打开代码翻到 EasyPOI 的实体类盯着Excel注解发愣——好像有个needMerge好像又有个mergeVertical到底该用哪个用了为什么没生效生效了为什么边框没了。这篇就把 EasyPOI 合并单元格这件事从机制到落地讲透适合已经能跑通基础导出、但一碰到纵向合并、跨行合并、分页合并就卡壳的同学也适合刚接手别人导出的代码、需要快速看懂的开发者。先把底层的机制摆出来。EasyPOI 本身并不发明合并逻辑它做的是把注解翻译成 Apache POI 的CellRangeAddress然后调用Sheet.addMergedRegion()。你在 Excel 里看到的那个合并区域本质上就是一个矩形范围起始行、结束行、起始列、结束列。EasyPOI 的注解体系只是在帮你自动算出这个矩形而已。理解了这一层很多莫名其妙的问题就有解释了——比如为什么相同的内容不相邻就不合并因为算出来的矩形必须是连续的比如为什么合并之后只有第一格有值因为 POI 的合并语义就是保留左上角单元格其余清空显示。1.1 三种最常见的合并诉求对应的其实是三套逻辑实际项目里合并单元格这四个字至少包含三种完全不同的场景很多人踩坑就是因为把它们当成了一件事。第一种是同值纵向合并一列数据里相邻几行内容一样把它们合成一格。典型就是订单明细表里一个订单有多行商品订单号那一列要合并。第二种是一对多展开时主表字段合并实体里嵌套了一个 List导出时主表字段只应该出现一次明细行铺开。第三种是表头层面的合并多级表头、大标题跨列居中这类合并是 EasyPOI 自己生成表头时做的你甚至没写合并代码它自动就合了。这三种诉求在 EasyPOI 里对应的入口并不一样第一种靠mergeVertical加mergeRely第二种靠needMerge第三种靠groupName和ExportParams.setTitle。混用会出问题比如给一个嵌套集合的实体字段同时加needMerge和mergeVertical实测结果经常是意料之外的。1.2 为什么说合并是导出环节最容易被低估的坑不合并的时候导出的本质是一行一行填格子线性、可预测、出错了好定位。一旦引入合并事情就变成先填满、再回填矩形、再修样式中间任何一步顺序错了都会留下痕迹边框只剩外框、内容垂直方向贴顶、行高不够导致文字被裁、筛选和排序功能失效、后续读取的时候一半单元格是 null。更麻烦的是合并有副作用。Excel 的合并区域是不能重叠的一旦两个CellRangeAddress有交集POI 直接抛异常整个导出挂掉。而在分页导出、模板导出、动态列这些场景下合并区域的坐标是运行时算出来的很容易算出重叠。我见过最典型的一次事故是有人在循环里对每一行都调了一次合并结果相邻行的区域首尾相接还带重叠几百条数据直接抛错。所以这篇不打算只贴一个注解就完事。我会把三条路线摆出来对比然后拿一张订单明细表从头做到尾最后把踩过的坑整理成速查表你照着抄作业基本能覆盖八成场景。2. 三条实现路线对比注解、参数、手动在动手之前先想清楚走哪条路。EasyPOI 提供了从全自动到全手动的一条光谱选错了后面会一直别扭。我的经验是数据结构规整、合并规则简单走注解需要精细控制合并范围、或者要修样式走手动介于两者之间用参数配合注解。2.1 注解路线之一mergeVertical 做同值纵向合并Excel注解上有两个属性专门干这件事mergeVertical控制是否纵向合并相同内容mergeRely指定合并依赖的列索引。用法很直白Excel(name 客户名称, width 18, orderNum 2, mergeVertical true) private String customerName;这段代码的意思是导出时把customerName这一列里相邻且内容相同的单元格合并成一个。注意相邻两个字它是整个机制的前提。如果你的数据是乱序的同一个客户名分散在第 3 行和第 9 行中间夹着别人那它不会被合并——EasyPOI 只会把连续相同的行算成一个矩形。mergeRely则是用来解决多列联动合并的。举个例子你有客户名称和客户等级两列希望它们跟着客户一起合并而不是各自判断Excel(name 客户名称, orderNum 2, mergeVertical true) private String customerName; Excel(name 客户等级, orderNum 3, mergeVertical true, mergeRely {0}) private String customerLevel;mergeRely {0}表示我的合并取决于第 0 列也就是客户名称那一列。这样即使两个客户的等级碰巧一样也不会被误合并成一块视觉上和业务语义上都对得上。这个属性在实际项目里用得非常多但文档里讲得比较简略很多人不知道。这里有个必须提醒的点mergeRely里的索引是最终列索引不是字段声明顺序。EasyPOI 会按orderNum重排列顺序如果你设置了ExportParams.setExclusions排除掉某几列索引还会整体左移。写死数字是埋雷后面加一个字段就可能全错。稳妥的做法是用常量把列索引定义出来或者干脆走手动合并、按表头名反查列号后面第 3 章会给这个方法。2.2 注解路线之二needMerge 处理一对多展开needMerge和mergeVertical经常被混为一谈但它们的适用场景不同。needMerge的典型用法是实体里嵌套了一个集合导出时主表信息只占一行、明细铺开多行主表字段需要纵向撑满整个明细块。Data public class DeptExportVO { Excel(name 部门名称, width 20, orderNum 1, needMerge true) private String deptName; Excel(name 负责人, width 16, orderNum 2, needMerge true) private String leader; ExcelCollection(name 成员, orderNum 3) private ListEmpExportVO emps; }EmpExportVO里的字段正常写Excel就行。导出的时候EasyPOI 会先算出每个部门占几行取决于emps的 size然后把deptName、leader这两个标了needMerge的字段纵向合并到对应行数。视觉上就是左边一大块研发部 / 张三右边三行成员明细。对照一下就很清楚了mergeVertical是内容相同就合并是被动判断needMerge是我这个字段属于主表请按明细行数合并是主动声明。前者要求你先把数据排好序后者由 EasyPOI 根据集合长度自己算行数。还有一个细节值得说集合为空的时候needMerge的字段会单独占一行不会消失。这个行为是合理的但如果你业务上希望没有明细就整行不显示那就得在组装数据的时候把空集合的部门过滤掉别指望注解帮你处理。2.3 参数路线全局开关与多级表头EasyPOI 4.x 之后导出入口多了一个ExcelExportParams它继承自ExportParams多出来的一批能力里就包括全局的合并开关。我的习惯是显式打开不依赖默认值ExcelExportParams params new ExcelExportParams(); params.setSheetName(订单明细); params.setMergeCells(true);setMergeCells(true)相当于告诉 EasyPOI这个 sheet 允许做合并配合实体上的needMerge使用。为什么建议显式写因为版本之间有差异4.x 的不同小版本对默认值的处理不完全一致你今天跑通了明天升级个版本可能就静默失效排查起来极其费劲。显式写一行代码把不确定性消掉。多级表头这条线也值得单独说因为它本身就是合并的一种。给字段加上groupNameExcel(name 数量, groupName 商品信息, orderNum 4) private Integer qty; Excel(name 金额, groupName 商品信息, orderNum 5) private BigDecimal amount;导出来的表头会是两行第一行商品信息横跨数量和金额两列第二行才是具体列名。这个横向合并是 EasyPOI 自动算的你不用管坐标。但它的存在会影响后面手动合并的行号计算——表头占了几行数据从第几行开始都得重新算。顺带提一句ExportParams.setTitle(订单明细表)。加了大标题之后表格最上面会多出一行跨所有列居中的标题这一行也是合并出来的。它本身没什么问题问题在于很多人写手动合并的时候把起始行写死成 1加了大标题之后数据其实从第 2 行开始合并区域整体偏移一行边框就串位了。2.4 手动路线把 Workbook 拿回来自己动刀注解解决不了的时候就回到 POI 层。EasyPOI 的exportExcel返回的是Workbook你完全可以先让它正常导出拿到 workbook 之后再对 sheet 做二次加工Workbook workbook ExcelExportUtil.exportExcel(params, OrderExportVO.class, dataList); Sheet sheet workbook.getSheetAt(0); // 在这里做自定义合并、补边框、调行高这条路的好处是完全可控——你想按什么规则合、合哪几列、合完要不要补边框、要不要顺手加个条件格式都是你说了算。坏处是要自己处理坐标、自己处理重叠、自己处理样式。适用于这几种情况合并规则涉及跨列注解只支持纵向、需要在合并的同时改样式、或者数据结构太动态、注解根本表达不了。常见的做法是写一个通用的合并工具方法按列扫描、把连续相同值的行合成一块。第 3 章会给出完整实现包括空值处理、样式修复和列号反查。3. 从零落地一张带合并的订单明细表光讲机制不够直接走一遍完整流程。需求是这样的一张订单明细导出字段有订单号、客户名称、下单日期、商品名称、数量、金额。业务要求客户名称相同的行纵向合并并且表格要好看——边框完整、内容垂直居中、行高合适。3.1 依赖与版本锁定EasyPOI 4.0 之后拆成了多个模块base是核心annotation是注解Spring Boot 项目可以直接引 starterdependency groupIdcn.afterturn/groupId artifactIdeasypoi-base/artifactId version4.4.0/version /dependency dependency groupIdcn.afterturn/groupId artifactIdeasypoi-annotation/artifactId version4.4.0/version /dependency如果是 Spring Boot 项目引easypoi-spring-boot-starter更省事它会带上一套默认配置。但要留意 starter 里的 POI 版本和你项目里其他依赖比如某些报表组件带的 POI 版本可能打架出现NoSuchMethodError或者NoClassDefFoundError的时候先去mvn dependency:tree里看看是不是有两个 POI。我个人习惯是不用 starter直接引base和annotationPOI 版本由自己统一管。这样出问题时排查范围小不会莫名其妙多出一堆自动配置。3.2 实体设计哪些字段该加 orderNum哪些该加 mergeVertical实体类的设计直接决定了导出的可维护性。我的原则是orderNum全部显式写不留空mergeVertical只加在真正需要合并的列上宽度、格式、换行这些展示属性跟着字段一起写全别散落在调用处。Data public class OrderExportVO { Excel(name 订单号, width 22, orderNum 1) private String orderNo; Excel(name 客户名称, width 18, orderNum 2, mergeVertical true, isWrap true, height 20) private String customerName; Excel(name 下单日期, width 16, orderNum 3, format yyyy-MM-dd) private Date orderDate; Excel(name 商品名称, width 24, orderNum 4) private String productName; Excel(name 数量, width 10, orderNum 5) private Integer qty; Excel(name 金额, width 14, orderNum 6, numFormat #,##0.00) private BigDecimal amount; }几个参数的选择理由说一下。width的单位是字符宽度中文按两个字符算所以内容偏长的列给到 18~24 比较合适太窄了合并之后更难看。height 20是行高单位是磅中文默认 12 磅左右设 20 是因为合并之后内容垂直居中行高太小会显得很挤。isWrap true让超长内容自动换行但要注意 POI 的自动换行不会自动调整行高得靠height手动兜底。numFormat #,##0.00处理金额千分位这个比在代码里String.format好因为 Excel 单元格里存的还是数字业务方拿到之后还能做求和、透视格式只是展示层。3.3 数据预处理排序才是合并生效的前提这一步是整篇文章最容易被忽略、也最容易出事的地方。mergeVertical只合并相邻相同值所以数据必须先按合并列排好序。很多人写完注解发现没合并第一反应是注解写错了其实是数据乱序。ListOrderExportVO list orderService.queryExportList(query); list.sort(Comparator.comparing(OrderExportVO::getCustomerName, Comparator.nullsLast(String::compareTo)));如果合并的是多列联动比如客户 客户等级排序的字段要和mergeRely的依赖链保持一致否则会出现客户名相邻但等级不同的错位。这种情况下建议用多级排序list.sort(Comparator.comparing(OrderExportVO::getCustomerName, Comparator.nullsLast(String::compareTo)) .thenComparing(OrderExportVO::getCustomerLevel, Comparator.nullsLast(String::compareTo)));还有一个隐性的坑中文排序。String::compareTo用的是 Unicode 码位中文排出来不是拼音序但这对合并没影响——合并只要求相同的排在一起不要求按什么顺序。所以不必为此专门引 Collator。但如果业务方要求导出后按客户名拼音排序那还是得用Collator.getInstance(Locale.CHINA)这是另一个话题了。注意如果数据库分页查询之后再在内存里排序一定要确认排序是在全量数据上做的。分页查询 各自排序 拼接导出会出现同样的客户名散落在不同页跨页无法合并。这种问题表面上像是合并丢失实际上是数据被切碎了。3.4 导出调用与参数拼装参数这块我一般会封一个方法统一处理避免每个导出接口都复制粘贴。核心就是 sheet 名、标题、合并开关、冻结窗格这几项public Workbook buildWorkbook(ListOrderExportVO list) { ExportParams params new ExcelExportParams(); params.setSheetName(订单明细); params.setTitle(客户订单明细表); params.setTitleHeight((short) 12); params.setFreezeCol(2); ((ExcelExportParams) params).setMergeCells(true); return ExcelExportUtil.exportExcel(params, OrderExportVO.class, list); }setFreezeCol(2)冻结前两列数据量大的时候业务方横向滚动还能看到订单号和客户名这个细节加上之后体验提升很明显。setTitleHeight控制大标题行的高度不设的话默认值偏小标题会显得局促。然后输出到响应流Workbook workbook buildWorkbook(list); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); response.setHeader(Content-Disposition, attachment;filename URLEncoder.encode(订单明细.xlsx, UTF-8)); workbook.write(response.getOutputStream()); workbook.close();workbook.close()一定要写XSSFWorkbook 底层持有临时文件句柄不关在高并发下会慢慢把句柄耗光。这种问题不会立刻暴露跑几天之后突然开始报错排查起来很痛苦。3.5 合并后的样式修复边框、行高、居中走注解路线的时候mergeVertical合并完的区域通常会出现一个观感问题只有外框有边框内部横线消失了。原因是 POI 的合并只保留左上角单元格的样式被合并掉的单元格虽然边框属性还在但显示上不生效。解决办法有两个。第一个是在合并之前把所有单元格的边框设置好——EasyPOI 的默认样式其实已经这么做了但如果你自定义了IExcelExportStyler可能会覆盖掉。第二个是拿回 workbook 之后用RegionUtil补齐CellRangeAddress region new CellRangeAddress(startRow, endRow, col, col); sheet.addMergedRegion(region); RegionUtil.setBorderTop(BorderStyle.THIN, region, sheet); RegionUtil.setBorderBottom(BorderStyle.THIN, region, sheet); RegionUtil.setBorderLeft(BorderStyle.THIN, region, sheet); RegionUtil.setBorderRight(BorderStyle.THIN, region, sheet);RegionUtil是 POI 自带的工具类在org.apache.poi.ss.util包下专门用来给合并区域补边框。补的时候建议加上CellUtil.setAlignment之类的调用把垂直居中设上Cell firstCell sheet.getRow(startRow).getCell(col); CellStyle style firstCell.getCellStyle(); style.setVerticalAlignment(VerticalAlignment.CENTER); style.setAlignment(HorizontalAlignment.CENTER); firstCell.setCellStyle(style);这里有个细节getCellStyle()拿到的样式对象是工作簿级别共享的直接改会影响所有用了这个样式的单元格。如果你只想改合并区域得先workbook.createCellStyle()克隆一份再改。这个坑我踩过改一列的对齐方式结果整张表都歪了。行高的问题也顺手解决一下。合并之后内容垂直居中但如果原行高太小中文上下会贴边。可以在导出后统一遍历一遍for (int i firstDataRow; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row ! null) { row.setHeightInPoints(20); } }3.6 通用合并工具类按列扫描 列号反查注解搞不定的场景就上这个工具方法。逻辑很简单从数据起始行开始逐行读指定列的值遇到值变化就把上一段合掉。public static void mergeSameValue(Sheet sheet, int col, int firstDataRow) { int lastRow sheet.getLastRowNum(); if (lastRow firstDataRow) { return; } int start firstDataRow; String prev cellText(sheet.getRow(firstDataRow), col); for (int i firstDataRow 1; i lastRow; i) { String cur cellText(sheet.getRow(i), col); if (!Objects.equals(prev, cur)) { doMerge(sheet, start, i - 1, col); start i; prev cur; } } doMerge(sheet, start, lastRow, col); } private static void doMerge(Sheet sheet, int startRow, int endRow, int col) { if (endRow startRow) { return; } CellRangeAddress region new CellRangeAddress(startRow, endRow, col, col); sheet.addMergedRegion(region); RegionUtil.setBorderTop(BorderStyle.THIN, region, sheet); RegionUtil.setBorderBottom(BorderStyle.THIN, region, sheet); RegionUtil.setBorderLeft(BorderStyle.THIN, region, sheet); RegionUtil.setBorderRight(BorderStyle.THIN, region, sheet); Cell cell sheet.getRow(startRow).getCell(col); if (cell ! null) { CellStyle style cell.getCellStyle(); style.setVerticalAlignment(VerticalAlignment.CENTER); cell.setCellStyle(style); } } private static String cellText(Row row, int col) { if (row null) { return null; } Cell cell row.getCell(col); if (cell null || cell.getCellType() CellType.BLANK) { return null; } return new DataFormatter().formatCellValue(cell).trim(); }里面有两个设计点值得说明。第一endRow startRow时直接返回避免单行也生成一个合并区域——单行合并虽然不报错但会让后续的区域重叠检查变得复杂。第二用DataFormatter而不是getStringCellValue()取值因为日期、数字类型的单元格直接取字符串会抛异常或者拿到不友好的原始值DataFormatter拿到的是用户看到的那个值比较符合合并语义。列号这块我给的建议是别写死数字。加一个按表头名反查的方法比常量维护省心得多public static int findColByName(Sheet sheet, int headerRowIndex, String headerName) { Row header sheet.getRow(headerRowIndex); if (header null) { return -1; } DataFormatter fmt new DataFormatter(); for (int i header.getFirstCellNum(); i header.getLastCellNum(); i) { Cell cell header.getCell(i); if (cell ! null headerName.equals(fmt.formatCellValue(cell).trim())) { return i; } } return -1; }调用的时候就是int col findColByName(sheet, 1, 客户名称);第二个参数是表头所在行有大标题的话表头在第 1 行没有的话在第 0 行。这么写的好处是以后字段顺序调整、加排除列这段代码都不用改。4. 踩坑实录合并不生效、区域重叠、分页断裂下面这些都是我在实际项目里真遇到过、并且花了时间才定位的问题。按发生频率排序每条都给了排查思路。4.1 合并不生效的五种原因排第一的永远是数据没排序。前面讲过mergeVertical只认相邻行乱序数据一个都合不上。排查方法很土但有效把导出的数据打印出来看看相同值是不是挨着。排第二的是版本问题。EasyPOI 4.x 早期版本对mergeCells全局开关的处理和后期不同同一个实体换版本之后行为变了。锁定版本、显式setMergeCells(true)两个动作一起做。排第三的是orderNum和排除列打架。mergeRely里写的索引如果指向了一个被setExclusions排除掉的列合并逻辑会错乱表现出来的样子就是有时候合有时候不合。排第四的是自定义 styler 干扰。如果你实现了IExcelExportStyler并且自己在里面动了合并区域的样式可能会把 EasyPOI 内置的合并标记覆盖掉。表现为单元格看起来是合并的但双击编辑又变回单格。排第五的是needMerge用错场景。needMerge是给嵌套集合用的如果实体里没有ExcelCollection单加needMerge大概率什么也不会发生。这种情况换成mergeVertical就对了。现象最可能原因处理动作完全没合并数据未按合并列排序导出前对 list 排序部分行合了部分没合相同值分散在非相邻行同上确认全量排序换版本后失效全局 mergeCells 默认值变化显式调用 setMergeCells(true)看起来合了但编辑又散开自定义样式覆盖检查 styler 实现加了 needMerge 无反应实体无嵌套集合改用 mergeVertical4.2 合并区域重叠异常怎么定位POI 抛的异常信息一般是Merged region A must not overlap another merged region带上两个区域坐标。看到这个别慌把两个坐标对一下就知道哪重叠了。最常见的成因是手动合并 注解合并混用。比如实体上已经加了mergeVertical导出后你又去手动扫一遍同样的列等于同一个区域合了两次。前者是 EasyPOI 在写入阶段合的后者是你导出后合的两个一叠加就重叠。第二个成因是嵌套集合的needMerge和手动的mergeSameValue撞车。needMerge算出来的区域是按集合长度分配的你手动扫描的时候用的是内容相同就合两套规则算出来的边界不一致中间就会出交叉。解决办法就一个原则一列只给一套规则。要么注解要么手动不要既注解又手动。如果必须混用比如 A 列用注解、B 列手动就把手动的列范围严格限制在注解没覆盖的列上。4.3 分页/大数据导出时合并断裂数据量大到几十万行的时候一般会用ExcelExportUtil.exportBigExcel配IExcelExportServer分批取数。这套机制底层是SXSSFWorkbook边写边刷盘内存占用可控。但它和合并天生不兼容——合并需要知道这一段的最后一行在哪而流式写入的时候前面的行早就刷到磁盘上去了区域坐标没法回溯。我实测过的表现是跨批次边界的合并区域要么断裂要么直接抛异常。断裂的典型样子是第一页最后几行合成一块第二页开头几行又合成另一块中间接不上。处理方案按数据量分档。十万行以内我建议干脆别用 bigExcel直接exportExcel一次生成 XSSFWorkbook内存能扛住合并逻辑也简单。几十万行以上就接受不做纵向合并这个现实改成用分组小计行来体现层次或者拆成多个 sheet。还有一种折中方案导出时不做合并只输出扁平数据导出完成后如果业务方确实需要再用脚本做二次处理——但这属于把复杂度转移了不一定划算。如果一定要在 SXSSF 下合并有个经验参数可以调SXSSFWorkbook的 window size 决定了内存里保留多少行默认 100。把 window 调大比如几千让需要合并的整段数据都留在内存里能缓解一部分问题SXSSFWorkbook wb new SXSSFWorkbook(new XSSFWorkbook(), 5000);但这只是缓解不是根治而且 window 越大内存占用越高本质上是拿内存换功能。要不要这么干取决于你的数据规模和服务器的内存预算。4.4 导入端读到合并单元格是空值这个坑和导出是一体两面。文件交给业务方填完之后回收导入EasyPOI 的Excel读合并区域时只有左上角那格有值被合并掉的格读出来是 null。如果你的导入逻辑是一条一行逐行校验必填那十几行数据会全部报客户名称不能为空。处理思路是在读取之后做一次向下填充ListOrderImportVO list ExcelImportUtil.importExcel(file, OrderImportVO.class, params); String lastCustomer null; for (OrderImportVO vo : list) { if (StringUtils.isNotBlank(vo.getCustomerName())) { lastCustomer vo.getCustomerName(); } else { vo.setCustomerName(lastCustomer); } }填充的边界要小心如果表里本来就有合法的空值比如某些行客户名确实可以不填这种无脑向下填充会把上一行的值错误地带下来。稳妥一点的做法是先读sheet.getMergedRegions()把每个合并区域的值解析出来按区域回填到对应行而不是靠上一行有值就往下填。前者是精确的后者是猜的。还有一种更省事的做法导出模板的时候干脆不用合并用每行都写全的扁平结构导入端不用处理空值。代价是文件看着重复但对数据准确性来说更安全。这个取舍看业务方能不能接受。4.5 文件打开慢、体积膨胀合并单元格本身不会让文件变大太多真正拖慢打开速度的是合并区域数量。几百个合并区域没问题几万个就会明显感觉到打开卡顿因为 Excel 在渲染时要逐个计算区域的边界和样式。如果你的合并列是订单号这种基数极大的字段几乎每两行就要合一次合并区域数量会随着行数线性增长。这种情况下我一般会劝业务方换个表达方式比如订单号只写第一行、其余留空不做合并视觉上其实是一样的但渲染成本低得多。另一个膨胀来源是样式对象。给每个单元格都createCellStyle()一遍会导致文件里样式表爆炸。正确做法是创建有限的几个样式对象循环复用。判断方法很简单导出的 xlsx 解压之后看xl/styles.xml的大小超过几百 KB 基本就是样式没复用。5. 合并场景下的工程化收尾动作功能跑通只是第一步这类导出代码活得很久后面会不断有人加字段、改规则。下面几个动作不费什么力气但能让后来的维护者少骂几句。5.1 把列索引和合并规则集中管理不管是mergeRely里的数字还是手动合并时的列号都不要散落在业务代码里。我的做法是给每个导出场景配一个常量类或者配置项把哪一列要合并、依赖哪几列显式写下来public final class OrderExportColumns { public static final int CUSTOMER_NAME 1; public static final int CUSTOMER_LEVEL 2; // 需要纵向合并的列及依赖关系 public static final MapInteger, int[] MERGE_RULES new LinkedHashMap(); static { MERGE_RULES.put(CUSTOMER_NAME, new int[]{}); MERGE_RULES.put(CUSTOMER_LEVEL, new int[]{CUSTOMER_NAME}); } }看着像是多写了一层但收益很实在加字段的时候只改这一个地方不用满项目搜orderNum 2。而且这份规则可以直接拿去做单测的断言依据。5.2 用真实形状的数据做验证合并相关的 bug 有个共同特点——用完美数据测不出来。你拿十条不重复的数据跑一遍什么都是对的换成业务真实的、带重复值的、带 null 的、带超长中文的、带日期的数据问题才冒出来。我的测试数据一般会刻意构造这几种形状连续 3 行相同值验证正常合并、相同值中间隔一行验证不合并、连续 10 行相同值验证大区域、以及带 null 的行验证空值不参与合并。跑完这四组基本能覆盖八成的边界情况。再补一个我个人的小习惯导出之后不只用 Excel 打开看还会用代码把sheet.getMergedRegions()打印出来核对区域数量和坐标。肉眼看图容易被看起来是对的骗过去打出来的数字不会说谎。有几次图形上完全正常但区域数量多了一倍——是因为同一个区域被合了两次虽然 POI 没抛异常但文件里存了两份重叠记录业务方用 WPS 打开就出问题了。至于后续还能怎么扩展我目前正在做的一件事是把合并规则从注解里抽出来做成配置化——同一份实体不同客户要不同的合并方案与其复制一堆 VO 类不如在导出时传入一份合并策略。这条路走通了会省掉大量重复实体不过坐标计算和样式的耦合还需要再理一理等踩完坑有机会再单独写一篇。