C# WinForm多控件Excel导出:基于NPOI与DataTable的通用实现 简介面向使用 DevExpress WinForm 的 .NET 开发者这份资源是一套可复用的通用控件导出 Excel 解决方案适合需要把表格、透视表、图表等控件内容完整导出到 Excel 的项目场景。它统一处理 GridControl、PivotGridControl 等控件的导出逻辑弥补自带方法无法导出图片、多表头丢失的缺陷并解决数据透视表自动分组带来的格式偏差支持多个控件写入同一个 Excel 的不同工作表真正做到所见即所得。压缩包共 179 个文件约 35.09 MB以 DLL 运行库、XML 配置和 C# 源码为主体同时包含示例程序、工程配置文件及调试符号等便于直接引用或二次开发。目前已有 1021 人浏览学习。借助源码、示例和配套文件读者可以快速接入导出逻辑省去逐个控件适配的时间也能借鉴作者对 DevExpress 导出边界场景的处理思路适合有复杂报表导出需求的中高级 WinForm 开发者。1. 多控件导出Excel为什么不能只写一个控件的小工具接手WinForm报表类项目时最常见到的需求不是“导出某张表”而是“主界面里五六个表格一键导出到一个Excel每个控件一个Sheet”。一开始很多人会给GridControl写一个导出方法给TreeList再写一个给Chart再写第三个结果每个方法都在重复处理文件流、列头、单元格格式换一个报表页又要复制一遍。其实这件事的本质是把“控件的可见数据”变成“Excel里有格式的单元格”和具体控件类型无关。这里整理了一套基于Dev控件库的通用导出类支持多个控件按Sheet分包进同一个.xlsx覆盖取数、列头样式、统计行、内存控制和常见翻车点适合报表工具开发者和刚接触导出的程序员。2. 通用控件导出的实现路径从控件到DataTable再到Sheet2.1 选型为什么中间层选DataTable而不是直接写Excel在WinForm项目里导出Excel有三条常见路线先说清楚为什么最终落在DataTable上。第一条路线是直接用控件自带的视图导出比如GridView对应的ExportToExcelFile。优点是不需要处理xlsx内部细节连列宽、单元格样式都会自动生成缺点是它的函数签名里写着“把当前视图导出为一个完整工作薄文件”也就是说你拿到的每一个文件都包含一个Sheet。多个控件分开导出后往往还要再写一个合并脚本来拼成单文件麻烦且容易丢格式。更关键的是它通常弹出对话框或写到某个固定路径和“一键批量导出”的目标冲突无法在后台静默执行。第二条路线是拿到控件以后直接找出每个单元格的值往NPOI的Cell里填。这条路完全可以走但做出来的方法基本是给某个控件定制的。比如GridControl和TreeList一个用RowColumn一个用Node字段名相同但循环方式完全不一样Chart又完全不是列表型控件。如果每个类型都写一套“读出再写入”后续每多一种控件就要多改一次公共入口维护成本很高。第三条路线就是先做取数再做导出取数的结果统一落到DataTable。DataTable有自带的列名、行集合、类型信息而且是.NET体系里最常用的缓冲容器写导出时只需要面对一个DataTable不用管它原来是什么控件。这样导出层变化很小新控件只影响取数层这是做通用导出最划算的结构。这个选择还有一个隐藏好处DataTable可以做数据清洗比如分页过滤、剔除隐藏列、提前把计算列的值生成好统计行也可以在DataTable上先算好再写入Excel。后面几节的取数方法都按这个思路设计。2.2 三种控件取数GridView、TreeList、Chart先从GridControl开始。重点是把界面当前可见的列和行取出来不要取隐藏列也不要取数据源里但没挂在界面上的列。private static DataTable GridToDataTable(GridControl grid) { if (grid.MainView is not GridView view) return null; var dt new DataTable(); foreach (GridColumn col in view.VisibleColumns) { if (string.IsNullOrEmpty(col.FieldName)) continue; dt.Columns.Add(col.FieldName, typeof(object)); } for (int r 0; r view.DataRowCount; r) { var row dt.NewRow(); foreach (GridColumn col in view.VisibleColumns) { if (string.IsNullOrEmpty(col.FieldName)) continue; row[col.FieldName] view.GetRowCellValue(r, col); } dt.Rows.Add(row); } return dt; }参数说明view.VisibleColumns得到的是界面上可见的列隐藏列会自动剔除view.DataRowCount是数据源的总行数如果当前视图应用了筛选器需要改用view.RowCount否则导出结果和界面不一致。GetRowCellValue拿的是物理值如果列上设置了DisplayFormat这里拿到的仍是原始数据格式化放到写入阶段处理这样Excel里既能显示原始数也能按业务需求再改格式。接着是TreeList。TreeList不是简单表格它有父子层级AllNodes只返回根节点必须递归才能拿到所有子节点。private static DataTable TreeToDataTable(TreeList tree) { var dt new DataTable(); foreach (TreeListColumn col in tree.Columns) { if (string.IsNullOrEmpty(col.FieldName)) continue; dt.Columns.Add(col.FieldName, typeof(object)); } void AppendNode(TreeListNode node) { var row dt.NewRow(); foreach (TreeListColumn col in tree.Columns) { if (string.IsNullOrEmpty(col.FieldName)) continue; row[col.FieldName] node.GetValue(col.FieldName); } dt.Rows.Add(row); foreach (TreeListNode child in node.Nodes) { AppendNode(child); } } foreach (TreeListNode root in tree.Nodes) { AppendNode(root); } return dt; }这里用了一个本地函数AppendNode做深度递归每一行先写当前节点的所有列再往下钻子节点。如果有业务需要还可以在取数时增加一列“层级”把当前节点的层级数字填进去方便Excel里做排序或分组。TreeList的字段名经常和Grid共用所以后续写入逻辑可以复用。Chart控件不是行列表格它是一组序列点。导出时固定成三列更合理系列名、参数值、数值。private static DataTable ChartToDataTable(ChartControl chart) { var dt new DataTable(); dt.Columns.Add(Series, typeof(string)); dt.Columns.Add(Argument, typeof(object)); dt.Columns.Add(Value, typeof(double)); foreach (Series s in chart.Series) { foreach (SeriesPoint p in s.Points) { double value 0; if (p.Values ! null p.Values.Length 0) { value Convert.ToDouble(p.Values[0]); } dt.Rows.Add(s.Name, p.Argument, value); } } return dt; }p.Values是一个数组因为有些图表一个点有多个Y值这里取第一个作为主值。如果图表是现金流水趋势图则p.Argument往往是DateTime写入Excel时需要走日期格式化分支在后面的样式章节统一处理。为了让主导出方法不知道具体控件类型可以做一个分发入口public static DataTable ControlToDataTable(Control c) { switch (c) { case GridControl grid: return GridToDataTable(grid); case TreeList tree: return TreeToDataTable(tree); case ChartControl chart: return ChartToDataTable(chart); default: throw new NotSupportedException($不支持的控件类型: {c.GetType().Name}); } }这个分发逻辑把控件类型隔离在入口新增控件类型时只需要加一个case和对应取数方法导出层完全不用动。2.3 写入工作薄的通用方法NPOI的XSSFWorkbook取数完成后下一步是把DataTable写成一个Sheet。这里用NPOI作为写入工具输出xlsx格式。using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; public static void WriteDataTable(ISheet sheet, DataTable dt) { var headerRow sheet.CreateRow(0); for (int i 0; i dt.Columns.Count; i) { headerRow.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName); } for (int r 0; r dt.Rows.Count; r) { var row sheet.CreateRow(r 1); for (int c 0; c dt.Columns.Count; c) { var raw dt.Rows[r][c]; if (raw null || raw DBNull.Value) continue; var cell row.CreateCell(c); if (raw is int || raw is long || raw is float || raw is double || raw is decimal) { cell.SetCellValue(Convert.ToDouble(raw)); } else { cell.SetCellValue(raw.ToString()); } } } }这里的关键是类型判断。Excel脚本里如果全是文本用户打开后没法直接求和、过滤、分组所以基础数值类型提前转成doubleURL和字符串保持文本。DateTime也暂时转字符串下一章再引入真正的日期格式。主导出方法把多个控件按Sheet分包一次写出完整文件public static void Export(ListExportSource sources, string filePath) { using var fs new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb new XSSFWorkbook(); foreach (var source in sources) { var dt ControlToDataTable(source.Control); var sheet wb.CreateSheet(source.SheetName); WriteDataTable(sheet, dt); dt.Dispose(); } wb.Write(fs); }ExportSource是一个简单的绑定对象后面章节会给出完整定义。这里用using var wb导出结束后自动释放。值得注意的坑是wb.Write(fs)必须放在所有Sheet写完以后调用放在循环里会导致Excel文件只保留最后一个Sheet的内容甚至整个文件损坏。3. 把控件数据映射成Excel样式列头、宽度、统计行与类型3.1 自动生成报表列头与列宽GridControl的GridColumn有两个重要属性FieldName是数据源字段名Caption是界面列头显示文字。导出时应当用Caption作为Excel表头否则用户看到FieldName会很难读懂。可以在取数阶段把列头和字段名分开保存DataTable本身不关心展示名所以用一个小结构传递更稳妥public class ExportColumn { public string FieldName { get; set; } public string HeaderText { get; set; } }在GridToDataTable里把列头信息装进这个列表写入时再映射。树形控件同理TreeListColumn也有Caption可以直接用。列宽不能直接依赖NPOI的sheet.AutoSizeColumn。它在中文场景下估算偏大而且数据行多时会卡住界面。常见做法是扫描前200行按字符串长度估算宽度private static void SetAutoColumnWidth(ISheet sheet, DataTable dt) { for (int c 0; c dt.Columns.Count; c) { int maxWidth dt.Columns[c].ColumnName.Length * 2; for (int r 0; r Math.Min(dt.Rows.Count, 200); r) { var val dt.Rows[r][c]; if (val is string s) { int len System.Text.Encoding.Default.GetByteCount(s); maxWidth Math.Max(maxWidth, len 2); } } int width Math.Min(maxWidth * 256, 80 * 256); sheet.SetColumnWidth(c, width); } }参数说明Encoding.Default.GetByteCount(s)可以粗略把中文按双字节计算英文按单字节计算比简单用s.Length更接近实际像素。最后用80字符做上限避免某一列非常长时把整个Excel视图撑爆。NPOI中列宽单位是1/256字符宽度所以maxWidth * 256是通用换算方式。3.2 统计行与合计行改用Excel公式而不是拼字符串很多报表导出需求要求在末尾有合计行。有些项目会把“总计1000元”直接写成字符串塞进单元格这样Excel里没法继续运算用户拿到后还得手工改。正确做法是写Excel公式让Excel自己计算。private static void AppendSummaryRow(ISheet sheet, int startRow, int endRow, int columnIndex, string summaryType) { string colLetter new NPOI.SS.Util.CellReference(0, columnIndex).FormatAsString(); string range colLetter.Replace($, ) (startRow 1) : colLetter.Replace($, ) (endRow 1); string formula summaryType switch { Sum $SUM({range}), Avg $AVERAGE({range}), Count $COUNT({range}), _ $SUM({range}) }; var row sheet.CreateRow(endRow 2); var cell row.CreateCell(columnIndex); cell.SetCellFormula(formula); }参数说明colLetter.Replace($, )是为了从NPOI的引用对象里去掉绝对引用符号得到干净的列号。startRow 1是因为Sheet里第一行是表头数据从第二行开始。endRow 2表示合计行与数据末尾空一行视觉上更清晰。注意如果数据行为空直接写公式会让Excel显示错误值所以导出前要先判断dt.Rows.Count 0。如果要从Dev控件自带的SummaryItem中拿到列汇总类型可以这样映射foreach (GridColumn col in view.VisibleColumns) { if (col.SummaryItem null) continue; string type col.SummaryItem.SummaryType.ToString(); AppendSummaryRow(sheet, 1, sheet.LastRowNum, i, type); }这个方法比直接读取SummaryItem.DisplayFormat更可靠因为DisplayFormat里包含“#,##0.00”这样的格式串直接拼接会变成奇怪的文本。3.3 单元格格式日期、数字、百分比NPOI中每个单元格格式都依赖一个ICellStyle而ICellStyle和当前Workbook是绑定的。最稳的写法是用一个静态样式提供类按Workbook实例做缓存public static class ExcelStyleProvider { private static readonly Dictionarystring, ICellStyle Styles new(); public static ICellStyle GetStyle(IWorkbook wb, string format) { if (!Styles.TryGetValue(format, out var style)) { style wb.CreateCellStyle(); style.DataFormat wb.CreateDataFormat().GetFormat(format); Styles[format] style; } return style; } public static void Clear() { Styles.Clear(); } }注意这个字典只缓存format字符串如果同时打开多个Workbook不同Workbook的Style不能混用。所以导出一开始要调用ExcelStyleProvider.Clear()或者把字典换成DictionaryWorkbook, Dictionarystring, ICellStyle这里为了简洁约定每次导出前清空。写入单元格时按实际类型分配格式private static void WriteCellValue(ICell cell, object value, IWorkbook wb) { if (value null || value DBNull.Value) return; if (value is DateTime dt) { cell.SetCellValue(dt); cell.CellStyle ExcelStyleProvider.GetStyle(wb, yyyy-MM-dd HH:mm:ss); } else if (value is bool b) { cell.SetCellValue(b); } else if (value is int || value is long || value is float || value is double || value is decimal) { cell.SetCellValue(Convert.ToDouble(value)); cell.CellStyle ExcelStyleProvider.GetStyle(wb, #,##0.00); } else { cell.SetCellValue(value.ToString()); } }这里有个容易踩的细节bool不能放进IConvertible分支否则它会被Convert.ToDouble(true)转成1Excel里会出现数字1而不是TRUE/FALSE。字符串也不能走数值分支因为“0001”这种文本会被转成数值1丢失前导零。百分比格式可以复用同一个Provider用0.00%作为format。日期格式用yyyy-MM-dd HH:mm:ss这样时间戳导出后还能在Excel里做时间过滤。4. 多控件分工作薄的调度Sheet命名、顺序与内存控制4.1 用ExportSource对象管理每个Sheet的导出参数前文用到的ExportSource是承载导出参数的核心对象把它定义得完整一点可以避免后期在主方法里堆一大堆可选参数public class ExportSource { public Control Control { get; set; } public string SheetName { get; set; } public bool IncludeSummary { get; set; } public bool AutoColumnWidth { get; set; } true; public DataTable OverrideData { get; set; } public ExportSource(Control control, string sheetName) { Control control; SheetName sheetName; } }调用时按界面顺序加入列表导出方法按列表顺序创建Sheet这样Excel里的Sheet顺序就能和界面呼应。如果不希望某个控件参与导出直接在列表里不放进Control而不是设置隐藏标记逻辑更直观。主导出方法升级为接受ListExportSourcepublic static void Export(ListExportSource sources, string filePath) { using var fs new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb new XSSFWorkbook(); foreach (var src in sources) { var dt src.OverrideData ?? ControlToDataTable(src.Control); var sheet wb.CreateSheet(ValidateSheetName(src.SheetName)); WriteDataTable(sheet, dt); if (src.IncludeSummary src.Control is GridControl grid) { AppendSummaryFromGrid(sheet, grid, dt); } if (src.AutoColumnWidth) { SetAutoColumnWidth(sheet, dt); } dt.Dispose(); } wb.Write(fs); }OverrideData是一个逃逸口如果某个报表页不需要真从界面取数而是后端直接算好了一个DataTable就可以把它塞进这个字段绕过控件取数逻辑。这在做权限过滤、行级权限时特别有用。4.2 大数据量分批写入与内存控制WinForm里一次导出的行数常常能到十几万行如果先把所有控件的DataTable都塞进内存再写文件内存很快就爆了。经验规则是一个控件取完数写入Sheet后立刻释放DataTable不要等所有控件都转换完再写。上面的Export方法已经做到了这点。但要进一步控制内存还需要注意NPOI自身的加载方式。XSSFWorkbook在写入过程中会把每个Sheet的XML结构放在内存里如果一次性创建大量Sheet即使数据量不大内存也会涨。如果源头是10万行以上建议做分页读取每次从控件取一页数据写入一个临时Sheet最后再合并。不过大多数WinForm内部报表场景不会超过Excel的单表上限。这里提供一个更稳的写法在写入数据时尽量避开sheet.AutoSizeColumn因为它会遍历所有行做计算。前文已经用前200行的估算替代了它。另一个技巧是给文件流设置缓冲区并用异步方式写入减少UI线程卡顿感using var fs new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 8192, useAsync: true);4.3 多临时文件合并的替代方案有些项目已经用控件自带导出方法生成了多个单Sheet的xlsx文件不太想在取数层做大改动。这时可以用NPOI把多个临时文件合并成一个工作薄。public static void MergeExcelFiles(string[] filePaths, string outputPath) { using var targetFs new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite); using var targetWb new XSSFWorkbook(); foreach (var path in filePaths) { using var srcFs new FileStream(path, FileMode.Open, FileAccess.Read); using var srcWb new XSSFWorkbook(srcFs); foreach (ISheet srcSheet in srcWb) { ISheet targetSheet targetWb.CreateSheet(srcSheet.SheetName); for (int r 0; r srcSheet.LastRowNum; r) { var srcRow srcSheet.GetRow(r); if (srcRow null) continue; var targetRow targetSheet.CreateRow(r); for (int c 0; c srcRow.LastCellNum; c) { var srcCell srcRow.GetCell(c); if (srcCell null) continue; targetRow.CreateCell(c).SetCellValue(srcCell.ToString()); } } } } targetWb.Write(targetFs); }这个方法只搬了单元格值样式、列宽、合并单元格全部丢失。所以它只适合临时拼接不适合作为正式的通用导出方案。如果已有的控件导出方法本身带着样式且这些样式必须保留建议走OpOpenXML的底层文档复制而不是NPOI的逐格复制。这也是为什么前文反复强调绕开自带导出、统一走DataTable的原因——合并的坑比取数多得多。5. 常见问题与排查五个导出场景下的翻车点5.1 文件扩展名无效Excel打不开现象导出完成后双击文件提示“Excel无法打开文件因为文件格式或文件扩展名无效”。原因常见有两种。一种是用xls的扩展名保存XSSFWorkbook输出的内容NPOI的XSSFWorkbook写的是OpenXML格式必须用.xlsx后缀另一种是导出文件还在被上一个FileStream占用用户立即双击打开Excel认为文件不完整。解决导出方法开头先用扩展名约束格式if (!filePath.EndsWith(.xlsx, StringComparison.OrdinalIgnoreCase)) { filePath .xlsx; }同时在写入前检查目标文件是否被占用try { using var probe new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException) { // 文件被Excel或其他进程占用提示用户关闭后重试 }确保整个Export方法结束后再关闭FileStream外部不要提前释放流。导出完成后用File.GetLastWriteTime再次确认文件大小大于0也是一个低成本的自检手段。5.2 Sheet名称非法导致导出中断现象导出到第3个Sheet时抛异常提示Invalid sheet name前面的Sheet都没写入文件。原因用户给的Sheet名里包含\ / ? * [ ] :中的任意一个NPOI都不允许。超过31个字符同样会报错。解决创建Sheet之前做清洗private static string ValidateSheetName(string name) { if (string.IsNullOrWhiteSpace(name)) name Sheet Guid.NewGuid().ToString(N).Substring(0, 6); foreach (var ch in new[] { \\, /, ?, *, [, ], : }) { name name.Replace(ch, _); } if (name.Length 31) { name name.Substring(0, 31); } return name; }这个清洗函数要在所有Sheet创建处统一调用不要只在某个控件上临时判断。否则同一个方法在不同入口被调用时行为不一致。顺便注意Sheet名也不能为空字符串如果业务侧传了空值用随机占位名兜底。5.3 导出后只有最后一个Sheet有数据现象多控件导出的xlsx打开后每个Sheet都有但前面几个Sheet是空白的只剩最后一个Sheet有数据。原因写代码时把wb.Write(fs)放在了控件循环里。第一次循环结束就写了一次文件流第二次循环虽然又往Workbook里加了新Sheet但文件流已经定位到末尾或者被覆写导致前面Sheet的内容被截断。另一个可能原因是循环里反复创建XSSFWorkbook每个临时Workbook只写了一个Sheet最后只保存了最后一个对象。解决核心是只在一个地方写文件也就是所有Sheet都写完后再wb.Write(fs)。排查时先看导出方法里wb.Write的调用次数大于1就是问题。重构后的标准写法在4.1已经给出如果目录结构复杂可以在循环里使用局部方法BuildSheet但绝对不能在局部方法里写文件。5.4 统计行列号错位公式引用了空行现象合计行出现SUM(1:1)或A2:A9但A9是表头或者统计行的列顺序和表头错位。原因很多人在统计的时候用sheet.LastRowNum作为最后一行但如果前面有空行或隐藏行这个值就不准确。另一个原因是对应列的时候用i遍历DataTable列而GridView的VisibleColumns顺序与DataTable列顺序不完全一致特别是有列被隐藏时。解决在调用统计行之前先确定数据行的真实区间int startDataRow 1; int endDataRow sheet.LastRowNum;同时在生成DataTable时记录一个Listint映射列索引而不是直接按GridView列顺序写。最稳妥的排查方式是先导出一个小数据量的样例用OpenXML工具或Excel逐个看公式引用的行列和表头对照一次。统计列的编号最好用函数从DataTable.Columns中反查不要依赖外部传入的GridView列序号。5.5 中文列头变成乱码或显示为问号现象导出后打开xlsx中文表头显示成????或乱码但数据内容正常。原因NPOI写xlsx时内部使用UTF-8一般不会乱码。出现乱码多半是用户先导出成了CSV然后用Excel双击打开CSV而CSV没有BOM导致中文识别错误。如果确认是xlsx格式出错更多是因为在取数阶段把字符串做了Encoding.Default.GetBytes再转回new string编码被转换了两次。解决取数阶段不要做任何编码转换字符串原样放入DataTable。如果是CSV场景用UTF-8带BOM写入using var writer new StreamWriter(filePath, false, new System.Text.UTF8Encoding(true));另外列头优先取Caption而不是FieldName。如果FieldName本身是拼音或驼峰英文用户看起来就像乱码虽然不是真正的编码问题。6. 进阶技巧把导出改成可取消的异步任务并留下完整日志导出不是每次都能一次成功尤其是在数据量大、控件多的时候。常见的做法是做成异步任务用进度条显示“正在导出第几个Sheet”同时允许用户在卡住时取消。下面这个扩展方法可以直接接在通用导出类上public static async Task ExportAsync( ListExportSource sources, string filePath, IProgressstring progress, CancellationToken ct) { await Task.Run(() { using var fs new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb new XSSFWorkbook(); for (int i 0; i sources.Count; i) { ct.ThrowIfCancellationRequested(); var src sources[i]; progress?.Report($正在导出 {src.SheetName} ({i 1}/{sources.Count})); var dt src.OverrideData ?? ControlToDataTable(src.Control); var sheet wb.CreateSheet(ValidateSheetName(src.SheetName)); WriteDataTable(sheet, dt); if (src.IncludeSummary src.Control is GridControl grid) { AppendSummaryFromGrid(sheet, grid, dt); } if (src.AutoColumnWidth) { SetAutoColumnWidth(sheet, dt); } dt.Dispose(); } wb.Write(fs); }, ct); }在WinForm里调用var progress new Progressstring(msg statusLabel.Text msg); var cts new CancellationTokenSource(); try { await ExportAsync(sources, 报表.xlsx, progress, cts.Token); } catch (OperationCanceledException) { MessageBox.Show(导出已取消); } catch (Exception ex) { File.WriteAllText(export_log.txt, ${DateTime.Now}: {ex}); MessageBox.Show(导出失败已写入日志); }日志里至少要记录异常类型、堆栈和当前导出到的Sheet名。更细一点可以在每个Sheet写入成功后追加一行日志progress?.Report(${src.SheetName} 完成共 {dt.Rows.Count} 行);这种写法比一个大事务维护起来舒服得多。取消功能的关键是把CancellationToken传入Task.Run这样用户点取消后循环里的ThrowIfCancellationRequested能立即抛异常文件流会在using作用域里正常关闭不会留下半个文件。从那以后我每次设计导出功能都会先问一句这个界面会不会有多个Sheet如果有就直接套用这套通用类而不是先写一个单控件版本再去改造成多Sheet版本。希望帮到你。本文还有配套的精品资源点击获取