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 丢给 ZXing,ZXing 拿到的就是一张躺着的图,二维码自然识别不出来。
所以第一步,必须根据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 而不是 RGBA?YUV 是相机原生输出格式,不需要额外转换,性能更好。而且扫码只需要亮度信息,YUV 的 Y 通道直接就是灰度图,省去了 RGB 转灰度的步骤。
3.3 分析器线程与背压处理
setAnalyzer的第二个参数是一个Executor。这个 Executor 决定了分析回调在哪个线程执行。千万不要用主线程,否则解码会卡 UI。
我一般用一个单线程的ExecutorService:
private 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 = EnumMap<DecodeHintType, 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 参数。
这套方案后续还可以扩展的地方不少,比如加闪光灯控制、加相册图片识别、加识别区域的可视化调试开关。但核心的竖屏扫码逻辑,就是上面这些。把方向、裁剪、解码这三件事做扎实,剩下的都是锦上添花。