Spire.XLS for Java 实战:Excel 拆分工具高效开发与避坑指南 先说说我自己的遭遇。之前接了一个内部系统的需求业务方隔三差五丢过来一个几十兆的Excel里面堆了几十个Sheet、上万行数据要求按Sheet拆、按行数拆、甚至按“城市/负责人”这种条件自动拆成多个小文件。一开始拿开源库硬啃写出来能跑但样式丢失、合并单元格错位、大文件内存直接飙满折腾得够呛。后来换成了Spire.XLS for Java用它的高层API重新搭了一套拆分工具问题一下子清爽了。这篇就把我实际用下来的拆分思路、完整代码和踩坑记录整理出来给同样被Excel拆分折磨的人一个可以“抄作业”的参考。这篇内容适合三类人看想快速搞定Excel拆分的Java后端开发、需要给业务方做数据报送的运维/测试同学以及所有被POI API逼疯、想找个更省心方案的技术人员。我会把三种最常见的拆分场景按Sheet、按行数、按条件从原理到代码全部过一遍再附上真实遇到的性能、样式、许可证问题排查记录。1. 拆分需求梳理与方案选型1.1 三种最常见的拆分场景Excel拆分这件事表面看是“把一个文件变多个文件”实际上业务方口中的需求千奇百怪但归纳下来无非三类。第一类是按工作表拆分。一个大工作簿里有多个Sheet每个Sheet对应一个门店、一个部门或者一个报表周期。业务方一句“帮我把每个Sheet单独导出”本质就是希望一个文件变N个文件文件名和Sheet名保持一致。第二类是按行数拆分。常见于数据量超过Excel自身负载的情况或者下游系统导入时有行数上限。比如某个系统每次最多接受1万行那3.5万行的数据就要按1万行切割成4个文件。这类需求的关键是切分逻辑简单但要保证表头重复、内容连续、不丢行。第三类是按条件拆分。依据某个业务字段例如“城市”“订单状态”“负责人”把一张大表拆成多个小表每个小表只包含满足该条件的数据。这类需求最贴近真实业务也最考验代码对数据遍历和分组处理的表达能力。1.2 为什么选 Spire.XLS for Java在选型之前我自然想过Apache POI和EasyExcel。POI功能确实强大体量也确实是老牌标准但它的API设计偏底层操作单元格、样式、合并区域、公式计算时代码量会膨胀得厉害。尤其是把一个大Sheet的部分行复制到另一个Workbook时要手动处理行样式、列宽、合并单元格、数据验证等等一堆细节新手很难一次写对老手也容易在边界条件上翻车。EasyExcel在读写性能上做得很好适合阿里系那种海量数据导入导出场景但它在“拆分后保留原始样式”这个诉求上并不占优势。如果业务方只要求数据能打开、能看、能统计EasyExcel完全够用但如果要求拆分出来的文件尽量跟原始文件长得一模一样那还是得上一个封装程度更高的库。Spire.XLS for Java本身就是面向企业和开发者的商业级组件对象模型非常贴近Excel思维Workbook对应用工作簿、Worksheet对应工作表、CellRange对应单元格区域。很多操作都是一行方法调用例如通过copyFrom复制整个工作表、通过copy复制单元格区域。这种“高频API 底层封装”的组合正好契合拆分场景。当然选它不是没有代价。它是商业授权产品带免费版免费版有数据行数限制和附加水印的限制。对于个人学习、内部小工具完全够用如果要部署到生产环境给客户用务必确认授权方案。这个我在后面专门讲。1.3 环境准备与依赖引入用Maven引入依赖是最省事的方式。首先在pom.xml里添加Spire.XLS的仓库地址和依赖repositories repository idcom.e-iceblue/id urlhttps://repo.e-iceblue.cn/repository/maven-public//url /repository /repositories dependencies dependency groupIde-iceblue/groupId artifactIdspire.xls/artifactId version4.5.2/version /dependency /dependencies如果你所在的网络访问不上这个仓库也可以去官网下载jar包手动加到项目里。需要注意一点Spire.XLS for Java同时提供了JDK 8和JDK 9的版本下载时看清楚匹配自己的JDK避免引入后出现类加载异常。我本地的实测环境是JDK 8、Maven 3.8、Windows 10Excel文件为xlsx格式。老旧的xls格式也能读但拆分后另存时建议统一存为ExcelVersion.Version2013或Version2016保兼容性。2. 核心概念与API拆解拆之前先弄清楚这几件事2.1 Spire.XLS 对象模型Workbook / Worksheet / CellRange上手Spire.XLS首先要建立三个对象的心智模型。Workbook代表整个Excel文件。你加载一个.xlsx得到的就是一个Workbook对象里面包含了所有工作表、样式表、数据缓存、全局设置等。Worksheet代表Workbook里的一个Sheet。一个Workbook可以包含多个Worksheet每个Worksheet有名称、行列数、单元格、打印设置等。拆分的时候核心动作就是从源Workbook里拿Worksheet往目标Workbook里放Worksheet或单元格区域。CellRange代表单元格区域可能是单个单元格也可能是一整行、一整列或一个矩形范围。例如sourceSheet.getCellRange(1, 1)是A1单元格getCellRange(2, 1, 100, 10)是第2行到第100行、第1列到第10列的区域。很多复制操作都以CellRange为单位理解这个对象后面的代码就好写了。在拆分过程中我通常遵循“两个Workbook之间做数据搬运”的思路一个源Workbook负责读一个目标Workbook负责存中间用Worksheet或CellRange完成复制。2.2 拆分的底层逻辑复制还是搬移初次做拆分的人容易纠结一个问题拆出去的Sheet是“剪切”出来还是“复制”一份我的回答是在任何情况下都用“复制”。原因很简单原始文件是业务方的交付物万一拆分逻辑有bug或者数据不完整源文件还在可以随时重跑。而且Spire.XLS的底层本身也不会做“减源”操作它提供的方法多是Copy系。所以拆分工具的定位永远是“根据源文件生成若干新文件”而不是“修改源文件”。这一点想清楚之后代码结构也就清晰了。按Sheet拆分就是遍历源Workbook的所有Worksheet把每个Worksheet复制到新建的Workbook中再单独保存。按行拆分或按条件拆分本质上是在复制指定的区域只是边界条件不同。2.3 样式与公式的处理策略用高层API复制整张工作表时样式、列宽、合并单元格、数据验证通常都能一并带过去。但如果按区域复制或逐单元格复制样式能否保留就分情况了。CellRange.copy(CellRange)这类区域级方法会把范围内的单元格值、字体、边框、背景色、合并状态尽量都复制过去是首选。而当你用getCellValue()/setCellValue()逐单元格复制时复制的只有值样式不会跟过来。这里有个容易踩坑的点公式。如果原Excel里有公式用区域复制时公式会跟着公式文本一起过去但公式重新计算结果需要目标Workbook触发计算这依赖库的“重新计算公式”能力。如果业务方打开拆分文件后发现某些列显示为0、显示为空白或者弹“公式未计算”的提示多半就是公式没有重算。解决思路有两种第一种是拆分前用API将公式计算缓存为静态值再复制第二种是拆完后手动在Excel里全选重算或者用代码触发计算。更稳妥的是在复制完后调用目标Workbook的公式计算接口例如workbook.calculateAllValue()再做一次整体计算。3. 完整实操三种拆分代码逐步讲解3.1 按工作簿中的Sheet拆成多个文件先上一个最基础也最常见的场景把包含多个Sheet的工作簿按Sheet拆成一个Sheet一个文件。直接看代码import com.spire.xls.*; public class SplitBySheet { public static void main(String[] args) { Workbook sourceWorkbook new Workbook(); sourceWorkbook.loadFromFile(D:/data/sales_all.xlsx); for (int i 0; i sourceWorkbook.getWorksheets().getCount(); i) { Worksheet sourceSheet sourceWorkbook.getWorksheets().get(i); Workbook targetWorkbook new Workbook(); targetWorkbook.getWorksheets().get(0).copyFrom(sourceSheet); String fileName D:/data/split_by_sheet/ sourceSheet.getName() .xlsx; targetWorkbook.saveToFile(fileName, ExcelVersion.Version2013); targetWorkbook.dispose(); System.out.println(已生成: fileName); } sourceWorkbook.dispose(); } }代码逻辑很简单但有几个细节必须说明。targetWorkbook.getWorksheets().get(0)返回的是新建Workbook默认自带的第一张空表。copyFrom(sourceSheet)会把源Sheet的整体内容复制进来默认看起来像是把源Sheet变为目标文件的第一个Sheet。如果你想让目标文件里的Sheet名称和源Sheet一致可以观察copyFrom后的实际效果——部分版本会自动带过来部分版本会保留目标Sheet的默认名。追求稳妥的做法是在copyFrom之后显式设置名称Worksheet targetSheet targetWorkbook.getWorksheets().get(0); targetSheet.copyFrom(sourceSheet); targetSheet.setName(sourceSheet.getName());另外dispose()这一步很多人会忽略。Spire.XLS基于本地组件实现如果不释放资源连续生成几十个文件后JVM可能报内存溢出或句柄不足。我在循环里做完整测试时不写dispose()跑第30个文件就卡死了加了之后稳定跑完。从实践角度说这个习惯必须养成。3.2 按固定行数拆成多个小文件按行数拆分需要设定一个“每文件多少行”的阈值。下面是支持自定义批大小、自动复制表头的实现import com.spire.xls.*; import com.spire.xls.core.spreadsheet.collections.XlsCellsCollection; public class SplitByRowCount { private static final int BATCH_SIZE 2000; private static final int HEADER_ROW_COUNT 1; public static void main(String[] args) { Workbook sourceWorkbook new Workbook(); sourceWorkbook.loadFromFile(D:/data/orders_all.xlsx); Worksheet sourceSheet sourceWorkbook.getWorksheets().get(0); int lastRow sourceSheet.getLastRow(); int lastColumn sourceSheet.getColumns().getCount(); int batchIndex 0; for (int startRow HEADER_ROW_COUNT 1; startRow lastRow; startRow BATCH_SIZE) { int endRow Math.min(startRow BATCH_SIZE - 1, lastRow); batchIndex; Workbook targetWorkbook new Workbook(); Worksheet targetSheet targetWorkbook.getWorksheets().get(0); targetSheet.setName(Data); // 复制表头保证每个拆分文件都有完整表头 if (HEADER_ROW_COUNT 0) { CellRange headerSource sourceSheet.getCellRange(1, 1, HEADER_ROW_COUNT, lastColumn); CellRange headerTarget targetSheet.getCellRange(1, 1, HEADER_ROW_COUNT, lastColumn); headerSource.copy(headerTarget); } // 复制当前批次的数据行 CellRange dataSource sourceSheet.getCellRange(startRow, 1, endRow, lastColumn); int targetStartRow HEADER_ROW_COUNT 1; int targetEndRow HEADER_ROW_COUNT (endRow - startRow 1); CellRange dataTarget targetSheet.getCellRange(targetStartRow, 1, targetEndRow, lastColumn); dataSource.copy(dataTarget); String fileName D:/data/split_by_row/orders_part_ batchIndex .xlsx; targetWorkbook.saveToFile(fileName, ExcelVersion.Version2013); targetWorkbook.dispose(); System.out.println(已生成: fileName 行范围: startRow - endRow); } sourceWorkbook.dispose(); } }这里有三个细节值得展开。第一getLastRow()获取的是工作表里实际有内容的最后一行但如果你源文件里存在大量格式残留比如有人把几百行空白行设置了边框、背景色这个值会虚高导致拆分出大量空文件。建议在拆分前对源文件做一次“去格式空白行”处理或者改为自己遍历数据判断实际行号。第二sourceSheet.getColumns().getCount()取的是列数但列数同样可能包含格式残留的空列。如果确实遇到空列被复制的问题用sourceSheet.getLastColumn()更接近内容的实际范围。第三使用CellRange.copy(CellRange)是尽量保留样式的关键。如果你们的Spire版本调这个方法报错或复制结果不对临时方案就是逐单元格备份for (int r startRow; r endRow; r) { for (int c 1; c lastColumn; c) { targetSheet.getCellRange(r - startRow HEADER_ROW_COUNT 1, c) .setValue(sourceSheet.getCellRange(r, c).getValue()); } }逐单元格复制虽然慢但至少能把数据完整搬过去必要的时候先用它跑通流程再把性能优化提上日程。3.3 按某一列的条件值拆分按条件拆分的核心是先确定按哪一列分再按该列的值对行做分组。比如我要把订单表按“城市”这一列拆分每个城市生成一个独立Excel文件。import com.spire.xls.*; import java.io.File; import java.util.HashMap; import java.util.Map; public class SplitByColumnValue { private static final String OUTPUT_DIR D:/data/split_by_condition/; public static void main(String[] args) throws Exception { Workbook sourceWorkbook new Workbook(); sourceWorkbook.loadFromFile(D:/data/orders_all.xlsx); Worksheet sourceSheet sourceWorkbook.getWorksheets().get(0); int headerRow 1; int conditionColumn findColumnIndex(sourceSheet, 城市, headerRow); if (conditionColumn 0) { throw new IllegalStateException(未找到条件列: 城市); } int lastColumn sourceSheet.getColumns().getCount(); int lastRow sourceSheet.getLastRow(); MapString, Workbook workbookMap new HashMap(); MapString, Worksheet sheetMap new HashMap(); for (int r headerRow 1; r lastRow; r) { String conditionValue sourceSheet.getCellRange(r, conditionColumn).getValue(); if (conditionValue null || conditionValue.trim().isEmpty()) { continue; } if (!workbookMap.containsKey(conditionValue)) { Workbook targetWorkbook new Workbook(); Worksheet targetSheet targetWorkbook.getWorksheets().get(0); targetSheet.setName(Data); // 先复制表头 CellRange headerSource sourceSheet.getCellRange(headerRow, 1, headerRow, lastColumn); CellRange headerTarget targetSheet.getCellRange(1, 1, 1, lastColumn); headerSource.copy(headerTarget); workbookMap.put(conditionValue, targetWorkbook); sheetMap.put(conditionValue, targetSheet); } Worksheet targetSheet sheetMap.get(conditionValue); int targetRow targetSheet.getLastRow() 1; // 复制当前这一行数据到对应目标文件 CellRange rowSource sourceSheet.getCellRange(r, 1, r, lastColumn); CellRange rowTarget targetSheet.getCellRange(targetRow, 1, targetRow, lastColumn); rowSource.copy(rowTarget); } // 保存所有生成的工作簿 File dir new File(OUTPUT_DIR); if (!dir.exists()) { dir.mkdirs(); } for (Map.EntryString, Workbook entry : workbookMap.entrySet()) { String conditionValue entry.getKey(); Workbook workbook entry.getValue(); String fileName OUTPUT_DIR conditionValue .xlsx; workbook.saveToFile(fileName, ExcelVersion.Version2013); workbook.dispose(); System.out.println(已生成: fileName); } sourceWorkbook.dispose(); } private static int findColumnIndex(Worksheet sheet, String columnName, int headerRow) { int lastColumn sheet.getLastColumn(); for (int c 1; c lastColumn; c) { String value sheet.getCellRange(headerRow, c).getValue(); if (columnName.equalsIgnoreCase(value null ? : value.trim())) { return c; } } return -1; } }这个实现里有几个我反复踩过坑的点。条件值最好做一个安全处理。如果原始数据里的城市有“上海”和“上海 ”这种带着前后空白的脏数据拆出来的文件会分成两份。在得到conditionValue后先trim()是成本最低的容错。用Map管理多个目标Workbook是一种直观思路但要注意如果你拆出的条件值非常多比如几百个城市内存里会同时驻留几百个Workbook极容易OOM。更好的做法是改用“聚合后再落盘”的两阶段方式第一趟遍历原数据得到所有条件值和对应行号集合第二趟按条件值逐个创建Workbook、写入、保存、释放把同时开着的Workbook数量压到最低。还有一点targetSheet.getLastRow() 1这个目标行号计算依赖目标Sheet的“最后有内容的行”。如果你在创建目标Workbook时没有往里面写过数据理论上getLastRow()是0或1算出来的下一行是安全的。但如果目标Sheet有格式残留getLastRow()可能虚高导致数据中间出现空白行。遇到这种情况最土的办法是自己维护一个rowCount变量而不是依赖API。3.4 文件命名与保存细节拆分工具的体验好不好一半在拆分逻辑一半在文件命名。按Sheet拆分直接用Sheet名作为文件名最不容易让业务方产生困惑但要提前处理非法字符。Excel的Sheet名里可以包含/、\、?、*、[、]等字符这些字符在Windows文件名里是被禁止的。所以保存之前要做一层清洗String safeName sheetName.replaceAll([\\\\/:*?\|], _);按行数拆分我习惯用序号_原文件名_起始行-结束行.xlsx这种格式比如orders_part_1_1-2000.xlsx。这样一旦拆分过程中断能直观看到哪些批次已经生成也方便后续按顺序合并回去。按条件拆分文件命名直接用条件值但要处理两个问题一是条件值里有空格、斜杠等非法字符二是条件值特别长导致文件名超长。建议加一个统一命名规则序号_条件值.xlsx例如01_上海市.xlsx既保持了顺序可读性又降低了文件名过长的风险。保存格式统一用ExcelVersion.Version2013不要默认存成xls。因为xls是老格式单个Sheet的行数和列数上限都比xlsx小拆分出来的文件数据量一大再保存成xls会直接触发行数溢出异常。4. 常见问题、性能优化与避坑手册4.1 大文件拆分的性能和内存优化我曾经跑过一个21万行、40多列的真实业务表按2000行一个文件拆分。第一版代码就是前面讲的逐区域内复制方案跑起来耗时接近三分钟内存峰值到了1.6G。虽然能出结果但交付体验很差尤其业务方改了拆分规则后要重跑这种耗时完全不能接受。后来做了三个优化第一把sourceWorkbook固定在内存里目标Workbook每处理完一个就dispose()避免GC来不及回收。第二不要用sourceSheet.getColumns().getCount()反复取列数。这个值其实在循环中是不变的提取成局部变量能省掉大量无意义的调用开销。第三如果数据确实到了百万行级别优先考虑分区读取。Spire.XLS本身是完整加载Excel的组件对超大文件并不像EasyExcel那样走流式读取路线。所以当单个Excel文件极大时我会先让业务方导出时按逻辑分表或者在进入Java程序之前用脚本按行做预处理把最终交给Java处理的文件控制在一个合理规模内。不要指望任何面向Excel的组件能把一个500MB的Excel玩出花来。另外如果拆分后的文件还要做二次处理比如导入数据库其实不一定非要转成Excel文件。直接在工作簿内存模型里遍历数据写入数据库比先生成文件再解析文件高效得多。拆分程序花大力气生成的文件很可能只是中间产物。4.2 样式与格式丢失怎么办如果业务方反馈拆分后文件打开“看着不对”优先级从高到低检查这几项合并单元格、列宽、行高、数据格式日期、数字、条件格式、公式。CellRange.copy(CellRange)在多数版本里能把合并单元格和常见样式带过去但仍然存在边界情况。比如源文件中一个合并区域横跨了拆分边界第1500行和第1501行是同一个合并单元格拆分后要么合并被截断要么合并区域报错。遇到这种数据只能和业务方确认是否可以跳过合并样式或者要求原始数据不要出现跨拆分边界的合并单元格。日期格式是另一个重灾区。如果原文件里的日期是文本字符串“2024-12-01”复制后一般没问题。但如果是真正的Excel日期单元格且自定义格式依赖特定locale拆分后可能显示成一串数字。解决方式是在复制完数据后把目标单元格的数值格式显示出来检查或统一用目标列格式覆盖一次CellRange targetRange targetSheet.getCellRange(row, dateColumn); targetRange.setNumberFormat(yyyy-mm-dd);公式这种问题前面提过。简单说能用静态值替代的就替代不能替代的记得在拆分完成后执行一次全簿计算。4.3 免费版限制与许可证注意事项Spire.XLS for Java不是完全免费的开源组件。它提供免费版但也有明确的商业限制。每个版本限制范围可能微调但我实测下来免费版普遍存在数据行数上限和附加水印的问题。如果你在拆分大文件时发现输出Excel带了提示水印或者超过某个行数后数据被截断基本就是免费版限制触发了。几个实用的规避思路跟着项目性质来如果只是个人学习、内部验证免费版够用。跑通逻辑后确认了自己要的功能再去评估商业授权。如果用在公司内部系统但不对客户分发可以先合规确认一下这种内部工具的授权边界通常需要购买个人或企业授权。如果项目不允许引入商业组件那么选择Spire.XLS这条路一开始就行不通。替代方案是回到POI或EasyExcel但要有心理准备代码量会显著增加。这时我建议把“拆分规则”和“底层读写”彻底分离上层用策略模式定义拆分逻辑底层分别实现POI和Spire两个适配器这样万一许可证出了问题不至于整个工具推倒重来。4.4 高频异常排查速查表实际操作中我遇到过的异常和要求整理成了一张表方便遇到问题时快速对照。现象可能原因处理建议FileNotFoundException输出目录不存在保存前先创建目录记住Windows下路径分隔符保存成xls报行数超限xls格式行数上限为65536统一保存为ExcelVersion.Version2013内存随循环持续上涨目标Workbook未释放每个Workbook用完后调用dispose()拆分文件出现水印/数据被截断免费版功能限制检查版本限制或调整输入数据规模复制后公式无值公式未重新计算保存前调用公式计算接口或把公式缓存为静态值部分单元格变成数字串乱码日期格式丢失复制后用setNumberFormat()重新设置目标列格式按条件拆分时某些文件缺失条件值包含前后空白/大小写不一致对条件值统一trim()必要时统一转大写或小写合并单元格复制报错合并区域跨越拆分边界和业务方确认是否可放弃合并样式中文文件名乱码控制台编码或文件名编码问题确保代码文件UTF-8编码保存文件名时使用字符串常量找不到copy方法Spire版本过旧升级到新版或者临时改用逐单元格复制排查顺序记住一条铁律先用最小数据量复现再一步步放大。我的习惯是先用一个只有十几行的测试Excel跑通全部逻辑再换真实生产文件。这样能把“数据问题”和“代码问题”快速区分开避免大文件处理半天才发现是源文件里有脏数据。关于拆分这件事我的几点实际体会做了这个拆分工具之后最大的感受是“Excel拆分”听起来简单做起来全是细节。业务方不会告诉你源文件里有合并单元格、有公式、有空行、有脏数据他们只会在你交付后打开文件说“这里不对”。所以做拆分工具的第一优先级不是炫技术而是把各个环节的边界情况提前想清楚。我自己后来给这套工具加了几个实用小功能拆分前自动打印源文件的Sheet数量、总行数、条件列的去重值数量拆分后自动做一次文件数校验每次运行都输出日志文件。这样哪怕偶尔出了岔子也能迅速定位是哪一步的问题。如果你用的也是Spire.XLS版本升级时务必重新跑一遍全量回归。不同版本之间的copy行为、样式保留程度、内存占用都存在差异。我以前就遇到过从旧版本升级后原来能复制的合并单元格在新版本里行为变了导致拆分结果和旧版本不一致。工具类的代码稳定压倒一切。最后分享一个小技巧如果拆分后的文件需要后续再合并回去建议在输出文件内保留一个隐藏Sheet记录原始文件名、原始Sheet名和本次拆分规则。这个隐藏信息平时不干扰使用一旦需要逆向合并或追溯数据来源价值立竿见影。希望这套基于Spire.XLS for Java的拆分方案能帮你少踩几个坑。