做扫码这块的同行大概都遇到过同一个场面:产品拿着两张图过来,一张是皱巴巴快递单上压扁的一维 Code128,一张是手机屏幕上反光严重的二维码,问你为什么这俩扫不出来。你打开工程一看,LBXScan 里挂的是默认那套引擎,换一个试试就好了——但到底该换哪个,很多人心里没底。LBXScan 这套封装里塞了三把刀:系统原生的 AVFoundation、移植过来的 ZXing、还有 C 写的 ZBar。三个人第一次翻它的源码,看到三套 Wrapper 就懵了,最后往往是“哪个能跑就留哪个”,结果上线后在某类码型上翻车。
我在几个项目里分别用过单引擎、双引擎并跑、三引擎全挂的配置,踩的坑够写一本小册子。这篇就把三把刀拆开:识别原理差在哪、格式覆盖差多少、单帧耗时差几个量级、包体积和许可证各是什么代价,以及在你现有工程里怎么接、怎么调、出问题怎么查。刚接手扫码模块的新人,和在为一张皱单子发愁的老手,下面的内容都能直接抄。
1. 三个引擎的来路与能力边界
先把“它们是什么”说清楚,后面所有取舍都建立在这个基础上。很多人把这三个当成“同一件事的三种实现”,其实它们的出身、设计目标、擅长的活完全不同,把它们放在一起比性能,就像拿瑞士军刀、菜刀、手术刀比谁切得好。
1.1 原生 AVFoundation:系统调度,硬件加持
AVFoundation 这条路上的扫码,核心是AVCaptureMetadataOutput。它不是“给你一张图让你自己算”,而是系统在相机管线里就把码找出来了,回调直接给你字符串和四个角点坐标。这件事的关键差别在于:解码发生在系统的图像处理链路里,用的是设备厂商调好的算法和硬件通路,你的 App 拿到的已经是结果。
这种设计带来三个直接后果。第一是快,快到你几乎感觉不到等待,尤其是二维码,基本是“框一放上去就响”。第二是省电,因为原始帧根本没进你的进程,没有大块内存拷贝,也没有你 CPU 上的浮点运算。第三是格式覆盖被系统锁死,你只能在availableMetadataObjectTypes给的列表里挑,列表里没有的,你写死也没用。
原生支持的码型大致是这些:二维码方向的 QR、PDF417、Aztec、DataMatrix,一维方向的 EAN-8、EAN-13、UPC-E、Code39、Code93、Code128、ITF-14、Interleaved 2 of 5,新系统上还多了 Micro QR、Micro PDF417 之类的扩展。注意 Codabar 不在里面——医疗试剂、血库标签、部分图书馆条码都用 Codabar,这个缺口是很多人第一次被原生引擎坑到的地方。另外一些工业标签上的畸形 DataMatrix、被塑封膜压变形的 PDF417,原生引擎的容错也比不上专业解码库。
还有一个容易被忽略的点:AVCaptureMetadataOutput只给结果,不给图。你想做“扫到之后截个缩略图给用户确认”,或者想把原始帧存下来做离线复现,它帮不了你。这时候就得挂第二个 output。
1.2 ZXing:格式覆盖最全的重装部队
ZXing 出身是 Java 世界的开源解码库,后来被移植成 Objective-C 版本进了 iOS 生态。它的定位从一开始就是“什么码都能解”,所以格式列表长得离谱:QR、DataMatrix、Aztec、PDF417、MaxiCode、UPC-A/E、EAN-8/13、Code39/93/128、Codabar、ITF、RSS-14、RSS Expanded,几乎你能在市面上见到的码型它都认。
代价是它干活的方式很“重”。ZXing 拿到一帧图像后要做完整的图像处理流水线:灰度化(如果给的是彩色)、二值化(默认走 HybridBinarizer,分块自适应阈值)、寻找定位图案、透视矫正、采样、纠错解码。这一套跑下来,在 640×480 的灰度帧上单帧几十毫秒是常态,比你想象的慢。
但慢不代表差,ZXing 真正的价值在鲁棒性。它的 HybridBinarizer 对光照不均的处理相当扎实——一半在阴影里一半在灯下的二维码,很多库直接放弃,ZXing 还能捞回来。它的 Reed-Solomon 纠错实现也很完整,污损面积接近上限的码它敢试。我在一个仓库盘点项目里做过对比,同一批打印质量很差的标签,原生的命中率明显低于 ZXing,差距不是一点点。
ZXing 还有个原生引擎完全没有的能力:静态图识别。用户从相册选一张图,或者你从服务端拉一张运单图,ZXing 直接在UIImage上跑就行。这个场景在售后、客服、后台审核类功能里非常常见,原生元数据输出根本做不到。
1.3 ZBar:一维码与弱光环境的老牌狙击手
ZBar 是纯 C 写的,体积小、依赖少、上手快。它的强项非常集中:一维码。Code128、EAN-13、UPC-A 这些在 ZBar 手里的识别率,尤其在中低端设备、光线一般、条码有轻微模糊或倾斜的情况下,经常比 ZXing 还稳。
原因在一维码的识别原理上。一维码本质是一串宽度不同的黑白条纹,解码的关键是准确地测出每一段的宽度比例,然后查表。ZBar 在这一步做了大量针对性优化:它沿着扫描线做亚像素级的边缘定位,对模糊边缘的容忍度高;它的扫描线密度和方向策略也更激进,一帧里会打好几条不同位置的扫描线去找。相比之下 ZXing 的一维识别更像“顺手支持”,能用但不是重点打磨的方向。
ZBar 的短板同样明显:二维只支持 QR,DataMatrix、Aztec、PDF417 一概不认。这个限制直接决定了它在很多项目里只能当配角。另外它的上游维护多年停滞,新系统、新架构的适配要自己动手,这一点后面会专门讲怎么绕。
1.4 一张表看清三者差异
下面这张表是我按实际使用感受整理的,数字部分来自模拟项目里同一批样本的粗略区间,设备和样本不同会有出入,只看量级,别当基准跑分。
| 对比维度 | 原生 AVFoundation | ZXing | ZBar |
|---|---|---|---|
| 二维码格式 | QR、PDF417、Aztec、DataMatrix | 全部主流二维格式 | 仅 QR |
| 一维码格式 | 约 8 种常见码型,无 Codabar | 20 种以上,含 Codabar、RSS | 10 种以上,一维识别率高 |
| 输入形态 | 仅视频流(元数据输出) | 视频流帧 + 静态图 | 视频流帧 + 静态图 |
| 单帧耗时(640×480 灰度) | 5~15 ms,且不占你的 CPU | 25~80 ms | 8~25 ms |
| 包体积增量 | 0 | 1.5~3 MB(dead strip 后) | 300~600 KB |
| 弱光、污损容错 | 强(系统调优) | 强(自适应二值化) | 一维强,二维码一般 |
| 多码同帧 | 支持,一帧可回多个对象 | 需换用多码 Reader | 支持,可遍历多个 symbol |
| 维护状态 | 随系统版本更新 | 社区活跃 | 上游长期停滞 |
| 许可证代价 | 系统框架,无额外成本 | 宽松许可,可静态链接 | 有传染性条款,法务会问 |
看完这张表其实结论已经浮出来了:原生打主力、ZXing 兜底二维和静态图、ZBar 补一维和弱光。但真要落到工程里,还得先搞清楚封装层为什么这么设计。
2. LBXScan 为什么要把三套引擎缝在一起
有人会问:既然原生最强,为什么不全用原生?答案很简单——没有一个引擎能在所有场景下都不丢分。封装层的存在不是为了炫技,是为了让你在遇到具体问题时手里还有牌可打。
2.1 单引擎的天花板在哪
只挂原生,你会在这几个场景翻车:Codabar 码型完全不支持;相册选图识别做不了;打印质量差、局部反光的标签命中率明显下滑;某些老设备上 DataMatrix 识别不出来的概率偏高。这些都是我在实际项目里遇到过的,不是理论推演。
只挂 ZXing,问题反过来:性能压力大。1080p 的帧直接喂进去,单帧可能要上百毫秒,你必须做降采样、做区域裁剪、做帧率节流,稍微调不好就是发热、掉帧、扫描框卡顿。而且 ZXing 的视频流方案本质是“拿帧当静态图反复解”,它没有系统那种跨帧的跟踪能力。
只挂 ZBar,二维格式的缺口直接卡死业务——现在哪个 App 不扫二维码。
2.2 统一抽象层要付的设计代价
LBXScan 的做法是把三套引擎各自包一层 Wrapper,对外统一吐同一个结果模型。这个结果模型至少要包含:原始字符串、码型标识、码在画面中的位置(用于画扫描框动画)、以及一个“哪个引擎解出来的”标记。
听起来干净,但代价有三个,我得说清楚,免得你后面掉进去。
第一,位置坐标不统一。原生元数据输出给的是归一化坐标,用layerRectConverted(fromMetadataOutputRect:)转成预览层坐标很直接;ZXing 给的是像素坐标,还要考虑你裁剪 ROI 时的偏移;ZBar 给的是它自己坐标系里的位置,方向可能和 UI 不一致。三个引擎的坐标要收敛到同一套,中间至少要写一层转换,而且这层转换是整个扫码模块最容易出 bug 的地方,扫描框动画对不上、在人脸旁边乱跳,八成是这里错了。
第二,回调时序不统一。原生是一帧可能回多个对象,ZXing 是一次一个结果,ZBar 也是一帧可多个 symbol。你要做去重——同一个码在连续几十帧里会被反复识别,不去重的话回调会疯狂触发,业务侧的“识别成功后跳转”会被打断好几次。
第三,配置项没法统一。原生要配metadataObjectTypes和rectOfInterest;ZXing 要配ZXDecodeHints的格式集合和是否尝试反色;ZBar 要逐个 symbol 开关。封装层只能给每个引擎单独留配置口子,别指望一套参数打通。
2.3 包体积、许可证、系统版本三条硬约束
包体积。ZXing 默认编进来 1.5~3 MB(开启 dead strip、只保留必要格式后能压到 1.5 MB 左右),ZBar 三四百 KB。如果业务只扫二维码,加这两个纯属浪费;如果业务要扫 Codabar,那 ZXing 就是必选项,这笔体积省不掉。我的经验是:先按业务码型清单算一遍,别一上来三个全挂。
许可证。这三个的授权条款差别不小。原生是系统框架,没有额外负担;ZXing 是宽松许可,静态链接没问题;ZBar 是带传染性条款的许可,静态链接进商业 App 时法务通常会要求你准备好可替换的目标文件或者改成动态库。这一点在项目早期就要和法务对齐,别做到后期才发现要换引擎,那成本是重写级别。
系统版本。原生的一些码型支持是跟着系统版本走的,比如 GS1 DataBar、Micro QR 都是在较新的系统上才有。你做兼容时要判断availableMetadataObjectTypes,而不是写死一个数组——写死的后果是低版本设备上直接抛异常崩掉。
注意:
availableMetadataObjectTypes必须在 session 配置完成、output 已经 add 到 session 之后才有正确值。在addOutput之前读,拿到的是空数组,然后你设置的metadataObjectTypes会全部失效,表现就是“相机画面正常但永远扫不出东西”。
3. 识别能力实测:用同一批样本跑三个引擎
性能对比最怕的就是“我这边很快啊”。要让结论可信,样本和流程都得控制住。这一节我讲怎么造样本、怎么测、以及测出来的差异背后到底是什么原因。
3.1 测试样本怎么造才有说服力
我在模拟项目 X 里用的样本集是这样的:二维码 120 张,涵盖普通黑白码、带 logo 的码、低对比度码(浅灰底黑码)、局部反光码、30° 到 60° 倾斜码、污损面积 10%~25% 的码;一维码 120 张,涵盖 Code128、EAN-13、Code39、ITF-14、Codabar,同样分出清晰、模糊、弯折、低对比度几档。全部打印在相纸和普通 A4 纸上各一份,因为纸张反光特性对识别率影响很大。
采集方式是同一台设备、同一个支架、同一套灯光,每个样本在三个引擎下各跑 30 帧,取“首次成功识别的帧序号”作为速度指标,取“30 帧内是否成功”作为命中率指标。这种做法比单次跑要慢,但结论稳得多——单次测出来的耗时波动能到 ±50%,根本没法比。
3.2 二维码:速度差距和容错差距是两码事
速度上原生是碾压性的。普通二维码基本在第一帧就出结果,从我按下开始的体感延迟在 100 ms 以内。ZXing 在同样 640×480 的帧上,普通码要 30~60 ms 解一帧,加上节流到 10 fps,体感延迟在半秒左右。ZBar 解 QR 大概 15~25 ms,比 ZXing 快一点,但对低对比度和倾斜码的命中率明显不如 ZXing。
容错上的排序就反过来了。低对比度和污损这两档,ZXing 的命中率最高,原生次之,ZBar 最差。倾斜 45° 以上的码,原生和 ZXing 都能扛,ZBar 掉得比较厉害。这里的原因前面说过:ZXing 的二值化是分块自适应的,局部光照差异被吃掉了;原生靠系统调优,也很强;ZBar 的 QR 解码器对几何畸变的处理相对简单。
多码同帧这件事值得单独说。原生AVCaptureMetadataOutput天然支持一帧回多个对象,你把回调里的数组遍历一遍就行。ZXing 默认的ZXMultiFormatReader一次只返回一个结果,要识别多个得换ZXGenericMultipleBarcodeReader,而且速度会明显下降(它要反复在剩余区域里找)。ZBar 支持一帧多个 symbol,用zbar_symbol_next遍历就行,这点上比 ZXing 省事。
3.3 一维码:ZXing 和 ZBar 的真正差距在哪儿
一维码这一档,ZBar 和 ZXing 都明显强于原生——不是因为原生算法差,是因为原生支持的码型少,Codabar 干脆没有,而我样本里有 24 张 Codabar,原生直接零分。
在两者都支持的码型上,差异主要出现在三个场景。
模糊条码。打印头老化或者热敏纸褪色的运单,条纹边缘是糊的。ZBar 在这种图上优势明显,它的边缘定位对灰度过渡更宽容。ZXing 需要更高的对比度才能切出准确的条宽。
弯折条码。贴在圆柱形包装上的条码会弯。这时候 ZBar 也会吃力,因为它假设扫描线是直的;ZXing 反而略好一点,因为它会在多个候选行上尝试。这种情况的通用解法是提示用户把标签摊平,或者上多角度旋转帧重试。
极短条码。比如只有 8 位数字的 EAN-8,或者被裁掉一部分的 Code39。ZXing 的格式校验更严格,残缺的直接拒绝;ZBar 有时候能“猜”出来,但猜错的风险也高。宁可扫不出来,也别扫出错——扫码错的业务代价远高于让用户重扫一次,这一点在支付、入库这类场景里是铁律。
3.4 静态图识别和视频流识别不是一回事
很多人以为“视频流都能扫,静态图肯定也行”,实际差别不小。视频流是连续的,你有几十上百次机会,光线稍微变一下、手抖一下,总有几帧是清楚的;静态图就一张,糊了就是糊了,没有第二次机会。
所以静态图识别必须用更“重”的算法。ZXing 的 HybridBinarizer 在单张图上会做多轮尝试,甚至尝试反色(白码黑底),这就是为什么它单张图能跑出几十上百毫秒。原生引擎在这条路上完全缺席,因为它拿不到静态图这个输入形态。
实际做法是这样:相册选图后先用缩略图快速试一次,失败再把原图缩到长边 1280 左右重试,还失败就提示用户手动输入。三档降级,成功率能到 95% 以上。直接用原图硬跑,一张 4000×3000 的图能把主线程卡住好几秒。
4. 接入实操:三套引擎的完整落地流程
这一节给能直接抄的代码。我用 Swift 和 Objective-C 混着写,因为两套桥接的代码风格天然不同,硬统一反而别扭。
4.1 会话骨架:一条 session 挂两个 output
要同时用原生和第三方引擎,session 里得挂两个 output:AVCaptureMetadataOutput负责原生识别,AVCaptureVideoDataOutput负责给 ZXing 和 ZBar 喂帧。两个 output 可以挂在同一个 session 上,不冲突。
private let session = AVCaptureSession() private let metadataQueue = DispatchQueue(label: "scan.metadata") private let videoQueue = DispatchQueue(label: "scan.video") func setupSession() throws { session.beginConfiguration() session.sessionPreset = .hd1280x720 guard let device = AVCaptureDevice.default(for: .video) else { throw ScanError.noCamera } let input = try AVCaptureDeviceInput(device: device) guard session.canAddInput(input) else { throw ScanError.configFailed } session.addInput(input) // output 1:原生元数据 let metadataOutput = AVCaptureMetadataOutput() guard session.canAddOutput(metadataOutput) else { throw ScanError.configFailed } session.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(self, queue: metadataQueue) // 关键顺序:addOutput 之后再取 availableMetadataObjectTypes let wanted: [AVMetadataObject.ObjectType] = [ .qr, .ean13, .ean8, .code128, .code39, .code93, .upce, .pdf417, .aztec, .dataMatrix ] let supported = Set(metadataOutput.availableMetadataObjectTypes) metadataOutput.metadataObjectTypes = wanted.filter { supported.contains($0) } // output 2:原始帧,给 ZXing / ZBar let videoOutput = AVCaptureVideoDataOutput() videoOutput.alwaysDiscardsLateVideoFrames = true videoOutput.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange ] videoOutput.setSampleBufferDelegate(self, queue: videoQueue) guard session.canAddOutput(videoOutput) else { throw ScanError.configFailed } session.addOutput(videoOutput) session.commitConfiguration() session.startRunning() }两个细节值得强调。sessionPreset我选.hd1280x720而不是最高画质,因为扫码不需要 4K,分辨率越高解码越慢,720p 在“够清楚”和“够快”之间是比较好的平衡点。alwaysDiscardsLateVideoFrames = true必须开,否则一旦你的解码耗时超过帧间隔,帧会在队列里堆积,延迟越来越大,最后卡死。
像素格式选420YpCbCr8BiPlanarFullRange的原因是:这个格式的 Y 平面就是一张灰度图,ZXing 和 ZBar 都可以直接用,省掉一次灰度转换。这是实打实的性能收益,我自己测过能省 5~10 ms 一帧。
4.2 原生元数据输出的关键配置
原生这条路的核心是rectOfInterest。它的作用是告诉系统“只在这个区域内找码”,能显著降低误识别和提升速度。但它用的是传感器方向的归一化坐标系,这是个经典陷阱:竖屏时你看到的画面是旋转 90° 的传感器画面,所以 x 和 y 是反的。
手算很容易错,我建议直接用系统的转换 API:
// scanRectInView:你在预览层上画的扫描框(预览层坐标系) func updateRectOfInterest(_ metadataOutput: AVCaptureMetadataOutput, scanRectInView: CGRect, previewLayer: AVCaptureVideoPreviewLayer) { let metadataRect = previewLayer.metadataOutputRectConverted(fromLayerRect: scanRectInView) metadataOutput.rectOfInterest = metadataRect }必须用转换 API,不要自己算。我见过不止一个项目在这上面花了一整天,最后发现是videoGravity设成了resizeAspectFill导致预览层显示的图像被裁掉了一部分,手算的坐标全部偏移。转换 API 内部处理了 gravity 和方向的组合,稳得多。
回调里拿到结果后要做的事:
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = obj.stringValue else { return } // obj.bounds 是预览层坐标系下已转换好的矩形(前提是做了下面的转换) let corners: [CGPoint] if let transformed = previewLayer?.transformedMetadataObject(for: obj) as? AVMetadataMachineReadableCodeObject { corners = transformed.corners } else { corners = [] } handleResult(value, type: obj.type.rawValue, corners: corners, engine: .native) }transformedMetadataObject(for:)这一步不能省。metadataObjects里拿到的bounds是原始传感器坐标系下的,直接拿去画扫描框会跑到屏幕外面。很多人卡在这里,明明识别成功了,框就是不显示。
4.3 ZXing 桥接:把帧喂给解码器
ZXing 那边的代码是 Objective-C 风格,桥接的关键是把CVPixelBuffer的 Y 平面包装成ZXPlanarYUVLuminanceSource。
- (NSString *)decodeSampleBuffer:(CMSampleBufferRef)sampleBuffer { CVImageBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); CVPixelBufferLockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); size_t width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0); size_t height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0); size_t stride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0); uint8_t *base = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0); // stride 必须传 bytesPerRow,传成 width 会让图像斜切,直接解不出来 ZXPlanarYUVLuminanceSource *source = [[ZXPlanarYUVLuminanceSource alloc] initWithYuvData:base dataWidth:(int)stride dataHeight:(int)height left:0 top:0 width:(int)width height:(int)height reverseHorizontal:NO]; ZXBinaryBitmap *bitmap = [ZXBinaryBitmap binaryBitmapWithBinarizer:[ZXHybridBinarizer binarizerWithSource:source]]; ZXDecodeHints *hints = [ZXDecodeHints hints]; hints.alsoInverted = YES; // 尝试白码黑底,很多包装上印的是反色码 hints.tryHarder = YES; // 代价是慢,只在对命中率要求高的场景开 NSError *error = nil; ZXResult *result = [[ZXMultiFormatReader reader] decode:bitmap hints:hints error:&error]; NSString *text = result ? result.text : nil; CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); return text; }dataWidth传 stride 这个点是我踩过最深的坑之一。传错之后的现象非常诡异:二维码能扫出来,一维码死活扫不出来,因为斜切对二维码的影响被纠错机制吃掉了,而一维码是逐行读的,斜一行就全废。找了两天才定位到。
如果要裁剪 ROI,不要直接传 left/top 到 source 上,先手动把需要的行拷贝到一个紧凑的缓冲区里(stride 等于裁剪宽度),再把这个缓冲区传给 source。原因同上:ZXing 的裁剪逻辑依赖dataWidth与width的关系,stride 不等于 width 时算出来的偏移是错的。多一次 memcpy 换来正确性,值。
4.4 ZBar 桥接:Y800 灰度图最省事
ZBar 只认灰度平面图,格式标识写Y800。它比 ZXing 更严格的一点是:stride 必须等于 width,否则必须逐行拷贝重排。
- (NSArray<NSString *> *)zbarDecode:(CVPixelBufferRef)pixelBuffer { CVPixelBufferLockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); size_t width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0); size_t height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0); size_t stride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0); uint8_t *base = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0); // stride 不等于 width 时重排,否则图和码会错位 uint8_t *gray = NULL; BOOL needCopy = (stride != width); if (needCopy) { gray = malloc(width * height); for (size_t row = 0; row < height; row++) { memcpy(gray + row * width, base + row * stride, width); } } else { gray = base; } zbar_image_scanner_t *scanner = zbar_image_scanner_create(); // 先全关,再按需打开,这一步能省 30% 以上的时间 zbar_image_scanner_set_config(scanner, 0, ZBAR_CFG_ENABLE, 0); zbar_image_scanner_set_config(scanner, ZBAR_CODE128, ZBAR_CFG_ENABLE, 1); zbar_image_scanner_set_config(scanner, ZBAR_EAN13, ZBAR_CFG_ENABLE, 1); zbar_image_scanner_set_config(scanner, ZBAR_QRCODE, ZBAR_CFG_ENABLE, 1); zbar_image_t *image = zbar_image_create(); zbar_image_set_format(image, zbar_fourcc('Y', '8', '0', '0')); zbar_image_set_size(image, (unsigned)width, (unsigned)height); zbar_image_set_data(image, gray, width * height, NULL); NSMutableArray<NSString *> *results = [NSMutableArray array]; int count = zbar_scan_image(scanner, image); if (count > 0) { for (const zbar_symbol_t *sym = zbar_image_first_symbol(image); sym; sym = zbar_symbol_next(sym)) { const char *data = zbar_symbol_get_data(sym); if (data) { [results addObject:[NSString stringWithUTF8String:data]]; } } } zbar_image_set_data(image, NULL, 0, NULL); // 防止 zbar 释放外部内存 zbar_image_destroy(image); zbar_image_scanner_destroy(scanner); if (needCopy) free(gray); CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly); return results; }两个必须注意的地方。zbar_image_set_data之后一定要把它重置为 NULL,否则zbar_image_destroy会尝试释放你不拥有的内存,直接崩。另外scanner和image在高频调用下要复用,上面的写法是为了代码清楚才每次都创建,实际项目里应该把它们提到成员变量里,每次只调zbar_scan_image——创建销毁的开销在 10 fps 下能占到总耗时的两三成。
4.5 结果收敛与去重
三套引擎的结果最后都要进同一个出口函数。这个出口要做三件事:去重、限流、打标签。
private var lastCode: String? private var lastTime = Date.distantPast private let dedupWindow: TimeInterval = 2.0 func handleResult(_ code: String, type: String, corners: [CGPoint], engine: ScanEngine) { let now = Date() if code == lastCode, now.timeIntervalSince(lastTime) < dedupWindow { return } lastCode = code lastTime = now DispatchQueue.main.async { self.scanViewController?.onScanSuccess(code, type: type, corners: corners, engine: engine) } }去重窗口我一般设 1.5~2 秒。太短了用户会收到好几次回调,业务侧可能重复下单;太长了用户扫第二个码的时候会被误判成重复。有个更稳的做法:去重的同时把“码的物理位置”也纳进去,同一个位置、同一个内容才算重复——但这会引入坐标精度问题,简单场景没必要。
5. 性能调优:帧率、ROI、对焦与功耗
扫码模块的体验好坏,八成在调优上。解码算法你改不了,但你可以控制喂多少帧、喂多大区域、什么时候喂。
5.1 采样频率怎么算才不浪费电
Android 上很多扫码库默认 30 fps 全速解,iOS 上这么干会明显发热。算一笔账:ZXing 单帧 40 ms,30 fps 的话每秒需要 1200 ms 的 CPU 时间——超出一个核心的 100%,必然多核并行、必然发热。降到 10 fps,每秒 400 ms,单核 40% 占用,机身温温的,扫码体验完全没影响。
节流的写法:
private var lastScanTime = CMTime.zero private let minInterval = CMTime(value: 1, timescale: 10) // 10 fps func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let delta = CMTimeSubtract(pts, lastScanTime) guard CMTimeCompare(delta, minInterval) >= 0 else { return } lastScanTime = pts // 到这里才真正调解码 runZxing(sampleBuffer) runZbar(sampleBuffer) }这里有个细节:节流要用帧的时间戳判断,不要用Date()。时间戳是视频源给的,稳定、不受主线程卡顿影响;用Date()在系统繁忙时会漂移,导致实际帧率忽高忽低。
还有一点,两个第三方引擎不要在同一帧上串行跑。原生识别成功就不必跑第三方了,这是最省的路径。策略是:原生回调到了直接返回;只有原生在 800 ms 内没有任何结果,才启动 ZXing / ZBar 兜底。这个“原生优先、第三方兜底”的策略,能让你在 90% 的正常场景下完全不碰那两个重引擎。
5.2 扫描区域的坐标换算
ROI 裁剪的收益非常直接。1280×720 的全帧是 92 万像素,裁剪中间 600×400 的框只有 24 万像素,像素数降到 26%——ZXing 的解码耗时大致跟着像素数线性走,从 40 ms 降到 12 ms 左右。
裁剪的做法是在 Y 平面上按行 memcpy:
func cropLuma(_ pixelBuffer: CVPixelBuffer, roi: CGRect) -> (buffer: [UInt8], width: Int, height: Int) { CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) } let stride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) let base = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0)!.assumingMemoryBound(to: UInt8.self) let x = Int(roi.origin.x), y = Int(roi.origin.y) let w = Int(roi.width), h = Int(roi.height) var out = [UInt8](repeating: 0, count: w * h) for row in 0..<h { let src = base + (y + row) * stride + x out.withUnsafeMutableBytes { dst in memcpy(dst.baseAddress!.advanced(by: row * w), src, w) } } return (out, w, h) }坐标换算是这里最容易错的地方。roi必须是在像素缓冲区坐标系下的矩形,而你在 UI 上画的是预览层坐标系下的矩形,两者中间隔着 gravity、缩放和方向三层变换。稳妥的做法是从metadataOutputRectConverted(fromLayerRect:)拿到归一化矩形,再乘以缓冲区的宽高得到像素矩形——用同一套转换逻辑,别手写两遍。
5.3 对焦、手电、亮度这些非识别细节
扫码体验差,很多时候不是算法的问题,是相机没调好。这几项我每个项目都会配。
连续自动对焦是必须的。默认可能是锁定对焦,用户从远处移近,画面一直是糊的。配置方法:
if let device = videoInput.device { try device.lockForConfiguration() if device.isFocusModeSupported(.continuousAutoFocus) { device.focusMode = .continuousAutoFocus } if device.isSmoothAutoFocusSupported { device.isSmoothAutoFocusEnabled = true } device.unlockForConfiguration() }isSmoothAutoFocusEnabled这个开关专为视频设计,开启后对焦过程平缓很多,不会出现“拉风箱”式的画面抖动,扫码时视觉体验差别很大。
手电筒要在暗光下自动提示。判断方式不是看环境光(你读不到),而是看“连续 N 帧没识别出任何东西且增益已经拉满”。简单可靠的版本是:进入扫码页 3 秒没结果,就显示“光线较暗,点击开启手电”的提示。别做全自动开灯,用户会被吓到。
曝光补偿在扫屏幕上的码时特别有用。手机屏幕亮度不够时,相机会把画面拉亮,反而把码的对比度压掉了,形成恶性循环。这时候把setExposureTargetBias(-0.5)往下压一点,码就清楚了。这个技巧在扫码支付、扫电子票的场景里非常实用。
扫码框的尺寸也有讲究。框太小用户不容易对准,框太大解码区域就大,速度慢。我的经验是框的宽度占屏宽 65%~70%,高度按 1:1 或 4:3 给,同时框内的实际裁剪区域要比视觉框稍大一圈(留 10% 余量),否则用户对准了但因为手抖出框,反而扫不出来。
6. 踩坑实录与排查速查表
下面这些是我这些年真正花时间调过的,按“症状—原因—解法”整理,遇到问题时照着查能省不少时间。
6.1 黑屏与回调不触发
最典型的现象是:相机权限给了,预览层画面正常,但扫码回调一次都没触发。
第一个要查的是metadataObjectTypes的设置时机。前面提过,必须在addOutput之后设置,且要和availableMetadataObjectTypes取交集。写死数组的后果在低版本设备上是直接抛异常。
第二个是rectOfInterest。如果设成了一个无效矩形(比如宽高为 0,或者超出 0~1 范围),系统会认为“没有有效的识别区域”,什么都不回。排查手法:先把rectOfInterest设成CGRect(x: 0, y: 0, width: 1, height: 1),如果这时候能扫出来,问题 100% 在 ROI 的换算上。
第三个是 session 的启动时机。startRunning()是同步阻塞的,在viewDidLoad里直接调会卡住主线程几百毫秒,正确做法是丢到后台队列:
DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }在viewWillDisappear里要stopRunning(),否则退出页面后相机还在跑,耗电不说,有些设备上会和其他相机功能抢占资源。
6.2 静态库链接与架构报错
ZXing 和 ZBar 都是老派静态库,接进来总会有几个经典错误。
Undefined symbols for architecture arm64:八成是没加-ObjC链接标志。ZXing 大量用了 Objective-C 的分类(Category),不带-ObjC的话分类里的符号不会被链接进最终产物。在 Build Settings 的 Other Linker Flags 里加上-ObjC就好。
building for iOS Simulator, but linking in object file built for iOS:静态库里没有模拟器的 arm64 切片。解法是在 Build Settings 的 Excluded Architectures 里给模拟器配置加上arm64,或者用 xcframework 重新打包一个。
ld: library not found for -lzbar:路径没配对。用 CocoaPods 的话检查 podspec 里的vendored_libraries;手动集成的话检查 Library Search Paths,注意相对路径的基准是项目目录还是构建产物目录——这两者搞混是很常见的失误。
还有一个容易被忽略的:ZXing 的实现里有 C++,如果你的工程里有.mm文件拼错了后缀,报错信息会非常莫名其妙(一大堆 C++ 模板错误),实际上就是把.mm写成了.m。
6.3 识别率忽高忽低的排查路径
“昨天还好好的,今天怎么扫不出来了”——这类问题最难查,因为往往不是代码改了,是环境变了。我一般按这个顺序排查:
先看是不是设备差异。拿三台不同代际的设备试同一个码,如果只有某一台不行,多半是相机硬件特性差异(比如某型号的最近对焦距离偏大),需要在业务侧加“请稍微拿远一点”的提示。
再看是不是光照。日光灯和 LED 灯的高频闪烁会造成画面明暗条纹,直接影响二值化。判断方式是录一段视频看预览画面有没有横向滚动条纹。有的话,把activeVideoMinFrameDuration调一下避开闪烁频率(国内市电 50 Hz,对应避开 100 Hz 的整数倍关系),或者干脆提示用户换个角度。
然后看码本身。把扫不出来的码打印出来,用手机拍张高清图,在本地跑一遍三个引擎的静态图识别。如果静态图也识别不了,那是码的问题(打印质量、对比度、模块尺寸太小),不是你的代码问题。这一步能省掉大量无谓的代码排查。
最后才怀疑版本。系统升级后相机管线行为可能有细微变化,尤其是曝光和对焦策略。这种问题你改不了,只能靠调参数绕过去。
6.4 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 预览正常但无任何回调 | metadataObjectTypes设置为空 | 与availableMetadataObjectTypes取交集,且必须在 addOutput 之后 |
| 能扫到但扫描框位置错乱 | 未做transformedMetadataObject | 回调结果转换到预览层坐标系再画 |
| 只扫得出二维码,一维码全废 | 传给 ZXing 的dataWidth用了 width | 改成bytesPerRow,或先重排成紧凑缓冲 |
| 暗光下几乎扫不出 | 未开手电、未做曝光补偿 | 加暗光提示 +setExposureTargetBias微调 |
| 扫一次触发多次回调 | 未做去重 | 内容 + 时间窗口去重,窗口 1.5~2 秒 |
| 长时间扫码手机发烫 | 未节流、全帧解码 | 降到 8~10 fps,裁剪 ROI,原生优先 |
| 相册选图识别率低 | 直接拿原图跑,未做降采样 | 缩到长边 1280 再解,失败再试反色 |