C#使用EPPlus实现Excel高效读写改的完整指南
2026/9/7 14:16:02 网站建设 项目流程

简介:在.NET桌面开发中,Excel文件的导入导出与报表生成是高频需求;EPPlus作为一款轻量级开源组件,可帮助C#开发者以面向对象方式操作xlsx数据表格。这份实例源码包恰好覆盖上述应用场景,内容由浅入深,适合初中级程序员系统练习。压缩包共包含41个文件,整体大小约为2.55MB,以源代码文件和动态链接库为主体,搭配解决方案、配置文件、资源文件、调试符号以及一个示例工作簿;全部文件按标准项目结构组织,解压后使用Visual Studio打开即可编译运行。源码完整演示了从创建包实例、添加工作表、设置单元格数值到批量装载列表数据的写入过程;同时实现读取指定单元格、遍历整行数据、修改后保存的完整流程。此外还展示字体加粗、背景填充、求和公式与柱状图等高级功能,并说明资源释放与异常处理要点,有助理解核心接口调用顺序与常见排错方法。已有3721人学习下载,既可运行观察实际效果,也可作为项目模板直接复用;配合清晰注释,学习曲线平缓,能快速融入日常开发。 做C#开发这几年,凡是和数据打交道的项目,基本都绕不开Excel。早年间我用NPOI,后来换成了EPPlus,再后来发现很多人还在用COM组件操作Excel,踩了无数坑。今天就把我用EPPlus实现Excel读、写、改的完整思路和代码整理出来,照着抄就行。

日常开发里,最常见的三类需求:生成报表给客户或领导看、把数据库数据批量导出成Excel、读取客户上传的Excel表格做数据入库。再加上工业上位机场景里,需要把设备采集的数据定时写入Excel,或者从配置表读取参数。这些操作EPPlus全都覆盖,而且不依赖Office环境,服务器上不需要装Excel也能跑,这一点在部署的时候特别省心。

这篇文章适合谁看?刚接触C#的初学者,可以照着代码顺利跑通第一个Excel操作程序;有经验的开发者,可以重点关注性能优化和踩坑部分,比如大文件写入、公式计算、数据类型判断这些细节。我会把每一步的代码、参数含义、为什么这么写都讲清楚。

1. 为什么是EPPlus:Excel操作库的选型解析

1.1 主流方案对比:EPPlus、NPOI、COM组件

先看一张对比表,这是我在实际项目里反复权衡后得出的结论,也代表了社区里的主流共识。

对比项EPPlusNPOICOM组件
环境依赖无需安装Office无需安装Office必须安装Office
性能快,内存占用中等较快,处理xls更稳慢,频繁创建进程
功能丰富度极强(图表、透视表、样式、公式)基础功能齐全功能全但调用繁琐
开源许可4.x免费,5.x起商业收费Apache 2.0免费需正版Office授权
上手难度简单中等较复杂

如果你只是简单读写,NPOI和EPPlus都能干。但EPPlus在样式控制、条件格式化、图表生成、数据透视表这些高级功能上做得很细致,API设计也更符合C#开发者的使用习惯,链式调用写起来非常顺手。

1.2 版本选择和License问题

EPPlus从5.0版本开始改为商业许可,公司项目使用必须购买授权。个人学习、练手可以用5.x版本(有30天评估期),也可以使用老版本4.5.3.3,这个版本完全免费,功能也足够稳定。网上大量教程基于4.x的写法,比如Worksheet.Cells["A1"]这种方式,在5.x版本里依然兼容,但要注意命名空间从OfficeOpenXml变成了OfficeOpenXml(实际没变,变化的是内部API)。

我的建议是:个人学习用4.5.3.3,代码跑通、理解机制最重要;商业项目如果预算允许,直接用最新的5.x(比如5.8.x),API面更完整,遇到问题时社区资料多。

提示:判断一个NuGet包是否被项目正确引用,最简单的方法是编译后在输出目录里找到 EPPlus.dll,如果找不到说明引用没生效。

2. 环境准备与Excel写入操作

2.1 通过NuGet安装EPPlus

创建一个.NET 6.0或.NET 8.0的控制台程序(老项目用.NET Framework 4.6.1以上也可以),然后在包管理器控制台执行:

Install-Package EPPlus -Version 5.8.19

或者用新版Visual Studio的NuGet包管理器界面,搜索“EPPlus”直接安装。如果你的项目用了老版本EPPlus,升级到5.x后需要改一行代码:

// 4.x时代不需要设置LicenseContext // 5.x需要在程序入口处配置: ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

这个设置是为了声明非商业用途,如果在商业环境中被审计到漏了这行,有可能收到律师函。个人项目写NonCommercial就好了。

2.2 基础写入:从创建Workbook到填充数据

EPPlus的核心对象模型是:ExcelPackage(整个Excel文件) → ExcelWorkbook(工作簿) → ExcelWorksheet(工作表) → Cells(单元格)。

最基础的操作是创建一个全新的Excel文件,往里填数据然后保存:

using OfficeOpenXml; using OfficeOpenXml.Style; using System; using System.IO; class Program { static void Main(string[] args) { // 5.x必须设置,4.x可以省略 ExcelPackage.LicenseContext = LicenseContext.NonCommercial; // 1. 创建一个ExcelPackage实例,可以传一个文件对象 string filePath = @"D:\demo\设备数据报表.xlsx"; using (var package = new ExcelPackage(new FileInfo(filePath))) { // 2. 添加工作表 ExcelWorksheet sheet = package.Workbook.Worksheets.Add("设备采集数据"); // 3. 写入表头 sheet.Cells["A1"].Value = "设备编号"; sheet.Cells["B1"].Value = "采集时间"; sheet.Cells["C1"].Value = "温度"; sheet.Cells["D1"].Value = "湿度"; // 4. 写入数据行 sheet.Cells["A2"].Value = "DEV-001"; sheet.Cells["B2"].Value = DateTime.Now; sheet.Cells["C2"].Value = 36.5; sheet.Cells["D2"].Value = 58.2; // 5. 保存文件 package.Save(); Console.WriteLine($"文件已保存:{filePath}"); } } }

这里有几个细节值得展开说说:

第一,用using包裹ExcelPackage是必须的,因为Excel文件的写出操作是在Dispose()时完成的。如果你不写using也不调用package.Dispose(),数据可能根本没写进文件,而且会占用文件句柄。

第二,sheet.Cells["A1"].Value可以直接接收object类型,所以传入字符串、DateTime、double都不需要手动转换,框架会自动处理类型映射。这在从DataTable导数据时特别方便,直接遍历DataRow往里塞就行。

第三,你可能会注意到,这个例子中FileInfo的对象并没有判断文件是否存在。EPPlus的设计是:如果文件不存在会创建一个空文件,如果存在会打开并加载内容。所以“写入”和“修改”其实用的是同一个入口,区别在于你传的文件路径是否存在。

2.3 批量写入:从DataTable导出数据

实际项目中,数据往往来自数据库查询结果,而DataTable是C#里最常见的数据载体。我封装了一个通用的导出方法:

/// <summary> /// 将DataTable导出到Excel /// </summary> public static void ExportDataTableToExcel(DataTable dt, string filePath, string sheetName = "Sheet1") { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using (var package = new ExcelPackage()) { var sheet = package.Workbook.Worksheets.Add(sheetName); // 写表头 for (int col = 0; col < dt.Columns.Count; col++) { sheet.Cells[1, col + 1].Value = dt.Columns[col].ColumnName; } // 写数据 for (int row = 0; row < dt.Rows.Count; row++) { for (int col = 0; col < dt.Columns.Count; col++) { sheet.Cells[row + 2, col + 1].Value = dt.Rows[row][col]; } } // 自动调整列宽(这个功能在导出报表时很好用) sheet.Cells.AutoFitColumns(); package.SaveAs(new FileInfo(filePath)); } }

注意我在这里用了Cells[row + 2, col + 1]这种行列索引的方式。Excel的行列从1开始,我第一行写了表头,所以数据行从第2行开始;第1列对应A,所以列号从1开始。

AutoFitColumns()会根据单元格内容的长度自动调整列宽,避免导出后数字变成了“###”或者文字挤在一起。不过如果数据量很大,比如上万行,全表自适应宽度会比较耗时,可以选择对指定的列做:

sheet.Column(1).AutoFit();

2.4 样式设置:合并单元格、边框、字体、背景色

导出报表肯定要一些基本样式,不然一坨数据丢给客户体验太差。EPPlus的样式API设计得挺人性化:

// 设置表头样式 using (var range = sheet.Cells["A1:D1"]) { range.Style.Font.Bold = true; range.Style.Font.Size = 12; range.Style.Font.Color.SetColor(Color.White); range.Style.Fill.PatternType = ExcelFillStyle.Solid; range.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(79, 129, 189)); range.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; // 添加边框 range.Style.Border.Top.Style = ExcelBorderStyle.Thin; range.Style.Border.Bottom.Style = ExcelBorderStyle.Thin; range.Style.Border.Left.Style = ExcelBorderStyle.Thin; range.Style.Border.Right.Style = ExcelBorderStyle.Thin; } // 合并单元格(A1和B1合并) sheet.Cells["A1:B1"].Merge = true; // 设置行高和列宽 sheet.Cells[1, 1].RowHeight = 20; sheet.Column(1).Width = 15;

这里有个常见的误区:range.Style.Fill.PatternType必须设置为ExcelFillStyle.Solid(纯色填充),否则背景色不生效,这是EPPlus的一个特性,很多新手都栽在这。顺序也很重要——先设置PatternType再设置BackgroundColor,否则颜色值可能被覆盖。

2.5 写入公式

Excel的精髓在于公式。我在上位机项目里,经常需要把采集到的原始数据写入Excel,然后让Excel自动计算均值和极差。

// 假设A2:A100是一列温度数据 sheet.Cells["C1"].Formula = "AVERAGE(A2:A100)"; sheet.Cells["C2"].Formula = "MAX(A2:A100) - MIN(A2:A100)"; // 如果希望文件打开时就强制重算一次公式 package.Workbook.CalcMode = ExcelCalcMode.Manual;

老版本EPPlus默认不计算公式结果,除非你调用sheet.Calculate()。如果你在代码里读取“带公式单元格的值”,读到的可能是null。所以上面的例子中,如果需要立即获取结果,应该写成:

sheet.Calculate(); var result = sheet.Cells["C1"].Value; // 计算后就拿得到值了

Calculate()是EPPlus的一个重要方法,它可以批量计算工作簿中所有公式。实测大文件时比较耗时,但比打开Excel再按F9要快得多。

3. 读取Excel数据

3.1 基础读取:遍历单元格内容

读Excel的时候,通常分两种情况:一种是格式很规整的表格,按行遍历就行;另一种是模板比较复杂,需要定位单元格。

先看最简单的按行读取:

public static void ReadExcel(string filePath) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using (var package = new ExcelPackage(new FileInfo(filePath))) { var sheet = package.Workbook.Worksheets[0]; // 读取第一个工作表 // 获取有数据的最大行列号 int rowCount = sheet.Dimension?.Rows ?? 0; int colCount = sheet.Dimension?.Columns ?? 0; Console.WriteLine($"共 {rowCount} 行,{colCount} 列"); for (int row = 1; row <= rowCount; row++) { for (int col = 1; col <= colCount; col++) { var cellValue = sheet.Cells[row, col].Value; if (cellValue != null) { Console.Write(cellValue.ToString() + "\t"); } } Console.WriteLine(); } } }

sheet.Dimension返回一个包含实际数据范围的ExcelAddressBase对象,如果整个工作表为空,它的值是null。所以要加?.的空判定,这是我在线上环境被空Event日志坑过之后加上的防护。

读取的时候要注意:sheet.Cells[row, col].Value返回的是object类型,真实类型可能是string、double、DateTime或者bool。如果直接ToString(),日期可能会变成一串数字(比如46452.654)。原因在于Excel内部把日期存储为自1899年12月30日以来的天数。遇到这种情况,建议用Text属性代替Value

string cellText = sheet.Cells[row, col].Text; // 返回单元格显示文本,格式已转换

Text属性读取的是单元格格式化后的文本,和Excel界面上看到的一致,省去了类型转换的麻烦。但注意Text对未格式化单元格返回空字符串,所以两种方式结合使用。

3.2 进阶读取:转换为DataTable和对象列表

在数据导入场景里,最终目的多半是把Excel数据装进DataTable再去入库。直接遍历单元格然后组装DataTable:

public static DataTable ExcelToDataTable(string filePath, int sheetIndex = 0) { var dt = new DataTable(); ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using (var package = new ExcelPackage(new FileInfo(filePath))) { var sheet = package.Workbook.Worksheets[sheetIndex]; if (sheet.Dimension == null) return dt; int rowCount = sheet.Dimension.Rows; int colCount = sheet.Dimension.Columns; // 用第一行作为列名 for (int col = 1; col <= colCount; col++) { string colName = sheet.Cells[1, col].Text; dt.Columns.Add(string.IsNullOrEmpty(colName) ? $"Column{col}" : colName); } // 从第二行开始填充数据 for (int row = 2; row <= rowCount; row++) { var newRow = dt.NewRow(); for (int col = 1; col <= colCount; col++) { newRow[col - 1] = sheet.Cells[row, col].Value; } dt.Rows.Add(newRow); } } return dt; }

这个方法是做Excel导入数据库的桥梁。比如上位机软件里,用户上传一份产品参数表,后台调用这个方法拿到DataTable,再拿DataTable直接SqlBulkCopy到数据库,一整条链路就通了。

如果要转成实体对象列表,只要把DataTable做一层映射即可,注意处理DBNull和EmptyString的情况,建议封装一个扩展方法GetValue<T>(this DataRow row, string columnName),这样能省去一堆if (row["xxx"] != DBNull.Value)的判断。

3.3 判断单元格类型和处理边界值

Excel单元格类型判断是个细节活。最常见的坑:一个数字列里某一格是空字符串,读取后类型变成string,而其他格子是double,导致后续强转失败。

实测下来没发现其他方式更靠谱,还是得自己写类型判断:

public static T GetCellValue<T>(ExcelWorksheet sheet, int row, int col) { var cell = sheet.Cells[row, col]; if (cell.Value == null || cell.Value == DBNull.Value) return default(T); Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); // 处理空字符串转数值的情况 if (targetType == typeof(double) && cell.Value is string s && string.IsNullOrWhiteSpace(s)) return default(T); // 日期类型直接用Text转换,避免出现double日期 if (targetType == typeof(DateTime)) { string txt = cell.Text; if (DateTime.TryParse(txt, out DateTime dt)) return (T)(object)dt; return default(T); } try { return (T)Convert.ChangeType(cell.Value, targetType); } catch { return default(T); } }

这段代码的核心思路是“能映射就映射,映射不了就返回默认值”。在实际项目里,比直接抛异常要舒服得多——毕竟Excel表格永远不可控,谁也不知道用户会在格子里塞什么奇怪的东西。

4. 修改Excel文档:更新数据与样式调整

4.1 打开已有文件并修改指定单元格

修改Excel使用的是同一个ExcelPackage入口,传一个已经存在的文件路径,然后定位单元格改值:

public static void UpdateExcelCell(string filePath, string cellAddress, object newValue) { ExcelPackage.LicenseContext = LicenseContext.NonCommercial; using (var package = new ExcelPackage(new FileInfo(filePath))) { var sheet = package.Workbook.Worksheets[0]; sheet.Cells[cellAddress].Value = newValue; package.Save(); // 覆盖原文件 } }

这段代码看起来简单,但有一个关键点:package.Save()是直接覆盖原文件,所以传入的cellAddress如果写错了,很容易把别人辛苦维护的数据搞坏。建议在修改前先备份,或者用SaveAs另存为新文件,给用户多一层保护。

对于批量更新(比如从数据库同步一批数据到Excel中固定的行),可以先用字典把要更新的行号、列号、值都收集起来,再一次性写入并保存,避免频繁打开关闭文件。

4.2 修改公式与清除内容

我遇到比较多的情况是:模板表格里已经预先填充了公式,但公式引用的范围是固定的,需要动态改成新的。比如模板里C1公式原来是SUM(A1:A10),现在数据变成了100行,需要改成SUM(A1:A100)

sheet.Cells["C1"].Formula = "SUM(A1:A" + rowCount + ")";

这个操作本质就是给Formula属性重新赋值。EPPlus对公式的处理是当字符串存储,你赋什么它就存什么。

清除内容可以用:

sheet.Cells["C1:C100"].Clear();

这个方法可以清除值、公式和注释,但保留样式。如果你连样式一起清,需要用Delete或重新设置Style

4.3 删除和插入行

上位机日志场景里,如果数据超过预计行数,我经常在写入前先删掉旧数据:

// 删除从第2行到第100行的数据(保留表头) sheet.DeleteRow(2, 99); // 在指定位置插入一行(在第5行上方插入一行) sheet.InsertRow(5, 1);

DeleteRow(第几行开始, 删除几行)InsertRow(从哪行开始, 插入几行)都有两个参数。注意InsertRow会复制被插入行的格式到你新插入的行,如果不想复制,可以用InsertRow(row, cnt, true)的第三个参数控制。这个细节我以前没注意,结果日志文件里越插越乱,排查了半天才发现是行格式串了。

4.4 写入图片:把设备和现场照片嵌入Excel

还有一个高频场景:生成质检报告时,要把现场照片或设备照片贴进Excel。EPPlus对图片的支持很成熟:

string imagePath = @"D:\photos\device_001.png"; using (var img = System.Drawing.Image.FromFile(imagePath)) { var picture = sheet.Drawings.AddPicture("DevicePhoto", img); picture.SetPosition(2, 0, 5, 0); // 行2,行偏移0,列5,列偏移0 picture.SetSize(300, 200); // 宽度和高度(像素) }

这里有两个注意点:AddPicture的第一个参数要求全工作簿唯一,重复会报错;SetPosition的第一、三个参数是从0开始的下标,0就是第1行、第1列,和Cells的从1开始不同,很容易混淆。

5. 常见问题、性能优化与实战心得

5.1 常见异常与解决方案

我把自己和身边同事踩过的坑汇总成了一张表,几乎囊括了EPPlus最常见的异常情况。

异常信息原因解决方案
The specified value is out of range行号或列号超出Excel界限(最大1048576行、16384列)检查行列号是否从1开始,是否超过限制
The package is not open / Cannot access a disposed objectExcelPackage已被释放但代码还在访问Worksheet对象检查using作用域,不要把Worksheet引用暴露到using外
Data error: cyclic reference / formula error公式引用自身或引用了错误单元格在代码里先用 Calculate() 测试公式,确认没有循环引用
File is corrupt / workbook cannot be loaded文件被占用或已经损坏确认文件没有在其他进程中被打开,将读取改为 FileShare.ReadWrite
Cannot save a package that is disposed在package释放后又调用Save()检查代码运行顺序,让Save在using内部执行

最后一个“文件被占用”的问题,最常见的原因是杀毒软件或Excel程序锁定了文件。在工业上位机项目里,数据采集软件可能每5分钟写一次Excel,同时在另一个进程里打开报表供人工查看,两个进程抢一个文件是家常便饭。我的做法是升级到“生成临时文件 + 替换原文件”的模式,而不是直接往原文件里写。

5.2 性能优化:大数据量写入的正确姿势

当数据量超过5万行时,使用sheet.Cells[row, col].Value = ...逐格写入已经明显变慢,10万行可能要几分钟。原因是每个单元格赋值都会触发一次内部的变更通知和结构校验。

正确的做法是利用二维数组整块写入:

int rowCount = 100000; int colCount = 10; object[,] data = new object[rowCount, colCount]; // 填充数据 for (int i = 0; i < rowCount; i++) { for (int j = 0; j < colCount; j++) { data[i, j] = $"row{i}col{j}"; } } // 一次性写入从A2开始的区域 var startCell = sheet.Cells["A2"]; var endCell = sheet.Cells[rowCount + 1, colCount]; string rangeAddress = $"{startCell.Address}:{endCell.Address}"; sheet.Cells[rangeAddress].LoadFromArrays(data);

实测同样10万行10列的数据,逐格写入耗时40秒左右,用LoadFromArrays整块写入只需要3秒,性能提升了10倍以上。

还有一个细节:写入前设置sheet.Workbook.CalcMode = ExcelCalcMode.Manual,避免写入过程中反复重算公式,也能提升一部分性能。

5.3 实战心得:文件并发处理的稳健策略

在C#上位机软件中,数据采集线程和Excel写线程经常是并行工作的。我的建议是:不要在线程里直接共享同一个ExcelPackage实例,EPPlus的API不是线程安全的。稳妥的做法是每次都打开独立的ExcelPackage对象,用完立即释放。

如果数据量不大(几千行),每次采集完成就打开->追加数据->保存->释放。如果数据量大,可以把待写入数据缓存到内存里,每10分钟或者每累积到5000行一次性写入,这样既减少了IO频率,也降低了文件锁冲突概率。

5.4 读取与写入时的编码和格式注意事项

日期格式和区域设置问题:同一Excel文件,在中文系统上显示日期可能是“2024/8/15”,在英文系统上可能是“8/15/2024”。在代码里读写日期时,强烈建议统一使用DateTime类型,然后通过NumberFormat设置显示格式:

sheet.Cells["B2"].Value = DateTime.Now; sheet.Cells["B2"].Style.Numberformat.Format = "yyyy-MM-dd HH:mm:ss";

字符串超过255个字符:EPPlus本身没问题,但旧版Excel打开时会警告。如果字符串超过255字符,考虑用sheet.Cells[row, col].IsRichText = true或者干脆把单元格设置为“自动换行”WrapText = true

首列/首行冻结:做报表时数据很多,用户滚动就忘记表头了,一行代码解决:

sheet.View.FreezePanes(2, 1); // 冻结第一行(表头)

FreezePanes(2,1)的含义是:在第二行、第一列的位置冻结,所以第一行固定不动。这在给项目组写周报模板的时候特别实用。

5.5 保存为CSV或转换格式

有时候客户要求数据同时输出Excel和CSV。EPPlus可以直接把工作表内容保存为CSV:

sheet.SaveToCsv(new FileInfo(@"D:\output\data.csv"));

这个方法在4.x版本里有,5.x版本可以通过LoadFromText配合TextFormat实现,或者直接把DataTable用CsvHelper导出,反正都很方便。顺带一提,如果Excel文件是.xls老格式,EPPlus是打不开的,需要在源头上做好规避,让上传模块只接受.xlsx

6. 结束前的一些经验分享

做Excel操作类功能,最容易翻车的往往不是语法和API,而是对数据格式的假设。我在项目里遇到最多次的意外包括:用户上传的Excel文件明明看着是数字,读到程序里却变成了科学计数法的字符串;单元格明明有值,用Text读取时返回的却是空串;日期列在不同Excel版本里显示的格式不一样,导致排序错乱。

针对这些不确定性,我的建议是统一做一个“数据清洗层”,在进入业务逻辑之前,先把Excel里的值转成期望的规范格式,比如所有数字统一为double、所有日期统一为yyyy-MM-dd HH:mm:ss、所有空字符串统一转换为 null。这一步在数据量不大时不会带来性能问题,但它能把后面的逻辑代码写得异常清爽,bug率直线下降。

EPPlus这个库整体学习成本很低,只要抓住ExcelPackage → Workbook → Worksheet → Cells这条主线,基本功能一天就能上手。但真正用好,还是要靠项目里反复打磨和踩坑。希望这篇实例总结能给你省下一些试错时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询