锐浪报表子表打印封装:从DetailGrid到DataTable的极简之路
简介一份面向C# WinForm开发者的锐浪报表集成封装类资源针对官方示例难以直接落地、数据库连接串易暴露等痛点提供了完整的报表与子表打印方案适合需要在项目中快速集成报表功能的VS2008及以上开发者使用。压缩包共72个文件、约6.67MB核心包括17个C#源码文件、4个.grf报表模板、20个DLL与Interop互操作程序集以及2个.mdb示例数据库和可直接运行的exe演示程序。通过Utility、GridppReport、GridppReportArr、DgvCheck等封装类可直接复用报表初始化、数据源配置、子表打印等功能无需每个报表手工连库也简化了连接字符串的管理。资源内还附带报表注册组件、Excel数据导入及空值处理等辅助代码配合示例模板和演示程序有助于快速理解锐浪报表的二次开发方式。目前已有945人学习浏览对希望省去底层研究、直接上手报表封装的开发者很有参考价值。 锐浪报表GridReport在WinForm圈子里的地位做过几年C/S开发的人应该都有体会——报表展示、套打、单据打印这些场景几乎绕不开它。但这控件有个特点功能强API却也啰嗦尤其是涉及到子表明细表的打印光是理解DetailGrid、Recordset、Field之间的关系就够喝一壶的。我自己在项目里也是踩了几次坑以后干脆写了一个封装类把所有繁琐的初始化、数据绑定、打印预览逻辑全收进去业务代码里只需要传一个DataTable进去就能出报表。这篇就分享一下这个封装类的设计思路和完整实现给同样被锐浪报表折磨过的朋友一个参考。先说清楚这个封装类解决了什么问题。凡是做过订单、结算单、出入库单这类带明细的打印需求就知道主表字段好办麻烦的是子表——一张单据对应多行商品明细每条明细还有数量、单价、金额。如果用锐浪报表原生API你得手动关联主表和子表的数据集处理父子记录的Append时机稍不注意子表数据就显示不出来。封装之后这些细节全部收敛到一个类里调用方只需要准备两个DataTable一个主表、一个子表传进去就完事。1. 设计思路一个类怎么把锐浪报表变“傻瓜”1.1 子表打印的痛点为什么需要封装先复盘一下锐浪报表做子表打印的典型流程。用GridReport的常规套路你要先加载模板文件.grf然后在代码里找到报表的DetailGrid不管是主表还是子表都是通过DetailGrid容器来承载数据。问题是子表数据并不是简单的SecondRecordset而是在主表的DetailGrid里再嵌一个DetailGrid。原生代码大概长这样外层DetailGrid遍历主表记录内层DetailGrid关联子表数据源两套Recordset要同步移动游标。如果直接在业务层写你会面临几个尴尬情况报表模板里主表、子表的DetailGrid顺序、索引需要写死模板一改代码就崩。主表一条记录对应子表多条记录“当前父记录”需要靠主键或游标位置来定位数据一多就乱。分组合计、金额小计这些功能在模板里做当然可以但因为子表数据绑定方式不对合计值经常算不出来。这些问题说白了都是容错率低。封装类的第一个目标就是把这种低容错的API操作转换为传数据、出报表的黑盒操作。业务人员不需要理解DetailGrid是什么他们只需要告诉我这是主表数据这是子表数据子表根据订单号关联就够了。1.2 封装的边界哪些该交给类哪些该留在模板很多人写封装类容易走极端恨不得把模板设计也搬到代码里。我个人的经验是模板设计留在锐浪报表设计器里代码封装只解决数据如何准确塞进模板以及如何稳定打印预览。说得直白一点模板负责表现样式——字段位置、线框、字体、纸张大小这些都该在模板里画好。封装类负责数据注入——把主表DataTable、子表DataTable、报表参数比如打印人、打印时间传进去内部自行组装成锐浪报表能识别的数据源。类的内部实现跟具体业务无关任何单据只要主表和子表的结构满足约定就能直接复用。这样划分以后类的职责非常清晰。它内部要处理以下几件事加载模板文件初始化报表对象。建立主表记录集逐条添加主表字段值。在每条主表记录下建立对应的子表记录集。处理参数页眉文字、打印日期等。调用预览、打印或导出接口。至于模板的DetailGrid叫什么名字、有什么输出字段则通过约定的字段名自动匹配。模板里画了哪些字段代码里就传哪些字段的DataTable列两边的列名保持一致这个封装类就能通吃所有业务场景。2. 核心API与数据流设计2.1 公开方法设计让调用方只碰DataTable封装类的公开接口我倾向于设计得越简单越好。说白了判断一个封装好不好就看业务层调用时要写多少代码。我最终定下的公开方法只有几个LoadTemplate(string templatePath)加载模板文件。SetParameter(string paramName, object value)设置报表参数。SetMasterData(DataTable table)设置主表数据。AddDetailData(string masterKeyField, string detailKeyField, DataTable table)添加子表数据通过两个的键字段关联。Preview()打开打印预览窗口。Print()直接静默打印。Export(string filePath, ReportExportFormat format)导出PDF、Excel等格式。为什么AddDetailData要传关联字段这是踩坑之后总结出来的。锐浪报表模板设计器里主表和子表的关联并不像SQL那样有外键概念它靠的是代码里控制记录集游标。所以我干脆在封装类里做一次伪关联遍历主表每一行把满足关联条件的子表行挂在当前主表记录下面。这样调用方不用关心游标问题只需要告诉我两个表用哪个字段关联。2.2 内部数据流从DataTable到报表记录集内部实现的核心逻辑是把传入的DataTable转换成锐浪报表的Recordset。我采用的是逐行Append的方式而不是走ConnectionStringQuerySQL那样还得配置数据库连接耦合太重。每拿到一张DataTable就遍历它的列结构对应创建Recordset的Field然后逐行添加记录。这里有一个关键点容易踩坑锐浪报表里Recordset的Field必须和模板里DetailGrid引用的Field名字一致否则预览时对应字段就是空白。所以封装类内部做了一次校验如果模板里DetailGrid的字段名在传入DataTable列里找不到就自动跳过并在日志里记录而不是直接抛异常。这个设计在处理子表时特别实用因为子表模板经常会多配置几个合计列这些列在业务DataTable里可能不存在。子表记录的挂载流程是我这个封装类最核心的一段逻辑。可以把主表想象成一个外层循环子表是内层循环。处理方式分两步对子表DataTable按关联字段建立字典索引Lookup这样不用每条主表记录都去全表扫描子表数据量大的时候性能差距非常大。每添加一条主表记录就把字典索引中对应的所有子表行按顺序添加到当前主表的子DetailGrid中。这么做的优势很明显不管子表数据顺序怎么乱只要关联字段值能对上基本不会出现子表串到别的父记录下面这种诡异现象。3. 实操写一个带子表的销售订单打印3.1 模板准备与约定我拿最常见的销售订单来演示。模板用锐浪报表设计器画好主表DetailGrid区域放订单号、客户名称、订单日期这些字段子表DetailGrid放在主表区域内部放商品名称、规格、数量、单价、金额这些字段通常还会加一个金额合计框。模板设计时有两个约定必须遵守。第一主表DetailGrid不能开启每页重复表头以外的新页属性否则子表会被分页切散。第二字段名一定要跟DataTable列名保持一致这是封装类自动匹配的前提。比如DataTable列名是ProductName模板字段名也得是ProductName。说句题外话很多人觉得模板导入主从数据结构麻烦其实锐浪报表设计器自带主从报表向导先建主表区域再在主表DetailGrid上插入子表区域设计器会自动生成嵌套结构。真正麻烦的是程序运行时把数据塞进去所以封装类才值得写。3.2 主表与子表数据组装这个示例的场景是界面上一个订单主表DataTableorderTable字段有OrderNo、CustomerName、OrderDate一个订单明细DataTabledetailTable字段有OrderNo、ProductName、Quantity、Price。两个表通过OrderNo关联。调用封装类的代码简化到不能再简化// 假设helper是封装类的实例 RuiLangReportHelper helper new RuiLangReportHelper(); // 1. 加载模板 helper.LoadTemplate(C:\Templates\SalesOrder.grf); // 2. 设置页眉参数 helper.SetParameter(PrintOperator, 张三); helper.SetParameter(PrintDate, DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss)); // 3. 设置主表数据 helper.SetMasterData(orderTable); // 4. 添加子表数据通过OrderNo关联主表 helper.AddDetailData(OrderNo, OrderNo, detailTable); // 5. 预览打印 helper.Preview();如果是一次打印多个订单封装类可以支持循环预览。这里有个性能细节SetMasterData之前应该先把数据准备好千万别在循环里频繁调用LoadTemplate模板加载是很耗时的操作。3.3 核心实现封装类内部都在做什么现在看一下封装类内部最关键的两个方法。第一个方法是把DataTable转换为Recordset代码逻辑如下private void FillRecordset(DataTable table, IRecordset recordset) { if (table null || recordset null) return; // 先根据DataTable列结构创建Field foreach (DataColumn column in table.Columns) { bool fieldExists false; for (int i 0; i recordset.Fields.Count; i) { if (recordset.Fields[i].Name column.ColumnName) { fieldExists true; break; } } if (!fieldExists) { recordset.Fields.Add(column.ColumnName, ColumnType.String, 50); } } // 逐行添加数据 foreach (DataRow row in table.Rows) { recordset.Append(); for (int i 0; i recordset.Fields.Count; i) { string fieldName recordset.Fields[i].Name; if (table.Columns.Contains(fieldName)) { recordset.FieldValue(fieldName) row[fieldName] ?? DBNull.Value; } } } }代码里我做了一个字段存在性判断这么做是有原因的。实际项目中子表DataTable通常比模板字段多不少列这些额外的列比如备注、换算率往往并不需要打印。如果全部尝试写入记录集某些版本的锐浪报表会直接抛字段不存在异常。加上这个判断以后额外列自动被忽略模板字段仍然能正常赋值容错率一下就上去了。第二个关键方法是子表挂载逻辑。主表Recordset每Append一行就立刻在该行的DetailGrid里挂上对应的子表记录这样锐浪报表在遍历打印时能够保证当前父记录跟子记录的关系始终是点对点private void AttachDetailData(IGridppReport report, string masterKey, string detailKey, DataTable detailTable) { if (detailTable null || detailTable.Rows.Count 0) return; // 对子表按关联字段建立索引避免O(n^2)扫描 Dictionarystring, ListDataRow lookup new Dictionarystring, ListDataRow(); foreach (DataRow row in detailTable.Rows) { string key row[detailKey]?.ToString() ?? string.Empty; if (!lookup.ContainsKey(key)) lookup[key] new ListDataRow(); lookup[key].Add(row); } IRecordset masterRs report.DetailGrid.Recordset; IDetailGrid detailGrid report.DetailGrid.SubDetailGrids[0]; IRecordset detailRs detailGrid.Recordset; masterRs.MoveFirst(); while (!masterRs.IsEof) { string currentKey (masterRs.FieldValue(masterKey) ?? string.Empty).ToString(); if (lookup.ContainsKey(currentKey)) { foreach (DataRow row in lookup[currentKey]) { detailRs.Append(); // 这里同样套用字段存在性判断逻辑 for (int i 0; i detailRs.Fields.Count; i) { string fieldName detailRs.Fields[i].Name; if (row.Table.Columns.Contains(fieldName)) detailRs.FieldValue(fieldName) row[fieldName]; } } } masterRs.MoveNext(); } }这里最坑的一个点就是masterRs.MoveFirst()和MoveNext()要配套使用很多新手容易在循环里忘记MoveFirst结果只从当前游标开始处理导致前几条主表记录的子表数据没挂上。封装类内部处理好了调用方就不用管这些了。4. 常见问题排查与优化4.1 子表不显示90%是这两个原因这个封装类最初投入使用的时候收到最多的反馈就是子表区域显示空白。排查下来的结果绝大多数是两种原因。第一种是模板里主表DetailGrid下的子表DetailGrid索引不对。代码里用report.DetailGrid.SubDetailGrids[0]去拿子表但如果模板设计时先加的合计区域、再加的子表区域索引顺序就会乱掉。稳妥做法是在封装类里加一个按字段名查找子表的逻辑找到包含特定字段名的那个SubDetailGrid而不是盲目取索引0。第二种原因是主表Recordset还没有MoveFirst就直接去遍历子表。锐浪报表的游标机制跟DataTable不一样它默认停留在第一条记录之前不移动游标的话子表关联判断拿到的是无效位置。这个问题在封装类里做了统一处理每次执行数据填充时强制MoveFirst()确保从首记录开始。4.2 批量打印时只出了第一张怎么办多个主表记录连续打印的场景比如批量打印班组派工单经常会遇到只打印第一张就停下来的情况。这是由于锐浪报表默认的打印范围是当前记录也就是游标所在的那一条。正确做法是在Print()之前调用MoveFirst()重置游标并把打印范围设为全部记录。封装类里我特意留了一个BatchMode属性默认false。如果调用方要批量打印把它设成true内部自动处理游标移动和页数重置public bool BatchMode { get; set; } false; public void Print() { if (BatchMode) { report.DetailGrid.Recordset.MoveFirst(); report.Print(true); // 参数为是否打印全部 } else { // 单张打印时游标保持在指定位置 report.Print(); } }4.3 大数据量下的卡顿与内存释放说完错误再说性能。一套ERP里面打印几千行明细并不稀奇如果不做处理预览或者打印的时候界面会卡死一两秒。锐浪报表在加载大数据量时如果开着屏幕绘制效率会非常低。封装类里可以在开始填充数据前关闭绘制填充完成后再恢复在Initialize的时候调用report.SetDrawEnable(false); // 数据填充 // ... report.SetDrawEnable(true);实测下来数据量上万行的时候这个开关能让预览速度提升30%以上。不过要注意关闭绘制状态下不能调用预览否则界面空白先把绘制打开再预览。最后说一个资源释放问题。锐浪报表是COM组件用完之后需要显式释放否则在长时间运行的WinForm程序里很容易出现内存持续增长。封装类里实现IDisposable接口在Dispose里把报表对象、Recordset对象全部置空public void Dispose() { if (report ! null) { Marshal.ReleaseComObject(report); report null; } GC.Collect(); }我见过不少项目报表功能正常但每次打印完内存涨几MB最后程序越来越卡基本都是没释放COM对象导致的。这个细节虽然不起眼但长期运行的服务或者频繁打印的收银台场景差别非常大。这个封装类用到现在最大的感受是把锐浪报表当成一个报表渲染引擎而不是开发工具集来用整个代码结构就清晰多了。模板继续用设计器维护数据组装全部交给封装类业务层只关心DataTable里有没有数据。没封装的同事还在被DetailGrid和Recordset折磨的时候这边已经躺着升级完功能了。如果你也在WinForm里做单据打印强烈建议花半天时间把这层封装做出来后面所有报表需求都能顺着这条路继续走下去。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →