
使用 Jetpack Compose 和 ML Kit 打造现代化二维码扫描应用做Android开发的朋友应该都有同感扫码这个功能看起来简单真正做起来坑不少。早年大家一提到扫码就是ZXing大法自定义View、SurfaceView、整页的相机控制代码堆在一起改个UI要折腾半天。后来又有过一段用Camera2 API的年代那工作量更是劝退新人。直到CameraX和ML Kit相继成熟我才觉得做扫码功能终于可以体面一点了。这次想和你分享的是一个完整的现代化扫码方案Jetpack Compose ML Kit Barcode Scanning。先说结论这套组合的核心价值在于把相机预览交给CameraX把识别解码交给ML Kit把界面渲染交给Compose三者的边界非常清晰你在实现扫码页的时候基本不用碰那些底层的相机API和各种图像处理算法。不管你是公司项目里突然接到加一个扫码功能的需求还是自己做一个小工具App这篇内容都可以作为一份直接可参考的落地清单来用。我在实际项目中用这套方案做了好几轮迭代从最初的黑屏、方向不对、识别率低到现在跑得还挺稳经验都在这里了。下面不废话直接开始。1. 项目整体设计与技术选型思路1.1 为什么选 Jetpack Compose ML Kit而不是 ZXing先说ZXing我不否认它当年几乎是扫码界的标准答案很多大厂的早期版本底层都是它。但问题是ZXing本质上是一个要用就得整个打包的方案相机预览的封装、各类码制的解码逻辑、甚至UI控件全都耦合在一起定制起来特别痛苦。而且它的源码年代比较久很多写法早就跟不上现在的Android生态了。Jetpack Compose也好ML Kit也好它们的共同特点是组件化和官方维护。在Compose里面整个界面就是函数组合扫码框、提示语、扫描线都是普通的状态驱动UI想改样式只动Composable就行不用再和View系统搏斗。ML Kit是Google提供的端侧机器学习套件条形码识别这块是它的能力之一识别速度快、支持格式多并且内置了所有模型不需要联网不额外产生费用。还有一个现实原因现在的业务需求经常不只是扫一下出个结果。很多App要做扫码后的历史记录、要能识别多张码、要根据码的类型跳不同页面。如果继续用ZXing那套实现这些需求的开发量是翻倍的。而Compose ML Kit的架构天然允许你把扫描逻辑和界面逻辑解耦扩展起来轻松得多。1.2 核心架构CameraX 负责看ML Kit 负责认整个项目的核心思路其实一句话就可以概括CameraX的ImageAnalysis把摄像头每一帧画面交给ML Kit的BarcodeScannerBarcodeScanner把识别结果回调给ViewModelViewModel再把状态更新给Compose UI同时控制是否继续扫描。这里有个很多新手容易搞混的点ML Kit本身不负责拍照它只负责识别。你要把图片数据送给它。用CameraX 来做这件事的好处是它自带生命周期感知可以自动处理相机开闭和应用的切后台场景。ImageAnalysis这个类就是为实时帧分析设计的默认会做一个比较均衡的帧率控制直接把它和ML Kit接起来就行。我在设计架构的时候把项目分成了四层数据层负责调用ML Kit包装识别请求尽量把ML Kit的细节藏起来。业务层ViewModel持有扫描状态StateFlow处理扫码结果的去重逻辑。UI层Compose页面渲染预览画面、扫描框、动画。相机层CameraX的封装负责生命周期和图像帧的生产。如果你只是做一个简单扫码Demo不搞这么分层也行。但如果你会在多个入口复用扫码能力或者后续要加扫码历史、结果处理等逻辑这套分层会省你非常多事。1.3 需要提前了解的几个关键概念在实际开始写代码之前有几个名词你必须先搞清楚不然会卡在各种报错里出不来。第一个是ImageProxy。CameraX的ImageAnalysis返回的不是普通的Bitmap而是包装过的ImageProxy。使用完必须调用它的close()方法释放否则一段时间后相机就会卡死因为帧缓冲区被占满了。这是新手最容易踩的坑我后面会专门讲。第二个是InputImage。ML Kit识别时接收的是InputImage对象它有几个工厂方法比如fromMediaImage、fromBitmap。使用ImageProxy时你需要走fromMediaImage(proxy.image, proxy.imageInfo.rotationDegrees)这条路这个rotationDegrees参数很关键传错的话识别的坐标和画面方向都会不对。第三个是BarcodeScannerOptions。ML Kit初始化的方式看起来很简单但一定要记得设置setBarcodeFormats。如果不设置ML Kit会尝试识别所有格式的码性能会明显变差。一般扫码应用只需要二维码QR_CODE和一维码EAN_13、CODE_128等设置好你需要的格式就好这样识别速度和准确率都会有提升。2. 从零搭建项目依赖、权限与基础配置2.1 依赖配置与版本选择新建一个空项目包名随意但注意minSdk不能低于21ML Kit和CameraX都要求API 21以上这个门槛其实很低了现在大多数国产应用都把minSdk抬到24甚至26了。我这边建议直接用API 24起步省去一些兼容性上的麻烦。在你项目的build.gradle.ktsModule: app里加上下面的依赖dependencies { // 基础 implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.7.0) implementation(androidx.activity:activity-compose:1.8.2) // Compose 基础 implementation(platform(androidx.compose:compose-bom:2024.02.00)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.ui:ui-graphics) implementation(androidx.compose.ui:ui-tooling-preview) implementation(androidx.compose.material3:material3) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0) // Compose 生命周期组件库用于绑定PreviewView implementation(androidx.compose.ui:ui-viewbinding) // CameraX implementation(androidx.camera:camera-core:1.3.1) implementation(androidx.camera:camera-camera2:1.3.1) implementation(androidx.camera:camera-lifecycle:1.3.1) implementation(androidx.camera:camera-view:1.3.1) // ML Kit 条码识别 implementation(com.google.mlkit:barcode-scanning:17.2.0) }版本我用的是当时测试过的稳定组合Compose BOM管理了所有Compose库的版本实际构建的时候建议去查一下最新的BOM版本号兼容性更好。这里特别提一下camera-lifecycle和camera-view。前者让你能把相机直接绑定到LifecycleOwner上后者里面有PreviewView这个类是我们展示预览画面的核心View。在Compose里使用PreviewView需要借助AndroidView来包装这部分后面会细说。2.2 权限申请清单文件和动态请求扫码必须用到相机权限。首先在AndroidManifest.xml里加上uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera android:requiredtrue /uses-feature声明成requiredtrue意味着没有摄像头的设备就无法安装这个App。如果你的扫码功能不是核心功能可以改成false并在代码里用PackageManager.hasSystemFeature做判断。动态权限请求在Compose下我推荐用rememberLauncherForActivityResult这种方式。在Composable里写起来很自然不需要引入额外库。就两步注册ActivityResultContracts.RequestPermission这个Launcher在LaunchedEffect里检查是否已授权没有则启动请求。权限请求完成后根据结果决定是否初始化相机。这里有一个细节在Android 6.0以上的设备里如果你不做动态请求就直接打开CameraX会抛SecurityException。我见过不少人项目能跑但一在消费者环境崩溃就是漏了这一步。2.3 Compose 页面用 AndroidView 包装 PreviewViewCompose里没有直接可用的PreviewView但提供了AndroidView这个桥接组件来包装View体系的东西。把PreviewView放到AndroidView内部并在update回调中把LifecycleOwner传进去即可。基本骨架就是这样Composable fun QRScannerScreen( viewModel: ScannerViewModel, lifecycleOwner: LifecycleOwner LocalLifecycleOwner.current ) { val previewView remember { PreviewView(context).apply { implementationMode PreviewView.ImplementationMode.COMPATIBLE } } AndroidView( modifier Modifier.fillMaxSize(), factory { previewView } ) }有人会问ImplementationMode.COMPATIBLE是什么意思。简单说PreviewView有两种渲染预览画面的方式PERFORMANCE和COMPATIBLE。前者在某些设备上可能会有角度或变形问题后者底层走的是TextureView兼容性更好但性能略低于前者。我一般默认用COMPATIBLE因为扫码页面帧率要求没那么苛刻稳定优先。2.4 把 CameraX 的生命周期绑定明白PreviewView只是个空壳真正要让相机跑起来我们需要用ProcessCameraProvider把Preview和ImageAnalysis绑定到LifecycleOwner上private fun bindCameraUseCases(cameraProvider: ProcessCameraProvider, previewView: PreviewView) { val preview Preview.Builder() .build() .also { it.setSurfaceProvider(previewView.surfaceProvider) } val analysis ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer(cameraExecutor) { imageProxy - processImageProxy(imageProxy) } } cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis ) }这里我用了STRATEGY_KEEP_ONLY_LATEST意思是当前帧分析没结束前新帧直接丢弃确保跟踪的是最新一帧。ML Kit的识别本身挺快的但也会有几毫秒到几十毫秒的耗时如果不用这个策略可能会积压很多ImageProxy导致内存泄漏。3. 核心扫描逻辑ML Kit 识别的接入与优化3.1 Analyzer中的图像处理processImageProxy是整条链路的核心。我会把ImageProxy转成InputImage然后交给ML Kit的BarcodeScanner去识别。代码看起来不复杂但里面有几个容易被忽略的细节private fun processImageProxy(imageProxy: ImageProxy) { val mediaImage imageProxy.image val rotationDegrees imageProxy.imageInfo.rotationDegrees if (mediaImage ! null) { val inputImage InputImage.fromMediaImage(mediaImage, rotationDegrees) scanner.process(inputImage) .addOnSuccessListener { barcodes - handleBarcodes(barcodes) } .addOnCompleteListener { imageProxy.close() } } else { imageProxy.close() } }看到没有close()的调用位置非常关键。一定要放在process的OnComplete里而不是OnSuccess里。万一识别过程抛异常OnSuccess不会走但ImageProxy如果不释放仍然会泄漏。所以用OnComplete兜底是最保险的。还有很多人会踩的坑是rotationDegrees。CameraX在不同手机上的图像rotation有可能是0、90、180、270值中的任意一个。ML Kit识别坐标的时候依赖这个值来把图像坐标映射到屏幕坐标。你要是图省事传了0结果就是扫码框画的识别区域和实际画面永远对不上扫描成功率会非常低。3.2 设置合适的BarcodeScannerOptionsML Kit初始化的时候建议明确指定要识别的条码格式。默认情况它会尝试识别所有格式这会增加模型的计算量在低端机型上能明显感觉到卡顿。private val scanner: BarcodeScanner by lazy { val options BarcodeScannerOptions.Builder() .setBarcodeFormats( Barcode.FORMAT_QR_CODE, Barcode.FORMAT_CODE_128, Barcode.FORMAT_EAN_13, Barcode.FORMAT_EAN_8, Barcode.FORMAT_UPC_A, Barcode.FORMAT_UPC_E ) .build() BarcodeScanning.getClient(options) }通常扫码工具类App需要同时识别二维码和一维码上面这组配置基本能满足大多数情况。如果只做纯二维码工具只保留FORMAT_QR_CODE就够了识别速度还能快一点。3.3 解析扫描结果的坐标信息ML Kit返回的Barcode对象里有几个常用字段rawValue码的内容比如网址、文本、JSON等。format码的类型。boundingBox码在图像中的坐标矩形用于画识别框。cornerPoints码的四个角点坐标精度更高。在我的业务里rawValue是使用频次最高的。有些扫码页想要实时的识别框动画这时候就可以用boundingBox或cornerPoints来做Canvas绘制。不过要注意这个坐标是在图像坐标系里的如果要画到屏幕上需要先把图像坐标转换到View坐标转换公式并不复杂val scaleX viewWidth / imageWidth.toFloat() val scaleY viewHeight / imageHeight.toFloat()但因为相机的成像通常带有裁剪直接用这两个比例会有偏移建议再叠加一个Matrix.mapRect来处理或者干脆用手动计算的方式省得被PreviewView内部的坐标变换搞晕。3.4 防抖与重复扫描的控制扫描结果一般只要一次就够了但相机在识别时是持续的同一张二维码在同一帧到下一帧都会被识别出来。这个时候就需要一个扫描成功之后暂停识别的机制。我的做法是在ViewModel里定义一个_isScanning状态用MutableStateFlowBoolean保存。UI层用它控制是否继续展示扫描界面数据处理层用它判断是否直接忽略新的识别结果class ScannerViewModel : ViewModel() { private val _scanResult MutableStateFlowString?(null) val scanResult: StateFlowString? _scanResult.asStateFlow() private val _isScanning MutableStateFlow(true) val isScanning: StateFlowBoolean _isScanning.asStateFlow() fun onBarcodeDetected(rawValue: String) { if (_isScanning.value) { _isScanning.value false _scanResult.value rawValue } } fun reset() { _scanResult.value null _isScanning.value true } }为什么要在ViewModel里做这个控制而不是在Analyzer里做因为Analyzer对象是由CameraX管理的它在某种程度上是无状态的而且如果识别到结果后你只是默默停掉AnalyzerUI层也感知不到扫描状态变了。ViewModel作为中转站既能控制逻辑又能驱动UI变化是比较合理的位置。4. Compose UI层的实现与细节优化4.1 自定义扫码框和扫描线动画说到扫码页的UI最常见的是四角边框加中间一条不断上下移动的扫描线。Compose里实现这个非常顺手用Canvas直接画Composable fun ScannerOverlay() { val linePosition remember { Animatable(0f) } LaunchedEffect(Unit) { while (true) { linePosition.animateTo( targetValue 1f, animationSpec tween(2000, easing LinearEasing) ) linePosition.snapTo(0f) } } Canvas(modifier Modifier.fillMaxSize()) { val scanRect ... // 计算扫码区域 // 画半透明遮罩 drawRect(color Color.Black.copy(alpha 0.5f)) drawRect( color Color.Transparent, topLeft scanRect.topLeft, size scanRect.size ) // 画四角边框 drawLine(...) // 画扫描线 val y scanRect.top scanRect.height * linePosition.value drawLine( color Color.Green, start Offset(scanRect.left, y), end Offset(scanRect.right, y), strokeWidth 4.dp.toPx() ) } }使用BlendMode.Clear把扫描区域挖空可以做出那种半透明背景配透明扫描区的效果。但有个兼容性问题在某些硬件加速配置下BlendMode.Clear会画出一块黑块保险起见我经常用drawRect(Color.Black.copy(alpha 0.3f))阴影再在上面绘制一个清晰边框的方式视觉上差别不大。4.2 用 Compose 状态驱动 UI 切换扫码页的状态其实不复杂大致有三种扫描中显示相机预览和扫描动画。获取结果显示结果弹窗或跳转页面。权限未开启显示权限引导界面。用Compose的collectAsStateWithLifecycle来观察ViewModel里的状态页面就非常清晰了Composable fun ScannerRoute(viewModel: ScannerViewModel) { val scanResult by viewModel.scanResult.collectAsStateWithLifecycle() val isScanning by viewModel.isScanning.collectAsStateWithLifecycle() Box(modifier Modifier.fillMaxSize()) { CameraPreview() ScannerOverlay() scanResult?.let { result - // 弹出结果对话框 or 导航到其他页面 } } }这一段的优势在于UI层、逻辑层各司其职。改UI不会动逻辑扩业务不影响相机。这就是选Compose而不是XML布局做这个页面最大的红利。4.3 扫码页的返回处理与前后台切换一个容易忽略的体验细节是用户扫码扫到一半退到后台回到App之后相机应该仍然可以正常工作。为什么不用自己写因为CameraX的bindToLifecycle已经把这个逻辑处理好了。当你把LifecycleOwner传给它之后它会根据Activity/Fragment的生命周期自动绑定和解绑相机。不过有一点要特别注意如果你的扫码页不是一个独立Activity而是Fragment或者单Activity架构里的某个页面那lifecycleOwner一定要用当前页面的而不是全局的。否则CameraX的生命周期监听错了对象切后台回来的时候相机会黑屏。我在项目中遇到过这个坑查了半天最后发现是LocalLifecycleOwner.current取到的是宿主Activity的owner页面已经pop掉了但Activity还活着相机没有正确解绑。4.4 识别框的精确绘制技巧ML Kit的cornerPoints返回的是图像坐标系的四个点。如果你希望扫描识别的区域跟UI上的扫码框完全对齐一种简单的做法是不管图像里识别到哪个位置只要识别的二维码中心落在扫码框范围内就算有效结果。实现这个逻辑需要把识别到的中心点转换到UI坐标然后判断是否落在扫描框内。转换时需要考虑画面裁剪问题因为PreviewView把相机的画面等比缩放到View里并且是CENTER_CROP效果所以存在偏移量。这里直接给一个参考公式val imageWidth imageProxy.width val imageHeight imageProxy.height val viewWidth previewView.width.toFloat() val viewHeight previewView.height.toFloat() val scale max(viewWidth / imageWidth, viewHeight / imageHeight) val drawWidth imageWidth * scale val drawHeight imageHeight * scale val offsetX (viewWidth - drawWidth) / 2 val offsetY (viewHeight - drawHeight) / 2 fun ImagePoint.toViewPoint(): Offset { return Offset( x this.x * scale offsetX, y this.y * scale offsetY ) }这个方式在识别框与预览画面对齐时特别有用。5. 常见问题与排查技巧实录5.1 预览黑屏或者只有画面没有扫描线黑屏这个事是扫码开发绕不开的坎。最常见的几个原因没有权限或权限被拒先杀掉App在系统设置里确认相机权限是打开的。生命周期绑定错误bindToLifecycle的第一个参数传入的LifecycleOwner不是当前处于STARTED状态的Owner。PreviewView没有设置SurfaceProvider忘了preview.setSurfaceProvider(previewView.surfaceProvider)。USB调试状态下部分设备相机被占用换个真机试试。还有一个罕见但确实存在的情况设备有多个摄像头有些前置摄像头没有对焦能力或分辨率异常用DEFAULT_FRONT_CAMERA可能会出现黑屏。因此要用后置摄像头做扫码并且用hasCamera判断一下设备是否有可用摄像头。5.2 识别率低、扫码很慢识别率低的排查思路先看硬件和光线再回到代码层面。光线不足时摄像头进光量不够图像噪点多ML Kit的模型再强也会误判。所以我在扫码页UI上会加手电筒按钮。闪光灯控制用CameraX的CameraControl.enableTorch就很方便val cameraControl camera.cameraControl cameraControl.enableTorch(true)代码层面识别率低绝大多数是帧率调整和分辨率设置的问题。ImageAnalysis.Builder支持setTargetResolution建议把分辨率设定在足够的范围内ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) .setTargetRotation(...) .build()720p的分辨率足够识别二维码同时又不会像1080p那样对设备性能造成太大压力。如果画面严重卡顿想想是不是后台有别的耗CPU任务在跑或者没有限制BarcodeFormat导致模型开销变大。5.3 识别结果重复、连续弹出这个在上面提过核心就是加扫描状态控制。如果你仍然遇到先弹A结果又弹B结果多半是Analyzer并发导致的。写ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)的另一个作用是它会确保同时只会有一个Analyzer在运行。但注意这个策略只保证了最新帧优先并不保证两个Analyzer回调之间没有重叠因此你最好再在单线程Executor上跑processImageProxy。5.4 ImageProxy泄漏导致相机死掉这个问题非常隐蔽症状是页面打开相机正常但切几次后台再回来画面卡在第一帧或者直接黑屏Logcat里也没什么明显的错误。其实是因为ImageAnalysis的帧没有全部close。我的处理方案是统一封装一个扩展函数inline fun ImageProxy.acquire(block: (ImageProxy) - Unit) { try { block(this) } finally { this.close() } }然后在Analyzer里这样调用imageProxy.acquire { proxy - // 处理逻辑 }这样就保证不管处理逻辑是否抛异常帧肯定会被回收。5.5 相机方向与扫码框角度不匹配竖屏扫码时相机传感器的原始方向其实通常是横向的CameraX已经帮你把旋转处理好但PreviewView内部和ML Kit的坐标映射之间偶尔会有差异。如果你发现识别框的检测坐标方向不对优先检查传给InputImage.fromMediaImage的rotationDegrees。这是这个方向问题的最主要来源。6. 性能优化与后续扩展6.1 缩小识别区域减少无效计算有些业务场景里屏幕很大但可扫描区域只在中间一块。与其让ML Kit每帧处理整幅画面不如在Analyzer里就对图像做裁剪。裁剪方法就是把MediaImage先转成Bitmap然后按扫码框的比例裁切。这一步会损失一点性能但对识别准确率和误报率的改善非常明显。如果你的扫码框位置固定甚至可以直接把ImageAnalysis的分辨率设置成接近扫码框的尺寸。注意设置setTargetRotation时要确保和PreviewView的旋转方向一致否则裁剪之后的图像是歪的。6.2 低端机型的降级方案ML Kit的条码识别模型虽然小但低端机运行起来也有压力。可以考虑做动态降帧。CameraX的ImageAnalysis没有直接的每N帧处理一次的API但你可以自己在Analyzer里计数private var frameCount 0 override fun analyze(imageProxy: ImageProxy) { if (frameCount % 3 0) { // 真正执行ML Kit识别 } frameCount imageProxy.close() }这个思路在性能要求严格的场景下很实用实测帧率降到10fps以下时只要画面不是快速移动识别率并不会明显下降。6.3 多种码制识别后跳转不同业务ML Kit返回的Barcode.format字段不同你可以做策略分发。比如识别到URL_FORM就直接跳WebView识别到WIFI就自动弹出连接Wi-Fi的确认框识别到TEXT就复制到剪贴板。Compose的UI分支配合ViewModel的when语句做起来非常舒服。这里推荐一个细分方法用Barcode.URL这个子类它包含url、title等字段比直接拿rawValue字符串去解析URL要更可靠。同样Wi-Fi码、日历、联系人都对应专门的类用前注意判断空指针。6.4 扩展为条码生成和批量识别做扫码App顺带做码生成是很常见的事。ML Kit只管识别不管生成但你可以用com.google.zxing:core库来做二维码生成这个库很轻只依赖core模块不涉及相机部分正好和ML Kit互补。生成一张二维码大概只需要这么几行val writer QRCodeWriter() val bitMatrix writer.encode(content, BarcodeFormat.QR_CODE, 500, 500) val bitmap Bitmap.createBitmap(500, 500, Bitmap.Config.RGB_565) // 从 bitMatrix 填充像素批量识别更是它的优势ML Kit的BarcodeScanner一次process调用可以返回多个Barcode一张图片里出现多个二维码也能一次拿全。7. 项目实战中的经验与心得这个项目落地之后我最大的体会是把复杂的事情交出去把简单的事情做好。CameraX和ML Kit已经帮你把相机控制和图像识别这些最复杂的部分处理好了你的核心竞争力应该在UI体验和业务逻辑层面。比如怎么设计扫码框才能让用户更精确地对准怎么在弱光环境下给出更好的引导怎么处理识别成功后的震动反馈这些才是真正拉开体验差距的地方。最后分享两个小技巧。第一个给扫码成功加一个震动反馈。ML Kit的addOnSuccessListener里判断barcode不为空然后调用Vibrator服务震动一下。这个反馈很细微但用户感知非常好比界面跳转更直接。第二个调试扫码功能时多准备几张不同格式的测试码。不用打印出来屏幕上放一张就行。我发现很多识别率问题不是代码问题而是测试用的码质量太差二维码褶皱、磨损、对比度不足这些都会让模型识别失败。换几张清晰的码试试可能就药到病除了。扫码功能是个看起来小、实际复杂度很高的需求。用Jetpack Compose和ML Kit这套组合能让你把精力从底层细节中解放出来专注在真正有价值的产品体验上。如果你正在做或者准备做扫码功能希望这篇文章能让你少走一些我踩过的弯路。