Java操作Excel冻结行和列:Apache POI的createFreezePane原理与实战
做导出功能这些年我接到过的需求里出现频率最高的一句大概是“导出的 Excel 把表头固定住不然数据一多往下翻几十行就不知道每列到底填的是什么了。”如果你也写过 Java 后端大概率能体会这种场景数据本身没什么难的无非是查库、循环、写单元格但“打开文件时表头自动冻结”这个细节偏偏要翻一会儿 API 才能搞定。后来我系统整理过一遍 Apache POI 里和冻结窗格相关的知识点又在几个实际项目里反复验证过才算是把这块彻底吃透。这篇文章就围绕“Java 操作 Excel 冻结行和列”这件事从原理到实践从最基础的 freezePane 到模板导出场景完整讲一遍。不管你是刚接触 POI 的新手还是在面试八股文里看到过相关 API 但没实际用过的开发者照着这份指南做10 分钟之内就能在自己的项目里把冻结功能跑起来。1. 开始之前先弄清楚“冻结”到底在解决什么问题1.1 一个真实到不能再真实的需求场景先别急着写代码把需求本身想清楚能帮你省掉后面很多返工。所谓“冻结行和列”在 Excel 里的行为是当你上下滚动大量数据时最上面指定的几行始终保持可见当你左右滚动时最左侧指定的几列始终保持可见。这个功能最常见的应用场景就是带表头的明细报表——表头在第一行下面跟着几百上千条数据用户往下翻到第 500 行时如果不冻结早就忘了第三列是“订单金额”还是“应收金额”了。我在实际项目里遇到的需求通常长这样财务部门每个月导出一份对账单第一行是字段名后面最长能到上万行供应链那边的月度物料清单也类似第一列是物料编码右边跟着几十个属性列横向滚动时物料编码必须固定住否则滚动到后面的“库存周转天数”时根本对不上是哪条物料的数据。第一种需求对应“冻结首行”第二种对应“冻结首列”两者同时要的情况也不少比如同时冻结首行和首列让用户无论怎么滚动表头和标识列都稳稳地停在那里。如果你也是这种场景恭喜你需求非常明确POI 里一个方法就能解决。但如果你接到的是“第 1 行是大标题第 2 行才是字段名要都固定住”这种需求就需要理解冻结行数是可以自由指定的并不只有 0 或 1。弄清楚这一点后面接需求时会顺手很多。1.2 方案选型POI、EasyExcel、直接改 XML 到底选哪个Java 操作 Excel 的方案圈子里聊来聊去主要就几个Apache POI、阿里开源的 EasyExcel、以及少数场景下直接改 XLSX 底层 XML 文件。先说结论如果任务重点是“自由控制冻结窗格、合并单元格、样式、公式”这类细节Apache POI 是最稳的选择。EasyExcel 在写大量数据时性能和内存表现很优秀但它在高级特性上做了很多裁剪直观的 API 里并没有像 POI 那样暴露 createFreezePane 方法后面我会专门讲 EasyExcel 用户的替代方案。直接改 XML 这个路子说实话不推荐普通业务开发去碰。XLSX 文件本质上是一个 zip 包里面的 xl/worksheets/sheet1.xml 中确实有一个pane元素来控制冻结行为。但手工拼接 XML 很容易因为一个小小的命名空间问题导致整个文件损坏而且改完还要重新压包维护成本极高。自己写着玩可以放到生产环境里我劝你慎重。所以这篇文章的主体我会全部围绕 Apache POI 展开。POI 5.x 的版本已经把 HSSF对应 .xls和 XSSF对应 .xlsx统一到了同一个org.apache.poi.ss.usermodel.Sheet接口下面这意味着你写冻结逻辑的代码基本不用关心文件后缀是 xls 还是 xlsx同一套代码两边都能跑。这种 API 设计对开发来说是非常友好的也是我选择 POI 作为主力工具的重要原因。2. 从 Excel 内部机制到 POI 的 API 映射2.1 Excel 冻结窗格的本质在 Excel 里冻结窗格的效果其实是由一个叫“窗格pane”的区域划分机制控制的。你可以把工作表想象成一个大的可视窗口冻结操作就是把窗口分割成几个互相独立的滚动区域上方冻结区域、左侧冻结区域、以及右下角那个可以自由滚动的数据区域。Excel 之所以能做到“标题行永远可见”本质上是渲染时把冻结区域固定住了只有数据区域在响应滚动条事件。POI 对应这个概念的方法组是Sheet.createFreezePane。这个方法有几种重载形式但核心思想是一致的指定要冻结的左侧列数和上方行数。列数和行数的语义是从 0 开始计数的比如createFreezePane(0, 1)表示冻结最上方的 1 行createFreezePane(1, 0)表示冻结最左侧的 1 列createFreezePane(2, 3)表示同时冻结左侧 2 列和上方 3 行。你不需要关心底层pane元素的细节POI 已经帮你把参数转换成 Excel 能识别的结构了。这里有一个很容易被忽视的点冻结窗格只能冻结“从第 1 行往上的若干行”和“从第 A 列往左的若干列”它没法把中间某个区域单独钉在屏幕上。比如你想让第 5 行到第 10 行始终可见其他行随便滚动这是冻结窗格做不到的Excel 本身也没有这种交互模式。识别这一点可以避免在产品评审时被不合理的需求带偏。2.2 createFreezePane 两个重载方法的完整拆解POI 的Sheet接口里createFreezePane有两个重载版本实际开发中第一个版本就覆盖了 90% 的场景。createFreezePane(int colSplit, int rowSplit)是最常用的写法。两个参数的含义非常直白colSplit是冻结列的列数索引rowSplit是冻结行的行数索引。举个例子createFreezePane(1, 1)的效果就是冻结 A 列和第一行也就是左上角那个单元格所在的整列和整行都被固定。如果你只需要冻结表头那一行就写createFreezePane(0, 1)只需要冻结最左边的物料编码列就写createFreezePane(1, 0)。createFreezePane(int colSplit, int rowSplit, int leftmostColumn, int topRow)这个重载稍微复杂一点。它除了指定冻结行列还额外指定了“冻结后右上角或右下角可视区域的左上角单元格”定位在哪里。前两个参数决定哪些行列被冻结后两个参数决定用户往下滚动或往右滚动后未冻结区域里最先呈现在屏幕上的单元格位置。举一个实际场景你冻结了前两列还想让用户打开文件后视线优先聚焦到 D 列而不是紧挨着冻结线的 C 列那就可以把leftmostColumn设成 3D 列索引滚动起始行topRow设成你要的位置。这个重载在需要精细控制初始可视区域时非常有用平时用不到也不用慌。2.3 createSplitPane 与 FreezePane 的区别与取舍POI 里还有一个方法叫createSplitPane(int xSplitPos, int ySplitPos, int leftmostColumn, int topRow, int activePane)第一次看文档的人很容易把它和冻结窗格搞混。从用户视角来说分割窗格也能做到类似“上方固定一部分行、左侧固定一部分列”的效果但两者的底层机制不一样。冻结窗格FreezePane是把指定区域固定住不可滚动其余区域在滚动时自动对齐分割窗格SplitPane则是把窗口按像素位置“切”成几个独立窗格每个窗格都有自己的滚动条而且分割线是可以拖动调整的。在实际业务中绝大多数“表头固定”的需求都用 FreezePane 实现SplitPane 更多用于需要同时对比两个相距很远的区域的特殊场景。从 POI 代码层面说SplitPane 的参数也比较麻烦xSplitPos和ySplitPos是以 1/20 磅为单位的像素坐标不是行列索引所以你想按“第几列”来分割还得先换算列宽非常不直观。我自己的建议是能用 FreezePane 就绝不用 SplitPane除非你和产品确认过用户真的需要拖动分割线来调整视图区域。2.4 别把冻结窗格和打印标题行混为一谈我在带新人的时候发现有不少同学会把“冻结窗格”和“打印时重复标题行”当成一回事。它们确实经常一起出现但语义完全不同。冻结窗格解决的是“在屏幕上滚动时保持表头可见”的问题作用域是屏幕显示而Sheet.setRepeatingRows(CellRangeAddress.valueOf(1:2))解决的是“打印多页时每一页纸上都重复打印表头”的问题作用域是打印输出。有些需求描述是“导出后用户要打印每页都要有表头”这时候光写createFreezePane是没用的必须要同时设置setRepeatingRows。反过来如果你的表头只是希望在屏幕上冻结住打印时并不需要每页重复那就不需要设置重复行。这两个功能不是替代关系更多是互补关系。我在做报表导出的时候凡是要交付给财务、仓库这类经常打印的部门都会把两者一起设置上宁多勿漏。3. 手把手实操Java 冻结 Excel 行和列的完整代码3.1 引入 Apache POI 依赖实操第一步先把依赖引进来。我用的是 Maven 项目POI 5.2.x 是目前稳定且社区反馈比较好的版本。需要引入两个核心依赖poi和poi-ooxml前者负责处理 .xls 格式的基础模型后者提供 .xlsx 格式的 OOXML 实现。dependency groupIdorg.apache.poi/groupId artifactIdpoi/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency如果你的项目用的是 Gradle对应写法也很简单implementation org.apache.poi:poi:5.2.5 implementation org.apache.poi:poi-ooxml:5.2.5这里必须提一个容易踩的坑POI 5.x 对底层的依赖管理比 3.x/4.x 严格一些它会用到log4j-api而且不同小版本之间可能存在传递依赖冲突。如果你发现启动时报类似NoClassDefFoundError的错先检查一下是不是项目里其他组件把 POI 或 log4j 的版本拉低了。我一般会在工程里显式声明 log4j-api 的版本避免被一些老旧的依赖树带偏。3.2 冻结首行、首列、左上角区域的三种基础写法依赖准备好之后直接跑一个最小 Demo。下面的类创建了一个 xlsx 工作簿造了 20 行测试数据然后把第一行冻结住。import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import java.io.FileOutputStream; public class FreezePaneDemo { public static void main(String[] args) throws Exception { try (Workbook workbook new XSSFWorkbook()) { Sheet sheet workbook.createSheet(订单明细); // 创建表头 Row header sheet.createRow(0); String[] titles {订单号, 客户名称, 商品, 数量, 金额}; for (int i 0; i titles.length; i) { header.createCell(i).setCellValue(titles[i]); } // 写入 20 行测试数据 for (int r 1; r 20; r) { Row row sheet.createRow(r); row.createCell(0).setCellValue(SO (1000 r)); row.createCell(1).setCellValue(客户 r); row.createCell(2).setCellValue(商品 r); row.createCell(3).setCellValue(r); row.createCell(4).setCellValue(99.9 * r); } // 冻结第一行向下滚动时表头始终可见 sheet.createFreezePane(0, 1); try (FileOutputStream fos new FileOutputStream(freeze_demo.xlsx)) { workbook.write(fos); } } } }跑完这段代码用 WPS 或 Excel 打开生成的freeze_demo.xlsx往下滚动就能看到第一行是钉住的。这里提醒一句尽早习惯“数据写入和冻结设置都写在write之前”这个顺序。POI 的写入是一次性序列化过程所有对 sheet 的修改必须在调用workbook.write()之前完成。你要是把createFreezePane写在write之后代码不会报错但生成的文件里就是没有冻结效果排查起来挺耽误时间。三种基础场景分别对应三行代码按需取用sheet.createFreezePane(0, 1); // 仅冻结首行 sheet.createFreezePane(1, 0); // 仅冻结首列 sheet.createFreezePane(1, 1); // 同时冻结首行和首列你可以把这三行理解成一把钥匙先把它们背下来再用的时候就不会慌。至于“冻结前两行”“冻结左侧三列”这类变体其实就是把参数 1 改成 2、3 而已没有其他花活。3.3 冻结任意位置并指定滚动后可视区域起始单元格说完了基础再来说说精细控制的场景。如果需求是“冻结前两行并且打开文件后未冻结区域从第 4 行开始显示”那就要用到第二个重载方法。// 冻结前两行未冻结区域默认从第 4 行开始显示 // 参数说明colSplit0 表示不冻结列 // rowSplit2 表示冻结上方 2 行 // leftmostColumn0 表示未冻结区域起始列为 A 列 // topRow3 表示未冻结区域起始行为第 4 行索引从 0 开始 sheet.createFreezePane(0, 2, 0, 3);这里很多第一次接触的同学容易对topRow 3产生困惑心想“我明明是冻结前两行为什么第三个参数传 3”原因在于索引从 0 开始计数第 1 行的索引是 0第 2 行是 1第 4 行自然就是 3。POI 的 API 参数基本都是 0-based 索引这一点和 Java 数组下标完全一致习惯了就不会再错。还有一个高级用法是动态计算冻结位置。比如你的表头有合并单元格第 1 行是“2025 年第一季度销售汇总”第 2 行是字段名那么冻结行数应该是 2。为了不让这个数字硬编码散落在代码里可以封装一个小方法在写表头的时候记录下表头实际占用的行数最后统一调用。这样后续表头结构变了只需要改一处。3.4 实战读取模板文件、冻结表头、再输出给前端下载实际项目中数据导出很少是从零创建一个全新的 workbook更多是读取一个预先设计好的模板往里填数据后再输出。这时候冻结设置可以事先在模板里做好也可以由代码在运行时写入。我更推荐后者因为同一个模板文件可能被多个导出接口复用有的接口需要冻结前两行有的只需要冻结首行代码里显式控制更灵活。下面是一个完整的模板导出示例演示了读取模板、填充数据、冻结表头、设置打印标题行、最后写出文件的全过程。import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.ss.usermodel.WorkbookFactory; import java.io.FileInputStream; import java.io.FileOutputStream; public class ExportWithFrozenHeader { public static void main(String[] args) throws Exception { // 使用 WorkbookFactory 读取模板它自动判断 xls / xlsx try (Workbook workbook WorkbookFactory.create(new FileInputStream(template.xlsx))) { Sheet sheet workbook.getSheetAt(0); // 假设模板第 1 行是大标题第 2 行是字段名数据从第 3 行开始 // 这里演示往第 2 行后面的数据区写入业务数据 for (int r 2; r 30; r) { Row row sheet.getRow(r); if (row null) { row sheet.createRow(r); } row.createCell(0).setCellValue(DATA- r); row.createCell(1).setCellValue(示例数据); } // 冻结前两行大标题和字段名都保持可见 sheet.createFreezePane(0, 2); // 同时设置打印时每页重复前两行方便财务打印 sheet.setRepeatingRows(CellRangeAddress.valueOf(1:2)); try (FileOutputStream fos new FileOutputStream(export_result.xlsx)) { workbook.write(fos); } } } }这段代码可以直接搬到你的项目里把模板路径、数据写入逻辑替换成真实业务即可。有一个小细节值得留意WorkbookFactory.create()会根据流内容自动识别是 xls 还是 xlsx不用你手动判断后缀。这比之前的new HSSFWorkbook(inputStream)或new XSSFWorkbook(inputStream)要省心很多强烈建议统一用WorkbookFactory。4. 高频问题排查与避坑清单4.1 xls 和 xlsx 兼容问题应该注意什么POI 5.x 虽然把 API 统一了但底层实现仍然是两套HSSF 处理 xlsXSSF 处理 xlsx。如果你用new XSSFWorkbook()去读一个真正老旧的 .xls 文件程序会直接报错反过来用new HSSFWorkbook()读 xlsx 也不行。这也是我在 3.4 里强烈推荐WorkbookFactory.create()的原因它内部帮你做了格式判断和对应 workbook 的实例化能避开大部分格式不匹配的尴尬。另外如果你生成的文件是 .xls 后缀冻结窗格功能本身是支持的但 Excel 对 xls 里某些新特性的兼容性有限比如一些样式细节在旧格式里会被丢弃。现在的新项目我基本只产出 xlsx除非上游明确要求兼容 Excel 2003。至于只读模板的场景只要模板文件本身格式正确WorkbookFactory基本都能搞定不需要额外关心版本问题。4.2 明明写了 freezePane打开 Excel 却没有冻结效果这个问题我前前后后被人问过不下十次。排查路径其实很固定按顺序检查这几项就够了。第一检查冻结设置到底加在哪个 sheet 上。多 sheet 工作簿里很容易把createFreezePane写到另一个临时 sheet 上打开文件后用户默认看到的是第一个 sheet自然觉得没生效。第二确认createFreezePane在workbook.write()之前执行顺序错了写入的文件里就没有这个配置。第三检查是不是用了在线预览工具查看效果。有些网页版表格预览器对冻结窗格的渲染支持不完整文件在本地 Office 或 WPS 里打开是正常的在线预览却不显示冻结线这种情况不是代码问题是预览工具的问题。我也遇到过一个更实际的情况用户反馈“导出的 Excel 数据没法复制粘贴了”排查到最后发现和冻结完全无关是用户本机的 Excel 进程卡死重启一下就好了。所以收到这类反馈时别第一时间怀疑是自己的代码先让对方换个设备或重启软件试一下。4.3 SXSSF 大文件导出的临时文件坑数据量大的导出场景很多同学会换成SXSSFWorkbook来做流式写入防止内存溢出。SXSSF 同样支持createFreezePane因为这个方法被定义在Sheet接口中SXSSFSheet 也实现了接口。但这里有一个不容易察觉的坑SXSSF 在写数据时会把超过保留行数的行刷到磁盘临时文件里如果临时目录空间不足或没有写权限导出过程中就会抛 IOException。排查方向也很明确如果大文件导出功能之前一直正常某天突然报临时文件相关错误先看服务器/tmp目录是不是满了再看应用容器的临时目录权限有没有被运维改过。这类问题往往不是代码逻辑错了而是环境变了。冻结窗格本身在这种场景下的代码量和普通导出没什么区别只是在压测时多关注一下内存和临时目录占用情况避免线上翻车。4.4 EasyExcel 用户遇到“找不到 freezePane”该怎么办我用 EasyExcel 做过不少项目平心而论它写大量数据的性能和易用性确实好但它不是万能的。很多刚接触 EasyExcel 的同事会在 API 里搜freeze结果发现找不到对应方法然后来问我原因。这其实是设计取向的问题EasyExcel 主打轻量、快速写入把复杂的 Excel 高级特性都转移给了模板你可以在模板文件里手动完成冻结设置然后把模板作为导出底座。实操流程是这样先用 Excel 或 WPS 制作一个模板文件在里面设置好需要冻结的行列然后在 EasyExcel 里写数据时指定这个模板生成的结果文件就会保留模板里的冻结配置。这种方法不一定适用于所有需求但应付常见的“导出报表带冻结表头”已经足够了。反过来如果你的核心诉求是代码里动态计算冻结位置、按业务条件灵活设置那还是老老实实用 Apache POI 更顺畅。从我个人实际经验来看冻结窗格这个功能体量不大但它带来的用户体验提升非常明显。凡是做报表导出、数据明细下载这类功能的项目我基本都会默认给表头加冻结哪怕产品经理没提这个需求。原因很简单一条数据几百行的表格打开后表头就在那儿用户对你的第一印象就是专业。这套代码写熟之后基本就是一行createFreezePane(0, 1)的事却能省下后面无数个“咦这个字段是什么来着”的困惑。建议你现在就打开工程把上面的 Demo 粘进去跑一遍亲眼看看滚动条往上翻时表头纹丝不动的效果比看十篇文章都管用。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →