这段时间陆续给两三个 Delphi 项目做了二维码生成模块,需求都差不多:离线状态下录入一串编号,程序生成二维码展示在界面上,能保存成图片文件归档。第一个项目我图省事,调了某个云接口把图片拉回来,结果部署环境没外网,项目差点延期。后面老老实实自己生成,把整个流程跑通以后发现,Delphi 里做这件事并不复杂,难点反而在几个容易被忽略的细节上——比如 PaintBox 绘制完以后怎么不闪,保存时怎么保证扫得出来,还有放到 Image 里显示会不会莫名其妙被拉伸变形。
这篇文章就把这套流程完整说一遍:从二维码生成的原理、PaintBox 画图、保存 PNG,再到 Image 控件加载显示,连带我在项目里踩过的坑一起写出来。适合正在用 Delphi 做桌面客户端、需要在本地生成和展示二维码的小伙伴,也适合想搞懂二维码生成底层概念再自己动手写的人。读的时候可以开着 IDE 跟着敲,代码片段都是可以直接粘进项目的。
1. 项目背景与需求拆解:桌面端二维码到底怎么用
1.1 三种典型应用场景
二维码在桌面程序里出现最多的场景,我总结下来就三类。第一类是设备资产标签,操作员录入设备编号、型号、序列号,程序生成二维码,要么直接打印到标签纸上,要么导出图片归档。第二类是授权激活,常见于工业软件或专业工具,程序在离线环境下根据授权码生成二维码,用户拿手机 App 扫一下完成激活绑定,这里数据完全不能出内网。第三类是工序流转,产线操作员选择工单号,二维码贴在物料流转单上,下一个工位扫码读取,不需要额外的扫码枪硬件,手机就能顶上去。
我做的某制造企业 MES 客户端就属于第一类和第三类的结合体。操作员在界面上选中设备编号,点击按钮之后,二维码立刻显示在右侧的 PaintBox 里,同时保存成一个 PNG 文件放到共享目录,最后在下方 Image 控件里再预显一次。整个流程看起来简单,但里面每一步都有选择可以做错,尤其是后面要讲的绘制和显示问题。
1.2 本地生成和外接接口怎么选
做二维码功能的第一道选择题,就是到底调用别人的接口,还是自己本地生成。在线接口的优势很明显,代码少,图片美观,人家还帮你处理了各种参数。但劣势同样明显:必须有外网、有接口限流、数据内容会经过第三方服务器,这三条在工控和政企项目里通常都是硬伤。
我第一版就是调接口,开发时一切正常,到了客户现场发现生产网段根本没有外网,所有二维码都拉不回来。后来改成本地生成,才意识到二维码生成本身就是一个纯计算过程,不涉及 OCR、不涉及大模型,普通 CPU 计算一个 V40 的二维码也就是几十毫秒的事,完全没有必要依赖外部服务。所以我现在的建议很简单:桌面程序里做二维码,默认走本地生成,除非你有批量生成几万张并且需要统一模板设计的需求,才考虑外部服务。
1.3 为什么是 PaintBox + Image 这个组合
Delphi 的 VCL 里,能显示图形的东西很多,但 PaintBox 和 Image 是分工不同的两个控件。TPaintBox 是轻量级绘图控件,它本身不保存图像数据,只是在 OnPaint 事件触发时把内容画到 Canvas 上,适合动态计算出来的画面。TImage 则是一个图像容器,有 Picture 属性,可以加载磁盘文件、TBitmap、TPngImage 等等,适合静态展示。
不少人会偷懒直接在 TImage.Canvas 上画二维码,我一开始也这么干过,结果窗体一缩放、一遮挡,TImage 上的内容就被系统擦掉了,因为它并不负责持久化绘制内容。用 PaintBox 就清楚很多,数据变了就调用 Invalidate 触发重绘,OnPaint 里统一处理,逻辑上是闭环的。而 TImage 用来做最终结果展示和文件预览,分工干净利落。
2. 二维码生成的底层逻辑:先把矩阵搞清楚
2.1 二维码本质上是一张位图矩阵
理解了底层再看代码,很多坑就能提前避开。QR Code 这个东西,表面上是一张黑白相间的方块图,实际上是一个行列固定的布尔矩阵:黑色模块代表 1,白色模块代表 0。这个矩阵里既有我们输入的数据内容,也有定位图形、校正图形、时序图形这些专门用于扫描定位的元素。
所以二维码生成库的工作本质就两步:一是把你的字符串编码成符合 QR 规范的数据位流,二是在内存里拼出一个 rows * columns 的二维布尔数组。绘制环节完全不需要关心里面的编码细节,只需要拿着这个布尔数组,把每个为 true 的格子画成一个带颜色的矩形,就得到了肉眼可见的二维码。这也是为什么 PaintBox 很适合做这件事,它的 Canvas 画矩形是基本操作。
2.2 生成器在中间做了四件事
如果你用封装好的库,这些事都是透明的,但知道一下没有坏处,至少调试的时候不会瞎猜。第一件事是数据分析,检查待编码内容属于纯数字、字母数字、字节还是汉字模式,选择最紧凑的编码方式。第二件事是数据编码,把内容变成比特流,加入模式指示符、字符计数符和终止符。第三件事是生成纠错码,这里用的是 Reed-Solomon 算法,把原始数据按照设定的纠错级别拆块,每块生成纠错码,这样二维码局部被遮挡或污损时依然能解出来。第四件事是把数据码和纠错码按规则填入矩阵,再执行掩码运算,并写入版本信息和格式信息。
掩码这一步可能很多人不知道,它的目的不是加密,而是让生成出来的二维码黑色模块分布尽量均匀,避免出现大面积的空白或密集方块导致扫描器无法定位。整个过程标准文档写了几十页,但你完全不需要自己实现,开源社区早就把这块固化成稳定的库了。
2.3 版本、纠错级别和参数选择
二维码版本从 1 到 40,版本越高,矩阵越大,容量越大。V1 是 21x21 的模块,每增加一个版本,矩阵一边增加 4 个模块,到 V40 是 177x177。同一个版本下,容量还受纠错级别影响。纠错级别分 L、M、Q、H,分别能容忍约 7%、15%、25%、30% 的码字损坏。级别越高,能存的有效内容越少,但抗污损能力越强。
我的经验是,普通业务场景选 M 级别就够,内容比较短的比如链接、编号,直接用 H 也不会亏。但是要注意,内容长度决定版本号,版本越高矩阵越大,如果显示尺寸固定,高版本的每个模块像素就越小,扫码设备识别难度会上升。所以不要盲目追求大容量,能短则短。另外还有一个参数容易被忽略,就是静区,也就是二维码周围一圈空白的宽度。规范要求至少 4 个模块宽,实际测试下来,少于这个值,手机还能扫,但工业扫码枪很容易翻车。这个参数后面绘制和保存时会反复用到。
3. 核心实现:PaintBox 上把二维码画出来
3.1 生成库的选型和引入
Delphi 生态里现成的二维码生成方案不算多,但有一款基于 ZXing 移植的开源库相当能打,纯 Delphi 实现,没有第三方动态库依赖,可以在旧版本的 Delphi 里直接用。市面上还有 Delphi XE 自带的某些组件,以及各种个人封装的单元,但对比下来,ZXing 移植版胜在编码规范、支持多版本多纠错级别、矩阵输出接口清晰,非常适合我们把绘制逻辑自己做一遍。
引入方式很简单,下载源码后把相关单元所在目录加入 Project Manager 里的 Search Path 就行,然后在你自己的单元 uses 里加上声明。整个过程不装包、不注册组件,和写普通代码完全一样。我建议不要用那种直接把整张二维码图片生成好的重量级封装,因为项目里后续需求往往会有变动,比如加 Logo、改静区尺寸、只生成矩阵自己排版打印,矩阵输出的接口比图片输出接口好用得多。
3.2 从字符串到矩阵再到 Canvas
核心代码看下面这段。首先生成器对象,设置 Data 为要编码的内容,Encoding 用字节模式,这样中文和特殊字符都能按 UTF-8 正确处理。QuietZone 设置为 4,对应静区 4 个模块。ErrorCorrectionLevel 这里设成 1,表示 M 级纠错。随后遍历矩阵,所有 IsBlack 为 true 的格子,都画成黑色矩形。
type TFormQRCode = class(TForm) PaintBox1: TPaintBox; btnGenerate: TButton; procedure btnGenerateClick(Sender: TObject); procedure FormCreate(Sender: TObject); private QRCore: TDelphiZXingQRCode; public procedure DrawMatrix(PBox: TPaintBox); end; implementation procedure TFormQRCode.FormCreate(Sender: TObject); begin QRCore := TDelphiZXingQRCode.Create; end; procedure TFormQRCode.btnGenerateClick(Sender: TObject); begin QRCore.Data := edtContent.Text; QRCore.Encoding := TQRCodeEncoding.qrEncodingByte; QRCore.QuietZone := 4; QRCore.ErrorCorrectionLevel := 1; PaintBox1.Invalidate; end; procedure TFormQRCode.DrawMatrix(PBox: TPaintBox); var Row, Col: Integer; ModuleSize, Margin: Integer; DrawWidth, DrawHeight: Integer; X, Y: Integer; begin ModuleSize := 8; Margin := ModuleSize * QRCore.QuietZone; DrawWidth := QRCore.Columns * ModuleSize + Margin * 2; DrawHeight := QRCore.Rows * ModuleSize + Margin * 2; PBox.Canvas.Brush.Color := clWhite; PBox.Canvas.FillRect(Rect(0, 0, DrawWidth, DrawHeight)); PBox.Canvas.Brush.Color := clBlack; for Row := 0 to QRCore.Rows - 1 do begin for Col := 0 to QRCore.Columns - 1 do begin if QRCore.IsBlack[Row, Col] then begin X := Margin + Col * ModuleSize; Y := Margin + Row * ModuleSize; PBox.Canvas.FillRect(Rect(X, Y, X + ModuleSize, Y + ModuleSize)); end; end; end; PBox.Width := DrawWidth; PBox.Height := DrawHeight; end;这段代码里最关键的一个点,就是绘制尺寸的计算。很多人直接把 PaintBox 撑满整个窗体,然后从左上角开始画,结果二维码挤在角落,静区也没有,扫起来特别费劲。正确的做法是先根据矩阵列数和模块尺寸算出总宽度,再把整个绘制逻辑和 PaintBox 的宽高绑定。这样无论内容长短、版本高低,二维码都能居中占满控件,且四周留有标准的白边。
3.3 绘制参数调试:模块尺寸与静区
模块尺寸选多大,取决于二维码最终要在哪看。如果只是在屏幕上展示,模块尺寸 6 到 8 像素很合适,画面干净。如果还要打印到小标签上,那模块尺寸建议按打印分辨率换算,通常至少 4 个打印像素。模块尺寸过小的直接后果是模块与模块之间几乎没有间隔,黑白边缘容易被打印机糊成一团。
静区在上面的代码里由 QRCore.QuietZone 参数控制,我固定设为 4。这里要提醒一点,如果你自己实现绘制,不要只在矩阵外边留几个像素就算静区,必须按模块尺寸乘以静区模块数来计算。还有颜色选择,我坚持用纯黑和纯白,不要为了 UI 美观把前景改成深蓝、深绿,很多扫码引擎对非标准黑色边缘的容错很差,尤其是低端扫码枪。如果想要“彩色二维码”的效果,更稳妥的做法是先保存黑白底图,再通过位图后处理给白色区域染色,黑色模块保持不动。
4. 保存本地文件并加载到 Image 控件
4.1 为什么要绕一层文件操作
有朋友问,既然已经画在 PaintBox 上了,直接 Image1.Picture.Assign(PaintBox1.Canvas) 这种思路行不行?答案是不行,Canva 不是图形对象,不能直接赋值给 Picture。另外标题里的需求本来就要求“保存为本地文件并添加至 Image 控件中”,这个文件是要拿来归档、打印、上传服务端的,不是临时展示用。
所以正确的链路是:PaintBox 绘制完成后,把它 Canvas 上的内容拷贝到一个 TBitmap,再由 TBitmap 转成 TPngImage 保存成磁盘文件。保存完之后,Image 控件再从磁盘加载这个 PNG。这样文件有了,展示也有了,后面想对这个文件做二次处理,比如加水印、压缩、合并,都有明确的操作对象。
4.2 保存为 PNG 的具体实现
下面的代码把 PaintBox 的内容拷贝到一个 24 位色的 Bitmap,然后转成 PNG 保存。注意拷贝之前一定要确保 PaintBox 已经完成了一次完整的重绘,否则 Canvas 上可能是旧数据或者空白。我习惯在生成函数里显式调用一下 PaintBox1.Update(),强制刷新整个窗口消息队列,确保画布内容落定。
uses System.SysUtils, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.ExtCtrls, Vcl.Imaging.pngimage; procedure SavePaintBoxToPng(PBox: TPaintBox; const AFileName: string); var Bmp: TBitmap; Png: TPngImage; W, H: Integer; begin W := PBox.Width; H := PBox.Height; if (W <= 0) or (H <= 0) then Exit; Bmp := TBitmap.Create; try Bmp.Width := W; Bmp.Height := H; Bmp.PixelFormat := pf24bit; Bmp.Canvas.Brush.Color := clWhite; Bmp.Canvas.FillRect(Rect(0, 0, W, H)); Bmp.Canvas.CopyRect( Rect(0, 0, W, H), PBox.Canvas, PBox.ClientRect); Png := TPngImage.Create; try Png.Assign(Bmp); Png.SaveToFile(AFileName); finally Png.Free; end; finally Bmp.Free; end; end;为什么不直接存 BMP 而转成 PNG,因为 PNG 是无损压缩,白底黑字的二维码压缩率极高,存出来可能只有几 KB,而 BMP 一张 300x300 的图就有二百多 KB。还有一点,PNG 自带 alpha 通道和像素格式信息,后续如果用其他平台处理,兼容性比 BMP 好太多。JPG 格式我是强烈不建议用的,JPG 是有损压缩,黑色模块边缘会出现颜色扩散和模糊,一旦模块较小,扫码识别率断崖式下降,后面讲踩坑会细说。
4.3 Image 控件显示参数设置与优化
文件保存好之后,加载进 Image 很简单,一行代码:
Image1.Picture.LoadFromFile(AFileName);但这里有个新手高频问题:加载进去之后显示只露出左上角一小块二维码,其他部分都是空白。原因很简单,TImage 控件的 Stretch 属性默认是 False,Picture 多大,它就按原始像素尺寸显示,如果控件比图片小,内容就会被裁剪。解决办法是把 Stretch 设为 True,Proportional 设为 True,AutoSize 保持 False。
- Stretch = True:图片自动缩放填满控件区域。
- Proportional = True:缩放时保持宽高比,不会把二维码压扁。
- Center = True:缩放后如果还有留白,让图片居中。
这三个属性配合起来,不管 Image 控件在窗体里被拉大还是缩小,二维码始终保持正方形且居中显示。如果你想把二维码放大预览,按钮里动态设置 Image1.Width 和 Height 就行,不需要重新生成文件。
5. 我踩过的坑与排查速查
5.1 绘制闪烁问题
第一次把绘制代码塞进 OnPaint 之后,每次鼠标划过窗体,二维码区域就疯狂闪烁。原因是 PaintBox 背景先被清成白色,再画黑色模块,重绘频率高的时候人眼就能捕捉到闪烁。解决方案有两个,一是 Form 的 DoubleBuffered 属性设为 True,让 VCL 在内存画布上完成整套绘制后再一次性复制到屏幕;二是减少不必要的重绘,只有数据改变或控件尺寸改变时才调用 Invalidate。
最稳的做法是把绘制逻辑封装成一个独立函数,按钮点击时设置矩阵数据、计算新尺寸,最后才 Invalidate。不要在 OnPaint 里去读取 Edit 框内容、去请求网络、去做 IO 操作。VCL 的绘制事件频率远比你想象的高,窗口遮挡恢复、窗体拖动、最小化还原都会触发,干重活会导致界面明显卡顿。
还有一个小技巧,如果某个界面里二维码和其他控件布局比较复杂,可以把 PaintBox 的 Enabled 设为 False。这样它不响应鼠标交互,从根本上减少无意义的刷新。
5.2 DPI 缩放导致的锯齿和虚化
高 DPI 屏幕上,VCL 窗体如果启用了缩放,控件会被整体放大。这时 PaintBox 的 Width、Height 是物理像素还是逻辑像素,会直接影响绘制清晰度。我遇到过 150% 缩放下,模块边缘出现灰边、扫码时识错的情况,原因是绘制时用了物理 Canvas,但坐标计算基于逻辑尺寸,黑色矩形被系统插值拉伸,边缘就虚了。
解决思路是按当前 PixelsPerInch 动态调整 ModuleSize,保证每个模块的物理宽度是一个整数。简单实现就是在绘制前读取 Form 的 PixelsPerInch 和设计时基准值,换算出实际模块尺寸。另外保存文件的过程不受屏幕影响,因为 Bitmap 尺寸完全由我们自己指定,和屏幕分辨率无关。所以我建议屏幕展示图稍虚没关系,保存出去的 PNG 一定要按固定逻辑尺寸生成,保证扫描稳定。
5.3 保存出来的图片扫不出来
这个坑最隐蔽,曾经让我排查了一个下午。现象是 PaintBox 上扫码正常,保存成 PNG 后,手机能扫,但工业扫码枪怎么都扫不到。后面对比两张图发现,PaintBox 上显示时周围有空白的窗体背景,实际上发挥了静区作用,而保存出来的 PNG 因为绘制时 Margin 设置为 0,黑白矩阵贴边生成,扫码枪读不了。
处理办法就是本文前面反复提到的静区参数,QuietZone 至少 4,保存时最外层 4 圈模块宽度必须是空白。另外一个导致扫不出来的原因是保存尺寸太小,如果二维码是 V10 以上的版本,模块数量多,但你把图片整体压到 100x100 像素,那每个模块平均只有不到 3 像素,很多扫码引擎直接放弃。我通常保证最终保存的图片边长不低于 400 像素,模块尺寸不小于 6 像素。
5.4 二次加载与文件占用问题
有业务需求是保存后立即把文件删掉,只让图片保留在 Image 控件里。实际操作时发现,LoadFromFile 之后立刻 DeleteFile 会失败,有一部分原因是杀毒软件还在扫描文件,另一部分是 Image 控件内部还没有完全释放文件句柄。稳妥做法是先用 Try 包装删除,确认延迟几百毫秒,如果真的必须马上删,可以先把 Picture 保存到内存里的 TBitmap,再加载到 Image,之后删除文件就毫无障碍。
另外一个相关问题是,PaintBox 绘制的二维码在窗体最小化再恢复之后,OnPaint 触发时会重新绘制,这个前提下代码里必须保证矩阵对象还在,或者至少数据源还在。我见过有人直接把循环画在 FormCreate 里,结果窗口一刷新,二维码就消失了。正确做法是把矩阵生成结果缓存成一个字段,OnPaint 只做绘制。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 二维码绘制区域闪烁 | PaintBox 频繁重绘,未开双缓冲 | Form.DoubleBuffered := True,数据变化再 Invalidate |
| 高 DPI 下边缘虚化 | 逻辑尺寸与物理像素不匹配 | 动态调整 ModuleSize,用整数像素绘制 |
| 保存后扫码枪识别失败 | 静区不足或模块太小 | QuietZone 设为 4,图片边长不低于 400 像素 |
| JPG 保存后无法扫描 | 有损压缩导致模块边缘模糊 | 改用 PNG 保存 |
| Image 控件只显示一角 | Stretch 为 False | 设置 Stretch / Proportional / Center 为 True |
| 窗体恢复后二维码消失 | 绘制逻辑不在 OnPaint 里 | 缓存矩阵,在 OnPaint 中统一绘制 |
| DeleteFile 删除失败 | 图片文件句柄未释放 | 加载到内存后再删除,或延迟删除 |
| 内容包含中文扫出来乱码 | 编码模式或字符集不一致 | Encoding 使用字节模式,UTF-8,扫码端约定一致 |
6. 扩展:从“能扫出来”到“扫得快、不翻车”
6.1 给二维码加 Logo 的注意事项
很多客户会要求在二维码中间放一个企业 Logo,外观上确实更有辨识度。实现上并不复杂,在生成好的 Bitmap 正中央,把 Logo 图片按比例缩小后绘制上去。但核心问题是,Logo 区域会遮挡一部分矩阵模块,QR 码的 Reed-Solomon 纠错能力能扛住一定程度的遮挡,H 级别最高可以容忍约 30% 的码字损坏。
所以加 Logo 时必须把纠错级别提升到 H,设置 ErrorCorrectionLevel 为 3。Logo 尺寸建议不要超过二维码边长的四分之一,也就是中心 1/4 区域内的遮挡会被纠错抵消。还有一个小细节,Logo 图案最好不要带大面积的纯黑或纯白背景,尤其是不能带有与黑色模块相似的规则纹理,否则扫描器会误判为数据模块。我一般会把 Logo 周围留一圈白边,再放到二维码上。
6.2 批量生成与文件命名策略
如果业务上要一次生成几十上百个二维码,循环调用生成库即可,每个二维码生成计算量很小,不需要额外开线程。但文件命名和日志管理值得提前规划。我习惯用“前缀 + 日期 + 业务ID + 随机短码”的方式,比如 DEV20250615-0001-X7K3.png,这样归档时按前缀分类、按日期检索、按业务ID关联,非常清晰。
批量场景还有一个常见需求是连同二维码一起导出 Excel 或 SQLite 记录表,生成后把文件名、内容、生成时间、版本、纠错级别一并记录。千万不要在文件名里放中文和特殊符号,有些共享目录和打印系统的编码处理很弱,极易出错。文件名里的信息越少越稳,真正的内容全部放在关联表里。
6.3 二维码内容协议与数据安全
二维码能承载的内容有限,但很多人会往里塞一长串 JSON,结果版本飙到 V20 以上,扫码变慢,打印变小,纯属自找麻烦。我建议在系统设计阶段就约定一个紧凑的内容协议,比如DEV|SN20250615|A1B2C3这种管道符分隔格式,扫描端按固定顺序拆分即可。如果是 URL,就尽量用短链接服务,或者干脆放一个跳转参数,别把完整参数往二维码里塞。
涉及敏感信息时,直接在二维码里放明文不可取。二维码本身没有任何加密能力,贴在产品标签上谁都能扫。要保护的内容先做加密再编码,例如用 AES 加密后转 Base64,扫描端再反向解密。如果项目允许离线验签,可以在内容尾部加一段 HMAC 签名,扫码后校验签名确认数据未被篡改。这些内容看起来超出“画二维码”的范畴,但实际项目里往往和二维码功能绑定出现,提前设计能省掉后面改协议的痛苦。
我自己在实际项目里踩得最惨的一次,就是把二维码当成普通图片处理,最后因为格式和静区问题耽误了上线。以后的习惯是:内容先定协议,生成用矩阵接口,绘制时算好模块尺寸和静区,保存永远走 PNG,显示用 Image 的三件套属性。这套流程顺下来,不管是画在 PaintBox、保存到本地文件、加载到 Image 控件,还是后续加 Logo、批量导出、加密签名,都只是在这个骨架上加东西而已。