表子表打印封裝:從DetailGrid到DataTable的極簡(jiǎn)之路)
簡(jiǎn)介一份面向C# WinForm開(kāi)發(fā)者的銳浪報(bào)表集成封裝類資源針對(duì)官方示例難以直接落地、數(shù)據(jù)庫(kù)連接串易暴露等痛點(diǎn)提供了完整的報(bào)表與子表打印方案適合需要在項(xiàng)目中快速集成報(bào)表功能的VS2008及以上開(kāi)發(fā)者使用。壓縮包共72個(gè)文件、約6.67MB核心包括17個(gè)C#源碼文件、4個(gè).grf報(bào)表模板、20個(gè)DLL與Interop互操作程序集以及2個(gè).mdb示例數(shù)據(jù)庫(kù)和可直接運(yùn)行的exe演示程序。通過(guò)Utility、GridppReport、GridppReportArr、DgvCheck等封裝類可直接復(fù)用報(bào)表初始化、數(shù)據(jù)源配置、子表打印等功能無(wú)需每個(gè)報(bào)表手工連庫(kù)也簡(jiǎn)化了連接字符串的管理。資源內(nèi)還附帶報(bào)表注冊(cè)組件、Excel數(shù)據(jù)導(dǎo)入及空值處理等輔助代碼配合示例模板和演示程序有助于快速理解銳浪報(bào)表的二次開(kāi)發(fā)方式。目前已有945人學(xué)習(xí)瀏覽對(duì)希望省去底層研究、直接上手報(bào)表封裝的開(kāi)發(fā)者很有參考價(jià)值。 銳浪報(bào)表GridReport在WinForm圈子里的地位做過(guò)幾年C/S開(kāi)發(fā)的人應(yīng)該都有體會(huì)——報(bào)表展示、套打、單據(jù)打印這些場(chǎng)景幾乎繞不開(kāi)它。但這控件有個(gè)特點(diǎn)功能強(qiáng)API卻也啰嗦尤其是涉及到子表明細(xì)表的打印光是理解DetailGrid、Recordset、Field之間的關(guān)系就夠喝一壺的。我自己在項(xiàng)目里也是踩了幾次坑以后干脆寫了一個(gè)封裝類把所有繁瑣的初始化、數(shù)據(jù)綁定、打印預(yù)覽邏輯全收進(jìn)去業(yè)務(wù)代碼里只需要傳一個(gè)DataTable進(jìn)去就能出報(bào)表。這篇就分享一下這個(gè)封裝類的設(shè)計(jì)思路和完整實(shí)現(xiàn)給同樣被銳浪報(bào)表折磨過(guò)的朋友一個(gè)參考。先說(shuō)清楚這個(gè)封裝類解決了什么問(wèn)題。凡是做過(guò)訂單、結(jié)算單、出入庫(kù)單這類帶明細(xì)的打印需求就知道主表字段好辦麻煩的是子表——一張單據(jù)對(duì)應(yīng)多行商品明細(xì)每條明細(xì)還有數(shù)量、單價(jià)、金額。如果用銳浪報(bào)表原生API你得手動(dòng)關(guān)聯(lián)主表和子表的數(shù)據(jù)集處理父子記錄的Append時(shí)機(jī)稍不注意子表數(shù)據(jù)就顯示不出來(lái)。封裝之后這些細(xì)節(jié)全部收斂到一個(gè)類里調(diào)用方只需要準(zhǔn)備兩個(gè)DataTable一個(gè)主表、一個(gè)子表傳進(jìn)去就完事。1. 設(shè)計(jì)思路一個(gè)類怎么把銳浪報(bào)表變“傻瓜”1.1 子表打印的痛點(diǎn)為什么需要封裝先復(fù)盤一下銳浪報(bào)表做子表打印的典型流程。用GridReport的常規(guī)套路你要先加載模板文件.grf然后在代碼里找到報(bào)表的DetailGrid不管是主表還是子表都是通過(guò)DetailGrid容器來(lái)承載數(shù)據(jù)。問(wèn)題是子表數(shù)據(jù)并不是簡(jiǎn)單的SecondRecordset而是在主表的DetailGrid里再嵌一個(gè)DetailGrid。原生代碼大概長(zhǎng)這樣外層DetailGrid遍歷主表記錄內(nèi)層DetailGrid關(guān)聯(lián)子表數(shù)據(jù)源兩套R(shí)ecordset要同步移動(dòng)游標(biāo)。如果直接在業(yè)務(wù)層寫你會(huì)面臨幾個(gè)尷尬情況報(bào)表模板里主表、子表的DetailGrid順序、索引需要寫死模板一改代碼就崩。主表一條記錄對(duì)應(yīng)子表多條記錄“當(dāng)前父記錄”需要靠主鍵或游標(biāo)位置來(lái)定位數(shù)據(jù)一多就亂。分組合計(jì)、金額小計(jì)這些功能在模板里做當(dāng)然可以但因?yàn)樽颖頂?shù)據(jù)綁定方式不對(duì)合計(jì)值經(jīng)常算不出來(lái)。這些問(wèn)題說(shuō)白了都是容錯(cuò)率低。封裝類的第一個(gè)目標(biāo)就是把這種低容錯(cuò)的API操作轉(zhuǎn)換為傳數(shù)據(jù)、出報(bào)表的黑盒操作。業(yè)務(wù)人員不需要理解DetailGrid是什么他們只需要告訴我這是主表數(shù)據(jù)這是子表數(shù)據(jù)子表根據(jù)訂單號(hào)關(guān)聯(lián)就夠了。1.2 封裝的邊界哪些該交給類哪些該留在模板很多人寫封裝類容易走極端恨不得把模板設(shè)計(jì)也搬到代碼里。我個(gè)人的經(jīng)驗(yàn)是模板設(shè)計(jì)留在銳浪報(bào)表設(shè)計(jì)器里代碼封裝只解決數(shù)據(jù)如何準(zhǔn)確塞進(jìn)模板以及如何穩(wěn)定打印預(yù)覽。說(shuō)得直白一點(diǎn)模板負(fù)責(zé)表現(xiàn)樣式——字段位置、線框、字體、紙張大小這些都該在模板里畫(huà)好。封裝類負(fù)責(zé)數(shù)據(jù)注入——把主表DataTable、子表DataTable、報(bào)表參數(shù)比如打印人、打印時(shí)間傳進(jìn)去內(nèi)部自行組裝成銳浪報(bào)表能識(shí)別的數(shù)據(jù)源。類的內(nèi)部實(shí)現(xiàn)跟具體業(yè)務(wù)無(wú)關(guān)任何單據(jù)只要主表和子表的結(jié)構(gòu)滿足約定就能直接復(fù)用。這樣劃分以后類的職責(zé)非常清晰。它內(nèi)部要處理以下幾件事加載模板文件初始化報(bào)表對(duì)象。建立主表記錄集逐條添加主表字段值。在每條主表記錄下建立對(duì)應(yīng)的子表記錄集。處理參數(shù)頁(yè)眉文字、打印日期等。調(diào)用預(yù)覽、打印或?qū)С鼋涌凇V劣谀0宓腄etailGrid叫什么名字、有什么輸出字段則通過(guò)約定的字段名自動(dòng)匹配。模板里畫(huà)了哪些字段代碼里就傳哪些字段的DataTable列兩邊的列名保持一致這個(gè)封裝類就能通吃所有業(yè)務(wù)場(chǎng)景。2. 核心API與數(shù)據(jù)流設(shè)計(jì)2.1 公開(kāi)方法設(shè)計(jì)讓調(diào)用方只碰DataTable封裝類的公開(kāi)接口我傾向于設(shè)計(jì)得越簡(jiǎn)單越好。說(shuō)白了判斷一個(gè)封裝好不好就看業(yè)務(wù)層調(diào)用時(shí)要寫多少代碼。我最終定下的公開(kāi)方法只有幾個(gè)LoadTemplate(string templatePath)加載模板文件。SetParameter(string paramName, object value)設(shè)置報(bào)表參數(shù)。SetMasterData(DataTable table)設(shè)置主表數(shù)據(jù)。AddDetailData(string masterKeyField, string detailKeyField, DataTable table)添加子表數(shù)據(jù)通過(guò)兩個(gè)的鍵字段關(guān)聯(lián)。Preview()打開(kāi)打印預(yù)覽窗口。Print()直接靜默打印。Export(string filePath, ReportExportFormat format)導(dǎo)出PDF、Excel等格式。為什么AddDetailData要傳關(guān)聯(lián)字段這是踩坑之后總結(jié)出來(lái)的。銳浪報(bào)表模板設(shè)計(jì)器里主表和子表的關(guān)聯(lián)并不像SQL那樣有外鍵概念它靠的是代碼里控制記錄集游標(biāo)。所以我干脆在封裝類里做一次偽關(guān)聯(lián)遍歷主表每一行把滿足關(guān)聯(lián)條件的子表行掛在當(dāng)前主表記錄下面。這樣調(diào)用方不用關(guān)心游標(biāo)問(wèn)題只需要告訴我兩個(gè)表用哪個(gè)字段關(guān)聯(lián)。2.2 內(nèi)部數(shù)據(jù)流從DataTable到報(bào)表記錄集內(nèi)部實(shí)現(xiàn)的核心邏輯是把傳入的DataTable轉(zhuǎn)換成銳浪報(bào)表的Recordset。我采用的是逐行Append的方式而不是走ConnectionStringQuerySQL那樣還得配置數(shù)據(jù)庫(kù)連接耦合太重。每拿到一張DataTable就遍歷它的列結(jié)構(gòu)對(duì)應(yīng)創(chuàng)建Recordset的Field然后逐行添加記錄。這里有一個(gè)關(guān)鍵點(diǎn)容易踩坑銳浪報(bào)表里Recordset的Field必須和模板里DetailGrid引用的Field名字一致否則預(yù)覽時(shí)對(duì)應(yīng)字段就是空白。所以封裝類內(nèi)部做了一次校驗(yàn)如果模板里DetailGrid的字段名在傳入DataTable列里找不到就自動(dòng)跳過(guò)并在日志里記錄而不是直接拋異常。這個(gè)設(shè)計(jì)在處理子表時(shí)特別實(shí)用因?yàn)樽颖砟0褰?jīng)常會(huì)多配置幾個(gè)合計(jì)列這些列在業(yè)務(wù)DataTable里可能不存在。子表記錄的掛載流程是我這個(gè)封裝類最核心的一段邏輯。可以把主表想象成一個(gè)外層循環(huán)子表是內(nèi)層循環(huán)。處理方式分兩步對(duì)子表DataTable按關(guān)聯(lián)字段建立字典索引Lookup這樣不用每條主表記錄都去全表掃描子表數(shù)據(jù)量大的時(shí)候性能差距非常大。每添加一條主表記錄就把字典索引中對(duì)應(yīng)的所有子表行按順序添加到當(dāng)前主表的子DetailGrid中。這么做的優(yōu)勢(shì)很明顯不管子表數(shù)據(jù)順序怎么亂只要關(guān)聯(lián)字段值能對(duì)上基本不會(huì)出現(xiàn)子表串到別的父記錄下面這種詭異現(xiàn)象。3. 實(shí)操寫一個(gè)帶子表的銷售訂單打印3.1 模板準(zhǔn)備與約定我拿最常見(jiàn)的銷售訂單來(lái)演示。模板用銳浪報(bào)表設(shè)計(jì)器畫(huà)好主表DetailGrid區(qū)域放訂單號(hào)、客戶名稱、訂單日期這些字段子表DetailGrid放在主表區(qū)域內(nèi)部放商品名稱、規(guī)格、數(shù)量、單價(jià)、金額這些字段通常還會(huì)加一個(gè)金額合計(jì)框。模板設(shè)計(jì)時(shí)有兩個(gè)約定必須遵守。第一主表DetailGrid不能開(kāi)啟每頁(yè)重復(fù)表頭以外的新頁(yè)屬性否則子表會(huì)被分頁(yè)切散。第二字段名一定要跟DataTable列名保持一致這是封裝類自動(dòng)匹配的前提。比如DataTable列名是ProductName模板字段名也得是ProductName。說(shuō)句題外話很多人覺(jué)得模板導(dǎo)入主從數(shù)據(jù)結(jié)構(gòu)麻煩其實(shí)銳浪報(bào)表設(shè)計(jì)器自帶主從報(bào)表向?qū)冉ㄖ鞅韰^(qū)域再在主表DetailGrid上插入子表區(qū)域設(shè)計(jì)器會(huì)自動(dòng)生成嵌套結(jié)構(gòu)。真正麻煩的是程序運(yùn)行時(shí)把數(shù)據(jù)塞進(jìn)去所以封裝類才值得寫。3.2 主表與子表數(shù)據(jù)組裝這個(gè)示例的場(chǎng)景是界面上一個(gè)訂單主表DataTableorderTable字段有OrderNo、CustomerName、OrderDate一個(gè)訂單明細(xì)DataTabledetailTable字段有OrderNo、ProductName、Quantity、Price。兩個(gè)表通過(guò)OrderNo關(guān)聯(lián)。調(diào)用封裝類的代碼簡(jiǎn)化到不能再簡(jiǎn)化// 假設(shè)helper是封裝類的實(shí)例 RuiLangReportHelper helper new RuiLangReportHelper(); // 1. 加載模板 helper.LoadTemplate(C:\Templates\SalesOrder.grf); // 2. 設(shè)置頁(yè)眉參數(shù) helper.SetParameter(PrintOperator, 張三); helper.SetParameter(PrintDate, DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss)); // 3. 設(shè)置主表數(shù)據(jù) helper.SetMasterData(orderTable); // 4. 添加子表數(shù)據(jù)通過(guò)OrderNo關(guān)聯(lián)主表 helper.AddDetailData(OrderNo, OrderNo, detailTable); // 5. 預(yù)覽打印 helper.Preview();如果是一次打印多個(gè)訂單封裝類可以支持循環(huán)預(yù)覽。這里有個(gè)性能細(xì)節(jié)SetMasterData之前應(yīng)該先把數(shù)據(jù)準(zhǔn)備好千萬(wàn)別在循環(huán)里頻繁調(diào)用LoadTemplate模板加載是很耗時(shí)的操作。3.3 核心實(shí)現(xiàn)封裝類內(nèi)部都在做什么現(xiàn)在看一下封裝類內(nèi)部最關(guān)鍵的兩個(gè)方法。第一個(gè)方法是把DataTable轉(zhuǎn)換為Recordset代碼邏輯如下private void FillRecordset(DataTable table, IRecordset recordset) { if (table null || recordset null) return; // 先根據(jù)DataTable列結(jié)構(gòu)創(chuàng)建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); } } // 逐行添加數(shù)據(jù) 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; } } } }代碼里我做了一個(gè)字段存在性判斷這么做是有原因的。實(shí)際項(xiàng)目中子表DataTable通常比模板字段多不少列這些額外的列比如備注、換算率往往并不需要打印。如果全部嘗試寫入記錄集某些版本的銳浪報(bào)表會(huì)直接拋?zhàn)侄尾淮嬖诋惓?。加上這個(gè)判斷以后額外列自動(dòng)被忽略模板字段仍然能正常賦值容錯(cuò)率一下就上去了。第二個(gè)關(guān)鍵方法是子表掛載邏輯。主表Recordset每Append一行就立刻在該行的DetailGrid里掛上對(duì)應(yīng)的子表記錄這樣銳浪報(bào)表在遍歷打印時(shí)能夠保證當(dāng)前父記錄跟子記錄的關(guān)系始終是點(diǎn)對(duì)點(diǎn)private void AttachDetailData(IGridppReport report, string masterKey, string detailKey, DataTable detailTable) { if (detailTable null || detailTable.Rows.Count 0) return; // 對(duì)子表按關(guān)聯(lián)字段建立索引避免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(); } }這里最坑的一個(gè)點(diǎn)就是masterRs.MoveFirst()和MoveNext()要配套使用很多新手容易在循環(huán)里忘記MoveFirst結(jié)果只從當(dāng)前游標(biāo)開(kāi)始處理導(dǎo)致前幾條主表記錄的子表數(shù)據(jù)沒(méi)掛上。封裝類內(nèi)部處理好了調(diào)用方就不用管這些了。4. 常見(jiàn)問(wèn)題排查與優(yōu)化4.1 子表不顯示90%是這兩個(gè)原因這個(gè)封裝類最初投入使用的時(shí)候收到最多的反饋就是子表區(qū)域顯示空白。排查下來(lái)的結(jié)果絕大多數(shù)是兩種原因。第一種是模板里主表DetailGrid下的子表DetailGrid索引不對(duì)。代碼里用report.DetailGrid.SubDetailGrids[0]去拿子表但如果模板設(shè)計(jì)時(shí)先加的合計(jì)區(qū)域、再加的子表區(qū)域索引順序就會(huì)亂掉。穩(wěn)妥做法是在封裝類里加一個(gè)按字段名查找子表的邏輯找到包含特定字段名的那個(gè)SubDetailGrid而不是盲目取索引0。第二種原因是主表Recordset還沒(méi)有MoveFirst就直接去遍歷子表。銳浪報(bào)表的游標(biāo)機(jī)制跟DataTable不一樣它默認(rèn)停留在第一條記錄之前不移動(dòng)游標(biāo)的話子表關(guān)聯(lián)判斷拿到的是無(wú)效位置。這個(gè)問(wèn)題在封裝類里做了統(tǒng)一處理每次執(zhí)行數(shù)據(jù)填充時(shí)強(qiáng)制MoveFirst()確保從首記錄開(kāi)始。4.2 批量打印時(shí)只出了第一張?jiān)趺崔k多個(gè)主表記錄連續(xù)打印的場(chǎng)景比如批量打印班組派工單經(jīng)常會(huì)遇到只打印第一張就停下來(lái)的情況。這是由于銳浪報(bào)表默認(rèn)的打印范圍是當(dāng)前記錄也就是游標(biāo)所在的那一條。正確做法是在Print()之前調(diào)用MoveFirst()重置游標(biāo)并把打印范圍設(shè)為全部記錄。封裝類里我特意留了一個(gè)BatchMode屬性默認(rèn)false。如果調(diào)用方要批量打印把它設(shè)成true內(nèi)部自動(dòng)處理游標(biāo)移動(dòng)和頁(yè)數(shù)重置public bool BatchMode { get; set; } false; public void Print() { if (BatchMode) { report.DetailGrid.Recordset.MoveFirst(); report.Print(true); // 參數(shù)為是否打印全部 } else { // 單張打印時(shí)游標(biāo)保持在指定位置 report.Print(); } }4.3 大數(shù)據(jù)量下的卡頓與內(nèi)存釋放說(shuō)完錯(cuò)誤再說(shuō)性能。一套ERP里面打印幾千行明細(xì)并不稀奇如果不做處理預(yù)覽或者打印的時(shí)候界面會(huì)卡死一兩秒。銳浪報(bào)表在加載大數(shù)據(jù)量時(shí)如果開(kāi)著屏幕繪制效率會(huì)非常低。封裝類里可以在開(kāi)始填充數(shù)據(jù)前關(guān)閉繪制填充完成后再恢復(fù)在Initialize的時(shí)候調(diào)用report.SetDrawEnable(false); // 數(shù)據(jù)填充 // ... report.SetDrawEnable(true);實(shí)測(cè)下來(lái)數(shù)據(jù)量上萬(wàn)行的時(shí)候這個(gè)開(kāi)關(guān)能讓預(yù)覽速度提升30%以上。不過(guò)要注意關(guān)閉繪制狀態(tài)下不能調(diào)用預(yù)覽否則界面空白先把繪制打開(kāi)再預(yù)覽。最后說(shuō)一個(gè)資源釋放問(wèn)題。銳浪報(bào)表是COM組件用完之后需要顯式釋放否則在長(zhǎng)時(shí)間運(yùn)行的WinForm程序里很容易出現(xiàn)內(nèi)存持續(xù)增長(zhǎng)。封裝類里實(shí)現(xiàn)IDisposable接口在Dispose里把報(bào)表對(duì)象、Recordset對(duì)象全部置空public void Dispose() { if (report ! null) { Marshal.ReleaseComObject(report); report null; } GC.Collect(); }我見(jiàn)過(guò)不少項(xiàng)目報(bào)表功能正常但每次打印完內(nèi)存漲幾MB最后程序越來(lái)越卡基本都是沒(méi)釋放COM對(duì)象導(dǎo)致的。這個(gè)細(xì)節(jié)雖然不起眼但長(zhǎng)期運(yùn)行的服務(wù)或者頻繁打印的收銀臺(tái)場(chǎng)景差別非常大。這個(gè)封裝類用到現(xiàn)在最大的感受是把銳浪報(bào)表當(dāng)成一個(gè)報(bào)表渲染引擎而不是開(kāi)發(fā)工具集來(lái)用整個(gè)代碼結(jié)構(gòu)就清晰多了。模板繼續(xù)用設(shè)計(jì)器維護(hù)數(shù)據(jù)組裝全部交給封裝類業(yè)務(wù)層只關(guān)心DataTable里有沒(méi)有數(shù)據(jù)。沒(méi)封裝的同事還在被DetailGrid和Recordset折磨的時(shí)候這邊已經(jīng)躺著升級(jí)完功能了。如果你也在WinForm里做單據(jù)打印強(qiáng)烈建議花半天時(shí)間把這層封裝做出來(lái)后面所有報(bào)表需求都能順著這條路繼續(xù)走下去。本文還有配套的精品資源點(diǎn)擊獲取