首页 / 资讯中心 / 文章详情

CameraX与Zxing竖屏扫码:YUV数据转换与方向校正实战

CameraX与Zxing竖屏扫码:YUV数据转换与方向校正实战 ★ FEATURED ARTICLE
1. 项目背景与整体设计思路扫码功能在移动端应用里属于那种“看起来简单、做起来坑多”的典型模块。CameraX 是 Jetpack 家族里负责相机能力的库Zxing 则是老牌的一维码/二维码解析库两者组合是目前 Android 原生扫码方案里比较主流的一条路线。但真正落地的时候很多人会卡在几个地方预览画面方向不对、竖屏下识别率低、识别框和实际画面坐标对不上、连续识别时性能抖动。这个项目的核心目标就是在竖屏场景下把 CameraX 的预览流和 Zxing 的解析能力串起来做到稳定、低延迟地识别一维码和二维码。先说清楚这个内容适合谁看。如果你已经写过 Android 相机相关代码但对 CameraX 的用例绑定、图像分析、坐标变换还不太熟那这篇会比较对路。如果你完全没接触过相机开发建议先把 CameraX 的基本概念过一遍否则后面讲 ImageAnalysis 和坐标系转换的时候会有点吃力。整篇内容会从方案选型讲到核心实现再到实际调试中遇到的坑尽量把每一步的“为什么这么做”讲透。1.1 为什么选 CameraX 而不是 Camera2Camera2 是 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或者调整裁剪比例让更多区域参与解析。也可以尝试不同的 BinarizerHybridBinarizer适合大多数场景但某些光照条件下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:3Preview 就选 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 不是 flashtorch 是常亮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 实际拿到的图像是什么样。方向对不对、有没有条纹、裁剪区域是否合理一眼就能看出来。这个习惯帮我省了很多猜测的时间。
阅读完成 · 觉得有帮助?
咨询建站