☰
DevExpress多控件分Sheet导出单个Excel工作簿实战
2026/10/28 7:38:06 网站建设 项目流程

简介:面向 DevExpress WinForm 开发者的通用控件导出 Excel 方案,聚焦 GridControl 自带导出无法保留图片、多表头,以及 PivotGridControl 导出时自动分组等常见痛点,力求实现所见即所得的打印与导出效果。方案支持多个控件一次性导出到同一 Excel,也可按控件分别放入不同工作薄,适合需要批量报表输出、复杂表头还原和图片保留的中高级桌面应用开发场景。压缩包共 179 个文件、约 35.09MB,包含 110 个 dll 依赖、23 个 xml 配置说明、10 个 cs 源码以及 exe、sln、config 等工程与运行文件,便于直接编译调试和对照改造。已有 1023 人学习下载,可作为通用导出模块的参考实现,帮助读者快速理解多控件分工作薄、图片与多表头导出的处理思路,并在此基础上接入自有项目。

1. 一个文件,多个 Sheet:这个导出器要解决的真实麻烦

需求方发来的话很简短:“导出的 Excel 别一个控件一个文件,我要一个工作簿里,订单明细一个 Sheet、组织架构一个 Sheet、趋势图一个 Sheet,顺序跟界面上一致。”听着像是调个参数就能完成的事,结果在 WinForm 里的 DevExpress 控件上反复出问题:GridControl.ExportToXlsx一次只能输出一个工作簿,TreeList走的是另一套重载,图表控件根本不输出单元格,最后只能人工拼装。

这份资源提供的,就是一套在 WinForm 项目里通用、可复用的“多控件分工作表导出”方案:把任意几个可导出控件按顺序收集起来,每个控件对应一个 Sheet,一次写出单个.xlsx。它适合正在用 DevExpress 做后台管理或报表界面、被“一个文件多个 Sheet”的需求反复困扰的中高级 WinForm 开发者。下面按我拆解这个项目的顺序讲:先讲清楚为什么不能用一行命令解决,再落到适配器和工作簿合并的核心代码,最后讲怎么把它改成能经得起实际使用检验的样子。

2. 架构与选型:为什么不能一行 ExportToXlsx 收工

先把结论放在前面:这个项目的骨架很简单,难的是边界条件。核心只有四层:控件适配层负责把各种控件统一成“能吐出一个单 Sheet 的 xlsx 流”;调度层负责按顺序处理;合并层负责把这些流拼进一个工作簿;命名层负责处理 Excel 那些不近人情的限制。四层拆开之后,每个适配器只有几十行代码,合并器只需要写一次。

2.1 三条技术路线的实际对比

在动手之前,我把能走的路线试了一遍,结论如下:

路线做法优点问题
A. 打印系统合并多个PrintableComponentLink挂到同一个PrintingSystem,再导出 xlsx不依赖任何第三方库Sheet 边界和顺序不好控,一个 link 分页后可能跨多页,表头样式统一靠猜
B. 分流后合并每个控件各自导出单 Sheet xlsx 到MemoryStream,再用 EPPlus/NPOI 拼装每个 Sheet 的内容、样式、名称、顺序完全可控多一个依赖,合并逻辑要自己写,图片类内容会丢
C. 手写单元格自己遍历行列往IXlSheet或 EPPlus 里写最灵活,能加公式、合并、条件格式等于重写一遍控件的渲染,列宽、换行、分组全得自己算

我选 B。原因很实际:需求方要的是“跟界面长得一样”,而不是“把数据倒出来”,WYSIWYG 的渲染活只有控件自己干得最像,我只需要保证拼装环节不出错。A 路线看着最省事,实际上是我最先砍掉的方案:一个PrintableComponentLink生成文档后,页与页之间的 Sheet 归属要靠导出选项统一指定,多个控件混在一起时表名基本只能听天由命,这是这次需求里最不能妥协的地方。

C 路线不是没有价值,它适合“第三方控件根本没有导出方法”的场景,比如某些自绘控件、某些只提供Print()的老控件。所以我在架构里给它留了位置:适配器接口的统一出口是一个流,你愿意用控件原生导出也行,愿意自己写单元格也行,上层不关心。

2.2 分层结构与职责边界

把项目拆成三个挨着但不耦合的部分,是我改这类工具代码的一贯习惯:

  • 适配层:ISheetExporter,每个控件类型一个实现。只干一件事:把控件渲染成一个单 Sheet 的 xlsx 写进流里。
  • 调度层:ExcelExportService,接收一串ExportItem,按Order排序,挑适配器、管流的生命周期、处理异常隔离。
  • 合并层:WorkbookMerger,接收若干个“流 + 目标表名”,输出一个完整工作簿。

这三层之间只用两个数据结构通信:进调度层的是ExportItem,进合并层的是SheetPayload。中间没有任何 DevExpress 类型泄漏出去,这意味着合并层可以单独写单元测试,不需要真的把窗体跑起来。做 WinForm 项目的老手都知道,能脱离窗体测的逻辑,价值翻三倍。

2.3 目录结构与依赖清单

真实项目里我一般这样摆:

ExcelExport/ ├─ Abstractions/ │ ├─ ISheetExporter.cs // 控件适配器接口 │ ├─ ExportItem.cs // 调用方传进来的“控件 + 表名” │ └─ ExcelExportOptions.cs // 导出参数 ├─ Exporters/ │ ├─ GridSheetExporter.cs // GridControl 适配器 │ ├─ TreeListSheetExporter.cs // TreeList 适配器 │ ├─ PivotSheetExporter.cs // PivotGrid 适配器 │ └─ ImageSheetExporter.cs // 图表/富文本,兜底走图片 ├─ Merging/ │ ├─ WorkbookMerger.cs // 核心:多流合一个工作簿 │ └─ SheetNameHelper.cs // 表名清洗与去重 ├─ ExcelExportService.cs // 调度入口 └─ ExcelExportExtensions.cs // 给调用方的扩展方法糖

依赖只有两项:DevExpress WinForms 本身(DevExpress.XtraGrid、DevExpress.XtraTreeList、DevExpress.XtraPivotGrid,按你实际用到的引),以及一个 xlsx 读写库。我用的 EPPlus,理由是它把 Worksheet 当对象操作,拷贝单元格、样式、列宽比 NPOI 顺手。

提示:EPPlus 5 之后是 Polyform 非商业授权,商业项目要么买授权,要么换 ClosedXML。这类库的授权问题必须在动手之前确认,不能等上线前才发现。

3. 核心实现:从控件到工作簿的完整链路

这一章是整份资源里最值得逐行看的部分。链路是单向的:界面上的控件 → 适配器渲染成流 → 合并器拼装 → 落盘。中途每一环都可能出问题,所以每一环我都留了退路。

3.1 统一抽象:ExportItem 与 ISheetExporter

调用方只跟ExportItem打交道,它刻意把Control属性声明成object,目的是让这一层不引用任何具体控件类型,后续加新控件不用动调用方代码。

// ExportItem.cs —— 调用方唯一需要构造的东西 public sealed class ExportItem { public object Control { get; set; } // 任意控件,运行时按类型匹配适配器 public string SheetName { get; set; } // 期望表名,交给 SheetNameHelper 清洗 public int Order { get; set; } // 排序号,保证 Sheet 顺序跟界面一致 public bool Hidden { get; set; } // 导出后是否隐藏该 Sheet } // ISheetExporter.cs —— 适配器接口,出口永远是“单 Sheet 的 xlsx 流” public interface ISheetExporter { bool CanHandle(object control); // 类型判定 void Export(object control, Stream output, ExcelExportOptions options); }

CanHandle用类型判断而不是字符串约定,好处是调度层可以无脑遍历适配器列表,第一个匹配上的就用。代价是适配器顺序有讲究:GridControl和TreeList没有继承关系,但如果你的项目里对GridControl做过二次封装(比如MyGrid : GridControl),子类适配器必须排在基类前面,否则永远走不到。这个坑我在两个项目里都踩过。

Export的第二个参数是Stream而不是文件路径,这是整个设计的关键决策:适配器不碰磁盘,只往内存流里写,所以才能在不落地临时文件的前提下拼装多表。代价是内存占用,这一点在第 5 章单独说。

3.2 表格类控件适配器:参数才是真正的技术活

GridControl的导出方法有一堆重载,最容易犯的错是直接调无参版本,导出来一堆看不见的列、被合并的单元格、全是文本的数字。我常用的参数组合是这样的:

public class GridSheetExporter : ISheetExporter { public bool CanHandle(object control) => control is GridControl; public void Export(object control, Stream output, ExcelExportOptions options) { var grid = (GridControl)control; var opt = new XlsxExportOptionsEx { // WYSIWYG:按界面渲染,保留列宽、字体、背景色;适合给业务方看的报表 ExportType = ExportType.WYSIWYG, // Text:编码类字段(订单号 001)不会变成 1;代价是数字列也变文本 TextExportMode = TextExportMode.Text, // 不导出底部汇总行,避免业务方在 Excel 里手动删 ShowTotalSummary = false, // 单文件 + 单页布局,保证一个控件只产出一个 Sheet ExportMode = XlsxExportMode.SingleFile, LayoutMode = LayoutMode.SinglePage, // 占位名,合并阶段会被 SheetNameHelper 覆盖 SheetName = "Sheet1" }; // 界面上被用户隐藏的列,默认不导出;要导出就打开这个开关 if (!options.IncludeHiddenColumns) opt.HiddenColumnIndicator = HiddenColumnIndicatorMode.Suppress; grid.ExportToXlsx(output, opt); output.Position = 0; // 别忘了复位,合并器要从 0 开始读 } }

几个参数值得单独说明。ExportType.WYSIWYG和DataAware的差别不只是好看:前者会严格按屏幕列宽渲染,列宽很窄的列在 Excel 里会被截断成####;后者按数据类型排布,数字能参与求和,但配色、行高全丢。我在“界面截图式报表”和“给财务算数的明细”这两个场景下用过不同取值,判断标准是:对方拿到文件之后会不会在里面按列求和?会,就用 DataAware。

TextExportMode.Text也是个取舍。用Value的时候,前导零的工号、身份证号、带短横的编号会被 Excel 自作主张转换,这类 bug 排查起来纯粹浪费时间,所以默认走 Text。如果某张表明确是数值报表,我就在ExcelExportOptions上加一个ForceNumeric开关单独放行。

TreeList的适配器几乎是同一份代码,只是类型换成TreeList、导出方法换成ExportToXlsx。真正需要注意的是PivotGrid:它导出的是交叉表结构,行区列区合并了一堆单元格,合并器拷贝时必须处理MergedCells,否则拼出来的表看起来像被删除线划过。这一点在 3.3 里处理。

至于ChartControl、RichEditControl这类不以单元格为单位的控件,我走的是ImageSheetExporter兜底:把控件DrawToBitmap成图片,插进 Sheet 的 A1 位置并缩放适配。图表进了 Excel 就是张图,不能改数据源,这点必须提前跟需求方说清楚,否则交付时又是一轮扯皮。

3.3 工作簿合并器:把 N 个流拼成 1 个文件

这是整份代码里唯一有技术含量的部分。EPPlus 没有提供“跨包复制工作表”的 API,得自己一个个单元格搬。

public class WorkbookMerger { public void Merge(IList<SheetPayload> payloads, Stream finalOutput) { using (var target = new ExcelPackage()) { foreach (var payload in payloads) { payload.Stream.Position = 0; using (var source = new ExcelPackage(payload.Stream)) { var src = source.Workbook.Worksheets[1]; if (src?.Dimension == null) continue; // 空表直接跳过,别生成空白 Sheet var dst = target.Workbook.Worksheets.Add(payload.SheetName); CopySheet(src, dst); if (payload.Hidden) dst.Hidden = eWorkbookWorksheetHidden.Hidden; } } if (target.Workbook.Worksheets.Count == 0) throw new InvalidOperationException("所有控件都没有可导出的数据"); target.SaveAs(finalOutput); } } private static void CopySheet(ExcelWorksheet src, ExcelWorksheet dst) { var dim = src.Dimension; // 列宽先搬,否则后面设置样式时会被默认宽度覆盖视觉预期 for (int c = dim.Start.Column; c <= dim.End.Column; c++) dst.Column(c - dim.Start.Column + 1).Width = src.Column(c).Width; for (int r = dim.Start.Row; r <= dim.End.Row; r++) { for (int c = dim.Start.Column; c <= dim.End.Column; c++) { var s = src.Cells[r, c]; var d = dst.Cells[r - dim.Start.Row + 1, c - dim.Start.Column + 1]; // 公式必须优先赋值,先赋 Value 会把公式位置占掉 if (!string.IsNullOrEmpty(s.Formula)) d.Formula = s.Formula; else d.Value = s.Value; CopyStyle(s, d); } } // 交叉表、分组表合并过单元格,不搬会出现“看起来错位”的表 foreach (var addr in src.MergedCells) dst.Cells[addr].Merge = true; } private static void CopyStyle(ExcelRangeBase s, ExcelRangeBase d) { d.Style.Font.Name = s.Style.Font.Name; d.Style.Font.Size = s.Style.Font.Size; d.Style.Font.Bold = s.Style.Font.Bold; d.Style.Font.Color.SetColor(s.Style.Font.Color); d.Style.HorizontalAlignment = s.Style.HorizontalAlignment; d.Style.VerticalAlignment = s.Style.VerticalAlignment; d.Style.WrapText = s.Style.WrapText; d.Style.Numberformat.Format = s.Style.Numberformat.Format; d.Style.Fill.PatternType = s.Style.Fill.PatternType; if (s.Style.Fill.PatternType == ExcelFillStyle.Solid) d.Style.Fill.BackgroundColor.SetColor(s.Style.Fill.BackgroundColor); d.Style.Border.Top.Style = s.Style.Border.Top.Style; d.Style.Border.Bottom.Style = s.Style.Border.Bottom.Style; d.Style.Border.Left.Style = s.Style.Border.Left.Style; d.Style.Border.Right.Style = s.Style.Border.Right.Style; } }

几个逻辑点必须解释清楚。

为什么用src.Workbook.Worksheets[1]而不是按名字取?因为适配器里的SheetName是占位值,DevExpress 有可能改写它(比如遇到非法字符自动替换)。取第一个 Sheet 最稳,反正一个控件只产出一个 Sheet。

为什么Dimension == null要continue?控件上没有任何数据行时,导出的 xlsx 里可能一个单元格都没写,EPPlus 解析出来Dimension就是null,这时候去读dim.Start.Row会直接空引用。第一次遇到这个异常时我还以为是流没复位,查了半小时才定位到。

为什么样式要一个个手工搬?EPPlus 内部的StyleID是包内索引,跨工作簿直接赋StyleID会串到别的样式上,表现是整张表颜色乱掉。手搬属性啰嗦但可靠,把CopyStyle抽出来之后也只写一次。

性能提醒:双层循环是行 × 列,一万行 × 三十列就是三十万次属性赋值。实测这个量级大概 3 到 5 秒,还能接受;十万行就要考虑先src.Cells[dim.Address].Value一次性取二维数组批量赋值,再单独处理样式行。第六章会给出这个改法的方向。

3.4 表名清洗:Excel 的限制可不少

需求方发来的表名是“2024年第一季度-华东区/直营网点销售明细(含退货)”,这里面同时踩了三个雷:超过 31 字符、带斜杠、带全角括号。Excel 的 Sheet 名规则是硬限制,直接传进去必然抛异常,而且抛的位置很靠后(SaveAs那一步),错误信息还看不出是表名的问题。

public static class SheetNameHelper { // Excel 禁止出现在 Sheet 名里的字符 private static readonly char[] Invalid = { ':', '\\', '/', '?', '*', '[', ']' }; public static string MakeUnique(string raw, HashSet<string> used) { var name = string.IsNullOrWhiteSpace(raw) ? "Sheet" : raw.Trim(); foreach (var ch in Invalid) name = name.Replace(ch, '_'); if (name.StartsWith("'") || name.EndsWith("'")) name = name.Trim('\''); // Sheet 名不能以单引号开头结尾 if (name.Length > 31) name = name.Substring(0, 31); // 硬限制,31 个字符 var candidate = name; var index = 1; while (!used.Add(candidate)) // Add 返回 false 说明重名 { var suffix = "_" + index++; candidate = name.Length + suffix.Length > 31 ? name.Substring(0, 31 - suffix.Length) + suffix : name + suffix; } return candidate; } }

used这个HashSet<string>要用StringComparer.OrdinalIgnoreCase构造,因为 Excel 判断 Sheet 重名是忽略大小写的,“Detail” 和 “detail” 会被认为是同一个。这个细节不写,用户导出两个名字只差大小写的控件时就会在保存阶段报错。

还有一个更隐蔽的问题:History这个表名在 Excel 里是保留的(旧版跟踪修订用),有些版本会拒绝创建同名工作表。真要稳妥,可以在Invalid之外再维护一个保留名黑名单。

4. 跑起来:从窗体到可交付的导出按钮

代码原理混熟了,接下来就关心怎么接进真实项目。这一章给的是可以直接照抄的最小可运行版本,包括窗体代码、参数表和进度反馈。

4.1 调度层与最小可运行示例

先把调度层补完,它是调用方唯一需要 new 的东西。

public class ExcelExportService { private readonly List<ISheetExporter> _exporters; private readonly WorkbookMerger _merger = new WorkbookMerger(); public ExcelExportService() { // 顺序有讲究:自定义子类适配器必须排在基类前面 _exporters = new List<ISheetExporter> { new GridSheetExporter(), new TreeListSheetExporter(), new PivotSheetExporter(), new ImageSheetExporter() // 兜底,放在最后 }; } public void Export(IEnumerable<ExportItem> items, string filePath, ExcelExportOptions options) { var payloads = new List<SheetPayload>(); var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); try { foreach (var item in items.OrderBy(i => i.Order)) { var exporter = _exporters.FirstOrDefault(e => e.CanHandle(item.Control)); if (exporter == null) continue; // 不认识的控件跳过,不打断整批导出 var buffer = new MemoryStream(); exporter.Export(item.Control, buffer, options); buffer.Position = 0; payloads.Add(new SheetPayload { Stream = buffer, SheetName = SheetNameHelper.MakeUnique(item.SheetName, usedNames), Hidden = item.Hidden }); } if (payloads.Count == 0) throw new InvalidOperationException("没有可导出的控件,检查一下适配器有没有注册"); using (var fs = File.Create(filePath)) _merger.Merge(payloads, fs); } finally { // 这条 finally 是保命的,少了它大数据量下内存直接翻倍 foreach (var p in payloads) p.Stream.Dispose(); } } }

窗体这一侧就三行有效代码:

private void btnExport_Click(object sender, EventArgs e) { var items = new[] { new ExportItem { Control = gridControl1, SheetName = "订单明细", Order = 1 }, new ExportItem { Control = treeList1, SheetName = "组织架构", Order = 2 }, new ExportItem { Control = chartControl1, SheetName = "趋势图", Order = 3 } }; using (var dlg = new SaveFileDialog { Filter = "Excel 工作簿|*.xlsx", FileName = "导出结果.xlsx" }) { if (dlg.ShowDialog() != DialogResult.OK) return; try { new ExcelExportService().Export(items, dlg.FileName, new ExcelExportOptions()); } catch (IOException ex) { // 用户开着同名 Excel 文件是最常见的失败原因 MessageBox.Show("文件正在被占用,请先关闭同名 Excel 后重试。\r\n" + ex.Message); } } }

SaveFileDialog之后那段try/catch不是走过场。File.Create在目标文件被 Excel 打开时会抛IOException,如果不去捕获,用户看到的就是一个英文异常弹窗,转头就来报 bug。

4.2 导出参数一览

ExcelExportOptions我做成了一个纯数据对象,方便从配置文件或界面上的勾选项填充:

参数默认值作用与取值建议
IncludeHiddenColumnsfalse界面上被隐藏的列是否导出。对外报表建议false,内部数据交换建议true
ForceNumericfalse是否强制走TextExportMode.Value,需要 Excel 内求和的报表打开
MaxRowsPerSheet1000000单表上限,超了就拆 Sheet,Excel 硬上限是 1048576 行
ScaleImageToCelltrue图表/富文本转图片时是否缩放贴合单元格,false保留原始像素
HiddenSheetModeVisible隐藏 Sheet 的处理方式,Visible/Hidden/Skip(直接不导出)
CultureName当前线程区域影响日期和小数的字符串化格式,跨境项目必设

MaxRowsPerSheet这个参数看着像凑数,实际很有用。GridControl里挂了十几万行的时候,一次导出成单个 Sheet,业务方打开文件大概率会感觉像卡死,而且 Excel 到 104 万行会直接拒绝写入。我一般在调度层做一次预检,超过阈值就按业务主键分段,表名后缀_1、_2,比让用户对着一个打不开的文件发呆强。

4.3 大数据量与进度反馈

导出放在 UI 线程里跑,几千行还行,上万行就能看到窗口变白。常见的处理是async/await加ProgressBar,但要注意 DevExpress 控件本身不能在非 UI 线程上访问,ExportToXlsx必须在 UI 线程发起。

我的做法是把“渲染”和“合并”拆到两个线程:渲染阶段切回 UI 线程逐个控件导出(每个控件之间await Task.Yield()让出消息循环),合并阶段完全脱离控件,扔到线程池跑。

public async Task ExportAsync(IList<ExportItem> items, string filePath, ExcelExportOptions options, IProgress<int> progress) { var payloads = new List<SheetPayload>(); var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); try { for (int i = 0; i < items.Count; i++) { // 渲染必须在 UI 线程,这里靠 await 回到调用方的同步上下文 var item = items[i]; var exporter = _exporters.FirstOrDefault(e => e.CanHandle(item.Control)); if (exporter != null) { var buffer = new MemoryStream(); exporter.Export(item.Control, buffer, options); buffer.Position = 0; payloads.Add(new SheetPayload { Stream = buffer, SheetName = SheetNameHelper.MakeUnique(item.SheetName, usedNames), Hidden = item.Hidden }); } progress?.Report((i + 1) * 100 / items.Count); await Task.Yield(); // 让出一次消息循环,进度条才动得起来 } // 合并阶段不碰控件,扔线程池 await Task.Run(() => { using (var fs = File.Create(filePath)) _merger.Merge(payloads, fs); }); } finally { foreach (var p in payloads) p.Stream.Dispose(); } }

await Task.Yield()这一行看起来没什么存在感,但少了它,一整套同步循环下来消息泵根本没机会处理重绘,进度条从头到尾停在 0%,用户以为程序卡死,实际一直在跑。这类“界面假死”问题,靠加Application.DoEvents()也能糊过去,但那玩意儿会把用户点击也放进来,导出过程中再点一次按钮就是两份任务并发写同一个文件,后果自己脑补。

5. 避坑与排查:五条踩过的坑

5.1 保存时抛异常,报错信息完全看不出跟表名有关

现象:点击导出后界面没有任何反应,或者弹出An exception occurred during saving the package之类的英文异常,堆栈全在第三方库里。

原因:Sheet 名带了:/?这类字符、超过 31 字符、或者跟已有表名只差大小写。这类错误在Worksheets.Add阶段不一定报,往往拖到SaveAs才爆。

解决:所有表名一律走SheetNameHelper.MakeUnique,不要相信调用方传进来的字符串干净。另外在异常捕获里加一层包装,把“第几个控件、原始表名、清洗后表名”打进日志,下次出问题三秒定位。

5.2 生成的 Excel 打开报“发现不可读取的内容”

现象:文件能生成,体积也正常,双击打开时 Excel 提示修复,修完发现某几个 Sheet 被清空。

原因:合并时先把Value赋给了单元格,后面又赋Formula,或者反过来。EPPlus 对这两种赋值有内部状态,重复操作会写出非法的sheet.xml。另一种可能是合并单元格的区域跟实际有值的单元格交叉冲突。

解决:公式和值严格二选一,用if (!string.IsNullOrEmpty(s.Formula))分支判断。合并单元格操作放在所有单元格赋值之后,别穿插在循环里。

5.3 图表和图片全部消失,Sheet 变成空白

现象:GridControl都好好的,只有图表那个 Sheet 打开是空的,或者只剩一个没有内容的矩形。

原因:EPPlus 的Drawings集合属于工作表级别,手工拷贝单元格不会带上图片对象;另外ImageSheetExporter生成的图片如果没有锚定到具体单元格,插入时就会跑到画布外面。

解决:图片类内容别指望“先导出再合并”,直接在合并阶段处理:适配器只把Bitmap塞进SheetPayload,合并器用dst.Drawings.AddPicture(...)并按ScaleImageToCell设置From.Row/Column和To.Row/Column。这样图片是“后贴上去”的,位置可控。

5.4 导出 5 万行之后进程内存飙到 2G

现象:小数据测试毫无问题,线上真实数据一跑,任务管理器里内存曲线一路往上,最后 OutOfMemory。

原因:每个控件的MemoryStream都完整持有一份 xlsx 字节,N 个控件就是 N 份;再加上ExcelPackage解析时会再展开一次,峰值是原始数据的好几倍。finally里漏掉Dispose的话,GC 回收不及时,现象更明显。

解决:三件事一起做——finally里统一Dispose;payloads里只存流不存字节数组;单次导出的控件数量超过 8 个时改成两阶段落盘(先写临时目录,合并完删掉)。另外GridControl的ExportToXlsx有分页导出模式,行数特别大时按页拆 Sheet 反而更省内存。

5.5 日期列全变成45231这种数字

现象:界面上显示2024-03-15,导出后 Excel 里是45366,字体还是右对齐。

原因:TextExportMode.Value下导出的是DateTime的 OADate 序列值,但单元格的Numberformat没有跟着设置成日期格式,Excel 就按数字展示。而CopyStyle里如果漏了Numberformat.Format这一行,合并阶段又会把原格式丢掉。

解决:CopyStyle里必须搬Numberformat.Format,这是最容易漏的一行;同时在适配器里显式指定日期列的格式字符串yyyy-mm-dd,别依赖控件的默认推断。

6. 二次开发:把它改成你自己项目里的样子

这套代码在我手里改过至少三个版本,每次都是同一个套路:先加适配器,再调CopyStyle,最后补测试数据。真要说有什么经验,就是别去改WorkbookMerger的核心循环,那是所有问题的集中地,改一处崩一片。要定制就往上抽象。

6.1 扩展一个新控件类型:两步

第一步,写一个适配器,实现CanHandle和Export。以某个自绘的甘特图控件为例,它没有ExportToXlsx,那就走图片:

public class GanttSheetExporter : ISheetExporter { public bool CanHandle(object control) => control is GanttChartControl; public void Export(object control, Stream output, ExcelExportOptions options) { var gantt = (GanttChartControl)control; // 自绘控件没有原生导出,先渲染成位图 using (var bmp = new Bitmap(gantt.Width, gantt.Height)) { gantt.DrawToBitmap(bmp, new Rectangle(0, 0, gantt.Width, gantt.Height)); using (var pkg = new ExcelPackage()) { var ws = pkg.Workbook.Worksheets.Add("Temp"); var pic = ws.Drawings.AddPicture("gantt", bmp); pic.SetPosition(1, 0, 1, 0); // 从 B2 开始放 if (options.ScaleImageToCell) pic.SetSize(100); // 百分比缩放,避免超宽 pkg.SaveAs(output); output.Position = 0; } } } }

第二步,把它注册进ExcelExportService的构造函数,且必须排在ImageSheetExporter之前,否则兜底适配器会先把这类控件接走,你写了也白写。这个顺序问题我在第一次扩展时遇到过,调了半天以为是CanHandle写错了。

6.2 样式统一:用一个模板工作簿兜底

业务方永远会说“表头要跟公司模板一致”。做法不是去改CopyStyle,而是准备一个空模板 xlsx,合并完成后打开模板、把 Sheet 追加进去、再统一套一遍表头样式。

private static void ApplyHeaderStyle(ExcelWorksheet ws, int headerRow, int lastCol) { using (var header = ws.Cells[headerRow, 1, headerRow, lastCol]) { header.Style.Font.Bold = true; header.Style.Font.Size = 11; header.Style.Fill.PatternType = ExcelFillStyle.Solid; header.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(0xF2, 0xF2, 0xF2)); header.Style.Border.Bottom.Style = ExcelBorderStyle.Thin; header.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; } ws.View.FreezePanes(headerRow + 1, 1); // 冻结表头,几万行的表没这个没法看 }

模板法的好处是样式集中在一处,业务方改配色不用重新发版。代价是多一次文件读写,所以只对“对外交付的报表”启用,内部数据交换走原样导出。

6.3 改完之后怎么验证

我自己的验证清单只有四条:真实数据量的三个档位(1 千 / 5 万 / 20 万行)各跑一遍;每个 Sheet 用 Excel 打开后按Ctrl+End确认没有幽灵单元格;公式列在 Excel 里改一个源值看能不能联动重算;把生成的文件用另一种工具(比如 LibreOffice)打开一次,确认没有私有格式依赖。

这里给一个性能优化的方向,也是我下一版要做的:CopySheet现在的双层循环在 20 万行时会跑到 40 秒以上,瓶颈是逐单元格赋样式。可行的改法是先用src.Cells[dim.Address].Value一次性把二维数组搬到目标区域,再按“样式相同的连续行区间”批量赋样式,把单元格级操作降到行级。这个改法只动CopySheet,不动Merge,风险可控。

最后说个教训。这套代码第一版上线后,有个客户反馈“导出按钮点两次会生成两个文件,第二个是空的”。查下来是导出期间按钮没禁用,用户连点,两个任务同时写同一个路径,后一个File.Create把前一个的句柄顶掉了。从那以后我每次给导出这类耗时操作接 UI,都强制走一遍“进函数先btn.Enabled = false,finally里恢复”的流程,不管看着多没必要。希望帮到你。

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

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

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

立即咨询