简介:面向Android开发者的ZXing扫码集成示例包,针对应用内快速接入二维码与条形码扫描、免去从零处理相机与解码流程的需求,涵盖依赖配置、扫描界面、结果回调、权限申请与闪光灯等环节。压缩包共1874个文件,约18.42MB,以flat、json、xml资源与索引文件为主,另含java源码、jar包及dex/class编译产物和少量png、说明文档,保留工程结构的同时包含构建结果,便于对照排查。已有2535人学习下载。包内附《说明使用文档.docx》与MyApplication示例,可参考Gradle依赖、CaptureActivity调用及onActivityResult中读取SCAN_RESULT的写法,也可安装APK快速验证扫码流程,适合需要把扫码功能落地到项目中的初中级Android开发者查漏补缺。
1. 从“扫不出来”的瞬间说起:二维码扫描与条形码扫描里 zxing 到底管哪一段
仓库那台手持机,预览画面清清楚楚,条码怼到镜头前还是没反应——这种现场见过太多次。第一反应往往是算法不行,其实九成问题出在喂进去的那一帧:行宽没对齐、ROI 裁得太小、反光被二值化当成白块。二维码扫描和条形码扫描这件事,zxing 只负责“拿到灰度图之后怎么把比特抠出来”这一段,相机、对焦、预览、UI 它一概不管。它是一套纯 Java 的图像解码引擎,QR、Data Matrix、PDF417、Aztec 和一维的 Code 128、EAN-13 都在支持范围,全程离线、不联网、不带商业授权包袱。想在 Android 或 JVM 服务端自己搭一条扫码链路、又不想被第三方 SDK 的包体和条款绑住的人,适合从它入手。下面按拆这套库的顺序讲:链路、接相机、调参、踩坑,最后落到上线前的验证。
2. 解码链路拆开看:从一帧 YUV 到 Result 的四个环节
动手之前先把链路摊平,不然后面每个参数都是玄学。zxing 的入口非常朴素:给它一个LuminanceSource(灰度源),它内部走“二值化 → 定位 → 采样 → 纠错解码”四段,最后吐一个Result。这条链路上每一段都有自己的失败模式,知道在哪一段断的,比盲目加TRY_HARDER有用得多。
2.1 输入边界:LuminanceSource 才是入口,不是 Bitmap
很多人第一版代码是Bitmap→getPixels成int[]→RGBLuminanceSource→ 解码,跑得通,但 720p 一帧就要多分配一个 3.6MB 的 int 数组,连续扫十秒 GC 就开始表演。zxing 抽象出来的LuminanceSource只有三个核心方法:getRow(y, row)取一行灰度、getMatrix()取整块灰度、isRotateSupported()判断能否免费旋转。它有几个现成子类,选哪个直接决定性能上限:
| 子类 | 数据来源 | 典型场景 | 代价 |
|---|---|---|---|
PlanarYUVLuminanceSource | 相机 YUV 的 Y 平面 | Android 实时预览 | 几乎零拷贝,裁剪免费 |
RGBLuminanceSource | int 像素数组 / Bitmap | 已有 Bitmap 的离线解码 | 一次全图分配 |
BufferedImageLuminanceSource | BufferedImage | 服务端、桌面端批处理 | 依赖 javase 模块 |
InvertedLuminanceSource | 包装另一个 Source | 反色码、暗色模式截图 | 一层包装,取反即可 |
PlanarYUVLuminanceSource的构造签名是(byte[] yuvData, int dataWidth, int dataHeight, int left, int top, int width, int height, boolean reverseHorizontal)。注意后四个参数是裁剪框,不是缩放——裁剪发生在灰度层,不产生新的像素,所以 ROI 越小后面整条链路越便宜。这也是为什么真实工程里很少见RGBLuminanceSource:多出来的那次像素搬运,换不来任何精度。
提示:
getMatrix()在PlanarYUVLuminanceSource内部会做一次整块拷贝。自己写裁剪逻辑时尽量用构造参数里的裁剪框,别在解码前手动getMatrix()再切数组。
2.2 二值化选型:GlobalHistogramBinarizer 与 HybridBinarizer 的分水岭
二值化是把灰度图变成黑白点阵,zxing 给了两个实现,选错就是“白天能用晚上瞎”的根源。
GlobalHistogramBinarizer把整幅图统计成 32 个亮度桶(LUMINANCE_BITS = 5),取直方图双峰之间的谷底当阈值,全图共用这一个阈值。它对光照均匀的场景——截图、扫描件、固定光源的产线工位——又快又准,因为只有一次统计。但只要画面里出现阴影、闪光灯高光、渐变背景,单一阈值就会把暗部整片压成黑,定位图案直接消失。
HybridBinarizer把图切成 8×8 的块(BLOCK_SIZE_POWER = 3),逐块算局部阈值,再用邻域块的平均值做平滑,最后才拼成整张BitMatrix。代价是每帧多几毫秒和几块临时数组,收益是不均匀光照下的成功率差一个量级。它有个边界:图宽或高小于 40 像素时会自动退回全局直方图,所以你把 ROI 裁到 30×30 再丢进去,实际跑的是GlobalHistogramBinarizer,行为和你以为的不一样。
口径很干脆:手机、手持机、货架这种真实场景默认HybridBinarizer;离线批处理里全是规整截图,用GlobalHistogramBinarizer省那几毫秒。
2.3 定位、采样与纠错:为什么“码占的像素数”是硬指标
二维码走的是Detector这条线:先找三个定位图案(Finder Pattern),判据是横向扫过时黑白比例接近 1:1:3:1:1;再按三个点的相对位置推定版本号和透视关系;版本 2 以上还要找校正图案(Alignment Pattern),用GridSampler按“模块数 × 模块数”的网格去采样,得到BitMatrix;最后Decoder读格式信息、去掩码、按 Reed-Solomon 纠错还原数据,再按 ECI 或默认字符集翻译成字符串。
一维码是另一条线:OneDReader在图上横向拉若干条扫描线,每条线上找“条空比”,命中后就近取多条线做校验。它天生怕倾斜——条的走向和扫描线夹角超过十来度就基本失效,这也是为什么一维码场景里图像旋转不是可选项。
两条线共享一个硬约束:单个模块(或最窄的条)至少占 2~3 个像素,低于 2 像素全靠运气。这解释了为什么远处的小码必须先把相机分辨率提上去,而不是无脑开TRY_HARDER——纠错码能补救污损和缺角(L 级约 7%、M 级 15%、Q 级 25%、H 级 30% 的冗余),但补不了“采样时模块已经糊成一团”。
2.4 Reader 分工:只扫二维码就别上 MultiFormatReader
MultiFormatReader是个门面,内部持有一张format -> Reader的表,decodeInternal会按 hints 依次尝试:先一维,再 QR、Data Matrix、Aztec、PDF417。每多尝试一种格式就多一轮全图扫描,720p 上能差出可观的一截耗时。所以:
- 只扫二维码:直接
QRCodeReader(),快且确定; - 二维码 + 一维码混扫:
MultiFormatReader+DecodeHintType.POSSIBLE_FORMATS把格式限死在你真正要的那几种,别留空(留空等于全开); - 同框多码:换
QRCodeMultiReader或GenericMultipleBarcodeReader,单码 Reader 只会返回第一个命中的。
复用姿势也有讲究:MultiFormatReader建一次,setHints一次,之后每帧用decodeWithState(bitmap),帧末reset()。每帧 new 一个 Reader 是最常见的隐形开销,尤其在一维码场景下 Reader 内部有状态缓存。
3. 把相机接上:CameraX + zxing 的最小可跑通链路
链路清楚了就可以接相机。Android 上现在没必要自己开 Camera2 的 session,CameraX 的ImageAnalysis已经把背压和线程模型处理好了,剩下的活就是“把 Y 平面正确抠出来”。
3.1 依赖与分析器线程模型
dependencies { // 纯 Java 解码核心,不含相机也不含 UI implementation("com.google.zxing:core:$zxingVersion") // 相机三件套:camera2 实现、生命周期绑定、PreviewView implementation("androidx.camera:camera-camera2:$cameraxVersion") implementation("androidx.camera:camera-lifecycle:$cameraxVersion") implementation("androidx.camera:camera-view:$cameraxVersion") }版本号用仓库里锁定的那一版,别照抄网上片段——ResolutionSelector、setOutputImageFormat这些 API 在不同版本间签名变过。三个依赖的分工是:core只做解码,camera-camera2提供底层实现,camera-lifecycle负责跟着页面生命周期自动开合,camera-view提供PreviewView。
分析器必须配单线程 Executor,并且背压策略选STRATEGY_KEEP_ONLY_LATEST。默认的STRATEGY_BLOCK_PRODUCER会让相机等解码,一帧卡住整条预览就跟着卡。setOutputImageFormat(OUTPUT_IMAGE_FORMAT_YUV_420_888)也要显式设,否则默认给你 RGBA,还得自己算灰度,白扔一次全图运算。
3.2 从 ImageProxy 抠出 Y 平面:rowStride 是第一个坑
// 把 ImageProxy 的 Y 平面整理成 zxing 认识的灰度源 private fun ImageProxy.toSource(): PlanarYUVLuminanceSource? { val plane = planes[0] // Y 平面正常 pixelStride 为 1;个别机型把 Y 交织进别的 buffer,这种帧直接放弃更安全 if (plane.pixelStride != 1) return null val rowStride = plane.rowStride val buffer = plane.buffer val data = ByteArray(width * height) // 想省 GC 就把这块提到成员变量复用,见 3.4 if (rowStride == width) { buffer.rewind() buffer.get(data, 0, width * height) } else { // rowStride 大于 width:每行尾部有填充字节,必须按行紧凑拷贝,否则图像会斜着错位 val row = ByteArray(rowStride) for (y in 0 until height) { buffer.position(y * rowStride) val len = minOf(rowStride, buffer.remaining()) buffer.get(row, 0, len) System.arraycopy(row, 0, data, y * width, width) } } // 后四个参数是裁剪框:left, top, width, height,此时先取整帧 return PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) }这段代码里最关键的是rowStride和pixelStride。相机为了对齐内存,每行末尾常常塞几个填充字节,于是rowStride > width。如果你直接buffer.get(data, 0, width * height)一把梭,读到的数据会随着行号累积偏移,图像看起来像被斜着推了一把——预览是好的,因为预览走的是另一条渲染路径,唯独解码端永远失败,非常迷惑。
reverseHorizontal传false即可,只有前置摄像头镜像场景才需要考虑。裁剪框必须落在dataWidth × dataHeight范围内,越界会抛IllegalArgumentException,这是 5.3 里那条坑的来源。
3.3 组帧解码:BinaryBitmap、hints 与结果回调
// Reader 建一次复用;只扫二维码就别用 MultiFormatReader,省掉其他格式的无效尝试 private val reader = MultiFormatReader().apply { setHints( mapOf( DecodeHintType.POSSIBLE_FORMATS to listOf( BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128, BarcodeFormat.EAN_13 ), DecodeHintType.CHARACTER_SET to "UTF-8" // 见 5.1,中文乱码的止血贴 ) ) } fun decodeFrame(image: ImageProxy): Result? { val source = image.toSource() ?: return null val bitmap = BinaryBitmap(HybridBinarizer(source)) return try { reader.decodeWithState(bitmap) } catch (e: ReaderException) { // NotFound / Checksum / Format 三种都归为“这帧没成”,靠下一帧,不要往外抛 null } finally { reader.reset() // 复用实例必须复位,否则状态会串到下一帧 } }ReaderException是整个 zxing 解码层的统一失败基类,NotFoundException(没找到图案)、ChecksumException(找到了但校验不过)、FormatException(格式信息读不出来)都是它的子类。三种在业务上处理方式不同——校验不过通常意味着图像质量差或者码本身破损,值得重试;找不到图案通常意味着码不在画面里或者太小——但都不该让异常穿透到 UI 层。
CHARACTER_SET这个 hint 要单独说一句:它只在码里没有 ECI 段时生效,属于“兜底猜测”。真要从根上解决中文问题,生成端必须写 ECI,这个在最后一章展开。
3.4 生命周期与背压:什么时候该丢帧
分析器里image.close()必须放在finally,漏一次就是相机管线永久占满一个 buffer,表现是预览几秒后彻底冻住——这种 bug 在低端机上尤其容易出现,因为 buffer 池小。绑定用bindToLifecycle(lifecycleOwner, selector),页面onDestroy时自动解绑,不要手动unbind之后再bind,容易踩到重复绑定的崩溃。
丢帧策略比想象中重要。真实场景里用户举着手机找码,大部分帧是无效的,每帧都全量解码纯属浪费。常见做法是:连续 N 帧(我一般设 3)解码失败后,才开始在后续帧上开TRY_HARDER;一旦命中,冷却 1.5 秒再恢复普通模式。这样既保证正常扫码的响应速度,又不会让极端场景无限期扫不出来。
4. 参数调优:ROI、降采样与 TryHarder 的三角取舍
跑到这一步基本能扫了,但能不能在低端机上扫得流畅,取决于三个参数的取舍:ROI 裁多大、要不要降采样、什么时机放开TRY_HARDER。这三个是互相牵制的,调一个必然影响另一个。
4.1 ROI 裁切:把算力花在取景框里
取景框只有屏幕中间那一块,但解码器默认在整帧 1280×720 上跑。裁到中间 640×480,像素量降到三分之一,耗时基本线性下降,而且顺带排除了画面边缘的干扰图案。换算要注意两件事:第一,取景框是屏幕坐标,要做 center-crop 换算;第二,相机 sensor 通常是横着出图的,竖屏时ImageProxy的宽高和屏幕正好差 90 度,坐标得先转置。
// 取景框(屏幕坐标)换算到图像像素坐标,并留出余量 fun Rect.toImageRect(imgW: Int, imgH: Int, viewW: Int, viewH: Int, margin: Float = 0.12f): Rect { // center-crop:图像按较大的那个比例缩放后铺满预览,所以取 max val scale = maxOf(imgW.toFloat() / viewW, imgH.toFloat() / viewH) var w = (width() * scale).toInt() var h = (height() * scale).toInt() // 留余量:用户手一抖码就出框了,裁太狠等于自己加难度 val mx = (w * margin).toInt() val my = (h * margin).toInt() val left = ((left * scale).toInt() - mx).coerceIn(0, imgW - 1) val top = ((top * scale).toInt() - my).coerceIn(0, imgH - 1) w = (w + mx * 2).coerceAtMost(imgW - left) h = (h + my * 2).coerceAtMost(imgH - top) return Rect(left, top, left + w, top + h) }余量参数margin建议 0.1~0.15。裁得太紧,用户明明看见码在框里却扫不出来,这种反馈最难排查——因为从用户视角看,取景框对齐了就是对齐了,他不知道后面还裁了一刀。
注意:裁剪框的宽高都要 ≥ 40 像素,否则
HybridBinarizer会静默退回全局直方图,你以为在用局部阈值,实际不是。
4.2 降采样:什么时候缩、缩多少
降采样的收益很直接:长宽各减半,像素量降到四分之一,耗时大致同比例下降。风险同样直接:模块像素数也跟着减半,原本 3 像素一个模块降到 1.5,直接跌破 2.3 里说的那条硬线。
判据不看分辨率看占比——二维码在画面里占的面积小于八分之一时,别缩。反过来,如果业务是扫大码(快递单、海报、门贴),缩到 640×360 完全够用,帧率能翻几倍。
最实用的还是两级流水线:先用降采样后的整帧做一次廉价解码,兜住“码很大、位置随意”的常见情况;失败再上取景框原分辨率 +TRY_HARDER,兜住“码很小、距离很远”的少数情况。第一级覆盖 80% 的帧且在几毫秒内出结果,第二级只在必要时付出代价。
// 最近邻抽点降采样:二维码是硬边图案,双线性插值会把模块边界糊掉,别用 private fun downsample(data: ByteArray, w: Int, h: Int, step: Int): Pair<ByteArray, Int> { val outW = w / step val outH = h / step val out = ByteArray(outW * outH) var i = 0 var y = 0 while (y < outH) { val rowStart = y * step * w // 原图对应行的起始下标 var x = 0 while (x < outW) { out[i++] = data[rowStart + x * step] x++ } y++ } return out to outW }step = 2是最常用的档位。step = 3只在极低端机上考虑,模块像素掉到 1 以下就纯靠纠错硬撑,成功率断崖式下跌。
4.3 DecodeHintType 组合:TRY_HARDER、ALSO_INVERTED、CHARACTER_SET
几个 hint 的实际代价和适用面差别很大,别一股脑全开:
TRY_HARDER会让一维码尝试更多扫描行、并额外做一次旋转重试,耗时是我这边量下来普通模式的 2~5 倍。它只在“连续失败 N 帧”之后开一次,不要当默认值。ALSO_INVERTED处理反色码(白条黑底、暗色模式截图),新版核心包里MultiFormatReader已经内置了这个分支;老版本没有的话,自己包一层InvertedLuminanceSource再解一次即可。POSSIBLE_FORMATS限死格式,收益是实打实的,尤其在混扫场景下。ASSUME_GS1只对 GS1-128 有意义,开了之后Result的元数据里会带上 FNC1 解析后的应用标识符,普通 Code 128 开了反而多一次解析。
一维码还有两个容易被忽略的:ALLOWED_LENGTHS用于 ITF 这类变长码制,长度不匹配会被判失败;NEED_RESULT_POINT_CALLBACK能在解码过程中拿到定位点坐标,做“扫码动画”时很有用,但它会显著拖慢解码,生产环境记得关。
4.4 连续扫描去重与冷却
相机一秒三十帧,同一个码会被连续命中几十次。业务层如果每次都弹一次结果页,用户体验直接崩。去重的键用format + text而不是text单独一个——同内容的二维码和 Code 128 是两件事。
冷却时间设 1.2~1.8 秒比较合适。太短,用户还没来得及把手机移开又弹一次;太长,连着扫两件不同商品时会觉得卡。另外记得在页面onResume时清空上次的结果缓存,否则从结果页返回后第一枪经常打不出来,这种小问题在测试阶段最容易被忽略。
5. 踩坑排查:扫不出来的 5 种典型现场
这一节全是血泪经验,每条都按“现象 → 原因 → 解决”写,遇到对得上的直接抄。
5.1 中文扫出来是问号或“锟斤拷”
现象:英文、数字一切正常,含中文的内容出来是乱码,有时是一串问号。
原因:二维码里存的是字节流,解码端要按某个字符集翻译。如果生成端没写入 ECI 段声明字符集,zxing 会按默认字符集去猜,猜错就是乱码。国内很多生成工具默认按 GBK 写,解码端按 ISO-8859-1 读,必然错位。
解决:解码端加DecodeHintType.CHARACTER_SET to "UTF-8"兜底;同时推动生成端统一用 UTF-8 并写入 ECI。如果生成端不受你控制,就在拿到Result后先按 UTF-8 试一次,失败再试 GBK——Result.getRawBytes()拿得到原始字节,自己重新解一遍,比在 hints 上反复试探靠谱。
5.2 竖屏下永远扫不出来,但预览完全正常
现象:预览画面清晰、对焦正常,任何码都识别不了;把手机横过来偶尔能成。
原因:相机 sensor 出图和屏幕方向差了 90 度,ImageProxy.getWidth()拿到的是横向宽度。如果按屏幕尺寸去解释这块 buffer,图像是被转置加错行的,rowStride那一层偏移又叠上去,实际喂给解码器的是一堆噪声。
解决:一切以ImageProxy.getWidth()/getHeight()为准,取景框坐标换算时显式处理旋转;rowStride != width时必须按行紧凑拷贝(见 3.2 那段)。排查时最省事的办法是把LuminanceSource的灰度图renderThumbnail出来看一眼,图像是不是斜的、是不是转了,一眼就清楚。
5.3 ROI 裁小之后抛 IllegalArgumentException 或全黑
现象:加了 ROI 优化,某些机型上直接崩,异常是IllegalArgumentException;不崩的机型上则永远返回 null。
原因:两种可能叠在一起。一是裁剪框越界,left + width > dataWidth时构造PlanarYUVLuminanceSource会直接抛异常;二是裁剪框小于 40×40,HybridBinarizer静默退回全局直方图,局部光照不均时整块被判成黑。
解决:裁剪前把矩形coerceIn到图像范围内并保证最小边长;加一条断言日志,把每帧实际传入的left/top/width/height打出来,跑十分钟看有没有异常值。这类问题靠读代码很难发现,靠日志两分钟定位。
5.4 手机扫手机屏幕,预览很清晰就是不识别
现象:纸质码一扫就中,对着另一台手机的屏幕怎么都对不上焦,或者对上了也解不出来。
原因:屏幕有刷新,相机曝光时间一长就会拍到半个刷新周期的条文;屏幕表面的高光在二值化时被当成纯白,把定位图案吃掉了;再加上摩尔纹,模块边界彻底糊掉。
解决:把相机曝光压一档(Camera2Interested的SENSOR_EXPOSURE_TIME或者直接用Camera2CameraControl调补偿),拉远一点让对焦稳下来,同时开ALSO_INVERTED应对暗色模式下的反色码。ROI 别裁太狠,屏幕翻拍时码经常偏出取景框。这套组合我一般叫它“屏幕翻拍三件套”,缺一个成功率就明显掉。
5.5 连续扫三十秒后开始卡顿、掉帧
现象:刚打开很流畅,扫一会儿预览开始一顿一顿,Logcat里 GC 频繁。
原因:每帧都 new 一个ByteArray(width * height),720p 就是 900KB,三十帧每秒就是每秒 27MB 的分配速率,中低端机扛不住。getMatrix()、RGBLuminanceSource、每帧 new Reader,都是同一类问题。
解决:把灰度缓冲区提到分析器成员变量里复用,只在分辨率变化时重建;Reader 建一次复用;能用PlanarYUVLuminanceSource就别走RGBLuminanceSource;背压用KEEP_ONLY_LATEST,让处理不过来的帧直接被丢掉而不是排队。改完这些,同样机型上的帧率通常能从个位数拉回二十以上。
6. 从“能扫”到“敢上线”:多码同框、误码校验与回归测试集
单码能稳定扫之后,真正决定能不能上线的是另外三件事:多码同框怎么处理、解出来的内容怎么校验、以及怎么证明改动没有让老场景退化。前两件是功能,第三件是纪律。
多码场景换QRCodeMultiReader或GenericMultipleBarcodeReader,它们返回Result[]。代价是慢——要反复在剩余区域上重跑定位,所以要配合 ROI 和降采样限流,并且业务上想清楚“同框多个码时到底要哪一个”:常见做法是按面积排序取最大,或者按ResultPoint算离画面中心最近的那个。
校验分两层。底层是一维码自带的校验位和二维码的 Reed-Solomon,这两个 zxing 内部已经做了,ChecksumException就是它们报的警,别把ChecksumException和NotFoundException混为一谈——前者通常意味着图像质量差但码确实在,值得重试。上层是业务校验:内容前缀、长度、正则,以及Result.getResultMetadata()里的ERROR_CORRECTION_LEVEL、BYTE_SEGMENTS。对支付、票据这类场景,我一般会要求纠错等级不低于 M,纯粹的 URL 码用 L 也无所谓。
真正省命的是回归测试集。做法很土但有效:把线上遇到过的疑难样本攒下来——弱光、反光、弯曲、屏幕翻拍、污损、超小码、反色码,每类十到二十张,凑够五十张左右,用一个纯 JVM 的批处理跑一遍,记录命中率和耗时分布。因为解码核心是纯 Java 的,Android 上跑的这套逻辑在服务端能原样复用,改完参数先在这儿过一遍,比装到手机上人肉试快得多。
// javase 模块里的 BufferedImageLuminanceSource,让服务端能复用同一套 Reader 配置做回归 public final class ScanRegression { public static void main(String[] args) throws Exception { Path dir = Paths.get(args[0]); Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class); hints.put(DecodeHintType.POSSIBLE_FORMATS, Arrays.asList(BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128)); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); MultiFormatReader reader = new MultiFormatReader(); reader.setHints(hints); // hints 只设一次,循环里不再动 int total = 0, hit = 0; List<Long> costs = new ArrayList<>(); try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "*.{png,jpg,jpeg}")) { for (Path p : ds) { BufferedImage img = ImageIO.read(p.toFile()); LuminanceSource src = new BufferedImageLuminanceSource(img); BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(src)); long t0 = System.nanoTime(); try { Result r = reader.decodeWithState(bitmap); hit++; System.out.printf("OK %s -> %s (%s)%n", p.getFileName(), r.getText(), r.getBarcodeFormat()); } catch (ReaderException e) { // 未命中统一归类,靠退出码或汇总行判断这一轮有没有退化 System.out.printf("MISS %s -> %s%n", p.getFileName(), e.getClass().getSimpleName()); } finally { costs.add(System.nanoTime() - t0); reader.reset(); // 复用实例,代价接近零 } total++; } } costs.sort(Long::compare); System.out.printf("hit=%d/%d p50=%.1fms p95=%.1fms%n", hit, total, costs.get(costs.size() / 2) / 1e6, costs.get((int) (costs.size() * 0.95)) / 1e6); } }跑法就是java -cp core.jar:javase.jar:. ScanRegression ./samples,输出里的hit和p95是两个必须盯住的数:hit掉一个百分点都要查是哪类样本退化了,p95涨了说明某个参数把耗时推上去了。测试集矩阵大概长这样,按业务裁剪即可:
| 场景类别 | 建议样本数 | 主要观察点 | 通过线 |
|---|---|---|---|
| 弱光 / 逆光 | 10 | 是否退回全局直方图 | 命中率 ≥ 90% |
| 屏幕翻拍 | 10 | 反色、摩尔纹 | 命中率 ≥ 80% |
| 弯曲 / 褶皱 | 8 | 定位图案变形 | 命中率 ≥ 85% |
| 超小码(模块 ≈ 2px) | 8 | 降采样档位 | 命中率 ≥ 70% |
| 一维码倾斜 15° | 8 | 扫描线角度 | 命中率 ≥ 75% |
| 规整截图 | 6 | 基线,不该退化 | 命中率 100% |
最后说一个我自己的习惯。每次动ROI 余量、降采样档位、TRY_HARDER 触发阈值这三个参数中的任何一个,我都会先把这五十张样本跑一遍,把hit/p50/p95三个数字贴进提交说明里,再上真机。因为这三个参数的调整全都是“这边好了那边坏”的类型,凭手感改,过两周就没人说得清当前值是怎么来的,出了问题连回滚到哪一版都不知道。从那以后我每次调参都强制走一遍这套回归,
本文还有配套的精品资源,点击获取