
1. 项目概述为什么表头操作是Excel导出的灵魂做后端开发尤其是涉及报表、数据导出的项目Excel操作几乎是绕不开的坎。从早期的Apache POI到后来各种封装工具我们一直在追求一个目标用代码优雅地“画”出一张符合业务需求的Excel表格。而这张表格的“脸面”和“骨架”就是表头。最近在重构一个数据中台项目的导出模块我再次深刻体会到表头处理得好整个导出功能就成功了一大半。用户第一眼看到的就是表头清晰、美观、符合逻辑的表头直接决定了数据报表的专业度和易用性。这次我们聚焦于EasyExcel——这个阿里出品的、以简洁和内存友好著称的Excel操作工具。网上关于EasyExcel基础导出的教程很多但一旦涉及到多级表头、动态表头、无表头动态填充这些稍微复杂一点的场景资料就变得零散踩坑的经历倒是不少。比如如何实现财务报表那种复杂的多级合并表头如何让表头某一列单独设置样式或添加下拉框甚至在某些数据看板场景下如何生成一个没有固定表头、但第一行需要动态显示某些标题信息的表格这些需求恰恰是真实项目中最高频的痛点。EasyExcel通过注解和编程式API为这些复杂表头操作提供了非常灵活的解决方案。但灵活也意味着需要更清晰的理解和更细致的配置。接下来我将结合多个实战场景拆解EasyExcel在表头操作上的核心实现从基础的注解驱动到高级的动态编程分享那些在官方文档里不会明说却能让你事半功倍的实操技巧和避坑指南。2. 核心思路与方案选型注解驱动 vs. 编程式API面对复杂的表头需求EasyExcel主要提供了两种设计思路理解它们的适用场景是高效开发的关键。2.1 注解驱动声明式的优雅与局限这是EasyExcel最广为人知的方式。通过在实体类的字段上添加ExcelProperty注解你可以声明表头的内容、顺序、甚至简单的合并。Data public class UserDTO { ExcelProperty(value {基本信息, 用户标识}, index 0) private Long id; ExcelProperty(value {基本信息, 用户姓名}, index 1) private String name; ExcelProperty(value {联系信息, 手机号码}, index 2) private String phone; ExcelProperty(value {联系信息, 电子邮箱}, index 3) private String email; }优点简洁直观表头结构与Java对象模型强绑定代码可读性高。维护方便修改表头只需调整注解的value数组业务逻辑代码通常无需改动。天然支持多级表头value数组中的每一个元素就对应表头的一行。上面的例子会生成一个两行表头“基本信息”和“联系信息”分别合并其下方的子列。局限与考量灵活性不足表头在编译期就已确定无法根据运行时条件如用户权限、查询参数动态生成不同的表头结构。样式统一通过ContentStyle等注解设置的样式通常作用于整列表头数据难以对表头中的某个特定单元格进行精细化样式控制。复杂合并无力对于跨行跨列的不规则合并比如斜线表头纯注解方式无法实现。实操心得注解驱动非常适合表头结构固定、业务逻辑稳定的导出场景如固定的数据模板、格式统一的统计报表。它是首选方案能用注解解决的绝不用编程。2.2 编程式API动态控制的强大武器当注解无法满足需求时就需要祭出WriteSheet和WriteTable并结合CellWriteHandler单元格写入处理器来实现编程式控制。核心对象解析WriteSheet代表一个工作表Sheet。我们可以在创建WriteSheet时直接传入一个ListListString作为表头。列表的外层List代表行内层List代表该行的列内容。这为动态表头打开了大门。WriteTable代表一个表格。它特别有用在同一个Sheet内需要绘制多个独立表格或者需要实现无实体类绑定的纯数据导出。WriteTable也可以独立设置自己的表头。CellWriteHandler这是一个拦截器接口。通过实现它你可以在单元格包括表头单元格和数据单元格被写入到Excel文件之前或之后对其进行任意操作如设置样式、合并单元格、添加批注、数据校验等。这是实现多级表头合并、表头单独样式等高级功能的基石。方案选型决策流表头是否完全静态且规整是 - 使用ExcelProperty注解。表头结构是否需要根据参数动态生成是 - 使用WriteSheet或WriteTable的head方法设置ListListString。是否需要为表头单元格设置不同于数据单元格的特殊样式如背景色、字体加粗、添加下拉框是 - 实现CellWriteHandler在afterCellCreate方法中判断当前单元格是否为表头 (cell.getRowIndex() headRowNumber) 并进行处理。是否需要创建复杂的多级合并表头是 - 实现CellWriteHandler在afterCellCreate或afterCellDataConverted方法中调用sheet.addMergedRegion()进行单元格合并。是否需要导出没有固定表头但第一行有特殊意义的“动态标题行”是 - 可以结合使用WriteTable不设表头和CellWriteHandler在第一行写入自定义内容或者直接将第一行数据作为“标题”写入。3. 多级表头与复杂合并的实现详解多级表头是复杂报表的标配如“年度-季度-月份”、“部门-科室-小组”、“项目-模块-功能”等层级关系。下面我们分场景实现。3.1 基于注解的规整多级表头这是最简单的情况。如上文的UserDTO示例只需在ExcelProperty的value中按层级顺序填写字符串数组即可。EasyExcel会自动根据相邻单元格内容的相同性进行行合并。关键点index属性至关重要它决定了列的顺序。同一层级的表头index必须连续且正确否则合并会错乱。表头的行数由value数组中最长的那个决定。例如如果大部分字段是二级表头但有一个字段是一级表头value {汇总},index 4那么最终表头行数仍然是二级那个“汇总”表头下方会出现一个空白表头行。3.2 基于CellWriteHandler的编程式多级合并当你的多级表头不规则或者需要在已有注解表头上追加额外的合并时就必须使用CellWriteHandler。场景导出一张销售报表表头为[ 2023年度 ] [ 2024年度 ] [ Q1 | Q2 | Q3 | Q4 ] [ Q1 | Q2 | Q3 | Q4 ] [销售额|利润|销售额|利润|...]这是一个三级表头且“年度”需要跨越多列合并。实现步骤定义实体类使用注解定义最基础的表头结构到季度和指标层。Data public class SalesReportDTO { // 2023 Q1 销售额 ExcelProperty(value {“2023年度” “Q1” “销售额”}, index 0) private BigDecimal sales2023Q1; // 2023 Q1 利润 ExcelProperty(value {“2023年度” “Q1” “利润”}, index 1) private BigDecimal profit2023Q1; // ... 其他季度字段注意index要连续 }这样会生成一个三级表头但“2023年度”和“2024年度”还没有跨列合并。实现自定义的CellWriteHandlerComponent public class ComplexHeaderMergeHandler implements CellWriteHandler { // 定义需要合并的单元格区域 private static final ListCellRangeAddress MERGE_REGIONS new ArrayList(); static { // 合并 2023年度第0行第0列到第7列 (假设共8个季度指标) MERGE_REGIONS.add(new CellRangeAddress(0, 0, 0, 7)); // 合并 2024年度第0行第8列到第15列 MERGE_REGIONS.add(new CellRangeAddress(0, 0, 8, 15)); // 如果需要合并季度的表头例如合并Q1下的销售额和利润列通常不需要因为它们是平级 // MERGE_REGIONS.add(new CellRangeAddress(1, 1, 0, 1)); } Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 只在处理表头时执行合并 if (isHead ! null isHead) { // 获取当前Sheet Sheet sheet writeSheetHolder.getSheet(); // 避免重复合并可以加个判断 for (CellRangeAddress region : MERGE_REGIONS) { // 简单判断该区域是否已存在实际生产环境可能需要更严谨的去重逻辑 boolean alreadyMerged false; for (int i 0; i sheet.getNumMergedRegions(); i) { if (sheet.getMergedRegion(i).equals(region)) { alreadyMerged true; break; } } if (!alreadyMerged) { sheet.addMergedRegion(region); } } } } // ... 其他方法可以空实现 }在写入时注册处理器public void exportComplexHeader(HttpServletResponse response) { // ... 准备数据 list EasyExcel.write(response.getOutputStream(), SalesReportDTO.class) .registerWriteHandler(new ComplexHeaderMergeHandler()) // 注册合并处理器 .registerWriteHandler(new HorizontalCellStyleStrategy()) // 可以同时注册样式策略 .sheet(销售报表) .doWrite(list); }避坑指南合并区域计算CellRangeAddress的四个参数分别是firstRow, lastRow, firstCol, lastCol。注意行和列的下标都是从0开始。表头通常从第0行开始。列的索引需要你根据表头结构精确计算一个容易出错的地方是忽略了隐藏列或顺序错乱。重复合并addMergedRegion方法如果添加了完全相同的区域会导致文件损坏。务必做好去重判断如上例所示。处理器执行顺序如果同时注册了多个CellWriteHandler如合并处理器和样式处理器它们的执行顺序就是注册顺序。如果样式处理器在合并处理器之后它设置的样式可能会被合并单元格的默认样式覆盖。需要仔细测试或在一个处理器内完成所有操作。性能考虑在afterCellCreate中频繁调用sheet.getNumMergedRegions()和循环判断在大数据量导出时可能有性能损耗。对于固定的合并规则可以在处理器初始化时就计算好或者使用SetCellRangeAddress来记录已合并区域。4. 表头单独操作样式、下拉框与高级功能很多时候我们只想美化表头或者给表头添加数据校验如下拉框而不影响数据区域。这同样需要依赖CellWriteHandler。4.1 为表头设置独立样式假设我们需要将表头字体加粗、居中并设置蓝色背景。Component public class HeaderStyleHandler implements CellWriteHandler { Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead ! null isHead) { // 创建表头专属样式 Workbook workbook writeSheetHolder.getSheet().getWorkbook(); CellStyle headerStyle workbook.createCellStyle(); // 居中 headerStyle.setAlignment(HorizontalAlignment.CENTER); headerStyle.setVerticalAlignment(VerticalAlignment.CENTER); // 字体加粗 Font headerFont workbook.createFont(); headerFont.setBold(true); headerFont.setFontHeightInPoints((short)12); headerStyle.setFont(headerFont); // 设置背景色天蓝色 headerStyle.setFillForegroundColor(IndexedColors.SKY_BLUE.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); // 设置边框 headerStyle.setBorderTop(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderLeft(BorderStyle.THIN); // 应用样式到当前表头单元格 cell.setCellStyle(headerStyle); } // 数据单元格的样式可以通过其他处理器或默认样式策略控制 } }关键点在afterCellCreate方法中isHead参数明确指示了当前单元格是否为表头。我们只为表头创建和应用独立的样式对象。注意每个单元格样式对象在POI中不宜重复创建对于大批量表头应考虑样式复用。4.2 为表头单元格添加下拉框数据验证这是一个非常实用的功能常用于导出“模板”文件用户下载后可以在指定列的下拉框中选择值进行填充再上传导入。例如“状态”列提供“进行中、已完成、已取消”下拉选项。Component public class HeaderDropdownHandler implements CellWriteHandler { // 假设我们只为第3列索引2的表头添加下拉框 private static final int DROPDOWN_COLUMN_INDEX 2; private static final String[] OPTIONS {进行中, 已完成, 已取消}; Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 只对表头行且指定列进行操作 if (isHead ! null isHead cell.getColumnIndex() DROPDOWN_COLUMN_INDEX) { Sheet sheet writeSheetHolder.getSheet(); DataValidationHelper helper sheet.getDataValidationHelper(); // 创建约束序列下拉列表 DataValidationConstraint constraint helper.createExplicitListConstraint(OPTIONS); // 定义数据验证生效的单元格范围从表头下一行开始到第1000行可根据需要调整指定列 CellRangeAddressList addressList new CellRangeAddressList( 1, // firstRow: 表头行是0数据从第1行开始 1000, // lastRow DROPDOWN_COLUMN_INDEX, DROPDOWN_COLUMN_INDEX ); DataValidation validation helper.createValidation(constraint, addressList); // 设置相关属性可选 validation.setShowErrorBox(true); // 将验证应用到Sheet sheet.addValidationData(validation); } } }重要提示数据验证DataValidation是作用于单元格区域的而不是单个单元格。上述代码将下拉框效果应用到了从第1行到第1000行的整个C列假设索引为2。这意味着数据区域的该列单元格也拥有了下拉框。这正是“导出模板”所需要的效果。如果你只想在表头单元格本身显示下拉框这很少见则需要调整CellRangeAddressList的范围。4.3 动态决定是否操作表头有时表头操作需要根据导出参数动态决定。例如只有管理员导出时才显示某些敏感信息列并加粗表头。public class DynamicHeaderStyleHandler implements CellWriteHandler { private final boolean isAdmin; public DynamicHeaderStyleHandler(boolean isAdmin) { this.isAdmin isAdmin; } Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, ListCellData cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { if (isHead ! null isHead) { // 示例如果是管理员且当前列是“薪资”列假设通过head对象判断则特殊标记 if (isAdmin head ! null head.getHeadNameList().contains(“薪资”)) { CellStyle style cell.getSheet().getWorkbook().createCellStyle(); Font font cell.getSheet().getWorkbook().createFont(); font.setColor(IndexedColors.RED.getIndex()); style.setFont(font); cell.setCellStyle(style); } } } } // 使用时根据权限传入参数 .writeHandler(new DynamicHeaderStyleHandler(currentUser.isAdmin()))5. 无固定表头与动态表头的实战有些场景下表头本身不是固定的而是根据查询条件、用户选择或数据字典动态生成的。这就是动态表头。5.1 使用WriteSheet实现完全动态表头这是最彻底的动态化方式。我们不需要任何实体类注解完全通过代码构建表头和数据。public void exportDynamicHeader(HttpServletResponse response, ListMapString, Object dataList, ListString dynamicHeaders) throws IOException { // 1. 构建动态表头。假设dynamicHeaders是动态决定的列名列表如 [姓名, 部门, 动态指标A, 动态指标B] // 如果需要多级则ListListString每个内层List代表一行的表头。 ListListString head new ArrayList(); // 这里构建一个单级表头 for (String header : dynamicHeaders) { head.add(Collections.singletonList(header)); } // 2. 构建数据。数据需要与表头顺序对应。 ListListObject data new ArrayList(); for (MapString, Object rowMap : dataList) { ListObject rowData new ArrayList(); for (String header : dynamicHeaders) { rowData.add(rowMap.get(header)); // 从Map中按列名取值 } data.add(rowData); } // 3. 执行写入。注意这里没有传入Class参数。 EasyExcel.write(response.getOutputStream()) .head(head) // 设置动态表头 .sheet(动态报表) .doWrite(data); // 写入动态数据 }优点极致灵活表头和数据的结构在运行时决定。缺点失去了类型安全数据映射需要手动处理容易出错也无法利用EasyExcel基于注解的自动类型转换如日期格式。5.2 使用WriteTable实现混合模式固定表头动态表头一个更常见的场景是表格的前几列是固定的如ID、姓名后面跟着N列动态的指标数据。这时可以用WriteTable。public void exportMixedHeader(HttpServletResponse response, ListMyFixedDataDTO dataList, ListDynamicColumn dynamicColumns) { // MyFixedDataDTO 使用 ExcelProperty 定义了固定列 // 1. 创建固定表头的Sheet WriteSheet writeSheet EasyExcel.writerSheet(混合报表).head(MyFixedDataDTO.class).build(); // 2. 创建一个WriteTable来承载动态列 // 构建动态表头 ListListString dynamicHead new ArrayList(); for (DynamicColumn col : dynamicColumns) { dynamicHead.add(Collections.singletonList(col.getColumnName())); } WriteTable writeTable EasyExcel.writerTable().head(dynamicHead).build(); // 3. 准备动态数据。需要将原始数据转换为与动态表头对应的ListListObject ListListObject dynamicData convertToDynamicData(dataList, dynamicColumns); // 4. 写入 EasyExcel.write(response.getOutputStream()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 自动列宽 .sheet().doWrite(dataList); // 先写入固定列数据 // 注意EasyExcel的API设计上WriteTable需要与WriteSheet配合并且数据写入逻辑是连续的。 // 更常见的做法是将固定列也作为WriteTable的一部分或者全部用编程式API。 // 对于这种“固定动态”的复杂结构另一种更清晰的思路是 // a. 动态生成一个包含所有字段固定动态的“虚拟”表头 ListListString。 // b. 动态将数据组装成对应的 ListListObject。 // c. 使用 .head(head).doWrite(data) 一次写入。这需要更复杂的数据组装逻辑。 }实操心得对于复杂的动态表头放弃注解全面拥抱编程式API往往是更明智的选择。提前在服务层将数据和表头结构组装好虽然增加了数据准备的复杂度但让导出逻辑变得清晰和可控。可以使用类似“表头配置器”的模式将动态列的元信息名称、数据类型、顺序存储起来在导出时根据配置器生成表头和数据行。5.3 “没有表头的动态值”实现这个需求可以理解为导出的Excel第一行不是传统的字段名表头而是一行动态的标题或汇总信息从第二行开始才是数据列。这可以通过在写入数据前先向Sheet中写入一行自定义数据来实现。public void exportWithTitleRow(HttpServletResponse response, ListYourDataDTO dataList, String title) { // 1. 获取ExcelWriter对象获得更精细的控制 ExcelWriter excelWriter EasyExcel.write(response.getOutputStream(), YourDataDTO.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(Sheet1).build(); // 2. 手动写入标题行第0行 WriteSheet titleSheet EasyExcel.writerSheet(Sheet1).head(Collections.singletonList(Collections.singletonList(“标题行”))).build(); // 这里需要一点技巧实际上我们是在同一个Sheet写两次。先写一个只有一列表头的“虚拟”表。 // 但更直接的方式是使用原生POI操作需要获取Sheet对象 // 我们可以通过注册一个SheetWriteHandler在创建Sheet后立即写入标题。 excelWriter.write(Collections.singletonList(Collections.singletonList(title)), titleSheet); // 注意这会在第0行写入一个单列表头可能不是你想要的格式。 // 3. 推荐方案使用CellWriteHandler在表头之前插入行 // 注册一个处理器在开始创建单元格之前先写入标题 CellWriteHandler titleInserter new CellWriteHandler() { private boolean titleInserted false; Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex, Integer relativeRowIndex, Boolean isHead) { Sheet sheet writeSheetHolder.getSheet(); if (!titleInserted row.getRowNum() 0) { // 在真正的表头行开始前先创建一行并写入标题 Row titleRow sheet.createRow(0); Cell titleCell titleRow.createCell(0); titleCell.setCellValue(title); // 可以合并单元格作为大标题 sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 10)); // 合并足够多的列 // 设置标题样式 CellStyle titleStyle sheet.getWorkbook().createCellStyle(); Font titleFont sheet.getWorkbook().createFont(); titleFont.setBold(true); titleFont.setFontHeightInPoints((short)16); titleStyle.setFont(titleFont); titleStyle.setAlignment(HorizontalAlignment.CENTER); titleCell.setCellStyle(titleStyle); titleInserted true; } } }; // 4. 重新构建写入流程注册处理器 excelWriter EasyExcel.write(response.getOutputStream(), YourDataDTO.class) .registerWriteHandler(titleInserter) .build(); writeSheet EasyExcel.writerSheet(带标题的报表).build(); excelWriter.write(dataList, writeSheet); excelWriter.finish(); }这种方法的核心是利用CellWriteHandler的生命周期钩子如beforeCellCreate在正式写入表头和数据之前对Sheet进行“预处理”插入自定义的行和内容。6. 常见问题、性能优化与排查技巧在实际使用中你会遇到各种各样的问题。下面记录一些典型的坑和解决方案。6.1 表头错位、乱码或丢失症状导出的Excel表头文字不对列顺序混乱或者中文显示为乱码。排查注解索引冲突检查所有ExcelProperty的index属性是否唯一且连续。重复的index会导致列覆盖缺失的index会导致列错位。建议要么全部指定index要么全部不指定依靠字段声明顺序不要混用。动态表头数据不匹配使用ListListString定义表头时确保内层List的数量列数与后续写入的ListListObject数据中每一行的列数完全一致。字体问题中文乱码通常是因为缺少中文字体。在服务器环境如Linux下需要确保系统安装了中文字体或者通过WriteCellStyle指定一个包含中文的字体。WriteCellStyle headWriteCellStyle new WriteCellStyle(); Font headFont new Font(); headFont.setFontName(“宋体”); // 或“SimSun” headWriteCellStyle.setFont(headFont); EasyExcel.write(...).head(head).registerWriteHandler(new HorizontalCellStyleStrategy(headWriteCellStyle, dataStyle));Http响应头确保通过HttpServletResponse导出时设置了正确的Content-Type和编码。response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-Disposition, attachment;filename URLEncoder.encode(fileName, UTF-8) .xlsx);6.2 合并单元格导致的样式和边框问题症状合并后的单元格边框缺失、样式不统一或者合并区域外的单元格样式被影响。解决方案先合并后设样式在CellWriteHandler的afterCellCreate方法中先执行sheet.addMergedRegion()然后再获取合并后区域左上角的单元格为其设置样式。合并单元格的样式由左上角单元格决定。边框补全合并单元格后只有整个区域的外围有边框。如果你需要内部也有“虚拟”边框需要在合并前为所有将被合并的单元格设置统一的边框样式。使用LoopMergeStrategy对于简单的、按行数循环合并如每10行合并一次EasyExcel提供了LoopMergeStrategy类可以简化操作但灵活性不如自定义Handler。6.3 大数据量导出时的内存与性能问题导出几十万行数据时内存占用高甚至OOM。EasyExcel的优势EasyExcel默认使用SXSSF模式流式写出不会将所有数据放在内存中。但如果你在CellWriteHandler中持有大量数据或创建了大量未复用的样式对象仍可能导致内存问题。优化建议样式对象复用在Handler中将常用的CellStyle和Font对象缓存起来避免为每个单元格都创建新对象。public class StyleCacheHandler implements CellWriteHandler { private CellStyle headerStyle; private CellStyle dataStyle; Override public void afterCellCreate(...) { if (isHead) { if (headerStyle null) { // 创建headerStyle } cell.setCellStyle(headerStyle); } else { if (dataStyle null) { // 创建dataStyle } cell.setCellStyle(dataStyle); } } }精简Handler逻辑避免在Handler中执行复杂的数据库查询或远程调用。分页查询与分批写入对于超大数据量即使流式写出一次性从数据库拉取所有数据也可能导致内存压力。应该在Service层进行分页查询并利用ExcelWriter的多次write方法分批写入数据。关闭资源务必在写入完成后调用excelWriter.finish()和关闭输出流。6.4 下拉框数据验证在WPS或低版本Excel中不生效问题使用POI的DataValidation创建的下拉框在微软Office Excel中正常但在WPS或某些旧版本Excel中可能显示不正常。排查范围过大确保CellRangeAddressList指定的范围是合理的不要超出Sheet的最大行列限制旧版本是65536行256列。公式长度如果下拉选项非常多生成的序列公式可能会很长。可以尝试将选项列表放在一个隐藏的Sheet中然后使用公式引用该区域而不是显式列出所有选项。兼容性写入创建ExcelWriter时可以尝试指定ExcelTypeEnum.XLS格式.xls文件该格式兼容性最好但功能受限且最大行数约6万行。对于.xlsx格式这是标准功能不兼容通常是客户端软件问题。6.5 表头行高与列宽自适应默认行为EasyExcel不会自动调整行高和列宽。多级表头可能导致行高不够文字被遮挡。解决方案自动列宽注册LongestMatchColumnWidthStyleStrategy策略它会根据单元格内容大致调整列宽但对中文支持不一定完美。.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())手动设置列宽在CellWriteHandler的afterSheetCreate方法中遍历表头行计算每列内容的宽度一个中文大致占2个字符宽度然后调用sheet.setColumnWidth(colIndex, width * 256)POI中单位是1/256个字符宽度。设置固定行高在创建行后可以通过row.setHeightInPoints(20)来设置固定的行高。最后调试复杂表头时一个非常有效的方法是先写出一个简单的、不带任何处理器的版本观察基础表头结构和列索引。然后再逐步添加合并、样式等处理器每次添加一个并确认效果。使用诸如System.out.println(“当前单元格: Row” cell.getRowIndex() “, Col” cell.getColumnIndex() “, Value” cell.getStringCellValue())在Handler中打印信息能帮你快速定位逻辑错误。表头操作就像在Excel画布上作画心中有清晰的坐标行列索引下笔才能准确无误。