Java POI 动态二级表头导出实战:并集计算 + 合并单元格 + 冻结列的完整实现

老炮踩坑录 · D02 · 技术深挖系列

· 基于「企业融合评估系统」真实源码

· 系统中拿到的每个企业的诊断数据维度都不一样,表头不固定,列数不固定,顺序也不固定。怎么导出一份整齐的Excel ?这篇文章,就从算法设计到每一行POI代码,进行完整的拆解。

⚠️提前预告:本文踩的最大一个坑,就是开篇承诺 "整齐",但核心算法用的 HashSet 根本不保证顺序。读完你会看到我是怎么栽的。

引子

您好,文接上篇,见字如面。

做过Excel导出的朋友都知道,最头疼的不是填数据,是画表头

固定表头简单------模板里画好,数据往里填就行。但如果表头是动态的呢?

这就是 POI 动态表头的核心难点。下面看真实业务场景。

业务场景

诊断系统返回的数据示例:

简化的数据结构(JSON):

json 复制代码
{
  "groupData": [
    {
      "name": "维度A",
      "detail": [
        {"name": "子项A1", "value": "85"},
        {"name": "子项A2", "value": "92"}
      ]
    },
    {
      "name": "维度B",
      "detail": [
        {"name": "子项B1", "value": "78"},
        {"name": "子项B2", "value": "88"},
        {"name": "子项B3", "value": "95"}
      ]
    }
  ]
}

结构说明:

groupData[].name 是一级表头,groupData[].detail[].name 是二级表头,groupData[].detail[].value 是数据值。

问题来了:每个企业返回的维度和子项不一样。

css 复制代码
企业A:维度A[子项A1, 子项A2] + 维度B[子项B1]
企业B:维度A[子项A1, 子项A2, 子项A3] + 维度C[子项C1, 子项C2]  ← 多了A3和C
企业C:维度B[子项B1, 子项B2] + 维度C[子项C1]                 ← 多了B2

但是导出Excel时,要一张表装下所有企业------表头必须是所有维度和子项的并集

没有的填 "-",有的填上。

这个算法,我写了360行左右的代码。今天来完整拆解。

问题拆解

首先把问题拆成五步:

vbnet 复制代码
第一步:数据清洗 → 过滤掉诊断系统返回异常的脏数据
第二步:一级表头并集 → 收集所有企业的一级表头(去重)
第三步:二级表头并集 → 对每个一级表头,收集其下所有二级表头(去重)
第四步:双行表头渲染 → 第一行一级表头(合并单元格),第二行二级表头
第五步:数据填充 → 按虚拟列Key匹配数据,没有的填"-"

第一步:数据清洗

诊断系统偶尔会返回异常数据(网络抖动、服务超时)。如果不过滤,后面构建表头时会报NPE错误。

java 复制代码
List<Map<String, Object>> applyData = new ArrayList<>();
List<Map<String, Object>> queryData = applyInfoService.selectApplyInfoDiagnosisExport();

for (Map<String, Object> dataMap : queryData) {
    boolean flag = true;
    String resultStr = (String) dataMap.get("result");
    JSONObject result = JSONObject.parseObject(resultStr);
    
    // 检查1:HTTP状态码是否为200
    Integer code = result.getInteger("code");
    if (code == null || code != HttpServletResponse.SC_OK) {
        flag = false;
    }
    
    // 检查2:data节点是否存在
    JSONObject dataJson = result.getJSONObject("data");
    if (dataJson == null) {
        flag = false;
    } else {
        // 检查3:groupData数组是否存在且非空
        JSONArray jsonArray = dataJson.getJSONArray("groupData");
        if (jsonArray == null || jsonArray.size() == 0) {
            flag = false;
        }
    }
    
    if (flag) {
        applyData.add(dataMap);
    }
}

三层校验,层层过滤:

ini 复制代码
原始数据
  → code != 200?→ 扔掉(诊断系统异常)
  → data == null?→ 扔掉(数据为空)
  → groupData 为空?→ 扔掉(没有维度数据)
  → 通过 → 加入有效数据集

第二步:一级表头并集

遍历所有企业数据,收集所有一级表头名称,用 HashSet 自动去重:

java 复制代码
Set<String> oneTitle = new HashSet<>();

for (Map<String, Object> dataMap : applyData) {
    JSONObject result = JSONObject.parseObject((String) dataMap.get("result"));
    JSONObject dataJson = result.getJSONObject("data");
    JSONArray oneArray = dataJson.getJSONArray("groupData");
    
    if (oneArray != null) {
        for (Object objectOne : oneArray) {
            JSONObject jsonObjectOne = (JSONObject) objectOne;
            String nameLevelOne = jsonObjectOne.getString("name");
            oneTitle.add(nameLevelOne);  // HashSet自动去重
        }
    }
}

示例:

css 复制代码
企业A返回:维度A, 维度B
企业B返回:维度A, 维度C
企业C返回:维度B, 维度C

并集结果:{维度A, 维度B, 维度C}  ← 3个,不重复

⚠️ 踩坑提示:HashSet 无序,表头会乱跳

这步当时我直接用 HashSet 去重,结果第一版导出的 Excel,表头顺序每次都不一样------今天"维度A"在第一列,明天可能在第三列。

原因HashSet 去重没问题,但它不保证插入顺序。开篇承诺"导出整齐的 Excel",这里就是个隐藏雷。

正确做法:LinkedHashSet 替代 HashSet------既能去重,又保留首次出现的顺序。第三步的 HashMap 同理要换成 LinkedHashMap。重构章节会给出修正版。

第三步:二级表头并集

这是算法最复杂的一步。

目标: 对每个一级表头,收集其下所有二级表头(去重)。

难点: 不同企业的一级表头下的二级子项可能不同。

java 复制代码
// 先收集每个企业各自的"一级→二级"映射关系
List<Map<String, Set<String>>> relaList = new ArrayList<>();

for (Map<String, Object> dataMap : applyData) {
    JSONObject result = JSONObject.parseObject((String) dataMap.get("result"));
    JSONObject dataJson = result.getJSONObject("data");
    JSONArray oneArray = dataJson.getJSONArray("groupData");
    
    Map<String, Set<String>> relaMap = new HashMap<>();
    if (oneArray != null) {
        for (Object objectOne : oneArray) {
            JSONObject jsonObjectOne = (JSONObject) objectOne;
            String nameLevelOne = jsonObjectOne.getString("name");
            JSONArray twoArray = jsonObjectOne.getJSONArray("detail");
            
            Set<String> twoTitle = new HashSet<>();
            if (twoArray != null) {
                for (Object objectTwo : twoArray) {
                    JSONObject jsonObjectTwo = (JSONObject) objectTwo;
                    twoTitle.add(jsonObjectTwo.getString("name"));
                }
            }
            relaMap.put(nameLevelOne, twoTitle);
            relaList.add(relaMap);
        }
    }
}

// 然后对每个一级表头,合并所有企业的二级表头(并集)
Map<String, Set<String>> titleMap = new HashMap<>();
for (String one : oneTitle) {
    Set<String> twoRealTitle = new HashSet<>();
    for (Map<String, Set<String>> stringSetMap : relaList) {
        for (Map.Entry<String, Set<String>> entry : stringSetMap.entrySet()) {
            if (entry.getKey().equals(one)) {
                twoRealTitle.addAll(entry.getValue());
            }
        }
    }
    titleMap.put(one, twoRealTitle);
}

示例:

css 复制代码
维度A 在各企业中的子项:
  企业A: {子项A1, 子项A2}
  企业B: {子项A1, 子项A3}
  企业C: (没有维度A)

维度A 的二级表头并集:{子项A1, 子项A2, 子项A3}

最终 titleMap:
  维度A → {子项A1, 子项A2, 子项A3}
  维度B → {子项B1, 子项B2}
  维度C → {子项C1, 子项C2}

第四步:双行表头渲染

第一行:一级表头 + 合并单元格

css 复制代码
企业名称 | 所属行业 | ... | 维度A(合并3列)     | 维度B(合并2列)| 维度C(合并2列)
         |         |     |                      |                 |
         |         |     | 子项A1|子项A2|子项A3  | 子项B1 |子项B2  | 子项C1 |子项C2

说明:

startLength 是固定列数量(这里是 9,即企业名称、所属行业等 9 列)。动态表头从第 9 列(0-based)开始拼接。

⚠️ 关于 start + 1 的列偏移

下面的 CellRangeAddress(0, 0, start + 1, step) 看起来像是把合并区域整体右移了一列,和 startLength 的定义对不上。这一处我特意保留原貌------它要么是生产代码里用 1-based 思维写的遗留,要么是 start 的语义和注释不同步。

重构时我会改成符合 0-based 直觉的 new CellRangeAddress(0, 0, start, start + size - 1),并配上单元测试验证列对齐。

java 复制代码
// 固定列的表头
StringBuffer titleBuffer = new StringBuffer();
titleBuffer.append("企业名称,企业所属行业,业务类型,企业年营业额,企业总人数,");
titleBuffer.append("归属市,归属区,当前所属等级,诊断编号,");

// 动态列的表头(一级表头,每个一级表头占N列,N=其下二级表头数量)
for (Map.Entry<String, Set<String>> entry : titleMap.entrySet()) {
    String key = entry.getKey();
    Set<String> value = entry.getValue();
    titleBuffer.append(key).append(",");
    // 一级表头后面的空位(给合并单元格留空间)
    for (int k = 0; k < value.size() - 1; k++) {
        titleBuffer.append("").append(",");
    }
}

// 填充到第一行
String[] row_first = titleBuffer.substring(0, titleBuffer.length() - 1).split("\\,");
HSSFRow row0 = sheet.createRow(0);
for (int i = 0; i < row_first.length; i++) {
    HSSFCell tempCell = row0.createCell(i);
    tempCell.setCellStyle(headerStyle);
    tempCell.setCellValue(row_first[i]);
}

// 合并一级表头的单元格
int start = startLength;  // 固定列数量
for (Map.Entry<String, Set<String>> entry : titleMap.entrySet()) {
    int size = entry.getValue().size();
    int step = start + size;
    // 第0行,从start+1列到step列,合并
    CellRangeAddress region = new CellRangeAddress(0, 0, start + 1, step);
    sheet.addMergedRegion(region);
    start = start + size;
}

第二行:二级表头 + 虚拟列标识

这里有一个精妙的设计------虚拟列。

java 复制代码
// titleBuffer:  只存二级表头名称(用于Excel显示,可以重复)
// virtualColumn:存"一级表头-二级表头"(用于数据匹配,全局唯一)
StringBuffer virtualColumn = new StringBuffer(
    "企业名称,企业所属行业,业务类型,企业年营业额,企业总人数,归属市,归属区,当前所属等级,诊断编号,");

for (Map.Entry<String, Set<String>> entry : titleMap.entrySet()) {
    String key = entry.getKey();       // 一级表头
    Set<String> value = entry.getValue(); // 二级表头集合
    for (String twoTitle : value) {
        titleBuffer.append(twoTitle).append(",");              // 显示用
        virtualColumn.append(key + "-" + twoTitle).append(","); // 匹配用
    }
}

为什么需要虚拟列?

css 复制代码
假设两个不同的一级表头下,有同名的二级表头:
  维度A → "优化得分"
  维度B → "优化得分"

Excel显示的是二级表头名称(可以重复):
  第一行:... | 维度A(合并1列)| 维度B(合并1列)
  第二行:... | 优化得分       | 优化得分

如果只用"优化得分"做Key来匹配数据,会冲突------不知道填的是维度A的还是维度B的。

用虚拟列"维度A-优化得分"和"维度B-优化得分"就能区分。

固定列的跨行合并

前几列(企业名称等)需要跨两行合并:

java 复制代码
protected void setDataStyleAndHeight(Sheet sheet, Workbook wb) {
    // 第0-8列,跨第0-1行合并
    for (int i = 0; i <= 8; i++) {
        CellRangeAddress region = new CellRangeAddress(0, 1, i, i);
        sheet.addMergedRegion(region);
        // 设置四边边框
        RegionUtil.setBorderBottom(BORDER_THIN, region, sheet, wb);
        RegionUtil.setBorderTop(BORDER_THIN, region, sheet, wb);
        RegionUtil.setBorderLeft(BORDER_THIN, region, sheet, wb);
        RegionUtil.setBorderRight(BORDER_THIN, region, sheet, wb);
    }
}

原代码把这段写了137行------同样的4行代码重复9次。 一个for循环就能搞定。

第五步:数据填充

表头建好了,现在往里面填数据:

java 复制代码
List<Map<String, Object>> excelDataList = new ArrayList<>();

for (Map<String, Object> dataMap : applyData) {
    Map<String, Object> excelData = new HashMap<>();
    
    // 固定列数据
    excelData.put("企业名称", (String) dataMap.get("enterpriseName"));
    excelData.put("企业所属行业", ...);
    excelData.put("业务类型", ...);  // 0→"离散行业", 1→"流程行业"
    excelData.put("企业年营业额", ...);  // 0→"2000万以下", 1→"2000万至5000万"...
    // ...
    
    // 动态列数据:按虚拟列Key匹配
    JSONObject dataJson = result.getJSONObject("data");
    for (Map.Entry<String, Set<String>> entry : titleMap.entrySet()) {
        String key = entry.getKey();           // 一级表头
        Set<String> value = entry.getValue();  // 二级表头集合
        
        for (String twoTitle : value) {
            boolean haveTwo = false;
            JSONArray oneArray = dataJson.getJSONArray("groupData");
            
            if (oneArray != null) {
                for (Object objectOne : oneArray) {
                    JSONObject jOne = (JSONObject) objectOne;
                    if (key.equals(jOne.getString("name"))) {  // 找到匹配的一级表头
                        JSONArray twoArray = jOne.getJSONArray("detail");
                        for (Object objectTwo : twoArray) {
                            JSONObject jTwo = (JSONObject) objectTwo;
                            if (twoTitle.equals(jTwo.getString("name"))) {
                                // 匹配上了!用虚拟列Key存数据
                                excelData.put(key + "-" + jTwo.getString("name"), 
                                             jTwo.getString("value"));
                                haveTwo = true;
                            }
                        }
                    }
                }
            }
            if (!haveTwo) {
                // 该企业没有这个维度 → 填"-"
                excelData.put(key + "-" + twoTitle, "-");
            }
        }
    }
    excelDataList.add(excelData);
}

填充到Excel行:

java 复制代码
String[] row_second_real = virtualColumn.substring(0, virtualColumn.length() - 1).split("\\,");

for (int m = 0; m < excelDataList.size(); m++) {
    HSSFRow tempRow = sheet.createRow(rowNum++);
    Map<String, Object> map = excelDataList.get(m);
    // ⚠️ 这里应该用 row_second_real.length(虚拟列总数),不是 map.size()
    // 原代码能跑只是因为填数据时所有虚拟列都 put了(命中的填值,未命中的填"-")
    // 一旦某列漏 put,map.size() 就会小于虚拟列数,整行错位
    for (int n = 0; n < row_second_real.length; n++) {
        HSSFCell tempCell = tempRow.createCell(n);
        tempCell.setCellStyle(contentStyle);
        // 用虚拟列Key取数据
        String data = (String) map.get(row_second_real[n]);
        if (data == null) data = "";
        tempCell.setCellValue(data);
    }
}

冻结列

java 复制代码
sheet.createFreezePane(startLength, 2, startLength, 3);

参数含义:

参数 含义
colSplit 9 第9列以左冻结
rowSplit 2 第2行以上冻结
leftmostColumn 9 水平滚动时从第9列开始
topRow 3 垂直滚动时从第3行开始

效果: 横向滚动时,企业名称等9列始终可见;纵向滚动时,两行表头始终可见。

自适应列宽

动态表头的宽度根据文字长度自动调整:

java 复制代码
if (i > startLength) {  // 只处理动态列
    int byteLength = row_second[i].getBytes().length;
    if (byteLength <= 6) {
        sheet.setColumnWidth(i, byteLength * 512);   // 短文字,宽一点
    } else {
        sheet.setColumnWidth(i, byteLength * 256);   // 长文字,窄一点(能放下)
    }
}

算法总结图

为了让大家更好的理解算法逻辑,我画了一个整体流程图:

如果让我重构

360 行账本: 算法核心 80 行 + 样式设置 137 行 + POI 操作与拼装 143 行。重构主要砍前两块。

核心算法可以精简

360行代码中,真正有算法价值的只有并集计算部分(约80行),其余都是POI操作和样式设置。

如果用 Java 8 的Stream API重写并集计算:

java 复制代码
// 一级表头并集(LinkedHashSet 保序,呼应开篇"整齐"承诺)
Set<String> oneTitle = applyData.stream()
    .map(d -> JSONObject.parseObject((String) d.get("result")))
    .map(r -> r.getJSONObject("data").getJSONArray("groupData"))
    .filter(Objects::nonNull)
    .flatMap(JSONArray::stream)
    .map(o -> ((JSONObject) o).getString("name"))
    .collect(Collectors.toCollection(LinkedHashSet::new));

// 二级表头并集(LinkedHashMap 保一级顺序,LinkedHashSet 保二级顺序)
Map<String, Set<String>> titleMap = applyData.stream()
    .map(d -> JSONObject.parseObject((String) d.get("result"))
        .getJSONObject("data").getJSONArray("groupData"))
    .filter(Objects::nonNull)
    .flatMap(JSONArray::stream)
    .map(o -> (JSONObject) o)
    .collect(Collectors.groupingBy(
        o -> o.getString("name"),
        LinkedHashMap::new,
        Collectors.flatMapping(
            o -> o.getJSONArray("detail").stream()
                .map(d -> ((JSONObject) d).getString("name")),
            Collectors.toCollection(LinkedHashSet::new)
        )
    ));

80行 → 20行。

⚠️ 兼容性提示:

Collectors.flatMapping 是 Java 9+ API。本项目 Spring Boot 2.1.0 默认走 Java 8,这段重构代码直接拷过去跑不起来------要么升级 JDK,要么把 flatMapping 换成 mapping + 手动打平。

中间容器 relaList 是过度设计

第三步我先收集 List<Map<String, Set<String>>> relaList,再二次遍历合并到 titleMap。其实可以边遍历 applyData 边直接合并,省掉整个中间容器:

java 复制代码
Map<String, Set<String>> titleMap = new LinkedHashMap<>();
for (Map<String, Object> dataMap : applyData) {
    JSONArray oneArray = JSONObject.parseObject((String) dataMap.get("result"))
        .getJSONObject("data").getJSONArray("groupData");
    if (oneArray == null) continue;
    for (Object objectOne : oneArray) {
        JSONObject jOne = (JSONObject) objectOne;
        Set<String> two = titleMap.computeIfAbsent(
            jOne.getString("name"), k -> new LinkedHashSet<>());
        JSONArray twoArray = jOne.getJSONArray("detail");
        if (twoArray == null) continue;
        for (Object objectTwo : twoArray) {
            two.add(((JSONObject) objectTwo).getString("name"));
        }
    }
}

一次遍历搞定,少一个容器、少一次嵌套循环。

样式代码应该提取

137行的 setDataStyleAndHeight() 应该用一个for循环替代:

java 复制代码
for (int i = 0; i <= 8; i++) {
    CellRangeAddress region = new CellRangeAddress(0, 1, i, i);
    sheet.addMergedRegion(region);
    RegionUtil.setBorderBottom(BORDER_THIN, region, sheet, wb);
    RegionUtil.setBorderTop(BORDER_THIN, region, sheet, wb);
    RegionUtil.setBorderLeft(BORDER_THIN, region, sheet, wb);
    RegionUtil.setBorderRight(BORDER_THIN, region, sheet, wb);
}

137行 → 7行。

第五步四层嵌套循环的性能账

第五步数据填充是 4 层嵌套:企业数 N × 一级表头 M × 二级表头 K × 该企业 groupData 内层 m'×k'。最坏情况 O(N·M·K·m'·k')。企业数上百、维度十几个时就开始卡。

**优化方向:**把每家企业的数据预处理成 Map<虚拟列Key, value>,填充时直接 O(1) 查表,整体降到 O(N·K)。

导出逻辑应该下沉到Service

360行 POI 代码不应该在Controller里。应该提取到独立的 DiagnosisExportService

java 复制代码
// Controller只负责调度
@PostMapping("back/exportModel")
public void export(@RequestParam Integer paperid, HttpServletResponse response) {
    exportService.export(paperid, response);
}

// 导出逻辑在Service里
@Service
public class DiagnosisExportService {
    public void exportPlustekDiagnosis(HttpServletResponse response) {
        // 360行POI逻辑搬到这里
    }
}

老炮点评

老炮语录

  1. "动态表头的本质是集合的并集运算。" ------想通了这一点,360行代码可以缩到100行。但当时我想了三天才想通。

  2. "虚拟列是解决同名冲突的关键。" ------不同一级表头下可能有同名的二级表头。用"一级-二级"拼接做唯一Key,简单有效。

  3. "POI的代码不要写在Controller里。" ------360行POI操作和"接收请求"没有任何关系。它是"报表引擎"的事,不是"前台接待"的事。

  4. "先跑通再优化,但别忘了优化。" ------360行代码跑了两年没出Bug。但每次看都想吐。代码不是写给自己看的,是写给半年后的自己看的。

  5. "Excel导出最难的从来不是填数据,是画表头。" ------数据是确定的,表头是不确定的。处理"不确定性"才是真正的技术含量。


下期预告《雪花算法看似简单,但41位时间戳里藏了三个坑,我全踩了一遍》

下一篇,我拆解"雪花算法ID生成器"的完整实现------位运算、时钟回拨、分布式ID的坑。

我是老炮,18年仍然奋斗在一线的Java老兵。关注我,每期分享真实案例,让你少走弯路。

相关推荐
旺仔不是程序员1 小时前
LIMIT 1:PostgreSQL 只取一行的高效查询姿势
数据库·后端·sql
知守观1 小时前
Snowflake 雪花算法实战:41位时间戳里的三个坑(时钟回拨、workerId、位运算)
后端
花间相见2 小时前
【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错
java·linux·人工智能·后端·python·计算机网络·postman
BryceBorder2 小时前
Agent Memory 不只是聊天记录:手搓三大记忆系统
后端·agent·面经·codex·claude code·agent memory
一条泥憨鱼2 小时前
苍穹外卖【day11| 用户统计,订单统计,销量排名统计功能实现】
java·后端·苍穹外卖
她的男孩2 小时前
缓存明明命中了却报 ClassCastException:拆完多级缓存控制面,我挖出 5 个静默失效的坑
java·后端·架构
孙启超2 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
SimonKing2 小时前
SpringBoot 集成 SSE 实现服务端推送或可代替Websocket
java·后端·程序员