ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android扫码开发实战:CameraX+ZXing二维码条形码识别

Android扫码开发实战:CameraX+ZXing二维码条形码识别 简介面向安卓开发者的二维码与条形码扫描应用完整工程适合希望快速掌握扫码功能集成、动态权限申请、相机相册交互的初中级开发者。工程基于 ZXing 解码库采用 Java 语言编写主界面提供直接扫码与相册选图两种识别模式支持闪光灯控制并对扫描结果做成功与失败提示清单文件中已配置相机、存储等权限与组件信息可导入安卓集成开发环境编译调试。资源包共 705 个文件主要包含 Java 源码、可扩展标记语言布局与配置、编译产物、工程脚本及依赖库等类型覆盖较全整体 15.64MB目录结构清晰。当前已有 111 人学习下载主流程完整覆盖条码解码原理、运行时权限申请、扫码界面搭建与结果处理等核心环节。既可作为课程设计或毕设项目基础也能直接改造为购物扫码、票务验证等实际业务模块。1. 二维码条形码扫描应用QRbarcode到底解决什么问题很多人以为扫码就是把摄像头画面丢给一个库回调里拿到字符串就完事真做起来会在预览拉伸、相册图片旋转、条形码扫不进这几件事上反复返工。QRbarcode 这个名字听起来像是某个完整项目落地时本质上就是三件事用摄像头做实时识别从相册图片里识别二维码和条形码以及为弱光环境补一个闪光灯开关。这套能力可以直接复用到库存盘点、设备巡检、会议签到这类 App 里不需要引入一整套第三方扫码 SDK。如果你只体验过微信自带的扫一扫会忽略一个关键事实相机识别是视频流里逐帧分析相册识别是一张静态图跑一次解码两者共享同一套解码核心却在图像格式处理和 UI 线程调度上走两条路。这篇文章按“原理 → 实时扫码 → 相册与闪光灯 → 参数调优 → 验证”的顺序把 ZXing 与 CameraX 的组合讲完整附带的代码可以直接作为工程起点。2. 实时扫码的底层原理CameraX ZXing 是怎么分工的2.1 为什么选 ZXing 而不是系统自带扫码能力Android 官方没有通用扫码 API早期开发者常用系统 Intent 调起第三方 App 扫码但回调结果依赖外部应用是否存在无法定制 UI也无法在离线环境关闭后继续用。Google 的 ML Kit 条码扫描虽然识别率不错却需要 Play services 支持国内 ROM 上经常出现底层服务被省电策略杀掉的情况。ZXing 是纯 Java 库核心包里不依赖相机实现扫码结果可完全离线且支持 QR Code、Code 128、EAN-13、UPC-A 等几十种码制条形码的连续识别速度在低端机上仍然可用。选 ZXing 的另一个理由是自定义程度。你可以保留自己的扫码框、提示文案和闪光灯按钮只把识别算法交给MultiFormatReader。新版 ZXing 4.x 分包更干净只需要引入core模块相机帧解码工作自行完成不需要把整个 Barcode Scanner App 源码合进来。2.2 CameraX 的 ImageAnalysis 与分析回调线程CameraX 是 Android 官方 Jetpack 相机组件它帮你处理了设备兼容性和生命周期。ImageAnalysis用来接收相机帧设置setAnalyzer后系统会拿一个线程轮流回调每次回调里拿到ImageProxy内部是 YUV_420_888 格式的图像。它比 TextureView 加 Camera2 的方式更容易保证横竖屏旋转时预览方向正确。线程模型上要留意分析回调不是在主线程执行的但也不能在处理完一帧后占用太久否则相机管线会丢帧表现为扫码框卡顿。标准做法是每个分析任务里做“拷贝 YUV 数据 → 交给 Reader → 解码 → 关闭 ImageProxy”四个动作解码失败也不要在回调里弹 Toast把结果发到 Handler 或 LiveData 再更新 UI。2.3 ZXing 的条码种类和扫描性能边界MultiFormatReader默认会尝试识别多种条码格式理论上一次解码会遍历所有可能格式。但多个解码器会导致单帧耗时增加特别是面对低亮度图像时。性能边界体现在QR 码在 640x480 分辨率下可以稳定识别而一维条形码对横向像素非常有要求竖屏下条形码水平方向只有几百像素必须让预览 View 全宽显示条码区域否则容易“差几个像素放大倍率不对”。一个很实用的策略是初始化时用MapDecodeHintType, Object把支持的格式固定为二维码加常用一维码不要用PRODUCT_BARCODE这类宽泛配置。下面的对照表给出了不同码制对分辨率的最低要求在写死解码参数前值得先看一眼。码制典型场景最低横向像素参考说明QR Code微信加好友、链接跳转200px容错率高受损也能识别Code 128物流、库存标签500px一维码中密度最高EAN-13商品条码400px前 12 位数字识别容易受近景遮挡影响Data Matrix电子元器件小码300px需要比较近的对焦距离3. 落地一个最小可运行扫码页面3.1 依赖与权限配置工程基于 Android Studio 创建minSdkVersion设为 21 以上即可。在build.gradle里加入 CameraX 和 ZXing coredependencies { 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 com.google.zxing:core:3.5.3 }AndroidManifest 里需要声明相机权限如果只做扫码不保存图片不需要写存储权限uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera.any /CameraX 会在运行时由系统检查权限但 Android 6.0 以上动态权限必须主动申请通常放在 MainActivity 的onCreate里用 Activity Result API 申请。注意不要静态声明READ_EXTERNAL_STORAGE相册选图在 Android 13 上走 Photo Picker 不需要它。3.2 布局与坐标预览、扫码框和闪光灯按钮扫码页布局常见做法是PreviewView铺满全屏上面叠加一个自定义ScanBoxView画取景框底部放一个闪光灯按钮。取景框不是识别区域识别区域默认是整个预览图像只是 UI 上提示用户把码放在中央。androidx.constraintlayout.widget.ConstraintLayout android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.camera.view.PreviewView android:idid/previewView android:layout_widthmatch_parent android:layout_heightmatch_parent / com.example.scan.ScanBoxView android:idid/scanBox android:layout_widthmatch_parent android:layout_heightmatch_parent / ImageButton android:idid/torchBtn android:layout_width52dp android:layout_height52dp android:srcdrawable/ic_flash_off app:layout_constraintBottom_toBottomOfparent app:layout_constraintEnd_toEndOfparent app:layout_constraintStart_toStartOfparent android:contentDescription闪光灯 / /androidx.constraintlayout.widget.ConstraintLayoutPreviewView是 CameraX 提供的专用预览控件比起 TextureView 它自己处理表面生命周期和变换扫码框不需要跟着摄像头画面旋转而重算坐标。闪光灯按钮放在底部中央方便单手操作粗心的开发者会把它和相册按钮左右对称排列这里只做对闪光灯这一路。3.3 分析器代码与 YUV 转换CameraX 的ImageAnalysis拿到的是ImageProxyZXing 解码需要一个亮度数组也就是LuminanceSource。最稳妥的格式是PlanarYUVLuminanceSource它可以直接复用 YUV_420_888 的 Y 平面数据不做颜色转换内存分配最少。public class QrAnalyzer implements ImageAnalysis.Analyzer { private final MultiFormatReader reader; private final Handler resultHandler; public QrAnalyzer(MapDecodeHintType, Object hints, Handler handler) { reader new MultiFormatReader(); reader.setHints(hints); resultHandler handler; } Override public void analyze(NonNull ImageProxy image) { ImageProxy.PlaneProxy[] planes image.getPlanes(); ByteBuffer yBuffer planes[0].getBuffer(); byte[] data new byte[yBuffer.remaining()]; yBuffer.get(data); int width image.getWidth(); int height image.getHeight(); PlanarYUVLuminanceSource source new PlanarYUVLuminanceSource( data, width, height, 0, 0, width, height, false); BinaryBitmap bitmap new BinaryBitmap(new HybridBinarizer(source)); try { Result result reader.decodeWithState(bitmap); Message msg Message.obtain(resultHandler, MSG_DECODE_OK, result); msg.sendToTarget(); } catch (NotFoundException e) { reader.reset(); } finally { image.close(); } } }PlanarYUVLuminanceSource的第一个参数是 Y 平面字节数组0,0,width,height是裁剪矩形通常传入完整图像。遵守一个铁律image.close()一定要在finally里执行否则每秒 30 帧的相机管线的 Image 缓冲会被耗尽几秒后预览黑屏。decodeWithState解码成功后不会自动复位所以下次继续解码前需要调用reset()清掉上一个候选点。3.4 单次识别防抖与连续扫码很多业务不需要连续扫码扫描到一次后停在当前页面即可避免同一个二维码被触发两三次。常见做法是AtomicBoolean加compareAndSet控制在回调成功时立即置为已扫描需要连续扫码时则延时 2 秒再复位让 UI 有时间跳走或震动。private final AtomicBoolean scanning new AtomicBoolean(true); Override public void analyze(ImageProxy image) { if (!scanning.get()) { image.close(); return; } // 解码成功后执行 if (scanning.compareAndSet(true, false)) { resultHandler.post(() - handleResult(result.getText())); } }这里的scanning状态要从 UI 层控制例如扫码成功后点击页面任意位置重新开始扫描调用scanning.set(true)并reader.reset()。千万不要把scanning放在QrAnalyzer里做局部变量局部变量每帧都初始化等于防抖没生效。4. 相册选图识别与闪光灯控制的实现细节4.1 相册取图用 ActivityResultContracts 处理从相册选择图片已经不需要手写onActivityResult用ActivityResultContracts.GetContent()就可以拿到图片 Uri。这套 API 会自动处理系统文件选择器不需要许可声明。private val pickImage registerForActivityResult(ActivityResultContracts.GetContent()) { uri - if (uri ! null) { recognizeBitmap(uri) } } fun openAlbum() { pickImage.launch(image/*) }拿到 Uri 后直接用ContentResolver.openInputStream读取再做一次采样压缩避免 4000x3000 的大图直接进BitmapFactory.decodeStream吃满内存。GetContent返回的是content://开头的 Uri作用域存储下直接用原生 File 路径去读大概率会抛FileNotFound必须用 ContentResolver。4.2 Bitmap 转 RGBLuminanceSource 的尺寸陷阱ZXing 解码 Bitmap 的常见路径是取像素数组构造RGBLuminanceSource。但这套 API 在 Android 上有两个坑颜色通道顺序、旋转方向。用Bitmap.getPixels拿到的数组是 ARGB 排列RGBLuminanceSource构造时按 R、G、B 取值计算灰度它内部接受的 int 数组本质是 0x00RRGGBB完全匹配 Android 的 RGB_8888。旋转问题常见于相册图片自带 EXIF 方向解码前最好通过ExifInterface读取角度把 Bitmap 旋转到 0 度再交给解码器否则竖拍的二维码会被横着拿去解码成功率骤降。fun decodeBitmap(bitmap: Bitmap): String? { val width bitmap.width val height bitmap.height val pixels IntArray(width * height) bitmap.getPixels(pixels, 0, width, 0, 0, width, height) val source RGBLuminanceSource(width, height, pixels) val binaryBitmap BinaryBitmap(HybridBinarizer(source)) return try { reader.decodeWithState(binaryBitmap).text } catch (e: NotFoundException) { null } }这里要特别处理采样后的 Bitmap 宽高过大导致pixels数组 OOM 的隐患。识别一类码并不需要原图全尺寸长边缩到 1280 以内解码时间从 1 秒级别降到 100 毫秒以内识别率几乎没有变化。如果相册里选的是货架上的 Code 128 条码长边 1280 仍可能不够这时优先保证水平方向有 800 像素以上而不是等比缩小到固定宽度。4.3 闪光灯开关的 torch 状态与自动对焦关系CameraX 控制闪光灯非常直接打开CameraControl.enableTorch(true)关闭则传 false。但要处理用户切后台或相机重新绑定时的状态复位否则下次进页面闪光灯可能自动打开。fun toggleTorch() { val cameraControl cameraProvider?.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis )?.cameraControl isTorchOn !isTorchOn cameraControl?.enableTorch(isTorchOn) }扫码场景下闪光灯和自动对焦不是独立操作。强光下可以考虑关闭自动对焦、固定到微距模式因为二维码通常离镜头 10 到 30 厘米处于自动对焦扫描左右摇摆的区间。弱光下开启闪光灯后画面中心过曝ZXing 的HybridBinarizer会算出很多噪点导致解码失败。经验值是闪光灯打开后让扫码框对准码的边缘而不是正中心屏幕上看起来反光少一点的区域解码更快。5. 识别率相关的 5 个参数和 3 个常踩的坑5.1 三个核心参数TryHarder、纯条码、字符集ZXing 的DecodeHintType里能直接影响扫码成功率的有三个分别用在初始化 Reader 的时候DecodeHintType值类型建议值副作用TRY_HARDERBooleantrue单帧解码耗时增加 30%80%POSSIBLE_FORMATSListQR_CODE、CODE_128、EAN_13、EAN_8、UPC_A减少误识别提高速度CHARACTER_SETStringUTF-8避免中文二维码乱码TRY_HARDER的效果是让 ZXing 在解码失败后再尝试旋转、镜像、反色等策略低分辨率图上能救回一两个码但代价明显。如果你只需要实时扫码可以只对相册识别开启这个参数因为相册识别对速度不敏感。5.2 反色码、镜像码和模糊码一类常踩的坑是“白色背景黑色码”扫得出来黑色背景白色码扫不出来。ZXing 的HybridBinarizer默认假设深色码浅色背景遇到反色码需要把像素做一次取反。我一般会在解码失败后尝试构造RGBLuminanceSource把每个像素的颜色取反再解码一次。实时扫码更推荐在analyze里对 YUV 数据调用一次LuminanceSource的invert()方法。模糊码的根因是对焦没到位或者扫码框离码太近。ZXing 本身没有对退化图像做增强真正有效的处理是先做一次灰度归一化把亮度数组的平均值拉回 128 附近提升低对比度图片的识别稳定性。注意不要做高斯模糊模糊只会让一维码边缘进一步丢失。镜像码在垂直翻转相机预览时会出现用前置摄像头扫后置打印的二维码时经常遇到通过rotate()和flip()做镜像校正即可。5.3 横竖屏与扫码框的旋转翻车很多自定义扫码框没有处理旋转事件。CameraX 在这一代版本会自动纠正预览方向但分析图像里的二维码方向不会自动转需要从ImageProxy.imageInfo.rotationDegrees拿方向值动态构造PlanarYUVLuminanceSource时把旋转角度体现到DataMatrixReader里。不要在onConfigurationChanged里重建 CameraX。正确做法是扫码 Activity 锁定竖屏在Manifest里声明android:screenOrientationportrait这样分析器不需要感知旋转。如果你的产品必须支持横屏扫码单独写一套横屏布局而不是让 Activity 旋转后自适应。一个反向操作也很重要当 ZXing 一直显示NotFoundException时把当前帧的图像和旋转角度打日志记录下来离线用同一张图做解码测试就能区分是代码问题还是图像源问题。调试用的一帧截图分辨率不必大YUV 数据保存为 JPEG 后直接从文件分析即可。6. 验证扫码功能的一套可执行测试样本6.1 用 ZXing 生成测试码并统一管理用例人工测试最常见的问题是不同人用不同二维码坏掉的码没进回归样本。在生产前先准备一组固定测试码用 ZXing 的MultiFormatWriter生成到本地相册再导入工程 assetsMultiFormatWriter writer new MultiFormatWriter(); MapEncodeHintType, Object encodeHints new HashMap(); encodeHints.put(EncodeHintType.MARGIN, 2); BitMatrix matrix writer.encode(https://example.com?product001, BarcodeFormat.QR_CODE, 480, 480, encodeHints);生成的样本建议分四类普通白底黑码、深色背景反色码、贴纸褶皱导致局部遮挡的码、压缩后被 JPEG 噪声污染的条码。每类都准备一张竖拍和一张横拍版本。手头没有打印机时直接放电脑屏幕上扫码即可但要把屏幕亮度调到 60%亮度过高反而会产生摩尔纹误判为识码失败。6.2 弱光和运动状态的压测脚本最后一步是拿真实手机在弱光、手抖、快速靠近这三种场景各测十次。记录成功率不能只看“最终能不能扫出来”还要看一下平均识别时长。可以用System.nanoTime包住decodeWithState调用把耗时输出到 Logcat。当多帧平均耗时超过 300ms 时优先检查TRY_HARDER是否误开以及ImageAnalysis的setBackpressureStrategy是否设成了STRATEGY_KEEP_ONLY_LATEST。一个明显的信号是扫码框正对二维码却一直转圈但把手机稍微拿远一点反而瞬间识别。这说明打开了TRY_HARDER后单帧时间太长图像分析队列里积压了旧帧。此时需要把相机分辨率降到640x480同时关闭TRY_HARDER通常能恢复流畅。当ImageAnalysis的STRATEGY_KEEP_ONLY_LATEST不生效时记得检查PreviewView和ImageAnalysis是否绑定到了同一个ProcessCameraProvider实例上我遇到过多次这两个组件分别创建 provider 导致分析回调阻塞的问题。本文还有配套的精品资源点击获取
返回列表