使用 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的BarcodeScanner,BarcodeScanner把识别结果回调给ViewModel,ViewModel再把状态更新给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不能低于21,ML Kit和CameraX都要求API 21以上,这个门槛其实很低了,现在大多数国产应用都把minSdk抬到24甚至26了。我这边建议直接用API 24起步,省去一些兼容性上的麻烦。
在你项目的build.gradle.kts(Module: 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:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" android:required="true" />uses-feature声明成required=true意味着没有摄像头的设备就无法安装这个App。如果你的扫码功能不是核心功能,可以改成false,并在代码里用PackageManager.hasSystemFeature做判断。
动态权限请求在Compose下我推荐用rememberLauncherForActivityResult这种方式。在Composable里写起来很自然,不需要引入额外库。就两步:
- 注册ActivityResultContracts.RequestPermission这个Launcher;
- 在LaunchedEffect里检查是否已授权,没有则启动请求。
权限请求完成后,根据结果决定是否初始化相机。这里有一个细节:在Android 6.0以上的设备里,如果你不做动态请求就直接打开CameraX,会抛SecurityException。我见过不少人项目能跑,但一在消费者环境崩溃,就是漏了这一步。
2.3 Compose 页面:用 AndroidView 包装 PreviewView
Compose里没有直接可用的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 设置合适的BarcodeScannerOptions
ML 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状态,用MutableStateFlow<Boolean>保存。UI层用它控制是否继续展示扫描界面,数据处理层用它判断是否直接忽略新的识别结果:
class ScannerViewModel : ViewModel() { private val _scanResult = MutableStateFlow<String?>(null) val scanResult: StateFlow<String?> = _scanResult.asStateFlow() private val _isScanning = MutableStateFlow(true) val isScanning: StateFlow<Boolean> = _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管理的,它在某种程度上是"无状态"的,而且如果识别到结果后你只是默默停掉Analyzer,UI层也感知不到扫描状态变了。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这套组合,能让你把精力从底层细节中解放出来,专注在真正有价值的产品体验上。如果你正在做或者准备做扫码功能,希望这篇文章能让你少走一些我踩过的弯路。