實(shí)戰(zhàn):從模板布局到批量隊(duì)列)
簡(jiǎn)介一套基于 C# WinForms 開發(fā)的快遞單打印系統(tǒng)完整源碼面向物流軟件學(xué)習(xí)者、中小型快遞站點(diǎn)開發(fā)者及需要掌握桌面打印與數(shù)據(jù)庫(kù)操作的 C# 初學(xué)者解決快遞單模板維護(hù)、批量打印、歷史查詢和管理員權(quán)限控制等實(shí)際問題可快速搭建小型快遞打印工具或作為畢業(yè)設(shè)計(jì)參考。壓縮包共 97 個(gè)文件約 2.73MB主體為 34 個(gè) .cs 源碼文件與 14 個(gè) .resx 資源文件附帶 16 個(gè) .gif 圖片、14 個(gè) .ico 圖標(biāo)、數(shù)據(jù)庫(kù) .mdf/.ldf 與 .config 配置文件覆蓋界面設(shè)計(jì)、數(shù)據(jù)訪問、業(yè)務(wù)邏輯和部署配置等層面。目前已有 725 人學(xué)習(xí)下載。包內(nèi)包含 Express.sln 解決方案、DAL 數(shù)據(jù)訪問層、FormLogin 登錄窗體、AppForm 主窗體、CustomControl 自定義控件及 Database 數(shù)據(jù)庫(kù)目錄可完整運(yùn)行并便于二次開發(fā)。通過學(xué)習(xí)這套源碼可掌握 PrintDocument 打印事件處理、ADO.NET 數(shù)據(jù)綁定、DataGridView 條件查詢以及基于角色的管理員權(quán)限控制等關(guān)鍵技能是理解 WinForms 分層結(jié)構(gòu)與打印流程的實(shí)用參考。 接下快遞單打印系統(tǒng)這個(gè)需求時(shí)我以為這是WinForms生涯里最簡(jiǎn)單的一單。無非就是把收件人、寄件人、單號(hào)顯示出來拖一個(gè)PrintDocument選好打印機(jī)Print()一調(diào)用就完事。等真正把第一版放到電子秤邊上實(shí)測(cè)問題一個(gè)接一個(gè)冒出來?xiàng)l碼在熱敏紙上掃不出來、換了臺(tái)打印機(jī)后整張面單整體偏移、批量打三四十單時(shí)程序直接卡死。斷斷續(xù)續(xù)改了三版才把穩(wěn)定性和兼容性打磨到位。這篇不是講概念是我基于C# WinForms完成快遞單打印系統(tǒng)的完整落地記錄重點(diǎn)放在模板布局、條碼生成、掃碼槍聯(lián)動(dòng)、批量隊(duì)列和實(shí)測(cè)踩坑上適合要接手打印類桌面工具、電商打單或倉(cāng)庫(kù)發(fā)貨系統(tǒng)的朋友參考。1. 為什么快遞單打印我會(huì)選WinForms而不是Web或WPF1.1 這個(gè)系統(tǒng)的典型業(yè)務(wù)場(chǎng)景快遞單打印系統(tǒng)最常見的使用場(chǎng)景是三類快遞代收點(diǎn)、電商倉(cāng)庫(kù)發(fā)貨區(qū)、企業(yè)行政前臺(tái)。這些場(chǎng)景有幾個(gè)共同特征電腦配置不高、打印機(jī)是熱敏標(biāo)簽機(jī)或針式面單機(jī)、現(xiàn)場(chǎng)人員操作水平參差不齊、網(wǎng)絡(luò)環(huán)境不穩(wěn)定。很多時(shí)候就是一個(gè)Windows主機(jī)插一臺(tái)打印機(jī)、接一把掃碼槍任務(wù)很純粹掃描或者選中訂單打印出面單。在這種環(huán)境下軟件的要求是開機(jī)即用、離線可用、操作路徑短。WinForms打包成單文件或者免安裝綠色版放到桌面雙擊就能跑業(yè)務(wù)員不會(huì)有任何學(xué)習(xí)成本。這是Web方案很難替代的體驗(yàn)。1.2 三種技術(shù)方案的取舍對(duì)比我踩過一次用Web做打印的坑最后放棄的原因很直接瀏覽器調(diào)打印機(jī)權(quán)限受限。雖然Chrome的Web Print API能解決一部分但現(xiàn)場(chǎng)用的機(jī)器五花八門老的Win7系統(tǒng)、舊版瀏覽器搞一套兼容方案的時(shí)間成本遠(yuǎn)超功能本身。WPF我也考慮過打印能力和WinForms本質(zhì)一樣但表單開發(fā)效率確實(shí)低一截。方案開發(fā)效率打印機(jī)控制離線使用部署維護(hù)適合場(chǎng)景WinForms高直接使用PrintDocument可控性足完全離線拷貝即用單機(jī)/局域網(wǎng)打印工具WPF中同樣走PrintDocument完全離線需裝框架界面要求高的桌面應(yīng)用Web中受限需依賴瀏覽器特性受限需要服務(wù)器多門店集中管理WinForms還有兩個(gè)隱藏優(yōu)勢(shì)一是事件模型簡(jiǎn)單一個(gè)按鈕一個(gè)事件業(yè)務(wù)員誤操作恢復(fù)也容易二是可以輕松調(diào)用Win32 API我在處理掃碼槍這類“模擬鍵盤輸入”的外設(shè)時(shí)直接在本窗體級(jí)做消息攔截就行不需要引入額外權(quán)限。1.3 模塊劃分給后續(xù)擴(kuò)展留余地雖然是單機(jī)工具我還是把它拆成了四塊數(shù)據(jù)接入層、模板渲染層、打印調(diào)度層、外設(shè)交互層。數(shù)據(jù)接入層對(duì)接Excel導(dǎo)入或者簡(jiǎn)單的HTTP接口模板渲染層負(fù)責(zé)把訂單字段畫到畫布上打印調(diào)度層管批量隊(duì)列外設(shè)交互層管掃碼槍。后面業(yè)務(wù)方提了“對(duì)接ERP自動(dòng)取單”“多打印機(jī)分揀”這些需求基本都是在這四塊上做加法沒有推倒重來。2. 版面控制的核心毫米與像素?fù)Q算以及模板配置化2.1 打印的本質(zhì)是在Graphics上繪制而不是截屏很多第一次寫打印程序的人會(huì)繞一個(gè)彎把WinForms控件或者窗體截圖然后把這個(gè)圖片發(fā)給打印機(jī)。這個(gè)方案確實(shí)簡(jiǎn)單但后果很嚴(yán)重——界面縮放比例一變打印內(nèi)容就跟著變窗體被遮擋時(shí)截出來是黑塊高分屏下圖片模糊。正確做法是在PrintDocument的PrintPage事件里拿到Graphics對(duì)象直接在這個(gè)畫布上繪制文字、線條和圖片。值得說明的是PrintPage里的Graphics是打印機(jī)驅(qū)動(dòng)給你的設(shè)備上下文它的DPI不是屏幕的96而是打印機(jī)的實(shí)際分辨率。熱敏標(biāo)簽機(jī)一般是203 DPI稍微好一點(diǎn)的300 DPI。如果不注意換算界面看著正常打印出來位置全亂。2.2 毫米到像素的換算與原點(diǎn)選擇做快遞面單尺寸單位一定是毫米。比如一聯(lián)面單是100mm×180mm二聯(lián)是100mm×200mm。在PrintPage里畫東西時(shí)第一件事是根據(jù)當(dāng)前打印機(jī)的DPI把毫米算成像素private void PrintPageHandler(object sender, PrintPageEventArgs e) { Graphics g e.Graphics; float mm2pxX g.DpiX / 25.4f; float mm2pxY g.DpiY / 25.4f; // 以可打印區(qū)域的左上角為原點(diǎn)避開打印機(jī)物理邊距 float originX e.PageSettings.HardMarginX / 100f * g.DpiX; float originY e.PageSettings.HardMarginY / 100f * g.DpiY; g.TranslateTransform(originX, originY); // 在水平10mm、垂直15mm處繪制收件人 g.DrawString(收件人張三, _font, Brushes.Black, 10 * mm2pxX, 15 * mm2pxY); }這里最容易踩的坑是原點(diǎn)。e.PageBounds是整個(gè)紙張的邊界而e.PageSettings.PrintableArea是打印機(jī)真正能打印的區(qū)域兩者可能相差好幾毫米。如果不做處理直接在PageBounds原點(diǎn)開始畫換一臺(tái)打印機(jī)就會(huì)產(chǎn)生整體偏移。我后面專門為每臺(tái)打印機(jī)加了一個(gè)偏移量配置項(xiàng)默認(rèn)值取HardMargin實(shí)測(cè)下來兼容性好了很多。2.3 把模板做成可配置的JSON快遞面單的樣式其實(shí)經(jīng)常變今天加一個(gè)“內(nèi)件品名”明天調(diào)一下二維碼位置。如果每次改樣式都要編譯一次程序后期會(huì)非常痛苦。我的做法是把模板定義放在JSON文件里運(yùn)行時(shí)加載字段坐標(biāo)全部用毫米表示{ TemplateName: 一聯(lián)100x180, PageWidthMm: 100, PageHeightMm: 180, Fields: [ { Key: ReceiverName, X: 10, Y: 15, FontSize: 12, FontName: 微軟雅黑 }, { Key: ReceiverPhone, X: 55, Y: 15, FontSize: 12, FontName: 微軟雅黑 }, { Key: ReceiverAddress, X: 10, Y: 32, FontSize: 10, FontName: 微軟雅黑 }, { Key: WaybillBarcode, X: 15, Y: 120, Width: 70, Height: 25, Type: Code128 } ] }反序列化成配置對(duì)象后PrintPage里遍歷字段、綁定數(shù)據(jù)源繪制。模板文件的優(yōu)先級(jí)比代碼高業(yè)務(wù)方要調(diào)位置時(shí)我只需要讓他們改JSON里的X和Y重啟程序生效完全不用動(dòng)代碼。這個(gè)設(shè)計(jì)看似多花了半天后面省下來的溝通成本是十倍不止。3. 條碼應(yīng)該用圖形畫而不是用條碼字體Code128與二維碼的生成細(xì)節(jié)3.1 為什么條碼字體方案不靠譜市面上有Code128字體裝到Windows后把字符串設(shè)置成那種字體就能顯示條碼。聽上去很方便但打印時(shí)你會(huì)發(fā)現(xiàn)不同打印機(jī)的驅(qū)動(dòng)對(duì)字體的抗鋸齒、縮放處理不一樣條碼線條的粗細(xì)在熱敏紙上會(huì)被渲染得粗細(xì)不均掃描槍識(shí)別率直線下降。我在第一版吃過這個(gè)虧普通激光打印機(jī)打出來能掃換到熱敏機(jī)上十條有三條掃不過去。正確的做法是用條碼生成庫(kù)先在內(nèi)存里生成Bitmap再把Bitmap按實(shí)際打印尺寸繪制到Graphics上。線條的粗細(xì)、模塊寬度都由算法保證打印機(jī)的字體渲染不摻和進(jìn)來識(shí)別率穩(wěn)定得多。3.2 Code128生成與繪制代碼Code128是我推薦用于快遞單號(hào)的編碼原因是它支持緊湊的Code C模式可以吧兩個(gè)數(shù)字壓縮成一個(gè)碼位同樣長(zhǎng)度下比Code39短一截。打印條碼時(shí)尺寸控制非常關(guān)鍵條碼高度不要低于8mm四周至少保留模塊寬度的靜區(qū)否則掃碼槍很難對(duì)焦。using ZXing; using ZXing.Common; using ZXing.Rendering; private Bitmap CreateCode128(string content, int pixelWidth 500, int pixelHeight 120) { var writer new BarcodeWriterBitmap { Format BarcodeFormat.CODE_128, Options new EncodingOptions { Width pixelWidth, Height pixelHeight, Margin 2, // 保留靜區(qū) PureBarcode true }, Renderer new BitmapRenderer() }; return writer.Write(content); }生成后畫到打印畫布上時(shí)按配置里的目標(biāo)尺寸縮放即可。我建議在圖片生成時(shí)的像素寬高至少達(dá)到500×120這樣即使熱敏機(jī)DPI只有203縮放之后條碼邊緣依然平滑不會(huì)出現(xiàn)鋸齒。3.3 二維碼生成與靜區(qū)/糾錯(cuò)級(jí)別二維碼在快遞場(chǎng)景里主要承載查件鏈接或者電子面單信息我用的QRCoder庫(kù)輕量而且API直觀。糾錯(cuò)級(jí)別我推薦M級(jí)L太脆弱面單在運(yùn)輸中難免有污損H等級(jí)冗余太多同樣是40個(gè)字符二維碼會(huì)比M級(jí)大一圈熱敏紙上擠占條碼區(qū)域。using QRCoder; private Bitmap CreateQrCode(string content, int moduleSize 10) { var generator new QRCodeGenerator(); var data generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.M); var code new QRCode(data); return code.GetGraphic(moduleSize); }這里有個(gè)細(xì)節(jié)GetGraphic返回的圖片默認(rèn)自帶一圈白邊那是二維碼標(biāo)準(zhǔn)要求的靜區(qū)不要為了省空間裁掉。打印尺寸上二維碼最小建議不小于25mm×25mm再小的話手機(jī)掃碼勉強(qiáng)可以掃碼槍反而容易誤判。4. 掃碼槍聯(lián)動(dòng)讓“掃一下”變成“打一張”的關(guān)鍵處理4.1 掃碼槍的輸入本質(zhì)絕大多數(shù)USB掃碼槍在系統(tǒng)層面被識(shí)別為鍵盤設(shè)備掃一下條碼就相當(dāng)于以極快的速度把這串字符一個(gè)一個(gè)發(fā)送進(jìn)來最后跟一個(gè)回車。這不涉及什么專用SDK難點(diǎn)在于如何跟人手工打字區(qū)分、如何避免焦點(diǎn)問題。有人會(huì)用全局鍵盤鉤子WH_KEYBOARD_LL來抓取我建議別這么干一是全局鉤子影響系統(tǒng)里所有鍵盤輸入回調(diào)寫得稍慢整個(gè)系統(tǒng)鍵盤都會(huì)卡頓二是很多安全軟件會(huì)攔截三是殺毒軟件誤報(bào)麻煩。我用的方案是WndProc攔截WM_CHAR消息范圍僅限本程序精準(zhǔn)而且安全。4.2 推薦做法主窗體級(jí)按鍵捕獲與間隔判定掃碼槍發(fā)送字符的間隔非常短一般在1到10毫秒之間而人手打字再快單字符間隔也很難低于30毫秒。利用這個(gè)差異我用80毫秒作為閾值超過這個(gè)間隔就視為新一次輸入這樣即使用戶在掃碼間隙操作了鍵盤也不會(huì)把內(nèi)容混進(jìn)條碼里。private readonly StringBuilder _scanBuffer new StringBuilder(); private DateTime _lastKeyTime DateTime.MinValue; protected override void WndProc(ref Message m) { const int WM_CHAR 0x0102; if (m.Msg WM_CHAR) { char c (char)m.WParam; if ((DateTime.Now - _lastKeyTime).TotalMilliseconds 80) _scanBuffer.Clear(); _lastKeyTime DateTime.Now; if (c \r) { string barcode _scanBuffer.ToString().Trim(); _scanBuffer.Clear(); HandleScannedBarcode(barcode); return; // 吞掉回車不讓它觸發(fā)默認(rèn)按鈕 } _scanBuffer.Append(c); return; // 吞掉字符避免干擾界面 } base.WndProc(ref m); }這個(gè)方案的巧妙之處在于不需要任何控件獲得焦點(diǎn)整個(gè)窗體處于激活狀態(tài)時(shí)掃描內(nèi)容自動(dòng)被捕獲。我在HandleScannedBarcode里先按快遞單號(hào)查數(shù)據(jù)庫(kù)命中后渲染模板并觸發(fā)打印整個(gè)過程掃一下單子就出來了。4.3 掃碼觸發(fā)后的打印流程與配置建議掃碼槍本身有些參數(shù)會(huì)影響體驗(yàn)最好在交付時(shí)幫用戶設(shè)置好后綴選回車、字符間隔選最短、輸入法強(qiáng)制英文狀態(tài)。之前在客戶現(xiàn)場(chǎng)遇到過中文輸入法把掃描數(shù)字串自動(dòng)轉(zhuǎn)成中文標(biāo)點(diǎn)導(dǎo)致單號(hào)匹配失敗后來我在程序啟動(dòng)時(shí)把當(dāng)前輸入法切到英文模式才解決。5. 批量打印隊(duì)列順序、重試與防止GDI句柄泄漏5.1 直接for循環(huán)打印為什么不行很多初學(xué)者批量打印就是foreach循環(huán)里調(diào)printDocument.Print()。小批量二三十張可能還能跑一旦上百?gòu)埥缑鏁?huì)卡到無法拖動(dòng)甚至打印機(jī)驅(qū)動(dòng)溢出報(bào)錯(cuò)。原因有兩方面Print()是同步操作它會(huì)在UI線程里完成整頁(yè)繪制另一個(gè)是打印機(jī)SPOOLER處理不過來UI線程被同步等待拖死。正確做法是把打印丟到后臺(tái)線程主界面只負(fù)責(zé)展示隊(duì)列狀態(tài)。業(yè)務(wù)員批量選擇100單后直接點(diǎn)“開始打印”界面依然可以正常操作。5.2 隊(duì)列實(shí)現(xiàn)與重試策略我用BlockingCollection做線程安全的打印隊(duì)列一個(gè)后臺(tái)線程不斷消費(fèi)任務(wù)。每張單的打印任務(wù)包含訂單數(shù)據(jù)和一個(gè)剩余重試次數(shù)失敗時(shí)先等幾秒再重新入隊(duì)連續(xù)失敗超過兩次就標(biāo)記失敗不影響后續(xù)單子。private readonly BlockingCollectionPrintTask _queue new BlockingCollectionPrintTask(new ConcurrentQueuePrintTask()); private void PrintWorker() { foreach (var task in _queue.GetConsumingEnumerable()) { try { using (var doc BuildPrintDocument(task.Order)) { doc.Print(); } MarkPrinted(task.Order.Id); } catch (Exception ex) { if (--task.RetryLeft 0) { Thread.Sleep(3000); _queue.Add(task); } else { MarkFailed(task.Order.Id, ex.Message); } } } }這里有個(gè)我自己總結(jié)的原則每個(gè)打印任務(wù)都new一個(gè)PrintDocument用完即Dispose。這樣可以保證GDI資源的生命周期清晰也避免了在同一個(gè)對(duì)象上反復(fù)Print導(dǎo)致狀態(tài)殘留。5.3 那個(gè)讓我排查到凌晨的GDI句柄泄漏批量打印系統(tǒng)上線第一周客戶反饋“打著打著就報(bào)內(nèi)存不足重啟又好一陣”。遠(yuǎn)程看任務(wù)管理器發(fā)現(xiàn)程序GDI對(duì)象數(shù)不斷上漲到一萬左右就崩。根因在PrintPage事件里我每次DrawString都new FontDrawImage都new Bitmap但沒有釋放。單次打印看不出問題批量幾百單下來句柄就累積了。修復(fù)方式很機(jī)械但很有效所有Graphics內(nèi)部創(chuàng)建的Font、Brush、Bitmap全部改用using包裹或者在using塊里創(chuàng)建。PrintDocument本身也在using里。改完后連續(xù)打八百?gòu)圙DI對(duì)象數(shù)始終穩(wěn)定在一百以內(nèi)。這個(gè)事給我最大的教訓(xùn)是寫打印代碼時(shí)腦子里要有一條“誰創(chuàng)建誰釋放”的線尤其批量場(chǎng)景任何一個(gè)對(duì)象泄漏都會(huì)被放大。6. 實(shí)測(cè)中遇到的四個(gè)“打印翻車”場(chǎng)景與排查鏈路6.1 坑一同一模板在不同打印機(jī)上位置偏移客戶一臺(tái)佳博熱敏機(jī)一臺(tái)愛普生針式機(jī)同一套模板打出來佳博正常愛普生整體往左上方偏移了約3mm。第一反應(yīng)是代碼坐標(biāo)算錯(cuò)了反復(fù)檢查都是按同一個(gè)公式算的后來打印測(cè)試頁(yè)才發(fā)現(xiàn)兩臺(tái)打印機(jī)的物理不可打印邊距不同。PrintPage里直接用PageBounds當(dāng)原點(diǎn)針式機(jī)左側(cè)不可打印區(qū)域比熱敏機(jī)大于是整張內(nèi)容被裁掉一部分。解決思路是先打印系統(tǒng)測(cè)試頁(yè)確定物理邊距然后代碼里用HardMarginX和HardMarginY做原點(diǎn)修正。如果還有細(xì)微偏差就在模板配置里增加一個(gè)全局OffsetX和OffsetY每臺(tái)機(jī)器微調(diào)一次后寫死到本地配置后續(xù)不再動(dòng)。6.2 坑二高分屏下界面錯(cuò)亂但打印結(jié)果不受影響WinForms在150%縮放的屏幕上如果不處理DPI控件會(huì)模糊錯(cuò)位。這個(gè)坑比較常見但重點(diǎn)是我發(fā)現(xiàn)它不會(huì)影響打印打印用的Graphics是從PrintDocument拿的和屏幕DPI無關(guān)所以模板畫出來依然是正常的。修復(fù)界面只需在app.manifest里聲明PerMonitorV2支持讓系統(tǒng)按實(shí)際DPI縮放。值得警惕的是反方向的問題如果有人在代碼里用屏幕Graphics的DpiX去算打印坐標(biāo)那就麻煩了。我見過把屏幕DPI誤用于打印坐標(biāo)的代碼打印結(jié)果會(huì)隨著顯示器DPI變化特別隱蔽。打印相關(guān)的坐標(biāo)計(jì)算一律從e.Graphics里取值不要引用屏幕。6.3 坑三批量打印幾十單時(shí)程序假死這個(gè)問題前面提到過for循環(huán)阻塞UI但還有一種情況已經(jīng)用了后臺(tái)線程大批量打印時(shí)依然卡頓。排查下來發(fā)現(xiàn)問題出在打印任務(wù)內(nèi)部同步查詢數(shù)據(jù)庫(kù)并渲染模板每個(gè)任務(wù)耗時(shí)不定后臺(tái)線程雖然不卡界面但打印機(jī)SPOOLER積壓了大量作業(yè)驅(qū)動(dòng)開始丟單。解決方法是把任務(wù)拆成“預(yù)渲染”和“發(fā)送打印”兩步先把所有訂單的模板渲染成Bitmap存起來再逐個(gè)發(fā)送給打印機(jī)同時(shí)控制SPOOLER的積壓量不超過20個(gè)作業(yè)。進(jìn)程內(nèi)的隊(duì)列只是邏輯控制真正要關(guān)注的是打印機(jī)驅(qū)動(dòng)能不能跟上你的投遞速度。6.4 坑四熱敏紙打印出淡字或空白代碼沒問題、打印機(jī)自檢正常但打出來的面單字跡發(fā)淡或者有時(shí)候整張空白。這種問題不是因?yàn)檐浖壿嫸菬崦艏埍旧砗万?qū)動(dòng)設(shè)置。排查鏈路我分享出來先確認(rèn)紙張是否裝反熱敏紙涂層一面要朝向打印頭再看驅(qū)動(dòng)里紙張類型是否選成“熱敏標(biāo)簽”如果選成熱轉(zhuǎn)印模式加熱強(qiáng)度就不對(duì)最后看驅(qū)動(dòng)的打印濃度等級(jí)熱敏紙一般要調(diào)到中等偏上。在WinForms里通過PrintDocument能控制打印濃度的能力很有限因?yàn)闈舛仁谴蛴C(jī)驅(qū)動(dòng)層的參數(shù)。我通常在程序設(shè)置頁(yè)里加一個(gè)“打開打印機(jī)首選項(xiàng)”的快捷按鈕引導(dǎo)用戶在驅(qū)動(dòng)里調(diào)整這比在代碼里寫死更可靠。如果重新再做一遍我會(huì)把今天寫的這些注意點(diǎn)直接做成一份檢查清單先拿三臺(tái)不同型號(hào)打印機(jī)跑同一套模板再批量壓測(cè)50單然后把掃碼槍連續(xù)掃100次看有沒有串碼漏碼。多數(shù)隱患都發(fā)生在你想不到的地方只有把標(biāo)準(zhǔn)場(chǎng)景過一遍系統(tǒng)才算真正落地。踩過這些坑之后我現(xiàn)在看到“打印”兩個(gè)字都會(huì)多留個(gè)心眼也希望這篇記錄能幫你少走幾段彎路。本文還有配套的精品資源點(diǎn)擊獲取