做企业内部工具这两年,最绕不开的需求就是打标签:固定资产贴条码、工单上印二维码、样品入库要扫码登记。用在线生成器吧,数据写死不说,还担心隐私和数量限制;用现成的商业标签软件吧,一套下来大几千,功能却有一半用不上。所以我自己用 C# 写了一个二维码、条形码生成打印的小工具,整个源码思路不算复杂,但真上手时会踩不少坑——比如条码扫不出来、打印位置偏了几毫米、批量生成内存直接涨到几百兆。这篇就把完整实现和踩坑记录一次讲清楚,源码直接抄就能跑,适合刚入门 C# 想做小工具的朋友,也适合已经在做进销存、MES、资产管理系统的开发者参考。
1. 项目整体设计与选型思路
1.1 为什么用 C# WinForms 做桌面工具
先说场景。大多数需要生成条码的环境都是 Windows 内网,机器配置不高,没有外网访问权限,员工只需要双击一个 exe 就能干活。这个前提下,Web 方案的部署成本反而高:要装 IIS、要配数据库、要考虑浏览器兼容性,对现场的维护人员来说太复杂。C# WinForms 天然适合这种场景——生成一个单文件 exe,拷贝到任意一台 Windows 机器就能跑,.NET Framework 又自带运行时,基本不用额外安装环境。
有人可能会问,为什么不用 WPF?老实说 WPF 做界面确实更现代,绑定机制也更优雅,但 WinForms 在这种工具型项目里有几个不可替代的优势:控件拖拽即用,Textbox、ComboBox、PictureBox 都是现成的,不需要写复杂的 DataTemplate;PrintPreviewDialog 和 PrintDocument 的配合是 WinForms 里打磨了十几年的成熟方案,做打印预览几乎是零成本;最重要的,WinForms 对低分辨率屏幕、老旧打印机驱动的兼容性更好,这在工厂车间的老电脑上尤其明显。
我做这个工具的时候就明确了一点:需求是生成快、打印准、操作简单,不是界面有多华丽。WinForms 的窗体布局虽然朴素,但胜在稳,能最快满足业务实际使用。
1.2 二维码生成方案怎么选才对
提到 C# 生成二维码,网上最常见的是三个库:ZXing.Net、QRCoder、ThoughtWorks.QRCode。我最后选了 ZXing.Net,原因很直接——它能同时搞定二维码和条形码,一个库解决全部需求,不用为了条码再引第二个包。
| 库 | 维护状态 | 支持格式 | 上手难度 | 说明 |
|---|---|---|---|---|
| ZXing.Net | 持续更新 | 二维码、Code128、EAN、PDF417、DataMatrix 等 20+ 格式 | 中等 | 跨平台移植自 Java,API 稍复杂,但功能最强 |
| QRCoder | 稳定 | 仅二维码 | 简单 | 纯 C# 实现,生成速度快,但条码需另找方案 |
| ThoughtWorks.QRCode | 停更多年 | 仅二维码 | 简单 | 老牌库,Bug 修得慢,遇到复杂内容容易出错 |
如果你的项目只需要二维码,QRCoder 确实更简单,两行代码就能出图。但作为标签工具,你永远不知道业务哪天会提出“再支持一下 Code128 条码”这种需求,用 ZXing.Net 从一开始就省了返工。还有一点值得注意:ZXing.Net 的强项是解析和生成一体的,后续如果你想把识别功能也加进来,比如用摄像头扫描校验打印结果,同一个库就能直接做到,不用再做技术选型。
1.3 条形码不是随便画个图案就行
条形码看起来就是黑白条纹,新手最容易犯的错误是觉得可以用 GDI+ 自己画几根线来代替。实际完全不是这回事。每种条码都有自己的编码规则,比如 Code128 的字符集切换、EAN13 的校验位计算、Code39 的起始符终止符,这些规则直接影响扫码枪能不能识别。
规格上,条码的模块宽度(最细那条线的宽度)有默认标准,静区(条码左右两侧的空白区域)宽度也有明确要求。自己画线很容易把宽度画错、静区画少,结果就是打印机打出来肉眼看没问题,但扫码枪死活不认。这也是我用 ZXing.Net 而不是自绘的原因——编码规则是现成的,算法经过大规模验证,能保证输出的条码符合标准。
具体格式选择上,我的经验是:内部资产管理和工业场景优先用 Code128,因为它支持全部 ASCII 字符,密度高、长度可变的适应性强;如果是要进商超零售的条码,那就必须用 EAN13,13 位数字是硬性要求,校验位算法也是固定的;Code39 虽然简单,但编码密度低,同样的内容会比 Code128 长出一大截,标签尺寸受限时不划算。
1.4 打印方案:PrintDocument 才是控制力最强的
打印这块,市面上有几种做法:用厂家提供的 SDK,比如斑马打印机的那套指令,打印速度快但只能适配特定机型;用 FastReport 这类报表控件,设计器强大但笨重,又要引入一堆 DLL;还有一种就是直接用系统自带的 PrintDocument,走 GDI+ 绘制,把图片和文字画到打印页面上。
我选择 PrintDocument 的原因是对控制力有要求。标签纸的尺寸五花八门,有 50mm x 30mm 的,有 100mm x 70mm 的,还有连续纸、不干胶、吊牌等不同规格。PrintDocument 的方案里,所有绘制逻辑都由你自己掌控,纸张大小、打印偏移、缩放比例、边距补偿全部可以精确到毫米,配合 PrintPreviewDialog 还能在打印前让用户预览效果,这对工厂车间太重要了——毕竟打错一批标签,损失的是材料和时间。
PrintDocument 真正的难点在于理解 Graphics 对象的坐标单位和打印机的可打印区域,这块我会在第 2 章详细拆解,这里先记住一点:所有打印机都有不可打印的物理边距,不是你在代码里设 X=0 就真能在纸张边缘开始打。
2. 核心代码实现拆解
2.1 二维码生成核心代码实现
ZXing.Net 生成二维码的标准方式是通过 BarcodeWriterPixelData 来输出像素数据,再转成 Bitmap。核心代码如下:
using ZXing; using ZXing.Common; using ZXing.Rendering; using System.Drawing; public Bitmap GenerateQrCode(string content, int size, int margin) { var writer = new BarcodeWriterPixelData { Format = BarcodeFormat.QR_CODE, Options = new EncodingOptions { Height = size, Width = size, Margin = margin, PureBarcode = false }, Renderer = new PixelDataRenderer() }; var pixelData = writer.Write(content); var bitmap = new Bitmap(pixelData.Width, pixelData.Height, PixelFormat.Format32bppArgb); var bmpData = bitmap.LockBits( new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.WriteOnly, bitmap.PixelFormat); Marshal.Copy(pixelData.Pixels, 0, bmpData.Scan0, pixelData.Pixels.Length); bitmap.UnlockBits(bmpData); return bitmap; }这里的 Margin 参数很关键,它代表二维码四周的静区宽度,默认是 0,但实际应用中我会建议至少给 2 到 4 个模块宽度。静区不足是扫码识别率低最常见的原因之一,微信扫一扫对这种问题尤其敏感。你可以把 Margin 理解成二维码的“安全距离”,留够了,识别才有保障。
有一点我得专门说明:网上很多代码用 BarcodeWriter ,老版本的 ZXing.Net 还支持直接 Write 返回 Bitmap,新版本里这个方式已过时或需要额外引用。BarcodeWriterPixelData 是更稳定的写法,先拿到像素数组,再手动转成 Bitmap,性能上反而更好,尤其在批量生成时差异非常明显。
2.2 条形码生成与编码格式选择
条形码的生成套路和二维码类似,只是要指定不同的 BarcodeFormat,并且要根据业务场景设置条码高度。Code128 的默认高度比较矮,打印出来容易被扫码枪吐槽,我通常会把它拉高到 60 到 80 像素左右。生成核心代码:
public Bitmap GenerateBarcode(string content, string format, int width, int height) { BarcodeFormat barcodeFormat; switch (format) { case "Code128": barcodeFormat = BarcodeFormat.CODE_128; break; case "EAN13": barcodeFormat = BarcodeFormat.EAN_13; break; case "Code39": barcodeFormat = BarcodeFormat.CODE_39; break; default: throw new NotSupportedException($"不支持的条码格式: {format}"); } var writer = new BarcodeWriterPixelData { Format = barcodeFormat, Options = new EncodingOptions { Width = width, Height = height, Margin = 8, PureBarcode = false } }; var pixelData = writer.Write(content); var bitmap = new Bitmap(pixelData.Width, pixelData.Height, PixelFormat.Format32bppArgb); // 像素数组转 Bitmap,代码同二维码 return bitmap; }注意,EAN13 对内容有硬性校验:必须是 12 位或 13 位数字,如果传 12 位,ZXing 会自动计算最后一位校验码;如果传 13 位,最后一位会被当作校验码验证,验证不通过会直接抛异常。这是一个容易踩的坑,我在界面层做了输入校验,提前告诉用户位数不对,而不是等二维码生成时才爆出异常。
条码下方要不要显示可读文本?这个要看应用场景。仓库扫码通常不需要,条码本身就是信息载体;但如果是打印产品标签,人需要肉眼核对编号,那就得在条码下面绘制文本。ZXing 的 Renderer 不会自动帮我们画文本,需要自己用 Graphics.DrawString 补上,我会在打印模块里统一处理,这样一个方法生成的图既能展示,又能打印。
2.3 打印模块与坐标控制
打印是整个工具里最考验细节的部分。首先,PrintDocument 的绘制事件里,Graphics 对象默认用的坐标单位是 1/100 英寸,也就是说你在代码里写 g.DrawImage(img, 100, 100),画出来的是距离纸张左上角 1 英寸的位置。这和我们平时想的“毫米”完全对不上,所以必须做单位换算:1 英寸等于 25.4 毫米。
实际用下来,我建议把 Graphics 的 PageUnit 直接设为 GraphicsUnit.Millimeter,这样后面所有坐标都按毫米来写,直觉又准确:
private void PrintDocument_PrintPage(object sender, PrintPageEventArgs e) { Graphics g = e.Graphics; g.PageUnit = GraphicsUnit.Millimeter; g.SmoothingMode = SmoothingMode.AntiAlias; g.InterpolationMode = InterpolationMode.HighQualityBicubic; float x = 5f; // 左边距 5mm float y = 5f; // 上边距 5mm // 绘制标题文字 using (var font = new Font("微软雅黑", 14, FontStyle.Bold)) { g.DrawString("产品标签", font, Brushes.Black, x, y); } // 绘制二维码 g.DrawImage(qrBitmap, x, y + 10f, 40f, 40f); // 绘制条码 g.DrawImage(barcodeBitmap, x, y + 52f, 60f, 30f); // 在条码下方补印人工可读文本 using (var font = new Font("Consolas", 8)) { g.DrawString(barcodeContent, font, Brushes.Black, x + 2f, y + 84f); } }关键的一步是打印机偏移补偿。每台打印机的物理不可打印边距都不一样,喷墨机和激光机不同,不同品牌差异也很大。如果打印机把内容打歪了,不要硬改代码里的坐标——先用一条测试纸量出实际偏了多少毫米,然后在代码里做一个全局偏移量补偿。我习惯在设置界面上放两个 NumericUpDown 让用户自己填 X 轴偏移和 Y 轴偏移,数值默认 0,现场调试时微调几毫米,保存后写入配置文件,以后再打印就一直是准的。
2.4 批量生成与文件导出
企业里很多时候不只是生成一张,而是生成一批:可能是从 Excel 导入几百个料号,也可能是从数据库查出来的订单列表。批量生成的代码逻辑很直白,但有两个坑必须提醒。
第一个坑是 Bitmap 资源释放。Bitmap 是 GDI+ 对象,不调用 Dispose 就会一直占用系统资源。如果你在循环里 new 了 500 个 Bitmap 不释放,内存会持续上涨,严重时直接报“内存不足”异常。正确写法是用 using 包裹,或者把生成逻辑封装成一个返回值的方法,让调用方明确释放。
public void BatchGenerate(DataTable table, string outputDir) { foreach (DataRow row in table.Rows) { string content = row["code"].ToString(); using (var barcode = GenerateBarcode(content, "Code128", 300, 80)) { string fileName = Path.Combine(outputDir, $"{content}.png"); barcode.Save(fileName, ImageFormat.Png); } } }第二个坑是文件名冲突。很多资产编号并不唯一,如果直接用编号做文件名,后面生成的文件会覆盖前面。稳妥的做法是在文件名里加上时间戳或 GUID。生成 500 个 Code128 条码,用 BarcodeWriterPixelData 的方式实测下来不到 3 秒,性能完全够用。
导出 PNG 时还要注意尺寸问题。屏幕显示 200x200 的图片看起来清楚,但打印到标签上可能就模糊了。建议生成时把位图尺寸定得比实际显示尺寸大 2 到 3 倍,比如打印尺寸是 40mm,按照打印机 300 DPI 算,图片宽度至少得 40 / 25.4 * 300 ≈ 472 像素,这样打印出来边缘才锐利。
3. 实操过程:从零搭一个可运行的生成打印工具
3.1 新建项目并引入依赖
打开 Visual Studio,创建 Windows 窗体应用,目标框架我推荐 .NET Framework 4.6.2 或 .NET 6 的 Windows Forms。如果是要部署到车间那种可能没升级过系统的老电脑,建议选 .NET Framework,兼容性最省心;如果是公司内网统一管控、系统都是 Win10 以上,那 .NET 6 也完全没问题,性能反而更好。
然后通过 NuGet 安装 ZXing.Net:
Install-Package ZXing.Net这个包是 ZXing 的官方 .NET 移植版,包名就叫 ZXing.Net,别装错了。装完之后,项目中会有 ZXing、ZXing.Common、ZXing.Rendering 这几个命名空间,用到哪些就 using 哪些。
3.2 界面布局与参数绑定
窗体上我放了这样一组控件,布局从上到下依次是:内容输入区、参数设置区、预览区、操作按钮区。
内容输入区是一个 Label 加一个 TextBox,文本框支持多行,方便批量粘贴多个编号,每行一个。参数设置区包括:ComboBox 选择条码类型(二维码 / Code128 / EAN13 / Code39)、NumericUpDown 设置图片尺寸、NumericUpDown 设置边距。预览区是一个 PictureBox,用来展示实时生成的图片。操作按钮区有三个按钮:生成预览、打印预览、批量导出。
生成的逻辑放在 TextBox 的 TextChanged 事件里,用户一改内容就自动刷新预览。但要注意,TextChanged 事件触发频率太高,如果内容很长或批量模式一次性粘贴几十行,连续生成图片会让界面卡顿。我的做法是加一个 debounce:用一个 System.Windows.Forms.Timer 延迟 300 毫秒再执行生成逻辑,用户停止输入后才刷新。
3.3 打印预览与打印完整流程
打印预览在 WinForms 里是标准流程:创建 PrintDocument 实例,订阅 PrintPage 事件,然后把 PrintDocument 赋给 PrintPreviewDialog,调用 ShowDialog。代码如下:
private PrintDocument printDoc = new PrintDocument(); public FormMain() { InitializeComponent(); printDoc.PrintPage += PrintDocument_PrintPage; } private void btnPrintPreview_Click(object sender, EventArgs e) { // 设置纸张大小:宽度 100mm,高度 70mm var paperSize = new PaperSize("Custom", 400, 280); // 注意单位是 1/100 英寸 printDoc.DefaultPageSettings.PaperSize = paperSize; printDoc.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); var preview = new PrintPreviewDialog { Document = printDoc }; preview.ShowDialog(this); }这里有个特别容易搞错的地方:PaperSize 的构造函数宽度和高度单位是 1/100 英寸,不是毫米。100mm 换算成 1/100 英寸大约是 394,70mm 大约是 276。我在实际使用时直接用了一个换算函数:int hundredthsInch = (int)(millimeters / 25.4 * 100),保证不会算错。
3.4 关键源码文件结构说明
把代码全塞在 Form1.cs 里当然也能跑,但为了后面维护方便,我把它拆成了三个类:
- FormMain.cs:界面逻辑,负责获取用户输入、调用生成方法、绑定打印事件。
- QrCodeService.cs:静态类,封装二维码和条形码的生成方法,输入内容返回 Bitmap。
- LabelPrinter.cs:负责纸张配置、坐标计算、打印绘制。
拆分的理由很简单:生成图片是纯逻辑,不依赖界面控件,以后想改成命令行工具导出、或者做成 Windows 服务自动打标签,直接调用 QrCodeService 就行,不用碰界面代码。LabelPrinter 单独抽出来是为了方便以后加新的打印模板,比如加个“合格证模板”“装箱单模板”,换一个类就行,不影响主窗体。
打印绘制时我把信息画在几个固定区域:标题在左上角,二维码在中间偏上,条码在下方,条码下面再补一行人工可读的编号。这个布局可以做成配置文件里的 JSON,让用户自定义坐标,但目前先写死在代码里也够用。能跑通之后,再考虑把参数开放出去。
4. 常见问题与排查技巧实录
4.1 条码和二维码扫不出来
这是被问得最多的问题。我排查的路径是一层层排除:先拿手机微信扫一眼生成的 PNG 图片,如果图片都扫不出来,问题在生成环节;如果图片能扫、打印出来扫不出,问题在打印质量。
生成环节常见的病因有三个。一个是静区太小,Margin 设 0 导致的,调大即可。一个是颜色太浅,有些模板为了好看把条码做成灰色,扫码设备会把它当成背景色,必须保证是纯黑条、白底。还有一个是内容里带了中文或特殊符号,二维码对内容有编码格式要求,QR_CODE 模式下默认是 ISO-8859-1 编码,中文需要转换成 UTF-8 再传入,否则生成的码扫出来是乱码。我用 Encoding.UTF8.GetBytes 和 ZXing 的 ByteMatrix 配合解决。
打印环节的问题多半是喷墨打印机墨量不足、打印介质反光太强、或者条码区域被压到了标签纸的折叠缝附近。车间里有一种光面不干胶,看上去很高级,但条码印上去反光严重,扫码枪就是识别不了,换成哑面材质立刻好。这种问题在代码里无解,只能换耗材。
4.2 打印位置偏移或尺寸不对
偏得不多但每次固定偏几毫米,这是打印机驱动里默认边距造成的。代码里我把 Margin 设为 0,但打印机驱动会在最外层再加一圈不可打印区域。解决方法是在 PrintPage 事件里读取 e.PageSettings.HardMarginX 和 HardMarginY,这两个值表示打印机的物理边距,然后把所有绘制坐标加上这个偏移量。
有的情况下不是偏移,而是整张图被缩放。检查 PaperSize 设置是否和你放入打印机的标签纸一致——如果你买的是 100mm x 70mm 标签纸,但代码里配置的是 A4,打印预览看着正常,实际打印出来却只有左上角一小块。我就在这上面栽过跟头,换纸后忘记改设置,打了半包废标签才反应过来。
还有个诀窍:在正式批量打印前,先用一个只含边框的测试页面打到一张普通 A4 纸上,量一下边框实际尺寸和你设置的是否一致。这一步能省下很多时间。
4.3 条码下方文字模糊或太小
扫码枪认条码不认文字,但人需要肉眼核对。如果文字偏小模糊,先看是不是高度不够——条码整体被压扁,文字只能挤在下边。解决方法是把条码高度从默认值往上调,同时把字体设得大一点。我的经验是条码高度至少 20mm,文字字号 9pt 以上,这样既能保证扫码枪快速识别,人眼也看得清。
另一个原因是缩放。如果打印时直接把一个 200x80 的位图拉大到 60mm 宽,图像就会发虚。正确做法是生成时就按目标尺寸计算像素,比如 60mm 宽的条码,300 DPI 情况下生成 709 像素宽的图片,打印时按 1:1 绘制,不要二次缩放。
4.4 批量生成时内存持续上涨
批量循环里忘写 Dispose 是最常见的。另一个隐藏坑是 Save 方法:如果保存成 JPG,内部要做压缩编码,这种操作会比较吃内存;保存成 PNG 相对轻量。我在 BatchGenerate 里显式调用了 GC.Collect,但注意不要在循环里频繁调用,而是在整个批处理结束后调用一次,否则反而会降低性能。
实测数据供参考:生成 500 个 Code128 条码并保存 PNG,在 i5 处理器、8GB 内存的机器上耗时约 2.8 秒,内存峰值约 120MB。如果的机器配置更低,可以把生成和保存分两个线程并行做,用 ThreadPool 控制并发数,一般控制在 4 个并发以内比较稳。
4.5 常见问题速查表
| 问题 | 常见原因 | 解决方案 |
|---|---|---|
| 图片能扫但打印后扫不出 | 墨水反光、打印精度低、颜色过浅 | 换哑面标签纸,纯黑打印,提高图片分辨率 |
| 图片本身就扫不出 | 静区不足、内容编码不对 | 调大 Margin,中文字符用 UTF-8 编码传入 |
| 打印位置固定偏移 | 打印机物理边距 | 读取 HardMarginX/HardMarginY 做坐标补偿 |
| 打印内容被裁切 | PaperSize 与标签纸不一致 | 确认标签纸尺寸并精确设置 PaperSize |
| 文字模糊 | 图片被拉伸、字体太小 | 按打印尺寸生成高分辨率位图,字号不小于 9pt |
| 批量生成内存爆涨 | Bitmap 未释放 | 使用 using 包裹,结束后调用 GC.Collect |
一路做下来,我最深的感受是:条码生成工具看着只是个简单的图片输出程序,但只要牵扯到“打印”二字,复杂度就翻倍。屏幕显示和纸面输出完全不是一回事,打印机驱动、纸张尺寸、物理边距、耗材材质,每一个变量都能让结果天差地别。如果你也在做类似工具,我的建议是先把最简单的“生成一张图 + 打印预览”跑通,再考虑加批量、加导出、加数据库对接。功能是慢慢长出来的,但基础路径必须一开始就走稳。