简介:一份基于C#与ActiveReports的WinForms报表设计源码项目,面向.NET桌面应用开发者,旨在解决数据可视化报表从布局设计到交互展示的完整实现问题。通过丰富的示例工程,演示了如何使用ActiveReports控件构建多业务场景的报表界面,适合中高级.NET开发者参考学习。资源包共276个文件,压缩包大小24.49MB,其中94个rdlx报表设计文件是核心布局定义,60个C#源码文件承载报表逻辑与事件处理,另含32个PNG图片、28个resx资源文件以及XML配置、MDB数据库、RPX和RDSX等辅助资源,覆盖报表配置、数据源、样式脚本等多层元素,便于了解完整项目结构。目前已有164人学习下载。项目提供多种典型报表实例,如月度销售、产品分类、预算清单、客户列表及多系列图表报表,并附带主窗体、报表逻辑和资源配置。读者可据此快速掌握ActiveReports的报表模板、数据绑定、图表交互和部署配置要点,直接复用到自己的业务系统中。
1. 从「基于C#和ActiveReports的WinForms报表设计源码」谈起:新源码到手先想清楚四件事
看到这个标题,我第一反应是:这是一套能直接运行的 WinForms 报表工程,不是一份产品说明书。ActiveReports 是 .NET 生态里老牌的报表控件,支持在 Visual Studio 里拖拽设计、运行时换数据源、一键导出 PDF,进销存、财务凭证、生产工单这类系统里到处都能见到它的影子。标题里的“源码”意味着你拿到的是一套整理过的工程,可以直接打开、改布局、换数据源,但也意味着你必须先看懂它的工程结构,否则改一行代码就可能三处报错。这篇文章按我平时的落地顺序讲:先讲这套方案的核心机制和选型逻辑,再给最小可运行步骤,接着补参数化与导出,最后把我踩过的坑一条条列出来。适合正在选型报表控件的 WinForms 开发者,也适合刚拿到源码不知道从哪下手的新手。
2. 报表控件在 WinForms 里的工作方式:两类模型、三种数据源和渲染生命周期
2.1 SectionReport 与 PageReport 怎么选:区域报表适合固定版式,页面报表适合动态表格
ActiveReports 内部跑着两套并行的实现,老一点的是 SectionReport,也就是区域报表;新一些的是 PageReport,也常被叫 RDL 报表。两套模型都能挂到同一个查看器控件上展示,但设计思路完全不同,源码组织方式也不一样,新手最容易在这个岔路口犯迷糊。
SectionReport 的布局由垂直堆叠的带区组成。从上到下是页眉、明细、页脚,需要分组统计时再插入组头和组脚。数据源有多少行,明细带就重复渲染多少行。这种模型的强项是版式固定:一张送货单,左边客户地址,右边联系电话,一套版式用十年不换。你打开设计器看到的就是整页效果,改完保存,打印结果和屏幕上基本一致,属于“所见基本即所得”。我做过一个五金行业的送货单项目,供应商要求每一联的边框线精确到毫米,这种情况下我只会选 SectionReport,不会考虑别的。
PageReport 的模型更接近 SQL Server Reporting Services,报表文件以 .rdlx 后缀存储,内部是 XML。数据组织主要靠 Table 控件和 Matrix 控件,Table 负责明细列表,Matrix 负责交叉汇总。列数不确定、记录条数每天变化、需要复杂分组和总计时,用它比较舒服。比如销售明细表,客户维度、业务员维度、日期维度都带合计,你用 SectionReport 写表达式会写到自己怀疑人生,放到 Table 控件里自带分组和合计,几分钟就完成。
选型时我的习惯是只问两个问题。第一个问题:这张报表将来会不会频繁加减列。如果会,选 PageReport。第二个问题:打印位置是否要求精确到毫米级。如果要求高,选 SectionReport。两个问题冲突时,优先满足打印精确度,因为桌面系统里单据打歪了,比列表难看更难处理。老工程师之间说的“ActiveReports 用哪种全看领导心情”,本质上就是没有把这两条标准想清楚。
还有一个容易被忽略的细节:SectionReport 的坐标单位不是像素,是缇。1 英寸等于 1440 缇,也就是说一个文本框的 Top 设为 1200,就等于向下偏移 0.833 英寸。设计器里可以切换单位,但老工程存盘时往往还是缇值。你在源码里看到 Top=1440、Width=7200 这种数字,心里要先换算,别当像素去理解。渲染到 WinForms 窗体的那一瞬间,所有控件都要落到 GDI+ 坐标系里,左上角是原点,X 向右,Y 向下,这和嵌入式中那些从底部向上排列的坐标习惯完全相反,从硬件行业转过来的开发者往往要在这里翻一次车。
2.2 三种数据源接入方式:DataTable、List 对象、JSON 数据源各自适合哪类项目
ActiveReports 报表不关心数据是怎么查出来的,它只关心你能不能按名字取到字段。因此 DataTable、List 、JSON 数据源三条路都能走通。三套方案我都实际交付过,各自的脾气也摸得差不多。
DataTable 是最不容易出错的路。报表表达式里引用字段靠的是Fields!字段名.Value这种写法,DataTable 的列名在这套表达式里解析最直接,大小写保持一致就不会出怪事。它容错最高,缺点就是查询代码会多一点。我在 Access 数据库项目里最喜欢这么干:OLEDB 查出结果直接丢给报表,省去建实体类这一步。
// 查询 Access 数据源并直接绑定报表 using (var conn = new OleDbConnection( @"Provider=Microsoft.ACE.OLEDB.12.0;Data Source=C:\data\erp.mdb")) { var sql = "SELECT 订单号, 客户名称, 金额 FROM 销售表 WHERE 日期 >= ?"; var cmd = new OleDbCommand(sql, conn); cmd.Parameters.AddWithValue("p1", DateTime.Today.AddMonths(-1)); var adapter = new OleDbDataAdapter(cmd); var dt = new DataTable(); adapter.Fill(dt); rptSales.DataSource = dt; // 把内存表直接赋给报表 }这段代码逻辑很直白,参数部分才是重点:OLEDB 的 SQL 里问号是占位符,参数顺序必须和 AddWithValue 的添加顺序一致。如果你把 SQL Server 的习惯带过来,写成 @p1,这行代码在 Access 上会直接报语法错误。很多报表工程翻车就翻在这,数据库一换,SQL 方言没跟着换。DataTable 绑定还有一条规矩:如果查询结果的列名是中文,报表表达式里也得用中文,表达式解析器不做名称翻译工作。
List 绑定是 WinForms 项目里最常见的做法。从数据库查出一堆实体,直接赋给 DataSource 就能用。但直接绑 EF 实体容易踩雷,我更倾向先投影成一个干净的 DTO 再绑:
// 用对象列表绑定报表 public class SaleItem { public string OrderNo { get; set; } public string Customer { get; set; } public decimal Amount { get; set; } } // 查询后组装成专用 DTO var items = context.SaleRecords .Where(x => x.ReportDate >= startDate) .Select(x => new SaleItem { OrderNo = x.OrderNo, Customer = x.CustomerName, Amount = x.Amount }) .ToList(); var report = new rptSales(); report.DataSource = items; // 传递强类型对象列表逻辑说明:EF 查出来的原始实体往往带延迟加载的导航属性,报表表达式引擎在逐行取字段时,可能因为碰了导航属性意外触发回表查询。表现就是预览报表卡住几秒甚至十几秒,还以为程序死锁了。所以在源码交付阶段,我规定所有绑定到报表的实体必须先投影成 DTO。参数方面,OrderNo、Customer、Amount 这些属性名要在报表文本框里写成=Fields!OrderNo.Value这种形式,属性名必须和表达式里写得一模一样,多一个空格都算错。
JSON 数据源是近几个版本逐渐主推的新能力,服务端返回 JSON 后可以直接作为数据源绑定。但在 WinForms 这种传统桌面应用里,我建议谨慎使用。JSON 嵌套结构会让表达式变成=Fields!data.order.amount.Value这种长链,一旦中间某个节点为空,整行记录就不显示,排查成本极高。如果实在要处理 JSON,我会先用 System.Text.Json 反序列化成 DTO,再走 List 通道,表达能力几乎不损失,调试时却舒服很多。这等于在报表里少放一个黑匣子,凡是能用强类型解决的问题,就不要留给运行时去猜。
2.3 渲染生命周期:为什么赋值 DataSource 之后报表还没有数据
这一节值得单独拿出来讲:ActiveReports 报表对象不是一个普通控件,它是一份模板。真正的渲染发生在你访问 Document 属性的一瞬间,而不是 DataSource 赋值的那一刻。DataSource 赋了值,模板只是拿到了数据引用,内部不会立刻逐行绘制,直到预览器访问 Document,渲染管线才真正启动。
这个生命周期对我们写代码有直接影响。第一,绑定数据源之前,布局必须已经加载完,也就是报表对象的构造函数里已经执行过 LoadLayout。第二,同一实例换数据源后重新预览,部分旧版本会沿用第一次渲染的缓存,出现“数据换了内容没换”的怪象。我见过不止一个同事在这个点上折腾一下午,最后把报表对象重新 new 一个才解决。这个细节我在第 5 章会再列成一条完整踩坑笔记。
3. 在 WinForms 里跑通最小报表:建工程、设计 rpx、用代码加载到查看器
3.1 建工程和装包:确认目标框架,再动手写代码
在 Visual Studio 里新建一个 Windows Forms 应用,目标框架用 .NET Framework 4.8 或更高。如果用 .NET 6 以上的 WinForms,也完全可行,但很多报表控件的官方示例和模板仍然默认 Framework 版本,遇到设计器打开特别慢时,先检查框架版本是不是和当前控件版本匹配。然后通过 NuGet 安装主包:
dotnet add package GrapeCity.ActiveReports这条命令装的是当前默认源里可用的正式版本。装完后的判断标准只有一个:工具箱搜索框里输入 ActiveReports,如果出现查看器、SectionReport、PageReport 这几个条目,说明引用成功。什么都搜不到时,不要去怀疑控件坏了,优先检查项目目标框架,然后重启 Visual Studio 再试一次。这是在旧版本时代就存在的经典问题,90% 的情况是工具箱项没有被正确注册。
3.2 用设计器拉出一个订单明细表:三个带区和一个表达式
下面按 SectionReport 模型走。右键项目,添加新项,选择 ActiveReports Section Report,命名为 rptOrders。设计器打开后,你会看到三个默认带区:报表页眉、明细、报表页脚。报表页眉通常放静态标题,明细放数据绑定文本框。
在明细带拖一个文本框,属性窗口里把 Text 设为:
=Fields!OrderNo.Value再拖两个文本框,分别绑定 Customer 和 Amount。页眉里放三个静态标签,写成订单号、客户、金额,保持和明细列的位置对齐。宽度上我的经验是,三列左右位置大约在 0.5cm、4cm、10cm 处,列间距别太密,打印出来才有呼吸感。这一步不需要一次做完美,先把绑定关系跑通,后续改位置只是拖动的事。
这里说一个我在上位机项目里养成的习惯:控件命名必须前缀可见。文本框叫 txtOrderNo、txtCustomer,静态标签叫 lblOrderNo,一看名字就知道这个控件是什么用途。拿到一份新源码时,如果发现控件名是 textBox1、textBox2 这种,第一件事就是在设计器里批量重命名,否则后面写代码维护成本高到想骂人。
3.3 在窗体里加载并预览报表:按钮事件里的完整链路
回到窗体设计器,把查看器控件拖到窗体上,命名 viewer。然后在代码文件里写加载逻辑:
public partial class FormMain : Form { private rptOrders _report; public FormMain() { InitializeComponent(); // 报表类构造函数内部会加载内嵌的 rpx 布局 _report = new rptOrders(); } private void buttonPreview_Click(object sender, EventArgs e) { DataTable source = BuildSampleData(); _report.DataSource = source; // 先绑数据 viewer.Document = _report.Document; // 再触发渲染 } private DataTable BuildSampleData() { var table = new DataTable(); table.Columns.Add("OrderNo", typeof(string)); table.Columns.Add("Customer", typeof(string)); table.Columns.Add("Amount", typeof(decimal)); table.Rows.Add("SO-1001", "客户A", 1280.0m); table.Rows.Add("SO-1002", "客户B", 546.5m); table.Rows.Add("SO-1003", "客户C", 3999.0m); return table; } }这段代码有两个细节值得说明。第一个:_report 放在构造函数里 new,不要放在按钮事件里 new。查看器控件的翻页状态、缩放比例会保留上一次文档的页面尺寸;如果报表对象跟着按钮重建,查看器经常会保留在旧页码或旧缩放比例上,用户会以为报表没有刷新。第二个:Document 属性是延迟渲染的,赋给 viewer.Document 之前,DataTable 必须已经填充完毕;如果是异步查询数据,要用 await 等结果回来再赋值,千万别在回调里只改 DataSource 后马上赋值 Document。
看完这套代码,你就能理解为什么标题里强调“源码”。源码工程的价值在于,rpx 文件是内嵌资源,报表对象在构造函数里就把布局加载好了,你只需要关心数据源怎么换。如果你拿到的版本里 Layout 加载依赖外部文件路径,那就要额外处理文件部署问题,这种工程结构明显不如内嵌式干净。
3.4 页面尺寸三个必调参数:PrintWidth、纸张方向和页边距
最小示例跑通后,第一屏预览往往不是宽了就是窄了。别急着调控件位置,先处理三个参数:纸张方向、页边距、PrintWidth。PrintWidth 是报表设计器的内容总宽度,它必须等于纸张宽度减去左右边距,否则打印或导出时会莫明其妙多出一页空白纸。
// 设置报表纸张方向和边距 _report.PageSettings.PaperOrientation = GrapeCity.ActiveReports.Document.Section.PageOrientation.Landscape; _report.PageSettings.Margins.Top = 0.5f; _report.PageSettings.Margins.Bottom = 0.5f; _report.PageSettings.Margins.Left = 0.5f; _report.PageSettings.Margins.Right = 0.5f; _report.PrintWidth = 10.5f;代码执行顺序有讲究:先设纸张方向,再设边距,最后设 PrintWidth。如果先设 PrintWidth 再切横向,PrintWidth 不会被重新换算,表格宽度可能超出一行。PrintWidth 的取值要留 0.5 到 1 厘米余量,避免边框线贴到纸张边缘被打印机裁掉。旧版本里 PrintWidth 默认单位是缇,1 厘米约等于 566 缇;如果你直接把别人源码里的 PrintWidth 数字抄进新工程,十有八九宽度不对。改这个值之前,先确认项目属性里默认单位是厘米还是缇。
4. 把报表推进到可交付状态:运行时换数据、传参和 PDF 导出
4.1 运行时切换数据源,不需要改一行布局代码
WinForms 报表项目的价值,就在于运行时机上多变的业务场景。同一个报表模板,点不同按钮显示不同数据范围,这是最优体验。常见做法是维护一个报表对象池,每次预览时按业务场景填充数据,然后重新加载。
private void ShowReportByDate(DateTime fromDate, DateTime toDate) { DataTable source = QueryData(fromDate, toDate); // 重新创建报表实例,避免旧缓存 _report = new rptSales(); _report.DataSource = source; viewer.Document = _report.Document; }参数说明:fromDate 和 toDate 是业务侧传入的时间范围,QueryData 内部拼 SQL 参数化查询;新建报表实例是为了绕过旧实例的 Document 缓存问题,上一章已经讲过,这是最省心的方案。实际工程里我不建议在报表类内部去连数据库,报表类保持只依赖 DataSource 的纯模板状态,以后升级维护会轻松很多。
4.2 给 PageReport 传递参数:报表参数的正确传法
如果你的源码用的是 PageReport,参数传递方式略有不同。PageReport 的 .rdlx 文件里可以定义报表参数,运行时通过 PageDocument 设置参数值,再赋给查看器。
var report = new GrapeCity.ActiveReports.PageReport( new FileInfo(@"C:\Reports\rptSales.rdlx")); var document = new GrapeCity.ActiveReports.Document.PageDocument(report); document.Parameters["startDate"].Value = dateTimePickerStart.Value.ToString("yyyy-MM-dd"); document.Parameters["endDate"].Value = dateTimePickerEnd.Value.ToString("yyyy-MM-dd"); viewer.Document = document;逻辑说明:PageReport 构造接收文件路径,PageDocument 负责具体渲染。参数名必须以 .rdlx 布局里定义的参数名为准,大小写不敏感。最常见的报错是参数名写错,系统抛“找不到参数”异常,这块没有运行时提示,只能去 .rdlx 文件里搜 Parameter 节点核对名称。
SectionReport 里没有完全等价的设计,老项目的做法是通过报表里的 DataSource 过滤条件来完成。也就是说,你在 SQL 里就过滤好数据,报表负责显示最终的 DataTable,这反而避开了双边缘参数传递问题。所以我经常对同事说:能用 SQL 解决的数据过滤,就不要折腾到报表参数里去,报表参数的本意是给 RDL 报表在服务器端做交互用的,桌面端没有这种刚需。
4.3 导出 PDF、Excel 和图片:保存对话框与导出参数的配合
报表最后还是要交到别人手里。PDF 是最保守的交付格式,打印不会走版,Excel 则方便对方二次加工。代码路上两者都简单,但细节在参数配置。
using GrapeCity.ActiveReports.Export.Pdf.Section; private void ExportPdf_Click(object sender, EventArgs e) { var dialog = new SaveFileDialog { Filter = "PDF 文件|*.pdf" }; if (dialog.ShowDialog() != DialogResult.OK) return; var pdfExport = new PdfExport(); pdfExport.PdfSettings.Encrypt = false; pdfExport.PdfSettings.FontEmbedding = true; using (var stream = File.Create(dialog.FileName)) { pdfExport.Export(_report.Document, stream); } MessageBox.Show("导出完成"); }参数说明:Encrypt 设为 false 是为了避免 PDF 每次打开都弹密码框,如果客户有保密需求再改回 true。FontEmbedding 是导出中文 PDF 的关键,设为 true 会把字体子集嵌入 PDF,防止换台机器打开 PDF 后中文全部变成方块。Export 方法内部是同步执行的,在 UI 线程里直接调用会卡住界面几秒,可以放在 BackgroundWorker 或 Task.Run 里跑,但导出完成后回到 UI 线程弹对话筐前要检查 InvokeRequired,否则又是“跨线程操作无效”。
Excel 导出在浏览量的叫法不一样,有的版本在 Section 导出器,有的在 Page 导出器,接口参数也不同。我一般只在客户明确要求做二次编辑时才用,因为 ActiveReports 导出 Excel 对复杂表头支持有限,合并单元格一旦多,文件打开后格式会乱,这属于导出引擎的通病,不是配置能解决的问题。
5. ActiveReports 报表项目排查笔记:5 个让我白加班的问题
5.1 换了数据源,预览还是第一遍的内容
现象:报表绑定新 DataTable 后,查看器显示的还是上一份数据的页面内容。 原因:同一报表实例第二次访问 Document 时,部分版本会直接从内部缓存取旧页面,DataSource 的新值没有触发重新渲染。 解决:不修改旧实例,重新创建一个报表对象再赋值:
_report = new rptSales(); // 换数据就重建实例 _report.DataSource = newTable; viewer.Document = _report.Document;这条规则稳到可以写进团队规范。凡是预览前都要走这一行,属于零成本规避,比研究内部缓存机制划算多了。
5.2 只有几行数据,预览出来多一页空白纸
现象:明细区只有三行记录,预览却显示两页,第二页是空的。 原因:明细带区域高度比实际内容高,或者 PrintWidth 大于纸张可打印宽度,把细目内容挤到了下一页。 解决:在设计器里把明细带区高度压缩到内容实际占用的位置;同时检查 PrintWidth 是否大于页面宽度减去边距。操作时先设纸张方向,再设边距,最后调 PrintWidth,避免 Width 因为横向切换而变乱。
这条坑在导出 PDF 时特别迷惑人,屏幕上查看器第二页没有明显标识,只有打印出来才发现多了一张白纸,时间都花在跟打印机厂商扯皮上,最后发现是自己的 PrintWidth 多设了半厘米。
5.3 窗体上中文显示正常,导出 PDF 后变成方块
现象:报表在查看器控件里中文显示完美,导出 PDF 后字全部变成方块或变成另一种字体。 原因:PDF 导出时没有嵌入字体子集,或者报表里指定的字体在目标机器上不存在,导致 PDF 阅读器用缺省字体渲染。 解决:把报表内所有文字字体显式设置为“Microsoft YaHei UI”或客户环境中确定存在的字体,同时把导出代码里的 FontEmbedding 设为 true:
var pdfExport = new PdfExport(); pdfExport.PdfSettings.FontEmbedding = true;这个问题我在交付物流单据时遇到过,客户把 PDF 发到总部,总部打开全是大同小异的方块,最终排查到是部署机器没装对应中文字体。代码里把字体锁定为微软雅黑后,再导出,问题就消失了。
5.4 开发机 32 位正常,64 位客户机上闪退
现象:报表源码在开发机上预览正常,部署到客户 64 位系统上后,打开预览窗体直接闪退,有时只弹一个空白窗体。 原因:旧版本 ActiveReports 的表达式引擎部分环节依赖 32 位 COM 组件,在 x64 进程下加载失败,异常被窗体吞掉导致闪退。 解决:优先检查项目解决方案平台是 AnyCPU 还是 x86/x64,把它们全部统一并重新编译;如果版本太老,可以在 NuGet 更新到当前稳定版本再测试。这属于环境兼容问题,没有调试器挂着时很难定位,所以我开发机上也会预留一份 x64 配置交叉验证。
5.5 报表打印宽度很宽,右侧内容被切掉
现象:查看器里所有列都完整,打印机上右侧两列被切掉一部分。 原因:报表 PrintWidth 超出打印机实际可打印区域,打印机驱动自动裁切。 解决:把 PrintWidth 设为页面宽度减去左右边距后再留 0.5 厘米余量,同时把页边距里的左右都设到 1 厘米以上。再有就是检查打印机驱动的“页边距偏移”设置,这台机器没问题,换台打印机又切边,多半是打印机偏移参数不对应。
这个案例来自一次财务凭证打印,我在代码里把 Margins.Left 调到 0.6 厘米后,A4 纸正好放得下,客户打了一个月没问题,结果换了一台兄弟打印机又开始切边。最后发现是那台打印机默认有“无边框打印”选项未勾选,驱动层又裁了一份。两层问题叠加,才出现了这种怎么调代码都没用的现象。
6. 一个好用的验证技巧:固定数据导出 PDF 后做回归对比
源码交付后最怕的不是功能写不出来,是改一个模块把另一个模块弄坏。报表项目尤其如此,布局属性之间互相牵制:改一个 Band 的高度,可能导致分组页码全乱;调一个文本框字体,可能让整行高度变化,牵动后续所有页面的分页位置。为了不让自己改到怀疑人生,我给自己定了一条规矩:给每一份源码头维护一份“黄金 PDF”。
做法不复杂。拿一个固定的数据集,比如构造 100 行销售记录,绑定到报表后导出 PDF,命名为 01_base.pdf 存档。以后每次改完布局,重新用同一份数据导出 PDF,命名 02_check.pdf,然后找一个支持 PDF 对比的查看器工具打开两份文件,逐页看差异。肉眼不方便时,还可以把 PDF 页面渲染成 PNG 图片,放在同一目录里做像素对比。这一步在开发机上很简单,系统没有额外依赖,只要有报表和 Windows 自带打印组件就能做。
我通常会把这个验证动作写成一个小的命令行工具,输入报表路径和数据集路径,输出 PDF。这样交付给同事后,他能独立跑回归验证,不用等着我口头解释哪几页改了。工具代码骨架不复杂,核心就是构造报表、绑定数据、导出三步,和前面章节的代码结构完全一致,只是把 UI 换成控制台入口。命令行工具的好处是能被批处理调用,放进 Jenkins 或者 Git 钩子里,每次提交时自动重新生成那份验证 PDF,有变动就标记出来。
这条习惯帮我省过两回大麻烦。一次是调整送货单的页脚高度,屏幕上看不出毛病,打印后发现所有单据的页码都挤到了页脚区域,黄金 PDF 对比后一眼看出第二页和第三页的底部边界集体变了。另一次是给报表统一替换字体,批量改完后用固定数据集导出,发现表格行高在不同字体下表现不同,导出的 PDF 整体偏移。如果不做固定数据回归,这些问题大概率会在客户现场被骂完才发现。
希望这个验证思路能帮到你。
本文还有配套的精品资源,点击获取