
二维码和条形码在我们日常开发里的出镜率是真的高仓储物流的箱贴、固定资产的盘点点位、零售小票上的券码、甚至会议室门口的名牌都离不开“生成图片 打印贴纸”这套组合活。我之前接过一个WinForms桌面工具的需求要在离线环境下把产品编号、URL、序列号这些信息一键变成可扫描的码再直接送打印机出标签当时把C#生态里主流的二维码条形码方案挨个试了一遍踩了不少坑最后沉淀出一套比较稳的代码结构。这篇文章会把方案怎么选、核心实现怎么写、打印怎么排版、以及我实际遇到的坑和排查思路都摊开讲希望能帮你把这类工具快速落地少走几个月的弯路。这篇文章适合谁看如果你的需求是做桌面端小工具、MES/ERP配套的标签打印模块、超市或仓库的条码管理或者你只是个刚学C#想练手做点能“看得见摸得着”的项目这篇文章的内容都能直接拿来用。我会把依赖库、关键代码、参数设置和排错经验都写出来代码量不大但每段都是能跑通的。1. 方案选型C#生态里生成二维码和条形码的几条路做这类功能第一步不是写代码而是先想清楚用什么库。C#这边可选的方案不少但每家的侧重点、许可证、依赖体积和输出质量都不一样。我建议先把需求列出来再选比如你要不要中文内容、要不要多种条码类型、要不要SVG矢量输出、要不要商用授权。1.1 主流库的横向对比我实测下来C#环境下最常用的有几个QRCoder、ZXing.Net、BarcodeLib也叫BarcodeLib.Barcode.Standard、还有Aspose.BarCode这种商业库。我做了一个简单的对比表方便你快速判断该选哪个。库支持类型依赖输出格式许可证我的评价QRCoder仅二维码无纯C#PNG/BMP/EPS/SVGMIT二维码首选API简单中文支持好ZXing.Net二维码条码类型极全无位图/像素数据Apache 2.0功能全面适合需要多种码制的场景BarcodeLib条码为主约20种码制无位图免费/开源生成条码很够用Code128/EAN13都没问题Aspose.BarCode二维码条码商业组件多种格式商业授权功能最强但收费个人练手没必要看到这个表你可能会问为什么不用一个库搞定所有我的结论是二维码用QRCoder、条码用BarcodeLib两者组合是大多数桌面工具的最优解。原因有三个。第一都是开源且API极其简单一个类、两行代码就能出图不需要读几十页文档。第二两个库都没有额外的运行时依赖部署到客户电脑上不会出现“缺DLL”这种幺蛾子。第三二维码和条码的实现细节差别很大二维码需要容错级别、版本控制条码需要校验位、编码规则两个库各干各的反而更专精。1.2 为什么我把 QRCoder 作为二维码默认选项QRCoder是我目前用过最顺手的纯C#二维码生成库。它最打动我的一点是支持UTF-8字符集所以中文内容直接扔进去就能生成不需要手动转码。之前用某些老库一遇到中文就乱码二维码虽然能扫出来但内容全是问号排查了半天才发现是编码问题。QRCoder内部默认就是UTF-8省掉了这个心病。另一个优势是它支持四种容错级别L7%、M15%、Q25%、H30%。这个容错率的意思是二维码图形有百分之多少的区域被遮挡或污损时依然可以正确解码。打印标签时如果二维码会贴到圆弧面上、或者可能被油污覆盖我建议用Q或者H级别宁可图案密一点也要保证可扫率。至于输出格式QRCoder原生支持PNG、BMP、EPS和SVG通过GetGraphic方法控制像素大小。PNG适合直接贴到WinForms的PictureBox里或者打印SVG适合做印刷级的矢量图比如你要给广告公司出设计稿用SVG放大多少倍都不模糊。我自己的工具里界面预览用PNG导出文件时给用户PNG和SVG两个选项反馈很好。1.3 BarcodeLib 处理条码的边界条形码这边BarcodeLib是我对比下来性价比最高的选择。它支持Code39、Code128、EAN-13、EAN-8、UPC-A、Interleaved 2 of 5等大约20种常见码制覆盖了绝大部分业务场景。其中Code128和EAN-13是我最常用的两个Code128密度高、字符集全数字字母混合适合产品编号、序列号这种内部管理码EAN-13是国际通用的商品条码校验位算法固定适合零售场景。这里有个关键细节要注意EAN-13这种标准条码最后一位是校验位EAN-13的第13位也是校验位通常由前12位通过固定算法计算得出。BarcodeLib内部会自动计算并补全校验位但如果你传入的字符串本身带校验位就不要再额外计算否则算法会报错或者生成一个错误的码。这是我和很多同事都踩过的坑后面在问题排查章节会展开讲。2. 功能拆解一个“生成打印”工具到底要做什么很多人以为这类工具的核心就是“生成图片”这一行代码其实真做起来需求远比想象的多。我常用的做法是先画一张功能清单把用户输入、码制选择、图片预览、打印设置、批量处理这些要素全部列出来再决定模块怎么切分。2.1 需求清单与模块划分以一个典型的固定资产标签工具为例实际需求可能长这样支持输入一行文本或URL生成二维码支持输入商品编号数字字母生成Code128条码可以设置图片尺寸、二维码容错级别、是否显示底部文字生成的图片能保存为PNG或SVG支持单张打印和批量打印比如一次生成100个连续编号打印前能预览纸张布局支持调整标签大小历史记录功能保存生成过的内容方便二次打印对应到代码模块我习惯拆成三层。界面层负责收集参数和展示预览业务层负责调用生成库、组织数据、缓存图片打印层负责排版和输出。这三层分开之后好处是如果客户说“我想把条码从左边挪到右边”你只需要改打印层的坐标计算不用碰生成逻辑。2.2 界面设计与数据流WinForms界面我一般不会做得太花哨但有几个控件组合是固定搭配一个ComboBox选码制QR_CODE、CODE128、EAN13等几个TextBox收录入内容一个NumericUpDown控制尺寸和容错级别一个PictureBox做实时预览再放两个按钮“保存图片”和“打印”。数据流上有个很重要的设计原则预览和打印共用同一套生成方法绝不在两个地方各写一遍生成逻辑。我见过有项目在预览时用Zxing生成、打印时又换成了另一个库结果两种图片的像素密度、留白边距完全不同打印出来的效果跟预览对不上。正确做法是写一个GenerateBarcodeImage(content, type, size)的公共方法所有调用方都走它保证所见即所得。2.3 打印方案的选型思考打印这块桌面工具最常见的做法是用System.Drawing.Printing命名空间下的PrintDocument。这个类虽然老但胜在稳定而且不需要额外引入NuGet包在Windows环境下跑得非常好。对于特殊需求比如要精确控制热敏标签机的出纸位置PrintDocument也能通过设置PaperSize和PrintPage事件里的Graphics对象做到毫米级控制。那什么时候需要考虑其他方案呢如果是要做网页端的批量打印那PrintDocument就不合适了建议走浏览器的打印功能通过CSS控制分页和尺寸如果是做跨平台应用WinForms天然不适合要用Avalonia或MAUI但打印API又不一样了。说到底每个方案都是为特定场景服务的没有绝对的好只有合不合适。我这篇文章里讲的都是WinForms PrintDocument这条线因为它是国内中小型桌面工具最主流、也最容易被验证的方案。3. 核心源码实现二维码到条形码的生成细节选型聊完下面进正题。我会把二维码、条形码、图片保存三段核心代码分别拆开讲每段都会给出完整可跑的代码并说明关键参数为什么要这样设置。3.1 用 QRCoder 生成二维码的完整代码先上二维码生成代码这是整套工具里最简单但最常被写错的部分using System; using System.Drawing; using System.Drawing.Imaging; using QRCoder; public static Bitmap GenerateQRCode(string content, int pixelsPerModule 20, QRCodeGenerator.ECCLevel eccLevel QRCodeGenerator.ECCLevel.Q) { if (string.IsNullOrWhiteSpace(content)) throw new ArgumentException(二维码内容不能为空); using (QRCodeGenerator generator new QRCodeGenerator()) { // CreateQrCode 是核心内容 容错级别 - 二维码数据 QRCodeData qrData generator.CreateQrCode(content, eccLevel); // QRCode 是渲染器负责把数据变成图片 using (QRCode qrCode new QRCode(qrData)) { // GetGraphic 的第二个参数表示每个模块小方块占多少像素 return qrCode.GetGraphic(pixelsPerModule); } } }注意几个容易出问题的地方。第一个是using的写法QRCodeGenerator和QRCode内部都持有非托管资源不用using包住长时间运行会内存暴涨。第二个是pixelsPerModule这个参数它不是图片总尺寸而是每个小模块的像素数实际图片大小等于模块数乘以这个值。比如一个33x33的二维码Version 5pixelsPerModule设为10最终图片就是330x330像素。如果你要固定最终图片尺寸更稳妥的做法是先算出模块数再反推pixelsPerModule或者干脆生成之后再等比缩放。如果想控制留白边距QRCoder提供了另一个方法GetGraphic(pixelsPerModule, darkColor, lightColor, drawQuietZones)第四个参数drawQuietZones控制是否绘制静区二维码四周的空白区域。这个静区在扫码时非常重要扫描器需要靠它区分二维码边界我建议保留默认的true不要为了省几个像素取消静区否则打印出来的标签在边距很小的时候很可能会扫不出来。3.2 用 ZXing.Net 做二维码的备选方案虽然我默认用QRCoder但ZXing.Net在某些场景下依然有优势。比如你的项目里已经有ZXing在做条码识别读码那再拿它做生成端可以少引一个库再比如你需要自定义二维码前景色、背景色、甚至错误校正级别ZXing的EncodingOptions给得更细。下面是ZXing.Net生成二维码的典型写法using System.Drawing; using System.Drawing.Imaging; using ZXing; using ZXing.Common; using ZXing.QrCode; public static Bitmap GenerateQRCodeWithZXing(string content, int size 300) { QrCodeEncodingOptions options new QrCodeEncodingOptions { DisableECI true, CharacterSet UTF-8, Width size, Height size, Margin 1, ErrorCorrection ZXing.QrCode.Internal.ErrorCorrectionLevel.H }; BarcodeWriter writer new BarcodeWriter { Format BarcodeFormat.QR_CODE, Options options }; return writer.Write(content); }这里有个细节我要特别提醒CharacterSet UTF-8必须显式设置。ZXing.Net从Java版移植过来后默认的字符集行为在不同版本里不一致如果内容里有中文不显式指定UTF-8就可能出现编码错乱。DisableECI true的意思是关闭ECI编码标识头这样做的目的是让老式扫码枪也能正确解析代价是部分超长Unicode内容的兼容性下降。对绝大多数中英文混排内容来说这个设置是安全的。3.3 条形码生成Code128 与 EAN-13 的细节条形码用BarcodeLib来生成核心就几行代码using System.Drawing; using System.Drawing.Imaging; using BarcodeLib; public static Image GenerateBarcode(string content, int width 400, int height 120) { Barcode barcode new Barcode(); barcode.IncludeLabel true; // 在条码下方绘制文字 barcode.LabelFont new Font(Consolas, 12); // TYPE.CODE128 支持全ASCII字符TYPE.EAN13 只支持12或13位数字 Image image barcode.Encode( TYPE.CODE128, content, Color.Black, Color.White, width, height); return image; }IncludeLabel这个属性要重点说一下。把它设为true之后BarcodeLib会在条码底部自动绘制人类可读的文本这对仓库操作员来说几乎是刚需——扫码枪坏了的时候人要能直接看到编号。但LabelFont的字体和字号建议统一用等宽字体比如Consolas、Courier New避免数字和字母宽度不一致导致条码下方文字和条码本身的宽度对不齐。如果你要生成EAN-13内容必须严格是12位或13位数字。传12位时库会自动补校验位传13位时它会校验最后一位是否正确。有一个真实案例某零售系统导出的商品编码是12位不带校验位的原始编码但集成方误以为是完整EAN-13把最后一位截掉了再传入结果每次生成的条码都校验失败。正确做法是传完整的13位让BarcodeLib内部去验证如果只有12位也放心传库会帮你算好校验位。3.4 图片保存与导出格式的选择生成Bitmap之后保存是另一个高频需求。我统一用一个工具方法处理public static void SaveBitmap(Bitmap bmp, string filePath, string format png) { ImageFormat imageFormat format.ToLower() switch { png ImageFormat.Png, bmp ImageFormat.Bmp, jpeg or jpg ImageFormat.Jpeg, svg throw new InvalidOperationException(SVG请使用QRCoder的GetGraphic(Svg)方法), _ ImageFormat.Png }; bmp.Save(filePath, imageFormat); }这里我强烈建议导出PNG而不是JPG。原因很简单二维码是二值图黑白方块PNG使用无损压缩像素边界锐利清晰JPG是有损压缩在黑白交界处会产生压缩噪点这些噪点叠加到二维码模块上会显著降低扫码成功率。打印标签也同理设计稿里尽量不要贴JPG格式的条码。如果你需要SVG格式输出QRCoder有专门的重载using QRCoder; QRCodeGenerator generator new QRCodeGenerator(); QRCodeData data generator.CreateQrCode(SVG模式, QRCodeGenerator.ECCLevel.Q); SvgQRCode svgCode new SvgQRCode(data); string svgContent svgCode.GetGraphic(20); System.IO.File.WriteAllText(qrcode.svg, svgContent);SVG的好处是无限缩放不模糊适合印刷厂制版。但有两点要留意第一SVG本质是XML文本体积比PNG大不少第二老的图形库不支持SVG直接渲染如果你还在用.NET Framework 4.x且不想引额外的解析包建议SVG只用于导出不用于界面预览。4. 打印实现批量标签打印的完整编码思路生成图片只是第一步打印才是这类工具最容易“翻车”的阶段。我在这个环节吃过不少亏下面把打印的核心逻辑和避坑思路一起讲清楚。4.1 PrintDocument 基础用法PrintDocument是WinForms里最经典也最枯燥的打印基板但理解它的执行模型是排错的前提using System.Drawing; using System.Drawing.Printing; PrintDocument printDoc new PrintDocument(); printDoc.PrintPage PrintDoc_PrintPage; printDoc.DefaultPageSettings.PaperSize new PaperSize(A4, 827, 1169); printDoc.Print();PrintDocument的PrintPage事件会被多次触发打印一页触发一次打印完当前页后如果我在事件里把e.HasMorePages设为true它就会继续触发下一页直到设成false为止。这就是批量打印的基础。PrintPage事件里的e.Graphics就是GDI绘图对象和画在PictureBox上的操作几乎一致可以用DrawImage画图、DrawString画文字、DrawRectangle画边框。你要做的就是根据纸张大小和标签布局计算每个元素应该画在什么坐标上。4.2 模板排版与坐标换算打印标签最核心的坑是坐标换算。屏幕上的分辨率通常是96DPI甚至更高而打印机的物理精度是打印机自己决定的PrintDocument内部已经做了DPI换算——你在PrintPage里用的坐标单位是百分之一英寸。这句话意味着如果你直接把屏幕上的像素坐标拿过来用打出来一定偏小。正确做法是定义一个“标签页”模型把物理尺寸作为基准。比如一张A4纸竖放实际可用宽度是210mm。我们做一张标签二维码边长25mm下方文字高度5mm水平边距10mm。那么代码里应该这样换算// 1 inch 25.4 mm, 1/100 inch 0.254 mm float mmToUnit(float mm) mm / 25.4f * 100; float labelLeft mmToUnit(10); float labelTop mmToUnit(10); float qrSize mmToUnit(25); float fontSize 12f; e.Graphics.DrawImage(qrBitmap, labelLeft, labelTop, qrSize, qrSize); e.Graphics.DrawString(itemNo, new Font(微软雅黑, fontSize), Brushes.Black, labelLeft, labelTop qrSize mmToUnit(2));把毫米转成百分之一英寸这一步不能省。很多国产热敏条码打印机对坐标非常敏感差几个毫米就会导致标签内容偏出可打印区域尤其是一排放多个标签时最后一个很容易被裁掉。我建议把转换公式封装成工具方法并在测试阶段用“打印测试页”反复校准。4.3 批量打印与进度控制批量打印时我推荐先一次性生成所有图像缓存到List 里再用一个循环控制打印页。这样有两个好处一是能准确计算总页数和进度百分比二是避免在PrintPage事件里临时生成图片导致界面卡顿。一个简化的批量打印代码是这样private ListBitmap _pages; private int _currentPage 0; void BuildBatchPages(Liststring contents) { _pages new ListBitmap(); foreach (string content in contents) { Bitmap page new Bitmap(827, 1169); // A4 using (Graphics g Graphics.FromImage(page)) { // 在页面上摆多张标签这里只画一张作为示意 g.DrawImage(GenerateQRCode(content), 100, 100, 200, 200); } _pages.Add(page); } _currentPage 0; } void PrintBatch() { if (_pages null || _pages.Count 0) return; PrintDocument doc new PrintDocument(); doc.PrintPage (sender, e) { if (_currentPage _pages.Count) { e.Graphics.DrawImage(_pages[_currentPage], 0, 0); _currentPage; e.HasMorePages _currentPage _pages.Count; } else { e.HasMorePages false; } }; doc.Print(); }有一个隐藏问题我要提醒如果标签内容特别多比如几千个序列号一次性把所有Bitmap都放到内存里内存占用会非常高。一张300x300的PNG二维码加载为Bitmap后大约要占300KB到1MB不等1000张就是几百MB搞不好直接OOM。我的经验是设置一个阈值比如每次最多缓存200页超过就改为在PrintPage事件里现场生成用空间换内存。另外PrintDocument.Print()是同步阻塞的标签多了会导致界面假死。如果项目对体验要求高可以用PrintController的PrintToFile或者放到BackgroundWorker里跑。不过打印机的驱动本身对连续任务有缓冲一般不用太担心我更在意的是别让UI线程卡死用户看着转圈圈会以为程序崩了。5. 常见问题与排错实录这部分是我最想写的因为这些坑都是真实项目里踩出来的很多问题排查了半天最后原因居然特别简单。5.1 中文内容生成二维码后扫不出来这个问题的经典表现是扫完出来一堆乱码或者扫完只显示“http://”后面的内容全变问号。最常见的原因是库没有用UTF-8编码。QRCoder默认没问题ZXing.Net必须显式设置CharacterSet UTF-8。但还有一个容易被忽略的原因WinForms的TextBox如果用多行模式会把\r\n回车换行带到二维码内容里有些扫码App会把这串换行符转成乱码。解决办法是把文本统一做一次清洗去掉\r保留\n最好在提交生成前Trim一遍。还有一次比较离奇的经历二维码内容是一个含中文和特殊符号的URL前缀生成时正常扫码也正常结果用特定的微信版本扫出来URL里的中文没有做URL编码导致页面404。这个就不是生成端的问题了是业务URL本身不规范。传给二维码生成器的内容如果有URL建议先做Uri.EscapeDataString处理保证特殊字符被正确编码。5.2 条形码扫描枪识别率低扫描枪识别率低我排查过三个方向。一个是条码宽度太窄Code128的最小模块宽度如果小于0.2mm很多扫描枪就hold不住了所以设置width参数时不要一味追求小。另一个是颜色对比度不够有些人为了好看把条码做成了深蓝色加浅灰底虽然肉眼看得清但扫描枪的红外光对蓝灰的敏感度不如纯黑纯白识别率明显下降。生产用的条码尽量保持黑条白底这个真的不是审美问题是可靠性的问题。第三个方向是字符集误用。Code39只能表示大写字母、数字和少数字符如果内容里有小写字母直接用Code39生出来的条码会在扫描时被解码成错误内容或直接失败。这类需求应该改用Code128它的字符集覆盖完整ASCII。我见过一个项目把产品型号“A2b-3”用Code39去生成看起来也扫得出但扫出来的结果是“A2B-3”大小写被吞了排查了整整一天才发现是码制选错。5.3 打印尺寸和实际标签对不上这个坑我栽得最深。有一次客户反馈说标签打印出来只有实际尺寸的70%我一开始怀疑是打印机DPI设置后来发现罪魁祸首是PaperSize没有设置正确。PrintDocument的DefaultPageSettings.PaperSize有时候会被打印机驱动覆盖导致你设的A4打印出来实际用了别的纸张尺寸。解决思路是在PrintPage事件里用e.PageSettings.PaperSize这种运行时参数而不是在外部预先假设纸张大小printDoc.PrintPage (sender, e) { float paperWidthUnit e.PageSettings.PaperSize.Width; float paperHeightUnit e.PageSettings.PaperSize.Height; // 基于这个实际纸张尺寸计算坐标 };另一个原因是显示器的缩放比例。如果你的开发机是4K屏幕、DPI缩放是150%WinForms默认坐标换算会出问题。解决办法是在Program.cs里加上SetProcessDPIAware调用或者在窗体上设置AutoScaleMode为Dpi保证打印逻辑不要受屏幕缩放影响。打印始终要基于物理单位百分之一英寸计算绝不要用屏幕像素做打印坐标。5.4 批量生成时的性能与内存问题批量生成1000个二维码如果逐个调用QRCodeGenerator.CreateQrCode再渲染耗时可能超过30秒。我实测过QRCoder生成单个二维码的时间大约在20到40毫秒1000个就是20到40秒而且GC压力很大。性能优化的第一个思路是复用QRCodeGenerator实例它是线程安全的不需要每次new一个。第二个思路是如果内容长短差异不大可以考虑用并行Parallel.For来生成但要注意控制并行度别把CPU吃满导致界面卡顿。还有一个内存相关的经典问题保存图片时如果不主动调用Bitmap.Dispose()内存会被GDI对象死死占住表现为程序越跑越慢最后抛OutOfMemory。这个问题在WinForms里尤其多因为Bitmap实现了IDisposable但很多人会忘记调用。我习惯用using或者try/finally包裹所有Bitmap的创建和保存尤其是在循环里。这里补充一个排查工具如果怀疑是GDI句柄泄漏用任务管理器看程序的“GDI对象”列如果这个数字随着操作次数持续上涨且不回落基本就是某个Bitmap或Graphics没有Dispose。GDI对象数量超过1万系统会开始抛异常。6. 我有一个印象很深的调试经历不是我故意卖关子而是这个经历太典型单独拿出来说比藏在上面几条里更有价值。之前做个标签打印工具用户反馈说同一份数据打出来的二维码有时候能扫有时候不能扫毫无规律。我先怀疑是打印机老化、墨粉不均但同一张纸上其他条码扫得好好的又怀疑是扫描枪的问题换了好几把都一样。后来我把有问题的图片逐像素放大看发现二维码模块边缘有一圈不是纯黑的灰色过渡带——问题出在生成二维码后我又对整个Bitmap做了一次缩放缩放算法选择了默认的双线性插值导致黑白交界处被插值出了灰色像素。扫码算法对灰度变化很敏感虽然人眼看不太出来但解码器在判定模块是黑是白时会出现边缘模糊容错率就大幅下降。解决办法是生成时直接指定合适尺寸不要生成后再缩放如果必须缩放用NearestNeighbor这种保持锐利边缘的插值模式别用高画质但会产生过渡色的模式。这个经历给我留下的不只是技术结论更是一条工作方式凡是和扫码可靠性沾边的逻辑一定要拿“最挑剔”的那个场景去验证。我在项目里加了一条测试规范——打印出来的标签必须用市场上常见的微信、支付宝、以及工业扫描枪各扫一遍任何一个解码失败都不能上线。这条规范帮团队挡掉了不少生产事故。7. 最后分享一个实用的小扩展如果你做的是一个长期要用的工具我建议给程序加一个简单的“历史记录”功能把每次生成的内容、码制、尺寸、时间存到一个本地SQLite或JSON文件里下次打开可以一键重新生成或批量补打。很多用户会遇到“这批箱子贴纸丢了20张想重新打印但原始数据找不到了”的情况。有了历史记录这个需求就是双击一下的事不需要重新录入对工具的口碑提升非常明显。这个小功能实现起来不复杂核心就是把生成参数序列化保存再提供一个列表界面让用户选择。但要注意一点不要把二维码图片直接存进历史记录存内容即可用的时候重新生成。理由还是内存和体积存一张300x300的PNG可能占几十KB但存一段文本只有几十字节而且重新生成的时间完全在可接受范围内。写在代码里的体会就是二维码和条码的工具看着简单真正到了生产环境随便一个编码细节、一个坐标换算、一个缩放插值都可能变成“线上事故”。我建议你动手做的时候不要只满足于“图片出来了”这个层面多花点时间在打印校准和扫码验证上。这两个环节做好了工具才算真正能交付。如果后面你有更多需求比如在Web端生成二维码、把条码工具接进WebAPI给其他系统调用或者用Avalonia做跨平台版本都可以基于这篇文章里的核心思路继续扩展。生成库和打印逻辑的边界拆得足够干净的话迁移到新平台时只需要替换界面层核心生成代码一行都不用改。