使用 Jetpack Compose 和 ML Kit 打造现代化二维码扫描应用
前言
在移动应用开发中,二维码扫描是一个非常常见的功能需求。从支付、社交分享到产品溯源,二维码无处不在。本文将分享我如何使用 Jetpack Compose、CameraX 和 Google ML Kit 构建一个简洁高效的 Android 二维码扫描应用------轻扫(LiteScan)。
项目概览
轻扫是一个纯 Kotlin 编写的 Android 应用,采用了最新的 Android 开发技术栈:
- Jetpack Compose:声明式 UI 框架,告别 XML 布局
- CameraX:简化相机开发的 Jetpack 库
- ML Kit Barcode Scanning:Google 提供的高性能条码识别 API
- Navigation Compose:类型安全的导航解决方案
- Material Design 3:最新的 Material 设计规范
核心功能
- 实时相机扫描二维码和条形码
- 从相册选择图片进行识别
- 智能识别 URL 并提供快速打开功能
- 扫描结果可选中复制
- 优雅的权限请求处理
技术实现详解
1. 项目架构
应用采用简洁的单 Activity 架构,通过 Navigation Compose 管理页面导航:
kotlin
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "scanner"
) {
composable("scanner") {
QrCodeScannerScreen(
onScanResult = { result ->
navController.navigate("result/${Uri.encode(result)}")
}
)
}
composable(
"result/{scanResult}",
arguments = listOf(
navArgument("scanResult") {
type = NavType.StringType
}
)
) { backStackEntry ->
val result = backStackEntry.arguments?.getString("scanResult") ?: "扫描失败"
ResultScreen(
scanResult = result,
onBackToScan = { navController.popBackStack() }
)
}
}
}
整个应用只有三个主要页面:
- PermissionRequestScreen:权限请求页面
- QrCodeScannerScreen:扫描页面
- ResultScreen:结果展示页面
2. 权限管理:优雅的用户体验
使用 Accompanist Permissions 库简化权限请求流程:
kotlin
@ExperimentalPermissionsApi
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val navController = rememberNavController()
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.CAMERA,
onPermissionResult = {
if (it) {
navController.navigate("scanner")
}
}
)
if (cameraPermissionState.status.isGranted) {
AppNavHost(navController = navController)
} else {
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
PermissionRequestScreen(
isPermissionRequested = true,
onRequestClick = { cameraPermissionState.launchPermissionRequest() }
)
}
}
}
}
权限请求页面提供了清晰的说明和两个操作选项:
- 直接授予权限
- 如果系统不再弹出权限对话框,引导用户前往设置页面手动开启
3. CameraX 集成:实时扫描的核心
CameraX 是 Jetpack 的一部分,大大简化了相机开发。在 Compose 中集成 CameraX 需要使用 AndroidView:
kotlin
@Composable
fun QrCodeScannerScreen(onScanResult: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val isScanningEnabled = remember { mutableStateOf(true) }
// 创建 BarcodeScanner
val barcodeScanner = remember {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
)
}
// 创建 PreviewView
val previewView = remember { PreviewView(context) }
// 绑定 CameraX 生命周期
LaunchedEffect(lifecycleOwner) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val cameraProvider = cameraProviderFuture.get()
// Preview 用例
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// ImageAnalysis 用例
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
ContextCompat.getMainExecutor(context),
BarcodeAnalyzer(barcodeScanner) { rawValue ->
if (isScanningEnabled.value) {
isScanningEnabled.value = false
onScanResult(rawValue)
}
}
)
}
// 选择后置摄像头
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalyzer
)
} catch (exc: Exception) {
Log.e("CAMERA", "Camera bind failed", exc)
}
}
// 将 PreviewView 嵌入 Compose
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
}
}
关键技术点
Preview 用例 :负责在屏幕上显示相机预览
ImageAnalysis 用例 :负责分析每一帧图像,检测二维码
背压策略 :使用 STRATEGY_KEEP_ONLY_LATEST 确保只处理最新的帧,避免积压
4. ML Kit 条码识别:强大的 AI 能力
Google ML Kit 提供了开箱即用的条码识别能力,支持多种格式:
kotlin
class BarcodeAnalyzer(
private val scanner: BarcodeScanner,
private val onBarcodeScanned: (String) -> Unit
) : ImageAnalysis.Analyzer {
@SuppressLint("UnsafeOptInUsageError")
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
barcodes.firstOrNull()?.rawValue?.let { rawValue ->
onBarcodeScanned(rawValue)
}
}
}
.addOnCompleteListener {
// 必须关闭 ImageProxy,否则会阻塞后续帧
imageProxy.close()
}
}
}
}
重要细节
- 旋转角度处理 :
imageProxy.imageInfo.rotationDegrees确保图像方向正确 - 资源释放 :必须在
addOnCompleteListener中调用imageProxy.close(),否则会导致相机卡死 - 异步处理:ML Kit 的识别是异步的,使用回调处理结果
5. 相册选择:Photo Picker 集成
Android 13 引入了新的 Photo Picker API,提供了更好的隐私保护:
kotlin
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
uri?.let {
if (isScanningEnabled.value) {
isScanningEnabled.value = false
processImageFromUri(uri, context) { result ->
if (result != null) {
onScanResult(result)
} else {
// 图片中没有二维码,重新启用扫描
isScanningEnabled.value = true
}
}
}
}
}
FloatingActionButton(
onClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 64.dp, bottom = 100.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "From Gallery",
modifier = Modifier.size(64.dp)
)
}
处理相册图片的逻辑:
kotlin
private fun processImageFromUri(
uri: Uri,
context: android.content.Context,
onResult: (String?) -> Unit
) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val inputImage = InputImage.fromBitmap(bitmap, 0)
val barcodeScanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
)
barcodeScanner.process(inputImage)
.addOnSuccessListener { barcodes ->
val result = if (barcodes.isNotEmpty()) {
barcodes.firstOrNull()?.rawValue
} else {
null
}
onResult(result)
}
.addOnFailureListener { exception ->
Log.e("QR_SCAN", "Error processing image", exception)
onResult(null)
}
} catch (e: Exception) {
Log.e("QR_SCAN", "Error reading image", e)
onResult(null)
}
}
6. 结果展示:智能识别与交互
结果页面不仅展示扫描内容,还能智能识别 URL 并提供快速打开功能:
kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResultScreen(
scanResult: String,
onBackToScan: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("扫描结果") },
navigationIcon = {
IconButton(onClick = onBackToScan) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("扫描结果:", style = MaterialTheme.typography.headlineSmall)
// 可选中复制的文本
SelectionContainer {
Text(
text = scanResult,
style = MaterialTheme.typography.bodyLarge
)
}
// 智能识别 URL
val isLink = Patterns.WEB_URL.matcher(scanResult).matches()
if (isLink) {
Button(onClick = { uriHandler.openUri(scanResult) }) {
Text("打开链接")
}
}
Button(onClick = onBackToScan) {
Text("返回并重新扫描")
}
}
}
}
亮点功能
- SelectionContainer:让用户可以长按选中并复制扫描结果
- URL 识别 :使用
Patterns.WEB_URL自动识别链接 - LocalUriHandler:Compose 提供的打开 URL 的便捷方式
性能优化
1. 防止重复扫描
使用 isScanningEnabled 状态标志,防止在导航到结果页面前重复触发扫描:
kotlin
val isScanningEnabled = remember { mutableStateOf(true) }
// 扫描成功后禁用
if (isScanningEnabled.value) {
isScanningEnabled.value = false
onScanResult(rawValue)
}
2. 背压策略
使用 STRATEGY_KEEP_ONLY_LATEST 确保只处理最新的帧,避免处理积压导致的延迟:
kotlin
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
3. 资源管理
确保及时释放相机资源和图像代理:
kotlin
.addOnCompleteListener {
imageProxy.close() // 必须关闭,否则会阻塞
}
开发中的挑战与解决方案
挑战 1:Compose 中集成 CameraX
问题:CameraX 基于传统 View 系统,如何在 Compose 中使用?
解决方案 :使用 AndroidView 包装 PreviewView,并通过 LaunchedEffect 管理生命周期绑定。
挑战 2:相机预览卡死
问题:扫描几次后相机预览卡住不动。
解决方案 :确保在 ImageAnalysis.Analyzer 的 addOnCompleteListener 中调用 imageProxy.close(),释放图像缓冲区。
挑战 3:导航参数传递
问题 :扫描结果可能包含特殊字符(如 URL 中的 /),导致导航失败。
解决方案 :使用 Uri.encode() 对结果进行编码:
kotlin
navController.navigate("result/${Uri.encode(result)}")
挑战 4:权限被永久拒绝
问题:用户选择"不再询问"后,无法再次请求权限。
解决方案:提供"去设置打开"按钮,引导用户手动开启权限:
kotlin
OutlinedButton(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
) {
Text("无法弹出提示?去设置打开")
}
依赖配置
完整的依赖配置如下:
kotlin
dependencies {
// Jetpack Compose
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
// 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 Barcode Scanning
implementation("com.google.mlkit:barcode-scanning:17.2.0")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.7.6")
// Accompanist Permissions
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
}
未来规划
虽然当前版本已经实现了核心功能,但还有很多改进空间:
- 扫描历史记录:保存扫描历史,方便回溯查看
- 批量扫描:一次扫描多个二维码
- 更多操作:分享、保存、搜索等
- 自定义扫描框:提供更多样式选择
- 扫描音效和震动反馈:提升用户体验
总结
通过这个项目,我深刻体会到了 Jetpack Compose 的强大和便捷。声明式 UI 让代码更加简洁易读,CameraX 和 ML Kit 的集成也非常顺畅。整个应用从零到完成只用了很短的时间,这在传统 View 系统中是难以想象的。
如果你也想开发类似的应用,希望这篇文章能给你一些启发。完整的源代码已经开源,欢迎 Star 和 Fork!
相关资源
- 项目地址 :GitHub - LiteScan
- Jetpack Compose 官方文档:https://developer.android.com/jetpack/compose
- CameraX 官方文档:https://developer.android.com/training/camerax
- ML Kit 官方文档:https://developers.google.com/ml-kit/vision/barcode-scanning
关于作者
我是 shenhua,一名 Android 开发者,热衷于探索新技术和分享开发经验。如果你对这个项目有任何问题或建议,欢迎通过以下方式联系我:
- 邮箱:shenhuanet@126.com
- GitHub :@shenhuanet
感谢阅读!如果觉得这篇文章对你有帮助,欢迎点赞、收藏和分享!