使用 Jetpack Compose 和 ML Kit 打造现代化二维码扫描应用

使用 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 设计规范

核心功能

  1. 实时相机扫描二维码和条形码
  2. 从相册选择图片进行识别
  3. 智能识别 URL 并提供快速打开功能
  4. 扫描结果可选中复制
  5. 优雅的权限请求处理

技术实现详解

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() }
                )
            }
        }
    }
}

权限请求页面提供了清晰的说明和两个操作选项:

  1. 直接授予权限
  2. 如果系统不再弹出权限对话框,引导用户前往设置页面手动开启

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()
                }
        }
    }
}
重要细节
  1. 旋转角度处理imageProxy.imageInfo.rotationDegrees 确保图像方向正确
  2. 资源释放 :必须在 addOnCompleteListener 中调用 imageProxy.close(),否则会导致相机卡死
  3. 异步处理: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("返回并重新扫描")
            }
        }
    }
}
亮点功能
  1. SelectionContainer:让用户可以长按选中并复制扫描结果
  2. URL 识别 :使用 Patterns.WEB_URL 自动识别链接
  3. 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.AnalyzeraddOnCompleteListener 中调用 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")
}

未来规划

虽然当前版本已经实现了核心功能,但还有很多改进空间:

  1. 扫描历史记录:保存扫描历史,方便回溯查看
  2. 批量扫描:一次扫描多个二维码
  3. 更多操作:分享、保存、搜索等
  4. 自定义扫描框:提供更多样式选择
  5. 扫描音效和震动反馈:提升用户体验

总结

通过这个项目,我深刻体会到了 Jetpack Compose 的强大和便捷。声明式 UI 让代码更加简洁易读,CameraX 和 ML Kit 的集成也非常顺畅。整个应用从零到完成只用了很短的时间,这在传统 View 系统中是难以想象的。

如果你也想开发类似的应用,希望这篇文章能给你一些启发。完整的源代码已经开源,欢迎 Star 和 Fork!

相关资源

关于作者

我是 shenhua,一名 Android 开发者,热衷于探索新技术和分享开发经验。如果你对这个项目有任何问题或建议,欢迎通过以下方式联系我:

感谢阅读!如果觉得这篇文章对你有帮助,欢迎点赞、收藏和分享!

相关推荐
杉氧16 小时前
副作用 (Side Effects) 全攻略:如何像大师一样掌控 Composable 的生命周期?
android·架构·android jetpack
杉氧2 天前
深入理解 Compose 重组机制:快照系统如何驱动 UI 精准刷新?
android·架构·android jetpack
杉氧2 天前
深度解析:Jetpack Compose 核心架构与底层原理 —— 十年安卓老兵的“破茧重生”
android·架构·android jetpack
李斯维4 天前
从历史的角度看 Android 软件架构
android·架构·android jetpack
alexhilton4 天前
Android车载OS中的Remote Compose
android·kotlin·android jetpack
alexhilton11 天前
使用Android Archive进行打包
android·kotlin·android jetpack
Junerver14 天前
我写了一个 Compose Multiplatform 组件库,你可能会用到
kotlin·android jetpack
我命由我1234515 天前
Jetpack Room - Room 查询返回列表无需判空、LIKE 关键字
android·java·开发语言·java-ee·android jetpack·android-studio·android runtime
QING61816 天前
Kotlin 日常开发常用语法糖整理 —— 速记
android·kotlin·android jetpack
我命由我1234516 天前
Android 开发问题:EditText 控件的 android:imeOptions=“actionDone“ 属性不生效
android·java·java-ee·android studio·android jetpack·android-studio·android runtime