CameraX+ZXing:Android扫码识别与优化实战

发布时间:2026/10/29 14:22:13
CameraX+ZXing:Android扫码识别与优化实战

简介:围绕ZXing实现二维码与条形码扫描的Android项目资料包,面向需要在移动应用中快速集成扫码能力的开发者,也可供物联网设备信息采集场景参考。内容涉及库依赖配置、自定义扫描界面、Intent启动扫描、结果回调解析、相机权限检查、闪光灯与扫描区域提示,以及异常处理和用户体验优化,并附说明使用文档与MyApplication示例,便于对照源码理解完整实现。压缩包共1874个文件,约18.42MB,以flat、json、xml、dex、class、java、jar等为主,涵盖Gradle配置、Java源码、编译产物、资源与清单文件,另含APK安装包和docx使用说明,目录保留较完整的Android工程结构。已有2535人学习下载,适合希望借助ZXing快速搭建扫码模块、排查集成问题或参考示例工程组织方式的中初级Android开发者。

1. 从仓库收货口扫码枪坏掉的那个下午说起

仓库收货口的扫码枪坏了,我临时接了个活:把二维码扫描和条形码扫描做进手机 App,让拣货员拿手机对着快递单和商品条码扫一下就能入库。当时我以为调个相机加个库就完事,真正上线才发现,光照、反光、旋转、条码褶皱每一样都能让识别率掉一半。这套代码就是那两周攒下来的最小可用骨架,解码核心用的是 ZXing,采集用的是 CameraX,跑通大概十分钟,改起来也不难。适合两类人:一类是手里有个扫码需求、想找个能直接改的起点;另一类是已经用了现成扫码界面、但被它的定制成本卡住的开发者。

2. 整体架构与技术选型:一帧画面到一串字符要走几道手

2.1 三段式链路:采集层、解码层、业务层

整套东西拆开其实只有三段,而且段与段之间用很薄的数据结构连接,改哪一段都不容易伤到另外两段。

采集层负责把相机吐出来的ImageProxy变成一块纯粹的灰度像素数组。这里有个容易被忽略的点:相机输出的YUV_420_888里,Y 通道本身就是灰度信息,色度通道对识别毫无价值,所以整条链路只在 Y 平面上干活,内存和 CPU 都省一大截。采集层还顺手把行填充(rowStride)裁掉、把旋转角摆正,输出一个GrayFrame(data, width, height)。

解码层拿到GrayFrame,包成PlanarYUVLuminanceSource,再套一层二值化器变成BinaryBitmap,最后交给MultiFormatReader.decodeWithState()。这一层对外只暴露一个decode(frame): Result?,返回 null 表示这帧没读到东西,不是错误。

业务层拿Result里的text和barcodeFormat做分发:是商品条码就走库存查询,是二维码里的链接就走跳转,是内部编码就走工单匹配。这一层最容易长歪,因为业务方总想在这里塞各种 if,我的做法是定义一个解析器,把字符串转成密封类,后面怎么处理都不影响解码链路。

三段之间的边界清楚,好处在排查问题时特别明显:识别不出来,先看采集层给的图对不对;图对了还识别不出来,那基本就是解码参数或者算法的事;解码出来了但业务没反应,那就跟扫码没关系了。这条判断路径省下的时间,比我写代码的时间还多。

2.2 解码内核选 ZXing 的几个理由

选型的时候对比过三条路:调用平台自带的机器学习识别能力、用现成的扫码 Activity 组件、直接拿 ZXing 的 core 模块自己拼。

平台自带方案的识别率确实好,弱光和污损码都能扛,但代价是包体积涨、离线场景受限、而且返回的字段是它定死的,你想拿到码在画面里的四个角点做定位框,就得看它心情。现成的扫码 Activity 组件上手最快,一行 Intent 就能出结果,但界面、交互、回调时机全是人家定好的,稍微要改个扫描框样式或者加个连续扫码模式,就得连源码一起扒下来改,改完还得跟着人家升级。

ZXing 的 core 模块是纯 Java 实现,没有 native 库,不需要任何初始化,扔进依赖里就能用。它支持的格式够全:QR Code、Data Matrix、Aztec、PDF417,一维的 EAN-8/13、UPC-A/E、Code 39/93/128、ITF、Codabar 都有。更关键的是它能在 JVM 上直接跑单元测试,不用连真机,这对识别逻辑的回归验证太重要了——我后来攒了一批样本图,每次改参数先跑一遍测试,比拿手机对着屏幕扫半天靠谱得多。

它的短板也得说清楚:纯图像处理算法,弱光、严重反光、大面积污损的码识别率明显不如深度学习方案。我的取舍是主链路走 ZXing,保证离线可用和可控性;真遇到识别率不达标的场景,再单独加一条兜底通道,而不是一上来就把整个扫码压在一个黑匣子上。

2.3 关键参数一览

调参这件事很玄学,同一个参数在不同机型上表现能差出一截。下面这几个是我实际调过、也确实影响结果的,先列出来,后面代码里会逐个对上。

参数典型取值作用与影响
分析分辨率1280×720太低小码糊成一团,太高每帧解码耗时翻倍,720p 是识别率和耗时的平衡点
背压策略STRATEGY_KEEP_ONLY_LATEST解码跟不上时丢旧帧,避免队列堆积导致预览卡顿
TRY_HARDERtrue允许更激进地尝试,旋转码和密集码成功率明显提升,代价是单帧耗时增加
POSSIBLE_FORMATS按业务白名单只开需要的格式,格式越多误识别概率越高、速度越慢
二值化器HybridBinarizer局部自适应,应对光照不均比全局直方图版好很多
去重窗口1500ms同一个内容在这个窗口内重复出现只回调一次

表里的去重窗口不是拍脑袋定的:相机一秒能解出十几帧,同一个码会被反复命中,不去重的话业务层会收到一串重复请求,库存能给你扣好几次。

3. 工程骨架:目录结构、依赖和相机绑定

3.1 目录结构

整个 Demo 的目录结构很扁平,没有按 feature 分层,因为这个场景只有一个 feature,硬套分层反而绕。

scan-demo/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/scan/ │ │ │ ├── ui/ScanActivity.kt 预览界面、权限申请、结果回调 │ │ │ ├── camera/CameraController.kt CameraX 绑定与解绑 │ │ │ ├── decode/FrameConverter.kt YUV -> 灰度 -> 摆正 │ │ │ ├── decode/ZxingDecoder.kt ZXing 封装,只暴露 decode │ │ │ ├── decode/ResultParser.kt Result -> 业务对象 │ │ │ └── util/ScanThrottle.kt 去重与节流 │ │ ├── res/layout/activity_scan.xml 预览 + 扫描框 + 提示文案 │ │ └── AndroidManifest.xml │ └── build.gradle.kts └── settings.gradle.kts

decode/这个包是整个工程的核,它不依赖任何 UI 类,所以能原样搬到别的项目里,也能单独抽成模块。ui/和camera/是壳,壳可以换,核尽量别动。

3.2 依赖与权限

依赖就两块:CameraX 负责预览和取帧,ZXing 负责解码。ZXing 的 core 模块坐标在依赖仓库里搜zxing就能找到,版本挑一个跟你项目里其他依赖不冲突的即可,它是纯 Java 的,不需要在Application里做任何初始化。

dependencies { // CameraX:预览 + 图像分析,版本对齐同一套 BOM implementation("androidx.camera:camera-core:1.3.4") implementation("androidx.camera:camera-camera2:1.3.4") implementation("androidx.camera:camera-lifecycle:1.3.4") implementation("androidx.camera:camera-view:1.3.4") // ZXing 解码内核:纯 Java 实现,无 native 依赖,无需初始化 // 坐标在依赖仓库里搜 zxing 即可,版本与其他依赖对齐 }

权限这块有三个容易漏的点。第一,CAMERA权限是危险权限,必须运行时申请,不能在 Manifest 里声明完就完事。第二,uses-feature一定要写android:required="false",否则应用商店会把没摄像头的设备直接过滤掉,平板和部分一体机就装不上了。第三,如果要做相册选图识别,还得加上读取图片的权限,或者直接用系统图片选择器拿 URI,避免申请存储权限。

<uses-permission android:name="android.permission.CAMERA" /> <!-- required=false:没有摄像头的设备也能安装,运行时再做能力判断 --> <uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />

3.3 相机预览的绑定与生命周期

CameraX 的绑定逻辑本身不复杂,坑主要在生命周期和线程上。ImageAnalysis必须指定一个后台执行器,绝对不能在主线程解码,否则预览会肉眼可见地卡。解绑则必须跟宿主生命周期对齐,页面销毁时不解绑,下次进来就是黑屏。

class CameraController( private val lifecycleOwner: LifecycleOwner, private val previewView: PreviewView, private val analyzer: ImageAnalysis.Analyzer ) { // 单线程执行器足够:解码是串行的,多线程只会抢 CPU private val executor = Executors.newSingleThreadExecutor() private var analysis: ImageAnalysis? = null fun start() { val future = ProcessCameraProvider.getInstance(previewView.context) future.addListener({ val provider = future.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val imageAnalysis = ImageAnalysis.Builder() // 720p 是识别率和单帧耗时的平衡点 .setResolutionSelector( ResolutionSelector.Builder() .setResolutionStrategy( ResolutionStrategy( Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER ) ).build() ) // 解码跟不上时丢掉旧帧,防止队列堆积 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer(executor, analyzer) } analysis = imageAnalysis provider.unbindAll() // 先解绑,避免重复 bind 抛异常 provider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis ) }, ContextCompat.getMainExecutor(previewView.context)) } fun stop() { analysis?.clearAnalyzer() ProcessCameraProvider.getInstance(previewView.context).get().unbindAll() executor.shutdown() } }

start()里那个provider.unbindAll()是先解绑再绑,看着多余,实际很有用:页面快速切前后台时bindToLifecycle可能被调用两次,不解绑会直接抛IllegalArgumentException。stop()里的顺序也不能反,先清分析器再解绑,否则可能有一帧正在解码的图被强行掐断。

STRATEGY_KEEP_ONLY_LATEST这个策略是必须的。默认的阻塞模式在低端机上会让分析队列越堆越长,表现出来就是预览画面延迟越来越大,用户举着手机等两秒才看到结果,体验极差。只保留最新帧相当于主动丢弃处理不过来的数据,画面流畅度优先。

4. 核心链路拆解:一帧画面怎么变成业务数据

4.1 图像分析器:从 YUV 帧到可解码的灰度数组

这是整条链路里最容易翻车的地方,因为相机给的ImageProxy并不是一块规整的width × height连续内存。Y 平面的rowStride通常大于图像宽度(硬件按对齐要求补了填充字节),不处理的话图像会斜着撕裂,识别率直接归零。

data class GrayFrame(val data: ByteArray, val width: Int, val height: Int) object FrameConverter { /** 取 Y 通道,裁掉行填充,再按旋转角摆正 */ fun fromImage(image: ImageProxy): GrayFrame { val plane = image.planes[0] // YUV_420_888 的 Y 平面就是灰度 val buffer = plane.buffer val rowStride = plane.rowStride // 每行实际字节数,可能大于 width val pixelStride = plane.pixelStride // Y 平面通常为 1 val w = image.width val h = image.height val tight = ByteArray(w * h) if (rowStride == w && pixelStride == 1) { buffer.rewind() buffer.get(tight, 0, w * h) // 无填充,直接整块拷 } else { val row = ByteArray(rowStride) for (y in 0 until h) { buffer.position(y * rowStride) val len = minOf(rowStride, buffer.remaining()) buffer.get(row, 0, len) for (x in 0 until w) tight[y * w + x] = row[x * pixelStride] } } return rotate(tight, w, h, image.imageInfo.rotationDegrees) } /** 只处理 90/180/270 三种情况,0 度原样返回 */ private fun rotate(src: ByteArray, w: Int, h: Int, degrees: Int): GrayFrame = when (degrees) { 90 -> { val out = ByteArray(w * h) for (y in 0 until h) for (x in 0 until w) out[x * h + (h - 1 - y)] = src[y * w + x] GrayFrame(out, h, w) // 旋转后宽高互换 } 180 -> { val out = ByteArray(w * h) for (y in 0 until h) for (x in 0 until w) out[(h - 1 - y) * w + (w - 1 - x)] = src[y * w + x] GrayFrame(out, w, h) } 270 -> { val out = ByteArray(w * h) for (y in 0 until h) for (x in 0 until w) out[(w - 1 - x) * h + y] = src[y * w + x] GrayFrame(out, h, w) } else -> GrayFrame(src, w, h) } }

旋转这一段是血泪经验换来的。一开始我图省事,直接把imageProxy的 buffer 塞给PlanarYUVLuminanceSource,横屏测的时候一切正常,竖屏怎么扫都扫不出来——因为相机传感器是横向的,竖屏时帧数据整体转了 90 度,而 ZXing 默认不认旋转后的二维码。加上这段手写旋转之后问题立刻消失,代价是每帧多一次数组遍历,720p 下大约 2 到 3 毫秒,能接受。

pixelStride在 Y 平面上通常是 1,但有些设备会返回 2,代码里用通用的x * pixelStride取下标,兼容性更好,不用为个别机型写特判。

4.2 MultiFormatReader 解码与 Hints 配置

解码这一层要做的事只有三件:配置格式白名单、选择二值化器、处理好异常。ZXing 抛出的NotFoundException、ChecksumException、FormatException都表示「这帧没读到有效内容」,属于正常流程,不要当崩溃往上抛。

class ZxingDecoder { private val reader = MultiFormatReader() // Hints 只配一次,复用 reader,避免每帧重建 private val hints = mapOf( DecodeHintType.POSSIBLE_FORMATS to listOf( BarcodeFormat.QR_CODE, BarcodeFormat.DATA_MATRIX, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A, BarcodeFormat.CODE_128, BarcodeFormat.CODE_39 ), DecodeHintType.TRY_HARDER to true, // 允许更激进的尝试,提升旋转码成功率 DecodeHintType.CHARACTER_SET to "UTF-8" // 中文二维码不乱码的前提 ) fun decode(frame: GrayFrame): Result? { // 注意参数是数据宽度/高度,不是显示尺寸 val source = PlanarYUVLuminanceSource( frame.data, frame.width, frame.height, 0, 0, frame.width, frame.height, false ) // HybridBinarizer:局部阈值,光照不均时比全局直方图稳 val bitmap = BinaryBitmap(HybridBinarizer(source)) return try { reader.setHints(hints) reader.decodeWithState(bitmap) } catch (e: NotFoundException) { null // 本帧没有码,正常情况 } catch (e: ChecksumException) { null // 一维码校验位不对,多半是拍糊了 } catch (e: FormatException) { null // 格式解析失败,同上 } finally { reader.reset() // 必须重置,否则下一帧可能沿用脏状态 } } }

几个参数值得单独说。POSSIBLE_FORMATS一定要显式列出白名单,默认配置下 ZXing 会尝试一大堆格式,误识别率会上升,而且单帧耗时会翻倍。TRY_HARDER打开后,ZXing 会尝试更多次采样,对倾斜、缺角的码帮助很大,代价是耗时增加约三成,在 720p 下依然控制在可接受范围。

reader.reset()放在finally里是必须的。MultiFormatReader内部持有一堆 Reader 实例和缓存状态,解码失败后不 reset,下一帧可能拿到上一次的残留状态,表现出来就是「第一帧扫不出来,第二帧莫名其妙扫出来一个错的」。这个问题没有任何日志提示,纯黑匣子,我排查了小半天。

HybridBinarizer和GlobalHistogramBinarizer的差别在光照不均的场景下非常明显。仓库那种顶灯加侧窗的环境,画面一半亮一半暗,全局阈值一化,暗的那半边直接糊成一块黑,局部自适应的版本还能把条码的边缘抠出来。代价是计算量稍大,但在 720p 下完全能扛住。

4.3 结果回调、去重与节流

相机一秒能出十几帧,同一张码会被连续命中,如果每命中一次就回调一次业务,库存能给你扣成负数。所以分析器里必须做两件事:单帧互斥,以及基于内容和时间窗口的去重。

class ScanAnalyzer( private val decoder: ZxingDecoder, private val onResult: (Result) -> Unit ) : ImageAnalysis.Analyzer { private val busy = AtomicBoolean(false) private var lastText: String? = null private var lastTime = 0L override fun analyze(image: ImageProxy) { try { // 上一帧还没解完就丢掉这帧,避免线程堆积 if (!busy.compareAndSet(false, true)) return val frame = FrameConverter.fromImage(image) val result = decoder.decode(frame) ?: return val now = SystemClock.elapsedRealtime() // 同一内容 1500ms 内只回调一次 if (result.text == lastText && now - lastTime < 1500) return lastText = result.text lastTime = now onResult(result) } finally { busy.set(false) image.close() // 必须关,不关后续帧全被堵住 } } }

AtomicBoolean那一行是防重入的。虽然ImageAnalysis配的是单线程执行器,理论上不会并发,但页面切换、分析器重新绑定时可能有一帧正在处理中又被投递进来,加了这层保险之后就没再出现过数组下标越界的崩溃。

image.close()在finally里是硬性要求。ImageProxy内部持有一个缓冲区,不 close 的话缓冲区不归还,相机会以为你还在处理,后续帧全部被阻塞,表现出来就是预览还动,但再也扫不出东西——这个现象特别像「识别率突然变成 0」,实际上是一个资源泄漏。

去重窗口 1500ms 是个经验值。太短了,用户手抖一下会被重复回调;太长了,连续扫不同商品时会有明显延迟感。如果业务是连续扫码模式(比如一单一单地扫),我会把这个窗口降到 600ms,同时把「同一内容」的判断改成「同一内容且扫描框内有位置偏移」,避免站在原地不动被反复触发。

4.4 相册图片与批量识别

除了实时预览,还有两个常见需求:从相册选一张图识别,以及一次性识别一张图里的多个码。前者用RGBLuminanceSource就够了,后者要用GenericMultipleBarcodeReader包一层。

fun decodeBitmap(bitmap: Bitmap, reader: MultiFormatReader, hints: Map<DecodeHintType, Any>): Result? { // 先等比缩到长边 1600 以内,避免大图 OOM 和无效计算 val scaled = scaleToMaxSide(bitmap, 1600) val w = scaled.width val h = scaled.height val pixels = IntArray(w * h) scaled.getPixels(pixels, 0, w, 0, 0, w, h) // 注意构造参数顺序是 (width, height, pixels) val source = RGBLuminanceSource(w, h, pixels) val binary = BinaryBitmap(HybridBinarizer(source)) return runCatching { reader.decode(binary, hints) }.getOrNull() }

RGBLuminanceSource的构造参数是宽度在前、高度在后、像素数组在后,这个顺序跟直觉相反,写反了不会报错,只会识别率归零。另外getPixels拿到的IntArray是 ARGB 打包的,ZXing 内部会自己算出亮度,不需要手动转灰度。

批量识别用GenericMultipleBarcodeReader包一层MultiFormatReader,一次扫描会把图切成若干区域反复尝试,返回一个Result数组。它的耗时是单码识别的数倍,所以只在相册这种一次性的场景用,实时预览里千万别开。

5. 常见问题排查:识别率和稳定性上的六个坑

5.1 识别相关:明明有码却扫不出来

这几个问题的共同点是都没有异常日志,只能靠对比实验定位。

现象:预览画面里码很清晰,就是死活扫不出来。原因:POSSIBLE_FORMATS里没开这种格式。默认配置下某些一维码格式是关着的,比如商品上常见的 UPC-A,跟 EAN-13 长得几乎一样但格式枚举不同。 解决:按业务把所有需要的格式显式列进POSSIBLE_FORMATS,尤其是做零售的,UPC-A 和 EAN-13 要一起开,很多进口商品印的是 UPC-A。

现象:横屏能扫,竖屏扫不出来,换个方向又能扫。原因:没处理imageInfo.rotationDegrees,帧数据跟屏幕方向差了 90 度,ZXing 只能从正立方向开始找定位图案。 解决:在FrameConverter里按角度旋转字节数组,同时注意旋转后宽高互换,传给PlanarYUVLuminanceSource的必须是互换后的尺寸。

现象:离得越近越扫不出来,退后半步反而好了。原因:一维码需要完整的条码宽度进入画面,贴太近时条码左右两端被裁掉,起止符不完整;另外近距离超出相机最近对焦距离,画面本身就是糊的。 解决:界面上给出扫描框和距离提示,一维码模式下把扫描框做成横向的长条;同时给ImageAnalysis开启连续自动对焦,别用单次对焦。

现象:暗光或反光环境下识别率骤降,白天好好的。原因:Y 通道在低照度下动态范围不足,码的黑白对比被压扁;反光则是局部过曝,二值化后直接连成一片白。 解决:开自动曝光和自动白平衡,不要手动锁死;黑暗环境用屏幕补光或者打开闪光灯;二值化器确认用的是HybridBinarizer,全局阈值的版本在这种场景下基本没救。

5.2 集成相关:跑着跑着就出问题

现象:连续扫十几秒后开始卡顿、掉帧,机身发烫。原因:每帧都ByteArray(w * h)新建数组,一秒十几次的 GC 压力很大;再加上如果解码跑在主线程,预览必然卡。 解决:把ImageAnalysis的setAnalyzer指定到单线程执行器,帧数组改造成复用的成员变量(注意用完再复用,别在解码中途覆盖),并且确认背压策略是KEEP_ONLY_LATEST。

现象:退出页面再进来,相机黑屏,或者直接崩溃。原因:页面销毁时没解绑 CameraX,也没clearAnalyzer,相机资源被上一个实例占着;或者ImageProxy没 close,缓冲区泄漏。 解决:在onDestroy里按「先 clearAnalyzer、再 unbindAll、最后 shutdown 执行器」的顺序释放,并且分析器的finally里无条件image.close()。

现象:相册选图能识别,相机反而识别不了同一张码。原因:相册走的是RGBLuminanceSource,相机走的是PlanarYUVLuminanceSource,两条路径的旋转处理不一样——相册图是正的,相机帧可能是转的。也可能是相机路径下没开TRY_HARDER,而相册路径开了。 解决:把两条路径的 hints 配置收敛到同一个对象里,别各写一份;旋转逻辑只放在FrameConverter,相册路径不参与旋转。

提示:排查扫码问题时,第一步永远是「把送进解码器的那块灰度图存下来看一眼」。存成 PNG 打开,如果图是斜的、糊的、撕裂的,那问题在采集层,跟 ZXing 一点关系都没有。这一步能省掉大量瞎猜。

6. 二次开发:把扫码能力拆成可复用的业务组件

这套骨架真正被复用的时候,我发现最该抽出来的不是解码器,而是「扫码这件事的契约」。业务方要的东西说白了就三样:扫什么格式、扫到之后怎么处理、什么时候结束。把这三个问题定义清楚,界面和解码都能换。

interface ScanContract { /** 本次扫码关心的格式白名单 */ fun formats(): List<BarcodeFormat> /** 扫到内容后的处理,返回 true 表示消费完成、结束扫码 */ fun onDecoded(result: Result): Boolean /** 是否允许连续扫码(true 用于批量入库,false 用于单次核销) */ fun continuous(): Boolean = false }

有了这层契约,业务侧只写实现类,不需要碰相机和解码。入库场景实现一个连续扫码的契约,核销场景实现一个单次扫码的契约,两者共用同一套CameraController和ZxingDecoder。这时候ScanActivity变成了一个纯粹的容器:拿契约、开相机、把Result转给契约、根据返回值决定要不要继续。

解析这一层建议做成链式的,别写成一坨 when。一维码给的是纯数字,二维码可能是 JSON、可能是 URL、可能是内部约定的短链,每加一种来源就得改一次解析逻辑,很快就没人敢动了。我一般会定义一串PayloadParser,每个只判断自己认不认,认就返回,不认就交给下一个,新增来源只要往链尾挂一个就行。

验证方式也得改。纯靠真机扫码验证,改一个参数就要重新扫一遍,效率极低而且不可复现。我后来攒了一批样本图——正常的、倾斜 15 度的、打了高光的、印在褶皱纸箱上的、截图压缩过的——放在测试资源目录里,写一个 JVM 单元测试,把每张图转成灰度数组喂给ZxingDecoder,断言识别结果。改参数先跑测试,通过率不掉再上真机,这一套下来能挡掉八成回归问题。

@Test fun decode_ean13_sample() { val gray = loadGray("samples/ean13_normal.png") // 读成 w*h 的灰度数组 val source = PlanarYUVLuminanceSource( gray.data, gray.width, gray.height, 0, 0, gray.width, gray.height, false ) val result = ZxingDecoder().decode(gray) assertEquals("6901234567892", result?.text) assertEquals(BarcodeFormat.EAN_13, result?.barcodeFormat) }

这套测试的价值在换机型的时候体现得最明显。同一批参数在主力机上 98% 通过率,换到一台老设备上掉到 70%,跑一遍样本测试就知道是分辨率的问题还是旋转的问题,不用举着手机在仓库里转圈。

还有一个我踩过的坑:别在ResultParser里做网络请求。解析应该是纯函数,输入字符串输出对象,请求交回业务层用协程或者线程池处理。我有一次偷懒在解析器里同步查库存,结果网络一慢,整个分析线程被堵住,预览直接定格,用户以为相机坏了。从那以后我每次往上加解析逻辑,都强制先问一句「这里面有没有阻塞调用」,有就拆出去。希望帮到你。

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