简介:面向C#开发者的NPOI操作Excel实例压缩包,覆盖旧版.xls与新版.xlsx两类文件的创建、读取、写入、样式设置及保存等常见场景,适合刚接触NPOI或需要在.NET项目中快速集成Excel导入导出的初中级开发者。压缩包共15个文件,以DLL动态库、C#源码文件和XML说明为主,整体大小约2.19MB;目录按2012Version与201607Version分版整理,并分别提供NPOIExcelHelper.cs与Helper1.cs工具类,便于对照不同NPOI版本选择使用。资源已吸引6347人学习下载,内容包括NPOI核心程序集、OpenXml4Net、SharpZipLib等依赖项,以及封装好的Excel操作辅助类,可帮助读者减少环境配置成本,直接理解HSSFWorkbook与XSSFWorkbook的差异,并快速把读写逻辑复用到实际项目中。 C#项目里折腾Excel读写,绕不开NPOI这个库。它是开源的,专门解决.NET环境下操作Excel的问题,最实在的一点是完全不用装Office,服务器上没装办公软件也照样能跑。我最早接触NPOI是因为要做上位机数据报表,现场工控机不可能装Office,Com组件也被系统服务调用搞得头疼,换成NPOI后这些问题统统消失。这篇文章我把常用场景和踩坑经验整理出来,涉及.xls和.xlsx两种格式的读写、样式设置、大数据量写入,附带模板方法与问题排查,适合正在做桌面工具、上位机数据导出、批量导入功能的C#开发者参考。
1. 项目核心思路与方案选型
1.1 为什么选NPOI而不是其他方案
先花点时间说清楚选型问题,这个决定会影响后续所有开发体验。目前.NET生态下操作Excel的主流方案有几条路线:微软官方提供的COM组件(Microsoft.Office.Interop.Excel)、开源库OpenXML SDK、商业库EPPlus,以及本文要讲的NPOI。
COM组件是最老牌的方案,缺点也很明显——依赖本机安装Office,服务器上没装就直接报错。而且COM对象在Web应用或Windows服务里频繁创建回收,容易造成进程卡死。我在维护一个旧项目时被它坑过,服务器上装的是精简版WPS,Excel对象怎么都创建不成功。OpenXML SDK走的是纯XML解析路线,不依赖环境,但对象模型特别繁琐,操作一个单元格要写一大堆代码,对快速开发不友好。EPPlus功能强大,但从4.5版本开始商用收费,个人和小公司用起来有授权顾虑。
NPOI的优势在于:纯托管代码、无外部依赖、完全免费、兼容.NET Framework和.NET Core/.NET 5+、同时支持老的.xls和新的.xlsx格式。底层实现借鉴了Java领域的POI项目,稳定性和社区活跃度都不错。做上位机和工业软件的朋友特别适合这套方案,因为部署环境往往很固定,不适合引入太多依赖,而NPOI就一个DLL的事。
1.2 理清xls与xlsx背后的底层差异
不少新手会在.xls和.xlsx格式之间栽跟头,本质原因是它们底层存储机制完全不同。.xls是Excel 97-2003时代的二进制格式,专业说法叫BIFF(Binary Interchange File Format),用字节流直接存储单元格数据和样式信息。.xlsx是Excel 2007之后采用的Office Open XML格式,本质是一个ZIP压缩包,内部包含多个XML文件,分别存储工作表数据、样式、共享字符串等内容。
这个差异直接决定了NPOI中的类选择:处理.xls文件用HSSFWorkbook类(Horrible SpreadSheet Format),处理.xlsx文件用XSSFWorkbook类(XML SpreadSheet Format)。很多初学者把这两个类搞混,拿HSSFWorkbook去读.xlsx文件,程序直接抛异常。如果希望代码同时兼容两种格式,可以写一个简单的判断逻辑,根据文件名后缀或文件头魔数(D0 CF 11 E0是OLE2格式,50 4B 03 04是ZIP格式)来决定实例化哪个类,后面我会给出具体代码。
这里还要提一个实际场景的注意点:.xls格式单个工作表最多65536行,.xlsx支持1048576行。如果你的数据量超过6万行,建议直接生成.xlsx,否则数据写入时会报“Row number must not be greater than 65535”之类的问题。我在做设备历史数据导出时,刚开始没注意这个限制,数据量一大就被坑了。
2. 环境准备与基础读写操作
2.1 NuGet安装与命名空间引入
新建一个.NET Framework或.NET Core项目后,在NuGet包管理器中搜索“NPOI”,安装最新稳定版即可。我用的是2.6.x版本,截至写这篇文章,稳定版本在2.7.x左右,安装命令如下:
Install-Package NPOI如果你的项目目标框架是.NET Framework 4.5,需要注意版本兼容性,2.5.x之后的版本基本都要求4.6.1或更高。还有一点值得留意,NPOI依赖一个叫“NPOI.OOXML”的底层层,NuGet会自动解析,不需要单独处理。
引入关键命名空间:
using NPOI.HSSF.UserModel; // 处理xls using NPOI.XSSF.UserModel; // 处理xlsx using NPOI.SS.UserModel; // 公共接口(Workbook、Sheet、Row、Cell等) using NPOI.SS.Util; // 单元格范围工具类(合并区域用) using NPOI.XSSF.Streaming; // SXSSFWorkbook,大数据量导出用 using System.IO;2.2 最基础的两个操作:创建文件与读取文件
先看一个最简单的创建Excel并写入数据的过程。这里我推荐用接口类型IWorkbook、ISheet、IRow、ICell来编写业务代码,而不是直接用HSSFWorkbook或XSSFWorkbook的具体类型。这样上层代码完全不关心最终生成的是xls还是xlsx,切换格式只需要改一行实例化代码。
IWorkbook workbook; // 根据需要的格式创建不同实例 if (isXlsx) { workbook = new XSSFWorkbook(); } else { workbook = new HSSFWorkbook(); } ISheet sheet = workbook.CreateSheet("测试表"); IRow row = sheet.CreateRow(0); ICell cell = row.CreateCell(0); cell.SetCellValue("你好,NPOI"); // 设置单元格值为数字 row.CreateCell(1).SetCellValue(3.14); // 保存到文件流 using (FileStream fs = new FileStream(@"D:\test.xlsx", FileMode.Create, FileAccess.ReadWrite)) { workbook.Write(fs); }这个例子基本展示了写Excel的最小闭环:创建工作簿、创建工作表、创建行、创建单元格、填值、保存。有几个细节需要提醒:
- 行和列的索引都是从0开始,CreateRow(0)表示第一行,CreateCell(0)表示第一列。
- 单元格有两种值写入方式,一种是SetCellValue传入string或double,NPOI会自动识别类型;另一种是直接给cell设置CellType,两种结果有细微差别,后面在类型处理部分详细讲。
- 保存用FileStream没问题,但注意Write完之后workbook对象不一定能重复写,最好一次性完成。
读取操作,核心思路和写入对称,先加载文件流,再获取sheet、遍历行列:
IWorkbook workbook; using (FileStream fs = new FileStream(@"D:\test.xlsx", FileMode.Open, FileAccess.Read)) { workbook = new XSSFWorkbook(fs); } ISheet sheet = workbook.GetSheetAt(0); // 按索引取第一个工作表 // 或者:workbook.GetSheet("工作表名"); for (int rowIdx = 0; rowIdx <= sheet.LastRowNum; rowIdx++) { IRow row = sheet.GetRow(rowIdx); if (row == null) continue; // 注意:空行GetRow可能返回null for (int colIdx = 0; colIdx < row.LastCellNum; colIdx++) { ICell cell = row.GetCell(colIdx); if (cell == null) continue; Console.Write(cell.ToString() + "\t"); } Console.WriteLine(); }这段代码逻辑很简单,但实际用的时候有几个隐藏陷阱,我在后面专门用一节来展开。这里先提一个最重要的:循环里不要写评论,啊不,是循环里务必判断row和cell是否为null,尤其是从外部系统导出的Excel,经常会有“看起来是空行但实际有格式”的脏数据,不判断的话很容易抛NullReferenceException。
3. 核心实操细节:单元格类型、样式与公式处理
3.1 单元格类型分类与正确取值方式
Excel单元格可以存储字符串、数字、日期、布尔值、公式等多种类型。用NPOI读取时必须区分类型,否则取出来的值可能是错的,或者类型转换直接抛异常。NPOI中的CellType枚举定义了这些基本类型:
| 枚举值 | 说明 | 对应判断 |
|---|---|---|
| String | 字符串类型 | cell.CellType == CellType.String |
| Numeric | 数字或日期类型 | cell.CellType == CellType.Numeric |
| Boolean | 布尔值 | cell.CellType == CellType.Boolean |
| Formula | 公式类型 | cell.CellType == CellType.Formula |
| Blank | 空白 | cell.CellType == CellType.Blank |
| Error | 错误值 | cell.CellType == CellType.Error |
读取单元格的通用方法,我认为最稳定的写法是这样:
private static object GetCellValue(ICell cell) { if (cell == null) return null; switch (cell.CellType) { case CellType.String: return cell.StringCellValue; case CellType.Numeric: if (DateUtil.IsCellDateFormatted(cell)) // 判断是否为日期格式 { return cell.DateCellValue; // 返回DateTime类型 } else { return cell.NumericCellValue; // 返回double类型 } case CellType.Boolean: return cell.BooleanCellValue; case CellType.Formula: return GetFormulaCellValue(cell); case CellType.Blank: return string.Empty; case CellType.Error: return cell.ErrorCellValue; default: return cell.ToString(); } }重点说说Numeric分支。Excel底层存储日期其实就是一个序列数,比如“2024-01-25”在内部就是数字“45222”这样的值。NPOI读取日期单元格时,会根据单元格的数字格式(DataFormat)判断它是不是日期。直接用cell.ToString()获取日期单元格的值,得到的是序列数,比如45300这种数字,而不是日期字符串。解决方法是先调用DateUtil.IsCellDateFormatted(cell)判断,是日期就取DateCellValue得到DateTime对象,之后怎么格式化就是.NET框架里的事。
3.2 关于公式单元格的两套处理方案
读取公式单元格是另一个高频问题。当我用NPOI读取一个带SUM函数、IF函数之类公式的单元格时,默认拿到的其实是公式本身字符串。比如我读到的可能是“SUM(A1:A10)”,而不是计算结果“150”。如果业务只需要结果,这里有两条路可以走。
第一种是使用NPOI的FormulaEvaluator,让NPOI自己计算一次公式:
IFormulaEvaluator evaluator = workbook.GetCreationHelper().CreateFormulaEvaluator(); ICell cell = row.GetCell(0); CellValue evaluatedValue = evaluator.Evaluate(cell); double result = evaluatedValue.NumericValue; // 或根据预期类型取 StringValue、BooleanValue这种方式不依赖Excel环境,NPOI自带计算引擎。缺点是如果文件里的公式引用了其他文件的外部链接,可能计算不出来。第二种是直接用cell.NumericCellValue或者cell.StringCellValue,但必须在打开工作簿时就设置公式求值模式,NPOI中可以用:
// 方式一:加载工作簿后立即设置 XSSFWorkbook xssfWorkbook = new XSSFWorkbook(fs); xssfWorkbook.SetForceFormulaRecalculation(true); // 强制重新算公式不过实测下来,这个设置对某些版本的效果不稳定,我一般直接用FormulaEvaluator,逻辑明确、结果可控。
3.3 样式设置:字体、背景色、边框与合并单元格
用NPOI生成带样式的报表,很多新手觉得代码量大,其实核心套路就几个对象。先看一个完整的例子,然后逐步解释:
ICellStyle style = workbook.CreateCellStyle(); // 设置背景色 style.FillForegroundColor = IndexedColors.LightBlue.Index; style.FillPattern = FillPattern.SolidForeground; // 设置边框 style.BorderTop = BorderStyle.Thin; style.BorderBottom = BorderStyle.Thin; style.BorderLeft = BorderStyle.Thin; style.BorderRight = BorderStyle.Thin; // 设置字体 IFont font = workbook.CreateFont(); font.FontName = "微软雅黑"; font.FontHeightInPoints = 11; font.IsBold = true; style.SetFont(font); // 居中 style.Alignment = HorizontalAlignment.Center; style.VerticalAlignment = VerticalAlignment.Center; // 对单元格应用样式 cell.CellStyle = style;有几个易错点值得单独说明。创建样式必须通过workbook.CreateCellStyle(),不能直接new ICellStyle,因为样式要挂在workbook内部样式表上,每个workbook有独立的样式管理机制。字体也必须通过workbook.CreateFont()创建,不能表白地复用其他workbook的字体。另外,如果循环给几千个单元格设置相同样式,不要每次循环都CreateCellStyle,这样会在workbook里产生海量重复样式,文件体积增大,性能也受影响。正确做法是把style创建到循环外面,循环里直接给cell.CellStyle赋值同一个样式对象。
合并单元格也是报表常用的操作,NPOI用CellRangeAddress类表示合并区域,配合Sheet的AddMergedRegion方法使用:
// 合并第一行第一列到第一行第三列,即A1:C1 sheet.AddMergedRegion(new CellRangeAddress(0, 0, 0, 2)); // 合并多行多列区域 sheet.AddMergedRegion(new CellRangeAddress(2, 4, 1, 1));这里有个细节:合并区域之后,只要左上角的单元格有值,其他区域的值会被丢弃。而且把整个区域的值都写满再合并并不会保留所有值,只保留区域左上角的值。所以推荐先合并,再给左上角单元格赋值。
3.4 从DataTable快速导入导出——通用模板方法
实际项目中,把DataTable导成Excel是非常高频的需求,比如把数据库查询结果导出为报表。这里贴一个我项目里常用的模板方法,支持xls/xlsx自动转换:
public static byte[] ExportDataTableToExcel(DataTable dt, string sheetName = "Sheet1", bool isXlsx = true) { IWorkbook workbook; if (isXlsx) { workbook = new XSSFWorkbook(); } else { workbook = new HSSFWorkbook(); } ISheet sheet = workbook.CreateSheet(sheetName); // 写入列头 IRow headerRow = sheet.CreateRow(0); for (int i = 0; i < dt.Columns.Count; i++) { ICell cell = headerRow.CreateCell(i); cell.SetCellValue(dt.Columns[i].ColumnName); // 给列头设置样式 ICellStyle headerStyle = workbook.CreateCellStyle(); IFont font = workbook.CreateFont(); font.IsBold = true; headerStyle.SetFont(font); headerStyle.Alignment = HorizontalAlignment.Center; headerStyle.FillForegroundColor = IndexedColors.LightGray.Index; headerStyle.FillPattern = FillPattern.SolidForeground; cell.CellStyle = headerStyle; } // 写入数据行 for (int r = 0; r < dt.Rows.Count; r++) { IRow row = sheet.CreateRow(r + 1); for (int c = 0; c < dt.Columns.Count; c++) { object val = dt.Rows[r][c]; ICell cell = row.CreateCell(c); if (val == null || val == DBNull.Value) { cell.SetCellValue(string.Empty); } else if (val is int || val is long || val is double || val is decimal) { cell.SetCellValue(Convert.ToDouble(val)); } else if (val is DateTime) { cell.SetCellValue((DateTime)val); // 可选:设置日期格式 } else { cell.SetCellValue(val.ToString()); } } } // 自动调整列宽 for (int c = 0; c < dt.Columns.Count; c++) { sheet.AutoSizeColumn(c); } using (MemoryStream ms = new MemoryStream()) { workbook.Write(ms); return ms.ToArray(); } }调用时直接byte[] data = ExportDataTableToExcel(dt); File.WriteAllBytes(@"D:\report.xlsx", data);即可。有几个实用小技巧:DataTable里如果是布尔值,直接ToString会得到“True/False”,如果业务需要“是/否”,在else分支里单独判一下类型再处理。列头样式如果在循环里重复创建不好,可以用一个全局的headerStyle对象在循环外创建好再复用,上面的代码为了演示便于理解放在里面,实际量产建议提出来。
4. 大数据量写入与性能优化
4.1 为什么XSSFWorkbook写大数据会卡死内存
很多开发说“我用NPOI导5万行数据,内存直接飙到2GB”,这个现象在XSSFWorkbook中很典型。原因是XSSFWorkbook把整个工作簿数据都放在内存对象模型里,每写一个单元格就生成一个XSSFCell对象,5万行×20列就是100万个单元格对象,每个对象还有丰富属性,GC压力极大。
NPOI参考POI的方案提供了SXSSFWorkbook类(Streaming Usermodel API),它的名字叫Streaming,核心设计思路是滑动窗口缓存。字面上的意思是:数据写到一定行数后就刷新到磁盘临时文件,内存中只保留最近N行,从而用极小的内存写出超大Excel文件。
我个人的经验是,要导超过1万行的数据,就直接用SXSSFWorkbook,别犹豫。下面是具体用法:
using NPOI.XSSF.Streaming; // 第二个参数是窗口大小,表示内存中保留的行数 SXSSFWorkbook workbook = new SXSSFWorkbook(100); ISheet sheet = workbook.CreateSheet("大数据导出"); for (int r = 0; r < 200000; r++) { IRow row = sheet.CreateRow(r); for (int c = 0; c < 30; c++) { row.CreateCell(c).SetCellValue("测试数据_" + r + "_" + c); } // 每1000行主动刷新一次到磁盘 if (r % 1000 == 0) { // SXSSF没有公开的flush方法,可通过WindowSize机制自动滑动, // 或调用 ((SXSSFSheet)sheet).FlushRows(); } }注意,窗口大小100表示内存中只保留最后100行。这个值不是越大越好,也不是越小越好。窗口太小,频繁写临时文件导致IO频繁;太大,内存释放效果不明显。一般100-500之间是比较合理的区间,具体取决于数据行数和服务器配置。
4.2 大数据场景下的几个性能习惯
除了换SXSSFWorkbook,性能优化还有几个很实用的习惯:
- 不要用AutoSizeColumn。
sheet.AutoSizeColumn()会遍历整列所有单元格来计算最佳列宽,在大数据量下这个操作等于双重遍历,极其耗时。我的做法是手动估算列宽:根据表头字符串长度乘以一个系数,用sheet.SetColumnWidth(c, width)设置固定值。 - 公式尽量少用。上千行的SUM或IF公式会让Excel打开时计算量暴增,生成时NPOI也要处理公式解析。如果只是导出数据,把计算在C#里做完,直接把数值写进单元格。
- 循环体里避免new样式。这个前面强调过,大数据量下问题更明显,几万行如果每行都创建一个样式对象,不仅仅内存爆,最终生成的Excel文件也会很大(因为样式表里记录了每一行的每一个重复样式)。
- 写完务必调用workbook.Dispose()或释放临时文件。SXSSFWorkbook在写入过程中会在系统临时目录生成文件,如果不释放,会残留大量临时文件占磁盘。正确做法:
try { // 写入逻辑 } finally { workbook.Dispose(); }SXSSFWorkbook实现了IDisposable,Dispose方法会清理临时文件。用完之后记得调用。
5. 常见问题与排查技巧实录
5.1 读取与写入的典型疑难杂症
我把项目中实际遇到的坑整理成了一张速查表,方便按图索骥排查:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 用HSSFWorkbook读取.xlsx文件抛异常 | 类与格式不匹配,前文提过的二进制格式与XML格式差异 | 改写为根据文件头自动判断格式,或统一使用XSSFWorkbook处理.xlsx |
| 日期单元格读出来是数字 | 前面提到Excel内部日期存序列数,NPOI默认没有识别为日期格式 | 使用DateUtil.IsCellDateFormatted判断,然后取DateCellValue |
| 读取带公式的单元格拿不到结果 | CellType为Formula时,NPOI默认返回公式文本 | 使用FormulaEvaluator或设置SetForceFormulaRecalculation |
| 空单元格直接被跳过 | 循环中GetCell返回null,直接访问属性抛异常 | 判空后再处理 |
| 合并单元格后取不到值 | 合并区域只有左上角单元格有值,其他格为null或Blank | 定位到合并的第一行第一列的cell去取值 |
| 写入的字符串显示为“########” | 列宽太窄,容纳不下内容 | 手动SetColumnWidth,不要依赖AutoSizeColumn |
| 数据超6万行写入.xls报错 | xls格式硬性限制为65536行 | 改用.xlsx格式(XSSFWorkbook/SXSSFWorkbook) |
| 大文件导出内存暴涨 | XSSFWorkbook将所有数据驻留内存 | 改用SXSSFWorkbook并设置合理窗口 |
| 服务器上打开生成的Excel提示“文件已损坏” | 简单场景下可能是生成过程中文件流未正确关闭导致文件不完整 | 确保using释放FileStream/MemoryStream,写完再关闭 |
| Web项目下载文件时中文文件名乱码 | 响应头的Content-Disposition未做URL编码 | 使用UrlEncoded或等价方式处理文件名 |
5.2 格式自动识别兼容方案与模板复用心得
最后一个实操建议:如果你的工具类需要同时支持xls和xlsx的读取,我强烈建议用一个自动识别方法,按文件头魔数判断,避免要用户手动选择格式。文件的前4个字节就能区分:
public static IWorkbook CreateWorkbook(string filePath) { using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { return CreateWorkbook(fs); } } public static IWorkbook CreateWorkbook(Stream stream) { byte[] header = new byte[8]; stream.Position = 0; stream.Read(header, 0, 8); stream.Position = 0; // xls文件的OLE2压缩头固定为 D0 CF 11 E0 A1 B1 1A E1 if (header[0] == 0xD0 && header[1] == 0xCF && header[2] == 0x11 && header[3] == 0xE0) { return new HSSFWorkbook(stream); } else { return new XSSFWorkbook(stream); } }这样输入文件的后续处理逻辑完全不用关心格式,都用IWorkbook接口操作,非常省心。这个工具方法我放在自己的C#公共类库中,互操作工具类、上位机报表功能全部复用,一次写好,所有项目都能用。
再说说模板复用。在工厂报表、导出对账单这类场景中,我推荐制作一个“Excel模板文件”,提前设置好列宽、列头样式、数字格式等,然后通过NPOI打开模板,把数据填入指定区域,而不是每次从零创建所有样式。这样生成的报表观感统一、代码量也更少。操作中只需要注意的一点是:模板中不要包含复杂的公式或图表,NPOI对图表支持程度有限,可能会丢失或错乱。
6. 我把这个项目落地后的整体感受
几个项目做下来,NPOI给我的整体印象是:学习曲线中等,底层能力扎实,功能覆盖满足90%以上的Excel读写需求。真正的困难不在于API本身,而在于对Excel文件格式的理解——无论是xls和xlsx的底层差异、日期序列值的转换规则,还是FormulaEvaluator的求值机制,把背后的逻辑吃透了,使用起来自然顺手。
如果手头正打算用C#处理Excel的任务,我的建议是:先从简单的一列一列读写开始,再逐步叠加样式、公式、大数据量写入,遇到问题先检查格式是否匹配、再检查类型是否正确,剩下的交给经验积累。文中涉及的代码可以直接复制到项目中测试,遇到报错欢迎对照“常见问题”表格排查。取数逻辑和性能优化的思路彼此独立,可以按需使用。
本文还有配套的精品资源,点击获取