使用 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 开发者,热衷于探索新技术和分享开发经验。如果你对这个项目有任何问题或建议,欢迎通过以下方式联系我:

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

相关推荐
EnzoRay14 小时前
DataBinding的使用
android jetpack
QING6181 天前
简单说下Kotlin 作用域函数中 apply 和 also 为什么不能空安全调用?
android·kotlin·android jetpack
我命由我123452 天前
Android 控件 - 悬浮常驻文本交互(IBinder 实现、BroadcastReceiver 实现)
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
我命由我123452 天前
Android Jetpack Compose - enableEdgeToEdge 函数、MaterialTheme 函数、remember 函数
android·java·java-ee·kotlin·android studio·android jetpack·android-studio
林栩link2 天前
【车载Android】多媒体开发入门(上) - MediaSession
android·android jetpack
用户69371750013843 天前
谷歌官方推荐:Android 性能优化全攻略——从工具到实战,两周提升 App 评分
android·android studio·android jetpack
ljt27249606613 天前
Compose笔记(六十六)--ModalNavigationDrawer
android·笔记·android jetpack
ljt27249606614 天前
Compose笔记(六十七)--LookaheadScope
android·笔记·android jetpack
alexhilton7 天前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack