ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯 Delphi 安卓扫码方案:不卡顿的二维码识别与性能优化

纯 Delphi 安卓扫码方案:不卡顿的二维码识别与性能优化

简介:面向使用Delphi开发原生Android应用的工程师,本方案全部以Delphi代码实现安卓二维码扫码功能。与直接调用Zxing等第三方识别库不同,项目自行完成摄像头图像捕获、预处理、二值化及识别流程,并将核心逻辑封装为静态库,以减少运行时依赖、保障扫码不卡顿,适合对隐私可控、离线环境或底层原理有要求的场景。压缩包共14个文件,大小约29.17MB,内含dpr/pas源文件、fmx界面文件、dproj与deployproj工程配置、AndroidManifest部署模板、APK成品、mp3辅助文件及ReadMe说明等,Source/Obj/Bin目录划分清楚,便于对照编译与产物验证。该资源已有3295人学习使用。通过研读源码可掌握Delphi中摄像头图像流获取、二维码定位与解码思路,以及静态库在安卓工程中的集成方式;结合ReadMe还能快速搭建编译环境并识别常见坑点,是一份兼顾学习与落地的实用工程参考。

1. 全 Delphi 代码实现的安卓二维码扫码:不卡顿的纯 Delphi 方案

做安卓扫码需求时,很多 Delphi 开发者的第一反应是封装一个 Java 的 ZXing 库,或者找现成的第三方控件。但在真机上跑起来往往翻车:预览卡顿、识别慢、打包体积膨胀,出了问题还得开 Java 环境去查 JNI 崩溃日志。今天拆的这套源码,全部用 Delphi 实现二维码扫码的完整链路——摄像头取流、YUV 转灰度、二维码定位与解码、结果回传,中间没有任何 Java 桥接。特别适合对包体大小敏感、希望在 Delphi 生态内独立完成安卓扫码功能的团队,新手能直接照着搭工程,熟手也能看参数边界。

2. 为什么坚持全 Delphi:从 ZXing 移植原理到原生控件选型

2.1 Java 桥接的隐性成本与全 Delphi 的边界

如果只是调一个系统 API,JNI 还能接受,但二维码解码不是一次调用。ZXing 在 Java 侧的解码流程包含图像灰度化、二值化、Finder Pattern 定位、采样、GF(256) 纠错、格式信息解析等十几个步骤,每一步都要跨 JNI。跨 JNI 本身有性能损耗,更现实的问题是崩溃定位难——Java 层异常不直接进 Delphi 的异常体系,只能看 logcat 慢慢猜。

全 Delphi 实现最大的收益不是性能,而是可维护性。整个扫码模块从相机回调到解码结果都在一个语言体系里,你可以在 Delphi 的断点里看解码到哪一步挂了,也可以把解码器单独拿出来写单元测试。边界是:移植版的解码速度在同等硬件上比原生 Java 略慢,但通过控制解析帧率和降低分辨率,实际体验差距很小,后面第 4 章专门讲这个。

再从包体角度看。封装 Java 版 ZXing 通常要带过去几十个 Java 类文件,还要做 ProGuard 混淆配置,最终 APK 体积增加 5MB 到 10MB 很常见;纯 Delphi 方案只新增几个 pas 文件,体积增加可以控制在几百 KB 内。如果你做的是工具类 App、POS 类应用或内部扫码器,这差别不可忽略。

2.2 ZXing 移植版的核心类与解码链路

我用的这套源码以某社区维护的 ZXing 移植版为基础(模拟项目X),核心类是 TQRCodeReader、TDecodeHints,以及图像源 TPlanarYUVLuminanceSource。解码链路大致是:

预览帧(NV21)→ TPlanarYUVLuminanceSource 包装成灰度源 → 二值化器(HybridBinarizer 或 GlobalHistogramBinarizer)→ 检测器定位三个 Finder Pattern → 透视校正 → 采样网格 → 解码纠错 → 返回结果。

这里有个关键取舍:TPlanarYUVLuminanceSource 可以直接消费相机给的 YUV 原始数据,不需要转 Bitmap。很多人扫不出来是死磕 YUV 转 RGB 再调解码器,多了一次全帧转换,既慢又损耗灰度信息。NV21 的 Y 分量本身就是灰度图,ZXing 只需要 Y 平面数据,所以直接把 AData 指针交给亮度源即可。

调试时可以通过断点看 Detector 返回的 Finder Pattern 数量。如果小于 3,说明图像质量问题或二值化阈值不对;如果等于 3 但解码失败,问题多半出在透视校正或者版本信息读取上,这两个环节对图像尺寸的奇偶性很敏感。记住这个排查顺序,能省不少时间。

2.3 摄像头组件选型与预览帧格式

Delphi 从 10.3 起提供了 TCameraComponent 控件,它在安卓下封装了 Camera 和 Camera2。我一般用它的 OnSampleBuffer 事件做帧回调。SampleBuffer 的事件参数里给的是 Pointer 和 Size,格式是 NV21,宽度高度通过 FPreviewWidth、FPreviewHeight 记录。要注意的是,TCameraComponent 在不同 Delphi 版本上回调的帧格式略有差异,有的版本是 NV21,有的版本带旋转参数。拿到帧后先确认格式,不要默认 YUV420,否则图像会偏色或错位。

还有一种做法是直接用原生 Camera API 的 JNI 封装,但那样又回到桥接的老路。TCameraComponent 虽然还是 Delphi 自带的封装,但至少事件回调里给的是原始指针,我们可以把最容易出性能问题的解码环节留在 Delphi 侧,这样可控性高很多。我的习惯是:预览回调只做入队,不做任何解码和 UI 操作,把帧指针交给后台线程,让 UI 线程保持随时可响应。

2.4 移植版与原版 ZXing 的差异点

移植版不是逐行翻译 Java 代码,有几个地方容易踩:

第一,部分移植版缺失 GS1 码的解析支持。如果你需要识别带 GS1 应用标识符的码,选库时先确认有没有 FNC1 处理逻辑。第二,Reader.Decode 的重载参数顺序不一致,有的版是 Decode(Bitmap, Hints),有的版是 Decode(IntfLuminanceSource, Hints),直接用默认参数最容易踩坑。第三,某些移植版对高分屏大图没有做内部降采样,需要你外部先缩放。这些差异意味着:拿到库以后别急着接业务,先写一个独立的解码单测,喂进去几幅已知的测试图确认行为。

编译选项也会影响解码速度。Release 模式下建议开启优化、关闭范围检查和溢出检查,解码耗能差 20% 到 40%。调试版下扫码慢是正常的,别在 Debug 下判断性能问题。

3. 把扫码工程跑起来:工程配置、相机预览与解码流程

3.1 AndroidManifest 权限与工程依赖

先把工程配置做对,这一步错了后面全是黑屏。安卓 6 以上属于危险权限的相机,必须在运行时申请。Delphi 的做法是在 Project Options 里勾选 Camera 权限,同时也要在 AndroidManifest.template.xml 里确认有 uses-permission。很多翻车案例是只在 options 里勾了,但 Template 里的 uses-feature 没配 camera.any,导致低版本机型直接找不到相机硬件。

工程里需要引入 ZXing 移植版的源码单元,一般是 ZXing.QRCode.Reader、ZXing.Common.HybridBinarizer、ZXing.PlanarYUVLuminanceSource 等几个单元。不要一股脑把整个库全部放进 uses,只引解码链路相关的,编译时间会短很多。代码里我会用接口或者单例把 TQRCodeReader 缓存起来,避免每帧 New 一个实例,因为 Reader 内部有 Detector 的临时对象,频繁 New 会频繁触发 GC。

3.2 相机预览:TCameraComponent 与回调入队

下面是初始化相机的核心代码,这步的关键是把预览回调的帧数据送进队列,不做任何重活。

procedure TScanForm.InitCamera; begin FCamera := TCameraComponent.Create(Self); FCamera.OnSampleBuffer := CameraSampleBuffer; FCamera.FocusMode := TMobileFocusMode.ContinuousAutoFocus; // 连续对焦 FCamera.Active := True; // 打开相机 end; procedure TScanForm.CameraSampleBuffer(Sender: TObject; const AData: Pointer; ADataSize: Integer); begin if FFrameCount mod 3 <> 0 then begin Inc(FFrameCount); Exit; // 每 3 帧才入队一次,省掉 2/3 的解码工作量 end; Inc(FFrameCount); // 帧数据生命周期只在回调内有效,所以这里做一次拷贝 // 也可以用环形缓冲区避免每次都分配内存,但先保证正确 FDecodeQueue.Push(CopyFrame(AData, ADataSize)); end;

逻辑说明:OnSampleBuffer 是高频回调,低端机一秒钟可能走 30 次。这里用计数抽样,把解码负载直接降为三分之一。CopyFrame 是必须的,因为回调结束系统可能会复用这块内存,直接把 AData 指针交给后台线程是悬垂指针,崩溃很难查。更高效的做法是预分配 N 块缓冲区轮转使用,后面第 4 章展开。

参数说明:FCamera.FocusMode 设置为 ContinuousAutoFocus 能保证扫码时自动对焦,但部分国产 ROM 对这个模式支持不好,表现为反复拉风箱。遇到这种情况改成 Macro(微距)模式,扫码距离更近,识别率反而更高。

3.3 后台解码线程:从 YUV 到二维码结果

解码线程用 TThread 的子类实现,循环从队列取帧,调用 ZXing 的 Reader 解码。

type TDecodeThread = class(TThread) private FQueue: TThreadedQueue<TBytes>; FReader: TQRCodeReader; FHints: TDecodeHints; protected procedure Execute; override; public procedure DecodeFinished(const AText: string); // 回调 end; procedure TDecodeThread.Execute; var Frame: TBytes; LuminanceSource: TPlanarYUVLuminanceSource; Bitmap: TBitmap; ResultData: TDecodeResult; begin FReader := TQRCodeReader.Create; FHints := TDecodeHints.Create; FHints.TryHarder := True; // 提高远距离和小尺寸码的识别率 while not Terminated do begin Frame := FQueue.Pop; // 阻塞等待帧数据 try LuminanceSource := TPlanarYUVLuminanceSource.Create( @Frame[0], FWidth, FHeight); Bitmap := LuminanceSource.RenderToBitmap; // 生成灰度 Bitmap try ResultData := FReader.Decode(Bitmap, FHints); if ResultData <> nil then TThread.Synchronize(nil, procedure begin DecodeFinished(ResultData.Text); end); finally Bitmap.Free; end; except // 解码异常不能影响线程,记录日志后继续取帧 end; end; FReader.Free; end;

逻辑说明:Execute 里是一个死循环,FQueue.Pop 在没有数据时阻塞,这个模式下线程不会空转吃 CPU。每次取到帧后构造亮度源并渲染成灰度 Bitmap 再喂给 Reader。TThread.Synchronize 把结果拉回 UI 线程,避免在后台线程直接操作界面控件导致崩溃。RenderToBitmap 这一步其实可以绕开——ZXing 的 Decode 可以直接接收亮度源,但大多数移植版本的 Reader.Decode 重载还是认 Bitmap 或灰度数组,所以这里画出 Bitmap 是兼容性最好的写法。

参数说明:FHints.TryHarder 打开后,解码器会在第一轮定位失败时对整张图做二次扫描,识别率提升明显,代价是耗时可能翻倍。在预览帧上建议打开,在拍照模式解高清图时反而建议关闭,避免识别太慢。有些版本还支持 PureBarcode 提示,适用于整张图只有一个码的场景,用不上就别开。

3.4 结果处理与震动反馈

扫码成功后的交互,常见的做法是震动加提示音。安卓震动需要用到系统服务,Delphi 里可以直接调用 JNI 的 Vibrator,也可以简单点用 TNotificationCenter 的震动通知。这里给一段结果处理的容错代码:

procedure TScanForm.HandleResult(const AText: string); begin FCamera.Active := False; // 先停相机,防止连续触发多次 if FCallback <> nil then FCallback(AText) else ShowMessage('扫码结果: ' + AText); // 震动 100ms Vibrate(100); end;

这段的关键是 FCamera.Active := False 放在最前面,不然解码线程可能刚出结果又收到下一帧,导致同一个码被反复弹窗。回调结果用了一个 FCallback 事件属性,这样可以把扫码窗体做成通用组件,调用方只要挂事件就能拿到结果,不需要强耦合 UI。

4. 不卡顿优化:解码线程、帧率控制与内存调优

4.1 队列深度与丢帧策略

扫码最常见的玄学问题是:单独的相机预览流畅,一旦跑解码线程,UI 就开始掉帧。原因不只是线程抢 CPU,还有队列堆积。当解码速度跟不上帧产生速度时,队列里积压大量帧,后台线程永远在处理旧帧,预览实时性会明显变差。

解决方法是给队列加最大长度。我用 TThreadedQueue 时把队列容量设为 2,Push 时设置 Timeout=0,满了直接丢弃新帧。这样后台线程永远在处理最近一两帧,视觉上扫码框的反馈才是实时的。别担心丢帧丢码——二维码在预览里至少停留几百毫秒,每秒解 5 帧足够抓住它。

这个策略等价于「只认最新,不认最早」。用户移动手机时,旧帧的二维码可能已经出了画面,解旧帧大概率失败还浪费 CPU。丢帧其实是主动放弃无效劳动,换来的是每一次取到的帧都有更大概率对应当前画面中心。

4.2 解析分辨率与裁剪的关注区

ZXing 的耗时基本跟像素数成正比。预览帧如果是 1920x1080,全帧解码在低端机上要 300ms 以上,不卡才怪。常用做法是把解码用的灰度图像缩放到宽 640 或 480 再喂识别。缩放有两种路径:一是直接改相机预览分辨率,比如请求 PreviewSize 为 640x480;二是拿到帧后只取中间区域的 Y 平面数据做裁剪。我推荐后者,因为很多 ROM 对请求的分辨率会向上取整,你请求 640 它给你 1280。

裁剪关注区还有一个额外好处:如果扫码框在界面中间,识别区域也集中在中间,那么把四周的干扰信息裁掉,误识别率会下降。裁剪时注意宽高必须是偶数,NV21 转换时奇数宽高会出绿边或偏色。

裁剪的代码我一般这样写:从 NV21 的 Y 平面中间截取一块,构造亮度源时传新的宽高和 offset。其实 ZXing 的 PlanarYUVLuminanceSource 构造函数支持 cropLeft、cropTop、cropWidth、cropHeight 四个参数,直接用就行,不需要手动拷贝 Y 数据。

4.3 内存管理:Bitmap 复用与对象池

扫码是个长时运行的功能,如果每帧都 New 一个 TBitmap 再 Free,时间长了内存碎片会非常严重,尤其是 Delphi 的安卓内存管理器在频繁分配大对象时表现不好。我的做法是建立 Bitmap 对象池:解码线程启动时预分配 2 张灰度 Bitmap,轮流使用,用完后回到池中而不是释放。

这里有个坑:ZXing 的 HybridBinarizer 会从 Bitmap 的 ScanLine 读取像素,如果你复用 Bitmap,Reader 内部生成的部分中间对象仍然是线程安全的,但你不能同时让两个任务用同一张 Bitmap。所以对象池严格控制:同一时刻最多借出 1 张。预分配 3 张就够循环了。

还要注意帧数据的拷贝内存。回调里如果直接分配 TBytes,等于每帧都在堆上折腾几十万字节。环形缓冲区加对象池是标准解法:相机回调写入预先分配好的缓冲区,解码线程读完后立刻标记该缓冲区可用,零分配运行。

4.4 绘制优化:扫描框与遮罩不要全屏重绘

扫码界面经常有四个角的亮框和半透明遮罩。如果直接在 FormPaint 里全屏重绘,即使只是绘制几个矩形,在低端机上也会占用不少 CPU。常见做法是把遮罩和扫描框缓存到一张 TImage 或 TCanvas 上,只有位置变化时才更新,平时不触发重绘。

扫描线动画也不要每帧重绘遮罩。可以让扫描线在一个单独的小控件上画,这个控件只有几十像素高,Invalidate 范围只影响那一小块区域,Overhead 小很多。我习惯把扫码框区域自己记录成 FScanRect,动画定时器 50ms 更新一次,实际对性能的影响可以忽略。

把这几条归纳一下:UI 线程只做两件事——把相机帧入队、把解码结果展示。中间所有图像处理、解码、纠错全放后台线程。检查是否卡顿的最快办法是看 CPU 负载,如果 UI 渲染线程占用超过 15%,多半是某个回调里做了重活。

5. 避坑指南:扫码集成中的常见问题与排查

5.1 权限已开但预览一直黑屏

现象:AndroidManifest 里加了 CAMERA 权限,运行时也弹窗允许了,但预览区域是黑屏,没有任何报错。

原因:大多数情况是相机权限声明了,但 uses-feature 的 required 值导致应用在部分设备上被系统判定为不可用,或者相机在后台被其他应用占用。另一种常见原因是 Activity 的启动模式导致相机初始化在错误时刻发生。

解决:先检查 AndroidManifest.template.xml 里是否包含<uses-feature android:name="android.hardware.camera.any" android:required="false" />。然后把 FCamera.Active := True 放在 FormActivate 或 OnResume 事件里,不要在 FormCreate 里直接打开,因为此时 Surface 还没准备好。最后用一个测试工程只保留相机预览,排除其他业务代码干扰。

5.2 扫码结果乱码或方向颠倒

现象:能识别出内容,但字符串是颠倒的、乱码的,或者同一个码横着扫扫不出。

原因:相机预览帧的方向和屏幕方向不一致。前置摄像头和后置摄像头在不同设备上旋转角度不一样,有些 ROM 会额外旋转 90 度。ZXing 的解码器假设图像是正立的,方向错了定位器就找不到。

解决:在构造 TPlanarYUVLuminanceSource 时传入 rotation 参数,比如 90、180、270,或者对亮度源做 transpose 预处理。最稳妥的做法是运行时从 Camera 的 CameraInfo 里拿到 orientation 值,动态计算旋转角度。注意无论怎么旋转,Y 平面的长度不变,只是灰度图像的内容方向变化,所以不用重新分配内存,可以直接映射原缓冲区。

5.3 关掉页面后相机仍在工作

现象:扫码页面关闭后,状态栏提示相机图标一直亮着,甚至重启应用后相机被占用。

原因:Activity 生命周期管理不当。OnPause 时没有释放相机,或者 FCamera.Active 只在 OnDestroy 里设置 False,而系统退回桌面时并未触发 OnDestroy。

解决:在 OnPause 里写 FCamera.Active := False,在 OnResume 里重新赋 True。iOS 和安卓的生命周期差异很大,这套代码按安卓的来。另外不要把 CameraComponent 挂在 Form 上让它随 Form 释放,主动控制 Active 状态比依赖组件销毁更可靠。

5.4 解码线程偶发崩溃:Cannot access a destroyed view

现象:扫码偶尔闪退,日志指向 TThread.Synchronize 的匿名过程,异常信息是访问已销毁的视图或控件。

原因:解码线程的生命周期比窗口长。用户扫到一半退出页面时,窗口控件已经销毁,后台线程还在 Synchronize 里排队准备访问这些控件。

解决:在线程 Execute 的循环里增加一个 fTerminated 标志。退出页面时先设置终止标志,再释放队列,让线程 Pop 返回并退出循环。Synchronize 之前再判断一次标志,如果窗口已销毁就直接放弃结果。这套「先停线程、再销毁资源」的顺序一定要遵守,顺序反过来必然概率性崩溃。

5.5 内存持续增长最终被杀

现象:连续扫码十分钟,内存占用从 80MB 涨到 300MB,最后应用被杀。

原因:忽视帧数据的拷贝。回调里如果只是把 AData 指针存起来,内存不涨;但一旦拷贝成 TBytes 并压进队列,而队列里的帧处理速度跟不上,内存就会堆积。还有一个隐蔽点:TPlanarYUVLuminanceSource.RenderToBitmap 每次生成的灰度 Bitmap 可能被 ZXing 内部的某些状态持有,Bitmap.Free 后内存不一定立即归还系统。

解决:用固定容量环形缓冲区代替无界队列,并限制缓存帧数不超过 2。同时打开内存分析工具,确认 Free 后堆内存曲线是否回落。如果问题依旧,把临时对象放入对象池,避免反复构造释放。

6. 进阶技巧:二维码生成、批量识别与扫描框动效

6.1 用 TDelphiZXingQRCode 生成二维码

解码之外,这套资源里也带了一个生成二维码的单元。生成比解码简单得多,设置文本、宽度、纠错等级即可。我常用来做模拟项目X的出入凭证:

uses DelphiZXingQRCode; var QR: TDelphiZXingQRCode; Bitmap: TBitmap; begin QR := TDelphiZXingQRCode.Create; try QR.Data := '模拟项目X-2025-001'; // 内容 QR.Encoding := TQRCodeEncoding.qrAuto; QR.QuietZone := 2; // 静默区宽度,过小会扫不出来 Bitmap := QR.GenerateBitmap(400, 400); // 输出 400x400 位图 try Image1.Picture.Bitmap.Assign(Bitmap); finally Bitmap.Free; end; finally QR.Free; end; end;

逻辑说明:GenerateBitmap 是封装好的单步方法,第二个参数是图像尺寸。QuietZone 默认是 2,很多新手清零,结果生成的码贴到白底上没问题,贴到深色背景上就扫不出来。纠错等级建议至少选 Q,这样码有污损或遮挡 25% 以内的情况都能正常读取。

6.2 批量识别:从相册读图并解码

扫码页面有时需要支持从相册选图识别。这里有个小技巧:相册图是 JPEG,直接喂给 ZXing 效果很差,先转灰度再识别。如果有多个码在图中,循环调用 Reader.DecodeMultiple 即可。我一般还会对图片做一次 2x 降采样,因为相册原图动辄 3000 万像素,全尺寸解码极慢,降采样后识别率几乎不受影响。

处理相册图时还有个细节:部分手机相册会输出旋转后的缩略图,而原图里带有 EXIF 方向信息。先检查 JPEG 的 EXIF 方向字段,如果非 0,旋转后再解码,否则可能出现在预览模式能扫、相册模式扫不出的怪现象。

6.3 扫描框动画的轻量实现

最后聊扫描线动效。不要用动画线程每帧全屏刷新,我之前的做法是:扫描框 Rect 不变,只有一条 4 像素高的扫描线在框内上下移动,控件是一个自定义 TScanBar,Paint 里只画一条渐变线。定时器 30ms 触发一次 Top 移动并 Invalidate,负载几乎为零。

从那以后我每次接扫码需求,都强制先把「UI 线程职责」「队列深度」「内存释放时机」这三件事写在设计文档里,再动手写代码。希望帮到你,少走点我踩过的坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进