1. 项目背景与整体设计思路
扫码功能在移动端应用里属于那种“看起来简单、做起来坑多”的典型模块。CameraX 是 Jetpack 家族里负责相机能力的库,Zxing 则是老牌的一维码/二维码解析库,两者组合是目前 Android 原生扫码方案里比较主流的一条路线。但真正落地的时候,很多人会卡在几个地方:预览画面方向不对、竖屏下识别率低、识别框和实际画面坐标对不上、连续识别时性能抖动。这个项目的核心目标,就是在竖屏场景下把 CameraX 的预览流和 Zxing 的解析能力串起来,做到稳定、低延迟地识别一维码和二维码。
先说清楚这个内容适合谁看。如果你已经写过 Android 相机相关代码,但对 CameraX 的用例绑定、图像分析、坐标变换还不太熟,那这篇会比较对路。如果你完全没接触过相机开发,建议先把 CameraX 的基本概念过一遍,否则后面讲 ImageAnalysis 和坐标系转换的时候会有点吃力。整篇内容会从方案选型讲到核心实现,再到实际调试中遇到的坑,尽量把每一步的“为什么这么做”讲透。
1.1 为什么选 CameraX 而不是 Camera2
Camera2 是 Android 原生的相机 API,控制粒度非常细,但代价是代码量大、状态机复杂、不同设备兼容性差异明显。用 Camera2 写一个扫码功能,光是处理预览尺寸、方向、生命周期就要写几百行,而且很容易在某些机型上翻车。CameraX 把这些复杂度封装掉了,它基于 Camera2 做了一层抽象,提供了 Preview、ImageAnalysis、ImageCapture 三个核心用例,绑定到 Lifecycle 之后会自动处理生命周期,省掉了大量样板代码。
对于扫码这个场景,我们主要用到两个用例:Preview 负责把画面显示给用户,ImageAnalysis 负责拿到每一帧图像数据交给 Zxing 解析。CameraX 的 ImageAnalysis 支持设置背压策略,这一点很关键,因为 Zxing 解析是耗时操作,如果不做背压控制,帧会堆积,导致内存上涨和识别延迟增加。Camera2 要实现同样的效果,需要自己管理 ImageReader 的缓冲区,麻烦得多。
当然 CameraX 也不是没有代价。它的抽象层意味着某些极端定制需求不好实现,比如需要精确控制每一帧的曝光参数。但扫码场景对这方面的需求很低,所以 CameraX 是更划算的选择。
1.2 为什么选 Zxing 而不是其他解析库
Zxing 是 Java 实现的条码解析库,支持格式非常全,一维码里的 Code128、EAN-13、UPC-A,二维码里的 QR Code、Data Matrix、PDF417 都能解析。它的核心解析逻辑是纯算法,不依赖平台特性,所以移植性好。虽然 Zxing 的 Android 端集成方式有点老派,但社区资料多,遇到问题容易找到参考。
另一个常见选择是 ML Kit 的条码扫描,识别率确实高,但它是 Google 的服务依赖,在某些设备上需要额外下载模型,而且对一维码的支持不如 Zxing 全面。Zxing 的优势在于离线、轻量、可控,解析参数可以自己调,适合对包体积和隐私有要求的项目。
这里要说明一点,Zxing 官方提供的android-core和android-integration模块主要是为旧版 Camera API 设计的,直接拿来配合 CameraX 用会有很多不匹配的地方。所以实际项目中,我们通常只依赖 Zxing 的core模块,自己写图像数据到BinaryBitmap的转换逻辑。
1.3 竖屏场景的特殊性
竖屏扫码看起来是默认需求,但相机传感器的输出方向天然是横向的。手机摄像头模组的物理排列决定了它采集到的图像是横着的,系统会根据设备方向做旋转。在竖屏模式下,ImageAnalysis 拿到的 ImageProxy 数据方向往往和屏幕显示方向不一致,如果不做处理,Zxing 拿到的就是一张旋转了 90 度的图,二维码能识别出来的概率会大幅下降,一维码基本没戏。
所以竖屏实现的核心难点不在 CameraX 本身,也不在 Zxing 本身,而在于两者之间的数据衔接:怎么把 ImageProxy 里的 YUV 数据正确转换成 Zxing 能吃的格式,同时保证方向正确、裁剪区域合理、性能可接受。这个项目标题里“竖屏实现”四个字,分量其实很重。
2. 核心细节解析与实操要点
2.1 CameraX 用例绑定与分辨率选择
CameraX 的用例绑定通过ProcessCameraProvider完成,核心代码结构大概是先获取 provider,然后构建 Preview 和 ImageAnalysis,最后调用bindToLifecycle。这里有几个参数需要仔细选。
Preview 的分辨率选择上,建议用ResolutionSelector配合AspectRatioStrategy,优先保证预览画面不变形。扫码场景不需要 4K 预览,720p 或 1080p 足够,分辨率太高反而增加 GPU 负担。ImageAnalysis 的分辨率则要单独考虑,因为它直接决定 Zxing 的输入图像大小。分辨率太高,解析耗时长;太低,小二维码识别不出来。实测下来,1280x720 是一个比较平衡的选择,既能识别中等距离的二维码,单帧解析时间也能控制在 30ms 以内。
ImageAnalysis 的背压策略建议设为STRATEGY_KEEP_ONLY_LATEST。这个策略的意思是,如果分析器还在处理上一帧,新来的帧会直接丢弃,只保留最新的一帧。扫码场景不需要处理每一帧,用户把码对准之后,只要有一帧清晰就能识别出来。用这个策略可以避免帧堆积导致的内存问题和延迟累积。
val imageAnalysis = ImageAnalysis.Builder() .setResolutionSelector( ResolutionSelector.Builder() .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) .build() ) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build()输出格式选 YUV_420_888 而不是 RGBA_8888,原因是 YUV 格式下我们可以只取 Y 平面做灰度解析,数据量只有 RGB 的三分之一,转换开销小很多。Zxing 解析二维码本质上用的是灰度信息,彩色数据对它来说是冗余的。
2.2 ImageProxy 到 Zxing 输入的数据转换
这是整个项目里最核心也最容易出错的一步。ImageProxy 拿到的 YUV_420_888 数据有三个平面:Y、U、V。Y 平面是亮度信息,U 和 V 是色度信息。Zxing 的PlanarYUVLuminanceSource正好可以直接接收 Y 平面数据,所以我们只需要提取 Y 平面,不需要做完整的 YUV 到 RGB 转换。
提取 Y 平面的时候要注意 rowStride 和 pixelStride 这两个参数。rowStride 是每一行占用的字节数,pixelStride 是相邻像素之间的字节间隔。理想情况下 rowStride 等于宽度,pixelStride 等于 1,但实际设备上 rowStride 往往大于宽度,因为硬件会做内存对齐。如果不处理这个差异,直接按宽度读取,图像会错位,表现为斜条纹或者识别完全失败。
val yPlane = imageProxy.planes[0] val yBuffer = yPlane.buffer val rowStride = yPlane.rowStride val pixelStride = yPlane.pixelStride val width = imageProxy.width val height = imageProxy.height val yData = ByteArray(width * height) var outputOffset = 0 for (row in 0 until height) { val rowStart = row * rowStride for (col in 0 until width) { yData[outputOffset++] = yBuffer.get(rowStart + col * pixelStride) } }这段代码看起来简单,但它是保证识别率的基础。我见过不少项目直接yBuffer.get(yData)一把梭,在部分机型上能用,换台设备就废了,问题就出在没处理 stride。
拿到 yData 之后,构造PlanarYUVLuminanceSource,再包一层HybridBinarizer,最后交给MultiFormatReader解析。这里有个细节:PlanarYUVLuminanceSource的构造函数需要传入 dataWidth 和 dataHeight,这两个值要和实际数据维度一致,否则裁剪区域会算错。
2.3 竖屏方向校正的两种思路
方向校正是竖屏扫码的关键。有两种常见做法。
第一种是在数据层面旋转。拿到 Y 平面数据后,手动做一次 90 度旋转,把横向图像转成竖向,再交给 Zxing。这种做法的好处是 Zxing 拿到的就是正向图像,识别逻辑不用改。缺点是旋转操作本身有开销,1280x720 的图像旋转一次大概需要 5 到 10ms,对性能有一定影响。
第二种是在解析层面处理。Zxing 的LuminanceSource支持通过rotateCounterClockwise方法旋转,但这个方法在PlanarYUVLuminanceSource里默认抛异常,需要自己继承实现。另一种方式是利用 Zxing 的DecodeHintType.TRY_HARDER配合PossibleNewDimensions,让解析器尝试多个方向。但这种方式对一维码效果不好,因为一维码有方向性,旋转 90 度后条码结构就破坏了。
实际项目中我更推荐第一种,在数据层面做旋转。虽然有一点性能开销,但逻辑清晰、可控性强。旋转的时候可以顺便做裁剪,只保留画面中间区域,进一步减少数据量。比如只取中间 60% 的区域做解析,边缘区域本来就不是用户对准的地方,裁掉能提升解析速度。
注意:旋转方向要根据设备方向动态判断。用
DisplayManager获取当前屏幕旋转角度,再结合相机传感器的 orientation,计算出实际需要旋转的角度。硬编码 90 度在某些设备上会翻车。
2.4 识别区域与坐标映射
用户看到的预览画面和 ImageAnalysis 拿到的图像,两者的坐标系和缩放比例都不一样。如果要在预览上画一个识别框,或者把识别到的条码位置映射回预览画面,就需要做坐标转换。
Preview 用的是PreviewView,它内部可能做了缩放和裁剪(取决于 scaleType)。ImageAnalysis 的图像尺寸是固定的。两者之间的映射关系需要根据实际尺寸比例计算。如果只是做识别,不做位置标注,这一步可以跳过。但如果要做“识别框跟随”的效果,就必须处理。
我的建议是,识别区域不要跟着预览框走,而是固定取图像中心区域。原因是:第一,用户扫码时天然会把码对准画面中心;第二,固定区域的计算逻辑简单,不容易出错;第三,动态区域需要实时计算映射,增加复杂度但收益有限。取中心区域的时候,建议宽高各取 70% 左右,留一点余量应对用户手抖。
3. 实操过程与核心环节实现
3.1 项目依赖配置
先看依赖。CameraX 需要引入 camera-core、camera-camera2、camera-lifecycle、camera-view 四个模块。Zxing 只需要 core 模块,不要引入 android-core 和 android-integration,那两个是为旧 API 设计的,会和 CameraX 冲突。
dependencies { def camerax_version = "1.3.0" implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}" implementation "com.google.zxing:core:3.5.2" }版本选择上,CameraX 1.3.x 对 ResolutionSelector 的支持比较完善,建议用这个版本以上。Zxing 3.5.x 修复了一些解析边界问题,也建议用较新的版本。
3.2 权限申请与相机启动
相机权限是危险权限,需要运行时申请。除了 CAMERA 权限,如果要做闪光灯控制,不需要额外权限。权限申请用 ActivityResultContracts.RequestPermission 就行,回调里根据结果决定是否启动相机。
启动相机的流程是:先获取 ProcessCameraProvider,然后构建用例,最后绑定到 LifecycleOwner。这里要注意,绑定操作要在权限授予之后执行,否则会抛异常。另外,相机启动是异步的,绑定成功后会回调,可以在回调里做一些初始化工作,比如启动解析线程。
private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ val cameraProvider = cameraProviderFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val imageAnalysis = ImageAnalysis.Builder() .setResolutionSelector(...) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() imageAnalysis.setAnalyzer(executor, QrCodeAnalyzer { result -> runOnUiThread { handleResult(result) } }) cameraProvider.unbindAll() cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis) }, ContextCompat.getMainExecutor(this)) }解析器要跑在独立线程上,不要用主线程 executor,否则会卡 UI。可以用Executors.newSingleThreadExecutor(),单线程足够,因为背压策略已经保证了不会有多帧同时处理。
3.3 解析器 QrCodeAnalyzer 的完整实现
解析器实现ImageAnalysis.Analyzer接口,在analyze方法里完成数据转换和解析。核心逻辑分四步:提取 Y 平面、旋转校正、裁剪中心区域、交给 Zxing 解析。
class QrCodeAnalyzer( private val onResult: (String) -> Unit ) : ImageAnalysis.Analyzer { private val reader = MultiFormatReader().apply { val hints = mapOf( DecodeHintType.POSSIBLE_FORMATS to listOf( BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A ), DecodeHintType.TRY_HARDER to true ) setHints(hints) } override fun analyze(imageProxy: ImageProxy) { val rotationDegrees = imageProxy.imageInfo.rotationDegrees val yData = extractYPlane(imageProxy) val rotated = rotateYData(yData, imageProxy.width, imageProxy.height, rotationDegrees) val cropped = cropCenter(rotated.data, rotated.width, rotated.height, 0.7f) val source = PlanarYUVLuminanceSource( cropped.data, cropped.width, cropped.height, 0, 0, cropped.width, cropped.height, false ) val bitmap = BinaryBitmap(HybridBinarizer(source)) try { val result = reader.decodeWithState(bitmap) onResult(result.text) } catch (e: NotFoundException) { // 正常情况,当前帧没识别到 } finally { reader.reset() imageProxy.close() } } }这里有几个点值得展开。TRY_HARDER设为 true 会提升识别率,但会增加解析时间,实测大概多 20% 到 30%。如果对延迟敏感,可以设为 false,靠多帧尝试来弥补。POSSIBLE_FORMATS要按需配置,不要把所有格式都加上,格式越多解析越慢。如果项目只需要二维码,就只留 QR_CODE。
decodeWithState和decode的区别在于,前者会复用 reader 的内部状态,性能更好,但用完必须 reset。后者每次重新初始化,开销大一些。扫码场景用decodeWithState更合适。
3.4 旋转与裁剪的具体实现
旋转 Y 数据的时候,要注意旋转后的宽高会互换。90 度和 270 度旋转后,宽度变成原来的高度,高度变成原来的宽度。旋转逻辑要处理四种情况:0 度、90 度、180 度、270 度。
private fun rotateYData(data: ByteArray, width: Int, height: Int, degrees: Int): RotatedData { return when (degrees) { 90 -> { val rotated = ByteArray(width * height) for (y in 0 until height) { for (x in 0 until width) { rotated[x * height + (height - 1 - y)] = data[y * width + x] } } RotatedData(rotated, height, width) } 180 -> { val rotated = ByteArray(width * height) for (i in data.indices) { rotated[data.size - 1 - i] = data[i] } RotatedData(rotated, width, height) } 270 -> { val rotated = ByteArray(width * height) for (y in 0 until height) { for (x in 0 until width) { rotated[(width - 1 - x) * height + y] = data[y * width + x] } } RotatedData(rotated, height, width) } else -> RotatedData(data, width, height) } }裁剪中心区域的逻辑相对简单,算出裁剪后的起始坐标和宽高,然后逐行拷贝。
private fun cropCenter(data: ByteArray, width: Int, height: Int, ratio: Float): CroppedData { val cropWidth = (width * ratio).toInt() val cropHeight = (height * ratio).toInt() val startX = (width - cropWidth) / 2 val startY = (height - cropHeight) / 2 val cropped = ByteArray(cropWidth * cropHeight) for (y in 0 until cropHeight) { System.arraycopy( data, (startY + y) * width + startX, cropped, y * cropWidth, cropWidth ) } return CroppedData(cropped, cropWidth, cropHeight) }裁剪比例 0.7 是我实测下来比较合适的值。太小了识别距离受限,太大了边缘噪声多。当然这个值可以根据实际场景调整,如果用户习惯远距离扫码,可以调到 0.8。
3.5 连续识别与结果去重
扫码场景通常需要连续识别,但同一个码在连续多帧里都会被识别到,如果每次都回调,会导致重复处理。所以需要做去重。最简单的做法是记录上一次识别结果和时间戳,如果结果相同且间隔小于一定时间(比如 2 秒),就忽略。
private var lastResult: String? = null private var lastResultTime: Long = 0 private fun handleResult(result: String) { val now = System.currentTimeMillis() if (result == lastResult && now - lastResultTime < 2000) { return } lastResult = result lastResultTime = now // 处理新结果 }去重时间窗口的设置要看业务。如果是支付码,2 秒比较合适;如果是商品码,可以短一点,1 秒。太短了会重复,太长了用户想扫下一个码要等。
另外,识别成功后建议暂停分析器,等用户处理完再恢复。暂停的方式是imageAnalysis.clearAnalyzer(),恢复时重新setAnalyzer。这样可以避免识别成功后还在后台消耗资源。
4. 常见问题与排查技巧实录
4.1 识别率低的排查思路
识别率低是最常见的问题,原因可能有很多。排查的时候按这个顺序来:先确认方向对不对,再确认数据转换有没有问题,最后看解析参数。
方向问题表现为:二维码偶尔能识别,一维码完全不行。因为二维码有容错和方向冗余,旋转 90 度有时也能识别,但一维码旋转后结构就破坏了。如果遇到这个现象,基本可以确定是方向没处理对。检查imageProxy.imageInfo.rotationDegrees的值,结合设备方向确认旋转逻辑。
数据转换问题表现为:图像有斜条纹,或者识别出来的内容是乱码。这通常是 rowStride 没处理导致的。可以在提取 Y 平面后,把数据存成图片看一眼,确认图像是否正常。如果图像本身就不对,那 Zxing 再强也没用。
解析参数问题表现为:清晰的大二维码能识别,小二维码或者一维码不行。这时候可以调TRY_HARDER,或者调整裁剪比例,让更多区域参与解析。也可以尝试不同的 Binarizer,HybridBinarizer适合大多数场景,但某些光照条件下GlobalHistogramBinarizer效果更好。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 一维码完全无法识别 | 方向未校正 | 检查 rotationDegrees 处理逻辑 |
| 图像有斜条纹 | rowStride 未处理 | 保存 Y 数据为图片查看 |
| 小码识别困难 | 裁剪比例过小 | 调大裁剪比例或关闭裁剪 |
| 识别延迟高 | 分辨率过高 | 降低 ImageAnalysis 分辨率 |
| 部分机型崩溃 | 数据长度不匹配 | 检查 stride 和 buffer 读取边界 |
4.2 预览画面拉伸变形
PreviewView 默认的 scaleType 是 FILL_CENTER,会裁剪画面以填满控件。如果控件宽高比和相机输出宽高比不一致,画面就会变形或者被裁掉一部分。扫码场景建议用FIT_CENTER,保证画面完整显示,虽然可能有黑边,但用户能看到完整的取景范围。
设置方式是previewView.scaleType = PreviewView.ScaleType.FIT_CENTER。另外,Preview 的 AspectRatioStrategy 要和 PreviewView 的宽高比匹配,否则还是会有问题。比如 PreviewView 是 4:3,Preview 就选 4:3 的策略。
4.3 低端设备性能问题
低端设备上,相机预览本身就可能掉帧,再加上 Zxing 解析,很容易卡顿。优化方向有几个:降低 ImageAnalysis 分辨率到 640x480,关闭 TRY_HARDER,缩小裁剪比例,减少支持的条码格式。这几个措施叠加,单帧解析时间能从 50ms 降到 15ms 左右。
还有一个容易被忽略的点:解析线程的优先级。可以用Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)把解析线程设为后台优先级,避免和 UI 线程抢 CPU。实测下来,这个调整能让预览流畅度明显提升。
4.4 闪光灯与对焦控制
暗光环境下扫码,需要开闪光灯。CameraX 通过CameraControl控制闪光灯,camera.cameraControl.enableTorch(true)打开,false关闭。注意是 torch 不是 flash,torch 是常亮,flash 是拍照瞬间闪一下,扫码要用 torch。
对焦方面,CameraX 默认是连续自动对焦,大多数场景够用。但如果用户距离码很近,可能对不上焦。可以监听CameraInfo的focusState,在需要的时候触发一次cameraControl.startFocusAndMetering,指定对焦区域为画面中心。这个操作不要频繁调用,否则会来回拉风箱。
提示:闪光灯控制要考虑设备是否支持。用
camera.cameraInfo.hasFlashUnit()判断,不支持的话要隐藏闪光灯按钮,避免用户点了没反应。
4.5 内存泄漏与生命周期
CameraX 绑定到 LifecycleOwner 之后,会在 onDestroy 时自动解绑,正常情况下不会泄漏。但解析器里如果持有 Activity 引用,就可能泄漏。解析器的回调建议用弱引用,或者在 onDestroy 时清空回调。
另外,ImageProxy 必须调用 close(),否则缓冲区不会释放,很快就会耗尽。我在 analyze 方法里用 try-finally 保证 close 一定执行,这个习惯能避免很多诡异问题。如果解析过程中抛异常导致 close 没执行,几帧之后相机就会卡死,表现为预览不动了。
5. 一些实操心得与扩展方向
扫码功能调通之后,还有一些细节可以打磨。比如识别成功时加一个震动反馈,用Vibrator或者VibratorManager,提升用户体验。再比如识别框的动画,识别到之后在预览上画一个框,让用户知道扫到了哪里。这些虽然不影响功能,但能明显提升使用感受。
关于识别速度,我实测下来,1280x720 分辨率、裁剪 70%、开启 TRY_HARDER 的情况下,二维码平均 2 到 3 帧能识别出来,一维码大概 3 到 5 帧。按 30fps 算,也就是 100ms 到 170ms 的延迟,用户基本感知不到。如果关掉 TRY_HARDER,二维码能快到 1 到 2 帧,但一维码识别率会下降。所以是否开启要看业务侧重。
扩展方向上,如果要做多码同时识别,Zxing 的GenericMultipleBarcodeReader可以支持,但性能开销大,需要单独优化。如果要做更远的识别距离,可以考虑用更高分辨率的 ImageAnalysis,配合更精细的裁剪策略,但要注意性能平衡。还有一个方向是结合机器学习做预处理,比如先检测码的位置再裁剪,这样能减少无效区域的解析,提升速度。不过这就超出 Zxing 的范围了,需要引入额外的模型。
最后分享一个小技巧:调试的时候,把 ImageAnalysis 拿到的 Y 数据存成 PNG 图片,用图片查看器打开,能直观看到 Zxing 实际拿到的图像是什么样。方向对不对、有没有条纹、裁剪区域是否合理,一眼就能看出来。这个习惯帮我省了很多猜测的时间。