资讯动态

WinForm批量生成二维码:ZXing.Net参数调优与校验实践

发布时间:2026/10/26 14:32:35 来源:尧图企业网站定制
批量生成二维码这件事听起来像是调个库、写个循环、把图片存到硬盘就收工真在 WinForm 里落地过的人心里都清楚麻烦基本都不在“生成”这个动作上。真正耗时间的是内容长度怎么定、图片存多大、一万张跑起来界面会不会卡死、扫不出来到底是编码问题还是像素问题。我这些年做过货品标签、资产铭牌、工牌吊牌、设备巡检点位卡这几类批量出图的活儿踩的坑加起来能写半本笔记这次就按一个完整项目的推进顺序把 WinForm 批量生成二维码的做法从头到尾捋一遍。这篇东西适合谁看手上有一个 WinForm 老项目、需要给一批数据配上二维码的开发者刚接手标签打印模块、被“扫不出来”和“界面假死”折磨过的同行以及想把这套流程做成一个可复用小组件、以后接别的业务直接改数据源就行的朋友。下面所有代码和参数都可以直接抄涉及具体业务的部分我会标出来哪些需要按你的场景替换。核心组件围绕 ZXing.Net 这一套展开也会给出备选方案的写法方便你按项目的老旧程度做取舍。1. 先把“批量”两个字拆开看1.1 单张生成和批量生产的差别在哪单张生成二维码的代码网上随便一搜就是十几行new BarcodeWriter()一包Write()一调Save()一存跑得通。可一旦把数量级从 1 张提到 5000 张问题就会成串冒出来内存曲线一路往上爬、跑到第 800 张左右抛出“GDI 中发生一般性错误”、进度条卡在 37% 不动、生成的图打开一看边缘糊成一团、扫的时候要把手机怼到屏幕上才认。这些问题里没有一个是“生成算法”本身的毛病全是批量场景下的工程问题。我把批量生成拆成四个相互独立的环节来看思路会清晰很多内容准备数据从哪来、长度控制在多少、编码渲染选哪个组件、纠错等级和像素怎么给、落盘组织文件名怎么起、目录怎么分、写入是否安全、交互反馈后台跑还是前台跑、进度怎么报、中途能不能停。四个环节里任何一个偷懒最后都会以“扫不出来”或者“程序卡死”的形式暴露给你而且报错位置往往离真正的病灶很远排查起来特别费劲。所以我在动手写代码之前习惯先把这四件事的规则定死尤其是内容长度和像素尺寸这两个参数它们决定了后面 80% 的质量问题。很多人一上来就纠结用哪个库其实库的差异远没有参数设置的影响大。1.2 内容长度如何反向决定图片质量二维码有一个很不讲道理的特性内容越长它需要的“模块”就越多图就越密同样的物理尺寸下每个黑点就越小摄像头就越难分辨。二维码的规格从版本 1 到版本 40版本 1 是 21×21 个模块版本 40 是 177×177 个模块中间每升一个版本边长加 4 个模块。你那串内容塞进去之后落在哪个版本是由编码模式和字符数共同决定的。我用一张表把常用容量列出来你对照自己的业务数据估一下位置纠错等级数字容量字母数字容量字节容量UTF-8L约 7%708942962953M约 15%559633912331Q约 25%399324201663H约 30%305718521273表里是版本 40 的极限值实际项目里没人会往这个方向靠。我的经验是内容落在80 字节以内是最舒服的区间对应的版本大概在 1 到 6 之间模块数 21 到 41随便给个 300 像素都能生成很干净的图。超过 200 字节就要开始警惕了尤其是要塞 JSON 或者长 URL 带一堆参数的场景模块会密到肉眼看着还行、扫起来就很挑角度。这里有个特别容易被忽略的取舍业务编号能不能不放进二维码。我见过有人把整条记录的主键、名称、规格、日期、操作员全串成一个 JSON 塞进二维码里扫码端确实省事了但生成出来的图密得像块砖打印到 2 厘米宽的标签上基本没法用。合理的做法是二维码里只放一个短标识或者一个短链接扫码端拿标识回查数据库信息量交给网络去传输别压在图形上。1.3 纠错等级怎么选纠错等级决定二维码被遮挡、污损、印刷糊掉多少还能读出来。L 大约能恢复 7% 的码字M 是 15%Q 是 25%H 是 30%。等级越高同样的内容需要的模块越多图越密但抗造能力越强。我的选择习惯很固定贴在金属设备上、会沾油污、会被人摸来摸去的铭牌用 Q 甚至 H印在纸箱上、一次性使用的物流标签用 M 就够屏幕显示、电子票据这类永远不会磨损的场景L 完全够用图还能小一圈。H 等级还有一个附加价值可以在二维码中间挖个位置放企业标识图案模块损坏控制在 30% 以内还能正常解码。有一点要提前说清楚纠错等级提高是有代价的。同样 60 字节的内容M 级能塞进版本 433×33 模块换成 H 级就要推到版本 6 甚至更高模块数涨上去你在同样的输出像素下每模块能分到的像素就变少了反而可能更难扫。所以这不是“越高越安全”的单向选择得跟输出尺寸一起算。2. WinForm 下二维码组件的选型与实测对比2.1 三套常见方案横向对比.NET 生态里能在 WinForm 里直接用的二维码生成方案主流就那么几套我把它们在一个实际项目里的表现整理成表都是我自己跑过的结果方案依赖情况输出形式老项目兼容上手成本批量表现ZXing.Net像素输出纯托管不依赖 System.Drawingbyte[] 像素数组好.NET Framework 4.5 以上都能用中等需要自己转 Bitmap稳定可控性最强ZXing.NetBitmap 渲染绑定需要额外的 System.Drawing 绑定包直接给 Bitmap分版本新包对老框架不友好低稳定QRCoder纯托管PngByteQRCode 无 GDI 依赖byte[] 或 Bitmap好低稳定代码最少已停止维护的老式二维码组件依赖老的 GDI 封装Bitmap仅限极老框架低高并发下容易出 GDI 异常表格里最后一行我不点名只说结论那些年久失修、只能靠 System.Drawing 硬画、源码里还写着邮件地址求反馈的库在新机器上跑批量任务时出问题的概率明显更高尤其是在长时间循环里创建不释放的 GDI 对象跑到一定数量必然崩。选型的时候先看维护状态再看是不是纯托管。2.2 我为什么把主力方案定在 ZXing.Net定它有三个理由。第一是解码能力配套完整同一个库既能生成也能解码这意味着我可以在生成完之后立刻用解码器回读校验确认这张图确实能扫出来这个自校验能力在批量场景下价值极高后面第 7 节会专门写。第二是像素级输出可控它提供的BarcodeWriterPixelData直接给你一个裸的像素数组不经过 GDI 绘制不存在“字体渲染差异”“缩放插值”这类玄学什么环境跑出来都一样。第三是它对老框架友好纯托管实现往 .NET Framework 4.7.2 的存量项目里扔进去不用改任何配置。代价就是多一点代码量你得自己把像素数组塞进 Bitmap得自己处理 stride 对齐。这点活儿比起后面省下的排查时间我认为完全值。注意ZXing.Net 的包在不同大版本之间 API 有变化网上搜到的示例经常是新老混着写直接抄会编译不过。判断方法很简单看你引用的包里有没有BarcodeWriterPixelData这个类有就是新结构没有就是老结构两套写法不能混用。2.3 环境搭建与依赖引入以 .NET Framework 4.7.2 的 WinForm 项目为例用包管理器控制台装两个包Install-Package ZXing.Net如果是 .NET 6 / .NET 8 的 WinForm 项目走命令行更顺手dotnet add package ZXing.Net项目属性里把目标框架的平台目标设成 x86 或 x64不要用 AnyCPU 混着来特别是还要接打印机和串口的时候。装完之后检查一下引用列表确认zxing.dll已经在里面然后在代码里加上这几个 usingusing ZXing; using ZXing.Common; using ZXing.QrCode; using ZXing.QrCode.Internal; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices;System.Drawing在 .NET 6 以上需要额外确认一下项目里是否引用了 Windows 兼容包WinForm 项目默认是带的一般不用管。真正容易漏的是System.Runtime.InteropServices后面转 Bitmap 要用Marshal.Copy没有它编译报错找半天。3. 生成核心从一段文本到一个像素精确的位图3.1 用 BarcodeWriterPixelData 拿到像素数组先看单张生成的基础封装。这段代码是整个模块的地基后面的批量循环、打印、校验全靠它public static Bitmap CreateQrBitmap( string content, int pixelSize 400, int marginModules 4, ErrorCorrectionLevel ecc ErrorCorrectionLevel.M) { var writer new BarcodeWriterPixelData { Format BarcodeFormat.QR_CODE, Options new QrCodeEncodingOptions { CharacterSet UTF-8, ErrorCorrection ecc, Width pixelSize, Height pixelSize, Margin marginModules } }; PixelData data writer.Write(content); var bitmap new Bitmap(data.Width, data.Height, PixelFormat.Format32bppRgb); var rect new Rectangle(0, 0, data.Width, data.Height); BitmapData bmpData bitmap.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format32bppRgb); try { Marshal.Copy(data.Pixels, 0, bmpData.Scan0, data.Pixels.Length); } finally { bitmap.UnlockBits(bmpData); } return bitmap; }几个参数说说为什么这么给。CharacterSet UTF-8是必须显式写的不写的话中文内容会按默认字符集处理生成出来的图看着正常一扫全是问号这个坑后面第 6 节还会展开。Margin是静区也就是二维码四周那圈空白规范要求至少 4 个模块ZXing 里这个值默认就是 4单位是模块数而不是像素——这点网上很多教程写错以为是像素值结果给个 10 以为留了 10 像素其实留了 10 个模块的空白图出来中间一小块。Width和Height是目标像素但它只是“期望值”ZXing 内部为了保证每个模块是整数个像素会向下取整算出一个实际尺寸所以你给它 400拿到的可能是 396 或者 369。想要精确控制看 3.3 节。3.2 转成 Bitmap 时的 stride 与像素格式上面那段Marshal.Copy是新手最容易翻车的地方。ZXing 给出的PixelData.Pixels是一个裸的字节数组长度是Width * Height * 4每像素 4 字节顺序是 BGRA。而Bitmap.LockBits拿到的内存每行字节数stride并不等于Width * 4它会按 4 字节边界对齐补位。当宽度恰好是 4 的倍数时两者相等直接整块拷没问题宽度不是 4 的倍数时直接Marshal.Copy整块长度会越界或者错位。稳妥写法有两种。第一种是像我上面那样用Format32bppRgb并且配合 ZXing 的输出宽度保证是 4 的倍数——但你不能保证因为取整逻辑在库里面。第二种更保险逐行拷贝for (int y 0; y data.Height; y) { IntPtr dst IntPtr.Add(bmpData.Scan0, y * bmpData.Stride); Marshal.Copy(data.Pixels, y * data.Width * 4, dst, data.Width * 4); }我实际项目里用的都是逐行版本虽然多几行代码但从此再没遇到过“图生成出来是斜的”或者“下半部分是乱码”这种怪问题。至于Format32bppRgb和Format32bppArgb的区别二维码没有透明度需求用不带 Alpha 的版本省内存一张 400×400 的图就差出 640KB 的临时占用批量跑的时候这个差距会累加。提示PixelData里的像素数组对应的是Format32bppRgb而不是Format24bppRgb有人图省内存用 24 位结果颜色通道错位图变成红蓝调的扫码器直接不认。3.3 想要像素精准就自己算放大倍数ZXing 的QRCodeWriter内部逻辑是先按内容算出模块矩阵大小然后用Math.Min(目标宽 / 矩阵宽, 目标高 / 矩阵高)得到一个整数倍数最后实际输出尺寸 矩阵尺寸 × 倍数。所以你想要一张刚好 400 像素、边缘不带任何冗余空白的图就得自己接管这一步public static Bitmap CreateQrExact(string content, int targetPx, ErrorCorrectionLevel ecc) { var hints new DictionaryEncodeHintType, object { { EncodeHintType.CHARACTER_SET, UTF-8 }, { EncodeHintType.ERROR_CORRECTION, ecc }, { EncodeHintType.MARGIN, 4 } }; var writer new MultiFormatWriter(); BitMatrix matrix writer.encode(content, BarcodeFormat.QR_CODE, 0, 0, hints); int margin 4; int totalModules matrix.Width margin * 2; int scale Math.Max(1, targetPx / totalModules); int outSize totalModules * scale; var bitmap new Bitmap(outSize, outSize, PixelFormat.Format32bppRgb); using (var g Graphics.FromImage(bitmap)) { g.Clear(Color.White); g.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor; g.PixelOffsetMode System.Drawing.Drawing2D.PixelOffsetMode.Half; using (var brush new SolidBrush(Color.Black)) { for (int y 0; y matrix.Height; y) { for (int x 0; x matrix.Width; x) { if (matrix[x, y]) { g.FillRectangle(brush, (x margin) * scale, (y margin) * scale, scale, scale); } } } } } return bitmap; }这段代码的价值在于你可以精确回答“这张图到底是多少像素、每个模块占几个像素”这两个问题。批量场景里这两个数字必须心里有数它直接决定了打印出来能不能扫。举个例子内容 60 字节、纠错 M落在版本 4矩阵 33×33加上静区33 4×2 41个模块。你给目标 300 像素scale 300 / 41 7实际输出41 × 7 287像素每个模块 7 个像素。这个密度打印成 3 厘米宽的标签普通手机一秒就能读出来。要是内容涨到 200 字节推到版本 10 左右57×5757 8 65300 / 65 还是 4每模块只剩 4 像素边缘开始考验打印精度了。3.4 QRCoder 作为备选方案的写法有些项目对第三方包的数量敏感或者团队更熟悉 QRCoder 的 API那套写法更省事核心就三行using QRCoder; public static byte[] CreateQrPngBytes(string content, int pixelsPerModule 10, QRCodeGenerator.ECCLevel ecc QRCodeGenerator.ECCLevel.M) { using (var generator new QRCodeGenerator()) using (var qrData generator.CreateQrCode(content, ecc)) using (var png new PngByteQRCode(qrData)) { return png.GetGraphic(pixelsPerModule); } }注意它这里的尺寸参数是“每模块像素数”而不是目标总像素给 10 意味着 33 模块的码会输出 330 像素。这个设计其实比 ZXing 的“目标像素”更符合批量场景的思维——你天然就在按模块思考不会出现给了一个奇怪的数字导致比例失真的情况。它返回的是 PNG 字节数组可以直接写文件全程不碰 GDI批量跑起来非常稳。两套方案我都用过结论是需要解码校验、需要精细控制用 ZXing.Net只想快速出图、不打算做回读校验QRCoder 代码更少。别在一个项目里混用两套依赖体积和维护成本都不划算。4. 批量流水线数据源、命名、异步与进度4.1 输入源的三种典型形态实际项目里数据基本来自三个地方我给每种都写了一套读取逻辑用接口隔开换数据源只改一个类的实现第一种是文本框粘贴也就是用户从 Excel 里复制一列编号直接粘进来。这种方式最灵活也最适合做验证阶段我习惯把它作为第一个做出来的功能先把生成链路跑通再考虑接数据库。解析的时候按行切去掉首尾空白和空行遇到重复的直接去重。第二种是CSV 文件。不要用简单的Split(,)因为字段里可能有逗号。要么用带引号处理的解析逻辑要么干脆约定文件里不允许出现逗号。我在项目里用的是自己写的轻量解析三十行左右处理引号转义就够了比为了读一个 CSV 引入一个完整的表格处理库要清爽。第三种是数据库直查。这种最省心但要注意查询本身别把大表全捞出来——一批十万条数据塞进DataTable再循环内存会很难看。稳妥做法是分页拉取每页 1000 条处理完一页再拉下一页这样内存占用是个常量。不管哪种来源读完第一件事都是校验内容长度超过 200 字节的行单独挑出来警告让用户确认。这一步能提前挡掉后面一半的扫不出来问题。4.2 文件命名与目录分片规则命名这块有两个硬性约束Windows 文件名非法字符反斜杠、斜杠、冒号、星号、问号、双引号、尖括号、竖线必须清洗路径总长在老框架下超过 260 字符会直接抛异常。我处理的方式是白名单过滤private static string SafeFileName(string raw) { var invalid Path.GetInvalidFileNameChars(); var sb new StringBuilder(raw.Length); foreach (char c in raw) sb.Append(invalid.Contains(c) ? _ : c); string name sb.ToString().Trim().TrimEnd(.); if (name.Length 80) name name.Substring(0, 80); return string.IsNullOrWhiteSpace(name) ? Guid.NewGuid().ToString(N) : name; }后面那个TrimEnd(.)很多人想不到文件名结尾带点在某些情况下会出问题。长度截到 80 是因为业务编号通常不会那么长超长的多半是误把整行数据当名字了。目录分片这件事单次生成超过 3000 张我就开始分了。按每 1000 张一个子目录或者按业务日期分。你试过在一个文件夹里放两万张 PNG 就知道了资源管理器打开要转半分钟用户会觉得程序死了。分片之后还有个好处出问题的时候能快速定位是哪一批重跑也只重跑那一个目录。重名冲突的兜底策略我用的很简单检测到同名就在后面加下划线加序号序号从 2 开始递增直到不冲突为止。别用时间戳做后缀批量生成时时间戳精度不够会撞车而且事后看文件名完全不知道对应哪条数据。4.3 异步执行、进度上报与中断WinForm 里最容易犯的错就是在按钮点击事件里直接写循环。5000 张图生成加上落盘快的机器跑两三分钟慢的跑十分钟这期间界面完全冻结用户第一反应就是“程序卡了”然后强杀进程你已经生成了一半的文件全成了半截垃圾。正确做法是async / await配IProgressT。别用Control.Invoke在循环里直接更新控件那个东西一次调用就是一次跨线程封送跑一万次性能损失明显而且容易把消息队列堵住。private CancellationTokenSource _cts; private async void btnGenerate_Click(object sender, EventArgs e) { var items ParseInput(txtInput.Text); if (items.Count 0) return; _cts new CancellationTokenSource(); btnGenerate.Enabled false; btnCancel.Enabled true; var progress new ProgressProgressInfo(p { progressBar.Value p.Percent; lblStatus.Text $已完成 {p.Done} / {p.Total}{p.Current}; }); try { await Task.Run(() RunBatch(items, txtOutput.Text, progress, _cts.Token)); MessageBox.Show($生成完成共 {items.Count} 张。); } catch (OperationCanceledException) { MessageBox.Show(已取消已生成的文件保留在输出目录。); } catch (Exception ex) { MessageBox.Show(生成过程出错 ex.Message); } finally { btnGenerate.Enabled true; btnCancel.Enabled false; _cts.Dispose(); _cts null; } }进度上报有一个细节值得说不要每生成一张就报一次。我给自己的规矩是每 50 张报一次或者每 200 毫秒报一次取先到的那个。5000 张就是 100 次 UI 更新进度条走得平滑线程开销又可以忽略。每张都报的话光是封送的开销就能让整体耗时涨 10% 以上得不偿失。取消逻辑里CancellationToken要在循环开头检查而不是等一张图生成完再检查。生成单张图虽然快但如果是大尺寸或者高纠错等级单张也可能要几十毫秒5000 张就是几分钟的延迟响应。稳妥做法是循环开头查一次、写入文件前再查一次。4.4 写入安全临时文件加原子替换批量写文件最怕的就是中途断电、崩溃或用户强杀留下一个 0 字节或者写了一半的 PNG。这种文件在目录里看着正常打开是一堆乱码事后清理起来非常麻烦尤其是几千张混在一起的时候。我的处理方式是先写入同目录下的临时文件写完之后用File.Move覆盖到目标路径。同一卷内的Move是原子操作要么成功要么没发生不存在中间态string tmpPath targetPath .tmp; using (var fs new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096)) { bitmap.Save(fs, ImageFormat.Png); fs.Flush(true); } if (File.Exists(targetPath)) File.Delete(targetPath); File.Move(tmpPath, targetPath);fs.Flush(true)那个true参数表示把数据真正刷到磁盘而不只是缓冲区这一步牺牲了一点性能但换来了断电安全。批量场景里我宁可整体慢 5%也不想事后去分辨哪些文件是好的。注意File.Delete之后立刻File.Move之间有极短的窗口期如果别的进程比如杀毒软件扫描正好在访问目标路径可能抛异常。我的做法是包一层重试最多重试 3 次每次间隔 100 毫秒实际项目里基本没再见过写入失败。5. 参数调优与输出画质控制5.1 尺寸换算到底该给多少像素这个问题我被问过太多次直接给一套可用的换算思路。核心公式只有一条输出像素 矩阵模块数 静区模块数 × 2 × 每模块像素数你真正需要决定的其实是最后那个“每模块像素数”。我的经验值是屏幕显示用 4 到 6普通激光打印用 8 到 10热敏标签打印用 10 到 12喷墨或者铜版纸印刷用 12 以上。低于 4 的时候图的边缘就开始被渲染器插值糊掉扫码成功率明显下降。反过来推如果业务规定标签宽度就是 3 厘米那就先把物理尺寸换算成像素。以 203 dpi 的热敏标签机为例3 厘米约等于 240 像素内容 60 字节落在版本 441 个模块含静区240 / 41 5.8取整 5每模块 5 像素实际输出 205 像素。5 像素在热敏打印上属于勉强可用想保险一点就把标签加宽到 4 厘米或者想办法把内容压到 40 字节以内降到版本 337 个模块含静区240 / 37 6.5取 6舒服很多。这就是为什么我一直强调先压内容再调尺寸。内容短一个版本抵得上你把像素翻一倍。5.2 静区、缩放与“看着清楚其实扫不出”有三个视觉陷阱我按踩坑频率排序陷阱一是静区不足。二维码四周那圈白边不是装饰是给解码器定位用的。有人为了“让图大一点”把静区设成 1 个模块甚至 0生成出来的图看着很饱满扫码时摄像头一凑近就识别不了。规范要求 4 个模块我从来不往下调。陷阱二是缩放插值。你在 WinForm 的 PictureBox 里用SizeMode Zoom预览图片被平滑插值放大看着清清楚楚但那只是预览观感。真正要担心的是你把这张图贴到 Word 里、或者用Graphics.DrawImage画到打印页面上时加了插值。所有绘制二维码的地方都要显式设置g.InterpolationMode InterpolationMode.NearestNeighbor; g.PixelOffsetMode PixelOffsetMode.Half; g.SmoothingMode SmoothingMode.None;这三个设置能让二维码在缩放绘制时保持硬边不会出现灰色过渡像素。少了这三行图会“变好看”但识别率下降。陷阱三是反色和透明背景。白底黑码是标准配置。我见过为了配深色主题做成黑底白码的普通扫码器直接不认。还有生成 PNG 时背景透明、放到深色页面上变成黑底黑码的这种问题排查起来极其费劲因为你看图的时候背景是白的一切都正常。批量生成时永远记得g.Clear(Color.White)并且用不带 Alpha 的像素格式导出。5.3 给二维码加文字标签与 LOGO实际业务里二维码很少单独存在下面往往要跟一行编号上面可能要加个标题。我的做法是在同一张 Bitmap 上预留底部区域用Graphics.DrawString画字int textHeight 40; var canvas new Bitmap(qr.Width, qr.Height textHeight, PixelFormat.Format32bppRgb); using (var g Graphics.FromImage(canvas)) { g.Clear(Color.White); g.DrawImage(qr, 0, 0); using (var font new Font(微软雅黑, 12, FontStyle.Regular, GraphicsUnit.Pixel)) using (var sf new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }) { g.DrawString(label, font, Brushes.Black, new RectangleF(0, qr.Height, qr.Width, textHeight), sf); } }字体用像素单位而不是点数这样在不同 DPI 环境下高度可预测。文字太长要截断加省略号别让它溢出画布被裁掉一半。字号别小于 10 像素打印出来会糊。LOGO 的处理要克制。中间挖一块放图案损坏面积必须控制在纠错等级允许的比例以内用 H 级最多挖 25% 左右用 Q 级最多挖 18% 左右M 级基本别想。我实际做的项目里LOGO 边长控制在二维码总边长的 1/5 以内外面再补一圈 2 到 3 像素的白边隔开实测扫码成功率不受影响。挖太大又没补白边模块边界被破坏就会出现“自己手机能扫、别人的扫不出来”这种玄学。6. 常见问题与排查实录6.1 中文乱码与扫码失败中文内容出问题九成是字符集没指定。ZXing 默认的字符集在部分版本里不是 UTF-8你不显式写EncodeHintType.CHARACTER_SET中文会被按 ISO-8859-1 处理生成出来的图结构完全合法就是解码出来一堆问号。这个坑的迷惑之处在于图看着和正常的没区别只有真的扫一次才会发现。第二个原因是扫码端字符集不匹配。有些老式扫码枪或者 App 的解码器默认按 GBK 解析这种情况你可以在内容里塞 ASCII 前缀做区分或者干脆约定内容全用英文数字中文只做展示用的标签文字。我在跨系统对接的项目里统一采用“二维码内只放 ASCII 编号”的约定省掉了所有字符集扯皮。第三个原因比较隐蔽内容里有不可见字符。从 Excel 复制过来的数据经常带尾部空格、零宽字符或者换行符。这些字符肉眼看不见但会被编进二维码导致解码出来的字符串和数据库里的对不上。我的处理是在入口处统一过滤content new string(content.Where(c !char.IsControl(c) c ! \u200B c ! \uFEFF).ToArray()).Trim();\u200B是零宽空格\uFEFF是 BOM这两个是最常见的隐形杀手。加上这一行之后我那批“扫出来多一个字符”的报障直接清零。6.2 GDI 通用错误与内存暴涨“GDI 中发生一般性错误”这个异常信息基本等于没说它可能是十几种原因。我按出现频率列一下排查顺序第一目标目录不存在。Bitmap.Save不会帮你创建目录路径里任何一级目录缺失都会抛这个异常。写文件之前先Directory.CreateDirectory一行的事。第二文件被占用。同名文件正被图片查看器、杀毒软件、或者你自己的另一个线程打开着Save就会失败。这就是 4.4 节要搞临时文件加原子替换的原因。第三路径超长。超过 260 字符在 .NET Framework 下直接崩报的还是这个错。分片目录加文件名截断就是为它准备的。第四Bitmap 没释放。这个最要命因为它不会立刻报错而是跑到几百上千张之后突然崩。循环里每一个new Bitmap都必须配using或者显式Dispose。我甚至会在循环里加一行计数器每 500 张调一次GC.Collect()——虽然一般认为不该手动调 GC但在这种“大量小对象快速创建销毁”的场景下实测能让内存曲线平坦很多代价是整体耗时增加 3% 左右我认为值得。for (int i 0; i items.Count; i) { token.ThrowIfCancellationRequested(); using (var bmp CreateQrBitmap(items[i], 400)) { SaveAtomically(bmp, GetTargetPath(items[i], i)); } if ((i 1) % 500 0) GC.Collect(); if ((i 1) % 50 0) progress.Report(new ProgressInfo(i 1, items.Count, items[i])); }6.3 UI 卡死与进度条不动的排查进度条走到一半不动但程序还在跑八成是你在Task.Run里直接操作控件了。跨线程访问控件在调试模式下会抛异常在 Release 下有时会被静默吞掉表现就是“界面不刷新了”。用IProgressT就能规避它内部会自动封送到 UI 线程。还有一种情况是进度条本身的问题WinForm 的ProgressBar最大值默认是 100你要是直接progressBar.Value ii 是 5000超过 100 就抛异常被 try-catch 吞掉之后进度条就永远停在某个位置。要么设Maximum total要么按百分比换算别混着来。第三个坑是在 UI 线程里做了耗时操作。比如生成完之后立刻把几千个文件路径加载进ListView显示这个操作本身就要好几秒用户看着就是界面又卡住了。我的做法是生成完只显示汇总信息成功多少、失败多少、输出目录明细用“打开目录”按钮交给资源管理器。6.4 高频问题速查表现象最可能的原因处理方式扫出来是问号字符集未指定为 UTF-8显式设置CharacterSet UTF-8图很清楚但扫不出静区不足或反色静区恢复 4 模块白底黑码跑到几百张报 GDI 错误Bitmap 未释放循环内全部using包裹跑到几百张报路径异常路径超过 260 字符分片目录加文件名截断进度条不动跨线程直接操作控件改用IProgressT文件名非法字符异常业务编号含 :*?中文内容长度超限中文按 3 字节算容量估算按 UTF-8 字节数打印出来边缘模糊绘制时用了插值设NearestNeighbor同一个编号生成两张输入源有重复行入口处去重图片体积莫名很大用了 32bppArgb换Format32bppRgb7. 从生成到落地打印、校验与分发7.1 批量打印的排版思路生成完只是第一步真正交付到用户手上还得打印。WinForm 里走PrintDocument就够不用引额外的报表工具。核心是把“标签纸的行列布局”抽象成几个参数每行几个、每列几个、页边距、标签间距、标签内部的二维码偏移量。private void OnPrintPage(object sender, PrintPageEventArgs e) { int cols 4, rows 8; float marginX 10f, marginY 12f, gapX 4f, gapY 4f; float cellW (e.MarginBounds.Width - marginX * 2 - gapX * (cols - 1)) / cols; float cellH (e.MarginBounds.Height - marginY * 2 - gapY * (rows - 1)) / rows; e.Graphics.InterpolationMode InterpolationMode.NearestNeighbor; e.Graphics.PixelOffsetMode PixelOffsetMode.Half; for (int r 0; r rows; r) { for (int c 0; c cols; c) { int index _printIndex r * cols c; if (index _printItems.Count) break; float x e.MarginBounds.Left marginX c * (cellW gapX); float y e.MarginBounds.Top marginY r * (cellH gapY); float side Math.Min(cellW, cellH) * 0.85f; e.Graphics.DrawImage(_printItems[index].Image, x, y, side, side); e.Graphics.DrawString(_printItems[index].Label, _labelFont, Brushes.Black, new RectangleF(x, y side, cellW, cellH - side)); } } }side那个 0.85 的系数是留白别让二维码顶到格子边缘。打印前务必用“打印预览”过一遍因为不同打印机对页边距的解释不一样A4 标签纸的实际可打印区域经常比你写的小一圈。物理尺寸的经验值二维码边长小于 1.5 厘米普通手机就很难扫了2 厘米是比较稳的下限3 厘米以上随便扫。所以排版的时候先算物理尺寸再倒推需要多少像素别反过来。7.2 生成即校验回读解码这一步很多项目省掉了但它是我认为批量场景里性价比最高的一个环节。ZXing 自带解码器生成完之后立刻把图读回来解一遍和原文比对不一致的直接记入失败清单。这个动作能挡掉 95% 的“扫不出来”投诉因为所有在源头就有问题的图根本不会流到用户手上。public static bool VerifyQr(Bitmap bitmap, string expected) { var reader new BarcodeReader { Options new DecodingOptions { TryHarder true, PossibleFormats new ListBarcodeFormat { BarcodeFormat.QR_CODE } } }; var result reader.Decode(bitmap); return result ! null result.Text expected; }校验失败的时候把文件路径和原始内容记进一个日志文本同目录下存一份。跑完一次批量任务直接看日志就知道有没有漏网的。我遇到过几次校验失败追下去发现是内容里有特殊符号导致编码模式切换、或者内容刚好卡在容量边界这类问题靠肉眼是绝对看不出来的。校验会带来额外开销实测大约增加 15% 到 25% 的总耗时。我的选择是数量在 5000 张以内全部校验超过 5000 张按 10% 抽样校验。抽样也能覆盖绝大部分系统性问题因为参数是统一的不会出现只有第 3172 张有问题的情况。7.3 打包分发要注意的运行时做成给同事用的小工具有几个细节不弄好会天天被问。第一把输出目录默认设在用户选的目录下新建一个带时间戳的子文件夹跑完自动打开别让用户自己找。第二配置文件里记住上次的输入输出路径、纠错等级、像素尺寸用Properties.Settings存一下下次打开就是上次的状态。第三生成完成后弹一个简明的汇总成功多少、失败多少、耗时多少、输出在哪失败的直接给一个“查看日志”按钮。打包我用的是单文件发布dotnet publish -c Release -r win-x64 --self-contained false依赖运行时的方式体积小。目标机器上如果没有对应的运行时就改成--self-contained true体积大一点但省去了装环境的麻烦。发给不熟悉技术的同事能少一步操作就少一步。还有一个小细节程序图标和窗口标题写清楚这是干什么的别用默认的Form1。这类内部工具往往会在文件夹里躺很久半年后你自己打开都不记得哪个是哪个。我个人在做的几个项目里这套结构已经稳定复用了好几年。真正让我少走弯路的不是某个库的某个参数而是把“内容长度、模块数量、每模块像素”这三个数字当成硬指标在界面上一开始就展示出来——用户输入内容的时候就能看到“当前 58 字节预计版本 4输出 287×287 像素”生成前心里就有数。后面无论出什么扫不出来的问题先看这三个数字基本都能定位到。另外一句实话批量生成二维码这种活儿快不难难的是稳难点全在那些看起来“反正数量小不会触发”的边界上。少写一个using、少判一次空目录可能就在第 3000 张的时候等着你。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑