资讯动态

Android竖屏扫码实战:CameraX与ZXing实现框内识别

发布时间:2026/10/15 20:00:21 来源:尧图企业网站定制
1. 为什么竖屏扫码在 Android 上总是不太顺做过 Android 扫码功能的人大概都有过这种体验横屏 Demo 跑得挺欢一切到竖屏预览画面要么被拉伸变形要么识别区域和实际画面错位扫半天扫不出来。更让人头疼的是用第三方扫码库虽然省事但定制化程度低UI 稍微改改就各种别扭而且包体积也上去了。我最近在一个项目里重新梳理了这套方案最终选的是CameraX ZXing的组合。CameraX 负责相机预览和图像分析ZXing 负责解码。这套组合的好处是CameraX 是 Jetpack 组件生命周期管理省心设备兼容性好ZXing 是纯 Java 的解码库轻量、可控不依赖任何 UI。两者结合竖屏扫码的体验可以做到相当顺滑。这篇文章会从实际落地的角度把整个实现过程拆开讲清楚。包括为什么竖屏需要特殊处理、CameraX 的预览和分析怎么配、ZXing 的解码参数怎么调、识别区域怎么和预览对齐、性能怎么优化以及我在真机上踩过的那些坑。适合已经了解 Android 基础、想自己动手实现扫码功能的开发者也适合正在被第三方库限制、想换成自研方案的团队参考。核心关键词就几个CameraX 预览、ImageAnalysis 分析、ZXing 解码、竖屏坐标变换、识别区域裁剪。把这几个点吃透竖屏扫码基本就稳了。2. 竖屏扫码的坐标系问题先搞懂再动手2.1 相机传感器方向和屏幕方向的错位很多人第一次做扫码会卡在一个地方明明预览画面是正的但传给解码器的图像数据却是歪的。这不是 bug而是相机硬件的工作方式和屏幕显示方式天然不一致导致的。手机相机的图像传感器Sensor有一个固定的物理方向。大多数手机的后置摄像头传感器是横向安装的也就是说传感器输出的原始图像是横的。而我们的手机屏幕通常是竖屏使用这就产生了一个 90 度的方向差。具体来说CameraX 在ImageAnalysis里拿到的ImageProxy它的imageInfo.rotationDegrees会告诉你这张图需要旋转多少度才能和屏幕方向对齐。竖屏手持时这个值通常是 90 或 270取决于设备。如果你直接把ImageProxy的 buffer 丢给 ZXingZXing 拿到的就是一张躺着的图二维码自然识别不出来。所以第一步必须根据rotationDegrees对图像做旋转或者把旋转信息传递给解码器。这是竖屏扫码最核心的一个认知点绕不过去。2.2 预览画面与解码图像的分辨率差异第二个容易忽略的点是分辨率。CameraX 的Preview和ImageAnalysis是两个独立的用例UseCase它们各自可以设置不同的目标分辨率。预览可能用的是 1080p而分析用的可能是 640x480 或者 1280x720。这意味着你在预览画面上画的识别框和解码器实际处理的图像坐标系和尺寸都不一样。如果你想让框内识别生效就必须做一次坐标映射把预览 View 上的识别框坐标换算到分析图像的实际像素坐标上。这个换算涉及几个变量预览 View 的宽高、分析图像的宽高、以及前面提到的旋转角度。我见过不少实现直接忽略这一步结果就是框在中间实际识别的却是整张图或者框的位置和识别区域完全对不上。2.3 竖屏下识别区域的裁剪逻辑假设我们要实现只识别屏幕中间那个方框内的二维码裁剪逻辑大致是这样的拿到预览 View 的尺寸比如 1080x1920和识别框在 View 中的位置比如居中的 600x600。拿到分析图像的尺寸比如 1280x720和旋转角度比如 90 度。先把识别框坐标按比例映射到分析图像上注意旋转会导致宽高互换。用映射后的矩形去裁剪ImageProxy的 YUV 数据或者只把这块区域转成 ZXing 能吃的格式。这里有个细节ImageProxy默认给的是 YUV_420_888 格式ZXing 需要的是灰度或 RGB 数据。你可以选择自己写 YUV 转灰度也可以让 CameraX 输出 RGBA_8888通过setOutputImageFormat后者更省事但性能略低。我一般用 YUV 自己转因为扫码只需要亮度信息Y 通道转换开销很小。提示裁剪区域不要设得太小。二维码有定位图案如果裁剪太紧可能把定位角切掉反而识别不了。建议识别框比二维码本身大 20% 左右。3. CameraX 的配置预览与分析用例怎么搭3.1 依赖引入与版本选择先看依赖。CameraX 的版本迭代比较快建议用稳定版。我写这篇文章时用的是 1.3.x 系列API 已经比较成熟。dependencies { def camerax_version 1.3.1 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 }ZXing 只需要core这个包不需要android-core或android-integration因为那些是给旧版 Intent 集成用的我们用不上。版本选择上有个经验CameraX 的camera-view提供了PreviewView它内部处理了预览的缩放和裁剪比自己用TextureView省心很多。强烈建议用PreviewView不要自己造轮子。3.2 绑定生命周期与用例组合CameraX 的核心是ProcessCameraProvider它把相机的生命周期和LifecycleOwner绑定在一起。这样你不用担心什么时候开相机、什么时候关相机组件会自动处理。private fun startCamera() { val cameraProviderFuture ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ val cameraProvider cameraProviderFuture.get() val preview Preview.Builder() .setTargetAspectRatio(AspectRatio.RATIO_4_3) .build() val imageAnalysis ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() imageAnalysis.setAnalyzer(executor, QrCodeAnalyzer { result - runOnUiThread { handleResult(result) } }) val cameraSelector CameraSelector.DEFAULT_BACK_CAMERA cameraProvider.unbindAll() cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageAnalysis ) preview.setSurfaceProvider(previewView.surfaceProvider) }, ContextCompat.getMainExecutor(this)) }这里有几个关键决策点值得展开说。为什么用STRATEGY_KEEP_ONLY_LATEST扫码场景下我们只关心最新的那一帧。如果分析速度跟不上相机出帧速度用STRATEGY_BLOCK_PRODUCER会导致帧堆积延迟越来越大用户体验就是扫了半天才反应过来。KEEP_ONLY_LATEST会丢弃旧帧保证处理的永远是最新画面。为什么目标分辨率选 1280x720分辨率太高解码耗时增加太低小二维码识别不了。720p 是个平衡点。实测下来1280x720 对大多数场景够用识别距离在 10-30 厘米之间表现最好。为什么用 YUV 而不是 RGBAYUV 是相机原生输出格式不需要额外转换性能更好。而且扫码只需要亮度信息YUV 的 Y 通道直接就是灰度图省去了 RGB 转灰度的步骤。3.3 分析器线程与背压处理setAnalyzer的第二个参数是一个Executor。这个 Executor 决定了分析回调在哪个线程执行。千万不要用主线程否则解码会卡 UI。我一般用一个单线程的ExecutorServiceprivate val executor Executors.newSingleThreadExecutor()单线程就够了因为KEEP_ONLY_LATEST已经保证了不会并发处理多帧。用线程池反而可能引入不必要的并发问题。背压处理是另一个重点。ImageProxy用完之后必须调用close()否则相机会停止出帧。这是新手最容易踩的坑忘了 close预览直接卡死。override fun analyze(imageProxy: ImageProxy) { try { // 处理图像 } finally { imageProxy.close() } }用try-finally包起来确保无论解码成功还是抛异常ImageProxy都会被关闭。4. ZXing 解码从 YUV 数据到识别结果4.1 YUV 转灰度与裁剪ZXing 的MultiFormatReader需要一个BinaryBitmap而BinaryBitmap可以从LuminanceSource构建。所以我们的任务是把ImageProxy的 YUV 数据转成LuminanceSource。YUV_420_888 的 Y 通道就是亮度直接拿来用就行。但要注意rowStride和pixelStride因为不同设备的 Y 平面可能有 padding。fun imageProxyToLuminanceSource(imageProxy: ImageProxy, cropRect: Rect?): LuminanceSource { val plane imageProxy.planes[0] val buffer plane.buffer val rowStride plane.rowStride val pixelStride plane.pixelStride val width imageProxy.width val height imageProxy.height val data ByteArray(width * height) val rowData ByteArray(rowStride) buffer.rewind() var outputOffset 0 for (row in 0 until height) { val bytesRead if (row height - 1) { buffer.remaining() } else { rowStride } buffer.get(rowData, 0, bytesRead) if (pixelStride 1) { System.arraycopy(rowData, 0, data, outputOffset, width) outputOffset width } else { for (col in 0 until width) { data[outputOffset] rowData[col * pixelStride] } } } return if (cropRect ! null) { cropLuminanceSource(data, width, height, cropRect) } else { PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) } }这段代码处理了pixelStride不为 1 的情况。有些设备的 Y 平面是交错存储的直接按行拷贝会出错。我一开始没注意这个在某个品牌的机器上死活识别不出来排查了半天才发现是pixelStride的问题。裁剪的话ZXing 自带的PlanarYUVLuminanceSource支持指定裁剪区域构造时传入left, top, width, height就行。但要注意裁剪区域必须在图像范围内否则会抛异常。4.2 旋转处理竖屏的关键一步前面说了竖屏下图像需要旋转。有两种处理方式方式一旋转图像数据。把 YUV 数据按旋转角度重新排列生成一张正向的灰度图。这种方式直观但需要额外的内存拷贝性能有损耗。方式二旋转解码结果。不旋转图像而是让 ZXing 解码后把返回的坐标点按旋转角度反向变换。这种方式性能好但需要自己处理坐标变换而且 ZXing 内部的一些优化可能依赖图像方向效果不一定好。我推荐方式一因为实现简单而且 720p 的图像旋转一次开销可以接受。旋转逻辑fun rotateLuminanceData(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) } }注意旋转 90 和 270 度后宽高会互换。这个细节在后续坐标映射时很重要。4.3 解码参数调优Hints 怎么设ZXing 的DecodeHintType可以显著影响识别率和速度。扫码场景下我一般这样配val hints EnumMapDecodeHintType, Any(DecodeHintType::class.java).apply { put(DecodeHintType.POSSIBLE_FORMATS, listOf( BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A, BarcodeFormat.UPC_E )) put(DecodeHintType.TRY_HARDER, true) put(DecodeHintType.CHARACTER_SET, UTF-8) }POSSIBLE_FORMATS限定支持的码制。如果你只需要二维码就只留QR_CODE这样解码器不用尝试其他格式速度会快不少。一维码和二维码混扫的话把常用的一维码格式加上。TRY_HARDER会让解码器做更努力的尝试提高识别率但会增加耗时。在KEEP_ONLY_LATEST模式下单帧耗时增加一点问题不大因为丢帧是常态。但如果你的设备性能较弱可以关掉它。CHARACTER_SET指定字符集中文内容建议用 UTF-8避免乱码。还有一个技巧如果连续多帧识别失败可以适当降低TRY_HARDER或者缩小识别区域减少无效计算。不过这属于进阶优化基础版本可以先不做。5. 识别区域与预览对齐让框内识别真正生效5.1 坐标映射的完整推导这是整个实现里最容易出错的部分。我们目标是在预览界面上画一个识别框只有框内的二维码才被识别。要做到这一点必须把预览 View 上的框坐标准确映射到分析图像的像素坐标上。假设预览 View 尺寸viewW x viewH竖屏比如 1080x1920分析图像尺寸imgW x imgH比如 1280x720这是旋转前的旋转角度rotation比如 90识别框在 View 中的位置Rect(viewLeft, viewTop, viewRight, viewBottom)第一步考虑预览的缩放模式。PreviewView默认用FILL_CENTER会按比例缩放并裁剪保证填满 View。这意味着预览画面和分析图像之间不是简单的等比缩放还有裁剪偏移。计算缩放比例scaleX viewW / imgH (旋转 90 度后图像宽高互换) scaleY viewH / imgW scale max(scaleX, scaleY) // FILL_CENTER 取较大值然后计算裁剪偏移offsetX (imgH * scale - viewW) / 2 offsetY (imgW * scale - viewH) / 2第二步把 View 坐标映射回图像坐标imgLeft (viewLeft offsetX) / scale imgTop (viewTop offsetY) / scale imgRight (viewRight offsetX) / scale imgBottom (viewBottom offsetY) / scale第三步考虑旋转。如果旋转是 90 度映射后的坐标还需要做一次变换因为图像旋转后坐标系变了。这套推导看起来复杂但核心就是缩放 裁剪 旋转三步。我建议把这部分封装成一个独立的工具类输入 View 尺寸、图像尺寸、旋转角度、View 上的矩形输出图像上的矩形。这样逻辑清晰也方便测试。5.2 裁剪区域的边界保护映射出来的矩形必须做边界检查。因为浮点运算和取整可能出现imgLeft 0或者imgRight imgW的情况。如果不处理ZXing 构造PlanarYUVLuminanceSource时会抛异常。val safeLeft max(0, imgLeft.toInt()) val safeTop max(0, imgTop.toInt()) val safeRight min(imgW, imgRight.toInt()) val safeBottom min(imgH, imgBottom.toInt()) if (safeRight - safeLeft MIN_CROP_SIZE || safeBottom - safeTop MIN_CROP_SIZE) { // 裁剪区域太小放弃这一帧 return }MIN_CROP_SIZE我一般设 100 像素。太小的话即使有二维码也识别不了不如直接跳过省点计算。5.3 实测中的对齐偏差与修正理论推导是一回事真机实测又是另一回事。我在几台设备上测下来发现映射后的区域和预览框有轻微偏差大概几个像素到十几个像素不等。原因可能有几个一是PreviewView的实际显示区域可能受状态栏、导航栏影响viewW和viewH要用实际测量值不能用屏幕尺寸。二是不同设备的相机输出可能有轻微的 aspect ratio 偏差setTargetAspectRatio只是建议不是强制。修正方法在识别框周围留一点余量比如把裁剪区域向外扩 10%。这样即使有偏差二维码也不会被切掉。代价是识别区域略大可能识别到框外的码但实际使用中影响很小。提示调试阶段可以在分析图像上画出裁剪区域保存成图片看是否和预览框对齐。这个方法很土但很有效能快速定位是映射算错了还是别的问题。6. 性能优化与真机踩坑记录6.1 解码耗时与帧率控制扫码是个持续的过程用户可能举着手机好几秒。如果每帧都全速解码CPU 占用会很高手机发热明显。我实测过720p 图像用 ZXing 解码单帧耗时大概 20-50 毫秒取决于码的复杂度和TRY_HARDER设置。如果相机出帧是 30fps每帧间隔 33 毫秒解码耗时 30 毫秒的话基本是满负荷运转。短时间没问题长时间手机会烫。优化思路识别成功后暂停分析或者降低分析频率。CameraX 没有直接提供每隔 N 帧分析一次的 API但可以在分析器里自己计数private var frameCount 0 private val analyzeInterval 2 // 每 2 帧分析一次 override fun analyze(imageProxy: ImageProxy) { frameCount if (frameCount % analyzeInterval ! 0) { imageProxy.close() return } // 正常分析 }这样能把解码频率减半CPU 占用明显下降识别响应速度影响不大因为用户举着手机的时间通常远大于两帧间隔。6.2 常见识别失败原因排查真机上识别不出来原因五花八门。我整理了一个排查表按可能性从高到低排列现象可能原因排查方法完全不识别图像未旋转方向错误保存分析图像看是否躺着偶尔识别裁剪区域偏移码被切掉扩大裁剪区域测试近距离不识别对焦问题图像模糊检查是否触发自动对焦远距离不识别分辨率不够码太小提高分析分辨率特定码不识别码制未加入 POSSIBLE_FORMATS检查 hints 配置中文乱码字符集未指定加 CHARACTER_SET UTF-8对焦问题值得单独说。CameraX 默认的对焦模式是连续自动对焦但有些场景下比如近距离扫码对焦可能跟不上。可以在识别失败几次后手动触发一次对焦val camera cameraProvider.bindToLifecycle(...) val cameraControl camera.cameraControl cameraControl.startFocusAndMetering( FocusMeteringAction.Builder( previewView.meteringPointFactory.createPoint(viewW / 2f, viewH / 2f) ).build() )在识别框中心触发对焦能显著提高近距离扫码的成功率。6.3 内存与线程安全注意事项ImageProxy的 buffer 是直接内存用完后必须 close否则会内存泄漏。前面强调过这里再提一次因为真的太容易忘了。另外解码结果回调到 UI 线程时要注意 Activity 或 Fragment 可能已经销毁。我一般用一个标志位或者isFinishing判断避免在已销毁的界面上更新 UI 导致崩溃。还有一点MultiFormatReader不是线程安全的。虽然我们用单线程 Executor理论上不会并发但如果你改成多线程记得每个线程用独立的 Reader 实例或者加锁。7. 一维码与二维码混扫的额外处理7.1 一维码的方向敏感性二维码有定位图案任意角度都能识别。一维码不一样它是一维的必须水平方向扫描才能识别。竖屏下如果一维码是竖直的ZXing 默认识别不了。解决办法有两个一是提示用户水平放置一维码二是对图像做多次旋转尝试。ZXing 的TRY_HARDER会尝试一些旋转但效果有限。更可靠的做法是自己把图像旋转 90 度再解一次。val result reader.decodeWithState(binaryBitmap) if (result null needRotate) { val rotated rotate90(binaryBitmap) reader.decodeWithState(rotated) }这会增加耗时所以只在第一次解码失败且格式包含一维码时才做。7.2 多格式解码的性能取舍同时支持二维码和多种一维码解码器需要逐个尝试耗时增加。如果业务场景明确只需要二维码就别加一维码格式。如果确实要混扫可以考虑根据用户选择动态切换格式而不是一股脑全开。我做过一个测试只开 QR_CODE单帧解码平均 25 毫秒开 QR_CODE CODE_128 EAN_13平均 40 毫秒。差距还是挺明显的。所以格式列表要按需配置不要图省事全加上。7.3 识别结果的去重与节流扫码成功后如果分析器还在跑同一张码会被连续识别多次导致回调触发多次。需要在回调层做去重。简单做法是记录上次识别结果和时间戳如果结果相同且间隔小于 2 秒就忽略。更严谨的做法是识别成功后立即暂停分析处理完结果再恢复。private var lastResult: String? null private var lastTime 0L fun handleResult(result: String) { val now System.currentTimeMillis() if (result lastResult now - lastTime 2000) { return } lastResult result lastTime now // 处理结果 }这个逻辑看似简单但少了它用户扫一次码可能触发好几次业务逻辑造成重复提交等问题。8. 写在最后的一些实操体会这套 CameraX ZXing 的方案我在几个项目里都用过整体稳定性不错。有几点体会比较深分享给准备动手的朋友。第一先把图像方向搞对再调识别率。很多人一上来就调各种参数结果方向是错的怎么调都没用。建议第一步就保存分析图像到本地肉眼确认方向正确再往下做。第二裁剪区域宁大勿小。我一开始为了精准识别把框设得很小结果识别率反而下降。后来把框放大到屏幕宽度的 70% 左右识别率明显提升。用户其实不介意扫到框外的码他们介意的是扫不出来。第三真机测试要覆盖不同品牌。不同厂商的相机实现差异很大尤其是 YUV 的rowStride和pixelStride以及旋转角度的返回值。我在一台设备上跑得好好的代码换一台就出问题排查下来都是这些底层差异。第四别忽略对焦。扫码场景下对焦的重要性不亚于解码本身。如果发现近距离识别率低优先检查对焦策略而不是去调 ZXing 参数。这套方案后续还可以扩展的地方不少比如加闪光灯控制、加相册图片识别、加识别区域的可视化调试开关。但核心的竖屏扫码逻辑就是上面这些。把方向、裁剪、解码这三件事做扎实剩下的都是锦上添花。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑