Compose中的CameraX二维码扫描器

本文译自「Goodbye AndroidView: A Real CameraX QR Scanner in Compose」,原文链接levelup.gitconnected.com/goodbye-and...,由James Cullimore发布于2026年1月14日。

在最近的一个项目中,我们需要添加一个二维码扫描器,以便用户快速连接到 Wi-Fi 网络。这并非一个新问题。多年来,我多次实现过 Wi-Fi 二维码扫描,理论上来说,这应该很简单:扫描二维码,提取 SSID 和密码,然后建立连接。

但实际上,它总是让人有点沮丧。即使使用 Jetpack Compose,基于相机的功能几乎总是不可避免地将我拉回 AndroidView。这感觉像是一种倒退,尤其是在一个完全由 Compose 驱动的屏幕上。每次我重新审视这个问题时,我都会问自己同一个问题:这真的是我们能做到的最好结果吗?

最近,答案终于改变了。

随着 CameraX Compose 组件和 CameraXViewfinder 的引入,现在可以构建完整的 Compose 原生相机体验,而无需回退到视图互操作。问题不在于功能,而在于文档。虽然 API 已经存在,但找到一个清晰、贴近实际应用且超越演示的示例却并非易事。

在偶然发现 Jolanda Verhoef 的一篇简短但极其有用的 gist(gist.github.com/JolandaVerh...

本文将详细介绍这一过程。我们将使用最新的 CameraX Compose API 构建一个二维码扫描器屏幕,使用 ML Kit 分析 Wi-Fi 二维码,并在完全不修改 AndroidView 的情况下连接到网络。如果你之前因为 Compose 的相机功能不够完善或操作繁琐而一直避免使用,那么这篇文章正是你所需要的。

为什么 Compose 中的 Wi-Fi 二维码扫描曾经令人烦恼

长期以来,在 Jetpack Compose 中构建基于相机的功能都存在一个隐性的妥协。即使你的 UI 其他部分完全是声明式的,相机预览本身几乎总是位于 AndroidView 中。CameraX 虽然成熟可靠,但它与 Compose 的集成却相对滞后。

这导致了一些反复出现的问题。

首先,思维模式出现了问题。Compose 鼓励在可组合层实现状态驱动的 UI 和生命周期感知,但 AndroidView 又将命令式的设置、回调和视图生命周期问题带了回来。虽然可以勉强实现,但代码往往感觉像是拼凑而成,而不是一个整体。

其次,手势处理变得很麻烦。诸如点击聚焦、叠加层或动画等操作都需要 Compose 和底层 PreviewView 之间进行精细的协调。调试触摸偏移和坐标转换曾经是件麻烦事,而且往往令人头疼。

此外,这些实现方式也容易过时。每次 CameraX 或 Compose 更新,都需要重新编写粘合代码。最初只是一个小小的互操作模块,最终却往往变成了屏幕上最脆弱的部分之一。

对于二维码扫描来说,这种繁琐的操作显得尤为不必要。它的使用场景很简单:显示相机预览、分析帧、对结果做出反应。然而,实现的复杂度却与要解决的问题不成比例。

正因如此,CameraX Compose 支持的引入才显得意义重大。它彻底消除了对视图互操作的需求,使相机预览能够像其他可组合元素一样运行。布局、手势、生命周期和状态再次统一在一个心智模型中。

下一节,我们将探讨究竟发生了哪些变化,以及哪些 CameraX Compose API 最终实现了这一切。

CameraX Compose 取代 AndroidView

Compose 中基于相机的功能真正迎来转折点,这要归功于 CameraX Compose artifacts 的引入,更重要的是,还引入了 CameraXViewfinder。现在,我们不再需要在 AndroidView 中嵌入 PreviewView,而是可以直接使用 SurfaceRequest 并将其渲染为可组合组件。

这是一个细微但重要的转变。

相机预览不再被视为不透明视图,而是成为 UI 中的一部分。它可以自然地参与 Compose 布局,可以进行裁剪、分层、动画处理,并且无需特殊处理即可响应指针输入。无需再绕过视图层级边界。

从总体上看,流程如下:

  • CameraX 生成一个 SurfaceRequest 请求

  • 可组合组件使用 CameraXViewfinder 收集并渲染该请求

  • 相机用例通过正常的生命周期感知进行绑定和解绑定

  • 手势输入和叠加层完全位于 Compose 组件中

这种方法也有助于更好地分离关注点。相机设置和绑定可以放在一个 ViewModel 中,而可组合组件则专注于渲染状态和处理用户交互。这种分离对于二维码扫描尤其重要,因为二维码扫描的帧分析和 UI 反馈是独立演进的。

在我们即将介绍的实现中,相机预览由一个小型、专注的 CameraPreviewViewModel 驱动。它公开了一个 StateFlow<SurfaceRequest?>,UI 只需收集并显示该状态流即可。没有视图互操作,可组合组件本身也没有命令式连接。

有了这些基础,我们就可以开始查看实际的屏幕了。接下来,我们将详细分析二维码扫描器屏幕的整体架构,以及可组合组件和视图模型之间的职责划分。

二维码扫描器屏幕的高级架构

在深入探讨摄像头设置或二维码分析之前,我们不妨先回顾一下屏幕的结构。这不仅仅是一个摄像头演示,而是一个实际的生产环境屏幕,它需要处理权限、生命周期变更、UI 状态、错误处理和导航等功能。

从顶层来看,屏幕主要分为三个职责:

  1. 导航和屏幕入口点

  2. Wi-Fi 相关状态和业务逻辑

  3. 摄像头预览和二维码分析

这种职责划分直接体现在可组合组件中。

第一个入口点是一个与导航集成的简单包装器:

kotlin 复制代码
@Composable
fun QrScannerScreen(navController: NavHostController, onGoToSettings: () -> Unit) {
   QrScannerScreen(
       onBack = { navController.popBackStack() },
       onGoToSettings = onGoToSettings
   )
}

这个函数的功能只有一个:将导航相关的逻辑转换为简单的回调函数。这样做,屏幕的其他部分就无需感知 NavHostController,从而使 UI 更易于预览、测试和重用。

屏幕的第二层处理权限、生命周期感知和 Wi-Fi 连接状态。WiFiViewModel 在此层使用,用于请求相机权限,并将二维码扫描结果转换为连接尝试。此可组合层拥有逻辑,但不拥有相机渲染本身。

最后,最底层完全专注于 UI。它渲染脚手架、相机卡、加载状态,并通过简单的回调将扫描结果向上层发送。此层不了解 Wi-Fi、权限或导航。

这种分离是有意为之。

通过将相机渲染与业务逻辑隔离,可以更轻松地独立地分析每个部分。相机代码往往对生命周期变化和线程敏感,而 Wi-Fi 逻辑会随着平台 API 和用户流程的演变而发展。将它们混合在一起会使两者都难以维护。

下一节,我们将更深入地了解权限和生命周期事件的处理方式,以及它们对于摄像头驱动的屏幕为何如此重要。

权限、生命周期和 UI 状态处理

相机界面的运行取决于生命周期。如果缺少权限,甚至无法启动。如果应用进入后台后再返回,则需要重新检查状态。如果扫描二维码触发 Wi-Fi 连接尝试,UI 必须立即做出响应。

在你的实现中,这项职责位于可组合的中间层。这里需要整合以下功能:

  • 相机权限处理(通过 Accompanist 权限)

  • 生命周期观察(ON_RESUME

  • WiFiViewModel 驱动的 Wi-Fi 连接状态

  • UI 响应(成功时返回上一级,失败时显示对话框)

  • 将干净的 onAnalyze 回调函数传递到相机层

以下是该层的代码,未做任何修改:

kotlin 复制代码
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun QrScannerScreen (
   onBack: () -> Unit,
   onGoToSettings: () -> Unit,
) {
   val viewModel: WiFiViewModel = viewModel()
   val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
   var password by remember { mutableStateOf("") }
   var ssid by remember { mutableStateOf("") }
   var security by remember { mutableStateOf(SecurityType.UNKNOWN) }

   val showConnectionFailedDialog = remember { mutableStateOf(false) }

   val wifiManager = LocalContext.current.getSystemService(Context.WIFI_SERVICE) as WifiManager
   val uiState: WiFiUiState by viewModel.uiState.collectAsState()

   val lifecycleOwner = LocalLifecycleOwner.current
   DisposableEffect(lifecycleOwner) {
       val observer = LifecycleEventObserver { _, event ->
           if (event == Lifecycle.Event.ON_RESUME) {
               if (!permissionState.status.isGranted) {
                   permissionState.launchPermissionRequest()
               }
               viewModel.isConnected(ssid, wifiManager)
           }
       }
       lifecycleOwner.lifecycle.addObserver(observer)
       onDispose {
           lifecycleOwner.lifecycle.removeObserver(observer)
       }
   }

   uiState.connectionState?.let {
       when {
           it == ConnectionState.CONNECTED -> onBack.invoke()
           !showConnectionFailedDialog.value && it == ConnectionState.FAILED -> showConnectionFailedDialog.value = true
           else -> { /* do nothing */ }
       }
   }

   when {
       showConnectionFailedDialog.value -> ConnectionFailedDialog(
           onConnectViaSettings = {
               showConnectionFailedDialog.value = false
               viewModel.setConnectionState(ConnectionState.NONE)
               onGoToSettings.invoke()
           },
           onDismissRequest = {
               showConnectionFailedDialog.value = false
               viewModel.setConnectionState(ConnectionState.NONE)
           }
       )
   }

   QrScannerScreen(
       connecting = uiState.connectionState == ConnectionState.CONNECTING,
       onBack = onBack,
       onAnalyze = {
           ssid = it.ssid ?: ""
           password = it.password ?: ""
           security = when (it.encryptionType) {
               Barcode.WiFi.TYPE_OPEN -> SecurityType.OPEN
               Barcode.WiFi.TYPE_WEP -> SecurityType.WEP
               Barcode.WiFi.TYPE_WPA -> SecurityType.WPA
               else -> SecurityType.UNKNOWN
           }
           viewModel.suggestWiFi(wifiManager, ssid, password.ifBlank { null }, security)
       }
   )
}

为什么这种方法有效

权限检查在正确的时间进行。 你请求了 Manifest.permission.CAMERA,但你是在 ON_RESUME 事件中执行的。这一点很重要,因为用户可能会跳转到"设置"再返回,或者先拒绝一次,之后再接受。如果仅在首次合成时进行一次检查,通常会错过这些流程。

生命周期观察是显式的,并且有作用域限制。

使用 DisposableEffect(lifecycleOwner) 可以将观察者与当前生命周期所有者绑定,并确保在 onDispose 中完成清理。这可以避免经典的"导航后观察者持续触发"的错误。

UI 会根据连接状态做出反应,而不是猜测。

与其假设扫描成功,不如让 uiState.connectionState 来决定结果:

  • CONNECTED 状态会返回上一级页面

  • FAILED 状态会打开一次对话框

  • CONNECTING 状态会成为下一个可组合组件的 UI 加载状态

二维码结果会立即翻译成应用程序语言。

Barcode.WiFi 会提供原始字段。你需要对它们进行规范化处理:

  • ssidpassword 存储在本地

  • encryptionType 映射到你自定义的 SecurityType

  • 连接尝试由 suggestWiFi(...) 触发

这样可以保持摄像头层的简洁。摄像头无需了解"连接到 Wi-Fi"的具体含义,它只需报告所看到的信息。

使用 CameraXViewfinder 进行相机预览

现在我们来看看之前强制使用 AndroidView 的部分。

这一层的设计非常简洁。它不了解 Wi-Fi 是什么,也不管理权限,更不会决定如何处理扫描结果。它只是一个用于显示相机预览、处理点击事件并将 Barcode.WiFi 结果向上报告的 UI。

首先,渲染一个包含简单顶部返回按钮、一些说明文字以及一个卡片的框架,卡片中包含进度指示器或实际的相机内容:

kotlin 复制代码
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
@Composable
fun QrScannerScreen(
   connecting: Boolean,
   onBack: () -> Unit,
   onAnalyze: (Barcode.WiFi) -> Unit
) {
   Scaffold (
       topBar = {
           Column(
               modifier = Modifier
                   .padding(top = 32.dp)
           ) {
               IconButton(onClick = onBack) {
                   ScaledIcon(
                       imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
                       contentDescription = stringResource(R.string.back)
                   )
               }
           }
       }
   ) { paddingValues ->
       Column(
           modifier = Modifier
               .padding(paddingValues)
               .padding(horizontal = 16.dp, vertical = 16.dp)
       ) {
           Spacer(modifier = Modifier.weight(0.75f))

           Text(
               text = stringResource(R.string.join_wifi_scanning_qr),
               style = MaterialTheme.typography.titleMedium,
               modifier = Modifier.fillMaxWidth(),
               textAlign = TextAlign.Center
           )

           Spacer(modifier = Modifier.height(16.dp))

           ElevatedCard(
               shape = RoundedCornerShape(16.dp),
               elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
               modifier = Modifier
                   .fillMaxWidth()
                   .height(320.dp)
           ) {
               Box(modifier = Modifier.fillMaxSize()) {
                   if (connecting) {
                       CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                   } else {
                       val viewModel = remember { CameraPreviewViewModel() }
                       CameraPreviewContent(viewModel, onAnalyze)
                   }
               }
           }
           Spacer(modifier = Modifier.weight(1f))
       }
   }
}

这一层的优势

相机预览位于普通的 Compose 布局中。

它只是 ElevatedCard 中的内容。这意味着它的行为与其他可组合元素一样。圆角、内边距、覆盖层、加载状态,所有这些都在同一个 UI 系统中。

连接状态是可视化的,而非隐式的。

connecting 标志由你的 WiFiUiState 驱动,UI 会如实显示当前状态。当扫描触发连接尝试时,你会显示进度指示器,而不是继续扫描。

相机逻辑被移至一个专用组件。

预览本身委托给 CameraPreviewContent(...),其中就用到了 CameraXViewfinder。这样可以保持屏幕结构的可读性,并方便在不影响屏幕其他部分的情况下切换预览行为。

这里需要注意的一点是,你使用的是 CameraPreviewViewModel,但它并非通常的 viewModel() 调用。你是通过以下方式创建它的:

kotlin 复制代码
val viewModel = remember { CameraPreviewViewModel() }

这样可以正常工作,并且符合你的预期:这是一个用于存放相机资源和状态的小型屏幕级组件,而不是一个应用级的模型。它在每个合成中只创建一次,并且只要该 UI 部分保持活动状态就会一直保留。

接下来,我们将深入了解 CameraPreviewContent,看看 SurfaceRequest 是如何被收集并使用 CameraXViewfinder 进行渲染的。

使用 CameraPreviewContent 渲染 SurfaceRequest

这就是现代 Compose 优先相机流程的真正体现。

你的 UI 不再嵌入 PreviewView,而是从 StateFlow 中获取一个 SurfaceRequest 并将其传递给 CameraXViewfinder。CameraX 提供 Surface,Compose 进行渲染,所有操作都保留在相同的输入和布局系统中。

以下是实现此功能的可组合组件:

kotlin 复制代码
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
@Composable
fun CameraPreviewContent(
   viewModel: CameraPreviewViewModel,
   onAnalyze: (Barcode.WiFi) -> Unit,
   lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
) {
   val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()
   val context = LocalContext.current
   LaunchedEffect(lifecycleOwner, onAnalyze) {
       viewModel.bindToCamera(context.applicationContext, lifecycleOwner, onAnalyze)
   }

   var autofocusRequest by remember { mutableStateOf(UUID.randomUUID() to Offset.Unspecified) }
   val currentOnTapToFocus by rememberUpdatedState(viewModel::tapToFocus)
   val autofocusRequestId = autofocusRequest.first
   val showAutofocusIndicator = autofocusRequest.second.isSpecified

   if (showAutofocusIndicator) {
       LaunchedEffect(autofocusRequestId) {
           delay(1000)
           autofocusRequest = autofocusRequestId to Offset.Unspecified
       }
   }


   surfaceRequest?.let { request ->
       val coordinateTransformer = remember { MutableCoordinateTransformer() }
       CameraXViewfinder(
           surfaceRequest = request,
           coordinateTransformer = coordinateTransformer,
           modifier = Modifier.pointerInput(coordinateTransformer) {
               detectTapGestures { tapCoords ->
                   with(coordinateTransformer) {
                       currentOnTapToFocus(tapCoords.transform())
                   }
                   autofocusRequest = UUID.randomUUID() to tapCoords
               }
           }
       )
   }
}

这里发生了什么

预览由状态驱动 这行代码是关键:

kotlin 复制代码
val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()

当 CameraX 准备好传输帧时,CameraPreviewViewModel 会发布一个 SurfaceRequest。UI 不会主动获取它,而是在它出现时做出响应。

相机绑定具有生命周期感知能力,但仍是 Compose 原生功能

你可以在 LaunchedEffect 中进行绑定:

kotlin 复制代码
LaunchedEffect(lifecycleOwner, onAnalyze) {
    viewModel.bindToCamera(context.applicationContext, lifecycleOwner, onAnalyze)
}

这意味着:

  • 当此可组合组件变为活动状态时,绑定运行

  • 如果生命周期所有者发生更改(例如,导航或配置),则效果会干净地重新启动

  • 绑定调用是一个挂起函数,非常适合在此处使用

**CameraXViewfinder** 渲染相机画面,无需视图互操作

一旦请求存在:

kotlin 复制代码
surfaceRequest?.let { request ->
    ...
    CameraXViewfinder(
        surfaceRequest = request,
        coordinateTransformer = coordinateTransformer,
        modifier = ...
    )
}

这是 AndroidView(PreviewView) 的替代方案。

点击聚焦功能完全保留在 Compose 输入框中 你可以将 pointerInput 直接附加到取景器,并使用 detectTapGestures 处理点击事件。关键在于坐标转换:

kotlin 复制代码
with(coordinateTransformer) {
    currentOnTapToFocus(tapCoords.transform())
}

这是过去在互操作中令人头疼的细节之一。现在,它变得明确、可控,并且仅限于需要它的 UI 内部。

自动对焦指示器的底层逻辑已经实现

你可以通过以下方式跟踪点击坐标:

kotlin 复制代码
var autofocusRequest by remember { mutableStateOf(UUID.randomUUID() to Offset.Unspecified) }

并在延迟后清除它们。即使这段代码片段尚未绘制指示器,但状态已经具备支持指示器的功能。这是一个很好的模式:将交互状态与输入处理保持紧密联系,即使视觉效果稍后才会出现。

接下来,我们将深入研究 CameraPreviewViewModel,了解它如何创建预览用例、发布 SurfaceRequest、绑定生命周期以及连接用于二维码检测的图像分析。

绑定 CameraX 用例并发布 SurfaceRequest

CameraPreviewViewModel 是核心组件。它负责两项关键任务:

  • 创建一个 CameraX 预览用例,并将其 SurfaceRequest 作为状态暴露给 Compose。

  • 将相机绑定到一个生命周期,并设置一个 ImageAnalysis 管道,将二维码扫描结果反馈给 UI。

以下是你的实现,无需修改:

kotlin 复制代码
@ExperimentalCamera2Interop
class CameraPreviewViewModel : ViewModel() {
   private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
   val surfaceRequest: StateFlow<SurfaceRequest?> = _surfaceRequest
   private var surfaceMeteringPointFactory: SurfaceOrientedMeteringPointFactory? = null
   private var cameraControl: CameraControl? = null

   private val cameraPreviewUseCase = CameraPreview.Builder()
       .build().apply {
           setSurfaceProvider { newSurfaceRequest ->
               _surfaceRequest.update { newSurfaceRequest }
               surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory(
                   newSurfaceRequest.resolution.width.toFloat(),
                   newSurfaceRequest.resolution.height.toFloat()
               )
           }
       }

   suspend fun bindToCamera(appContext: Context, lifecycleOwner: LifecycleOwner, onAnalyze: (Barcode.WiFi) -> Unit) {
       val processCameraProvider = ProcessCameraProvider.awaitInstance(appContext)

       val cameraExecutor = Executors.newSingleThreadExecutor()
       val imageAnalysis = ImageAnalysis.Builder()
           .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
           .build().apply {
               setAnalyzer(cameraExecutor, QrCodeAnalyzer { qrCode ->
                   qrCode.wifi?.let { wifi -> onAnalyze(wifi) }
               })
           }

       val camera = processCameraProvider.bindToLifecycle(
           lifecycleOwner,
           DEFAULT_BACK_CAMERA,
           cameraPreviewUseCase,
           imageAnalysis
       )
       cameraControl = camera.cameraControl

       try {
           awaitCancellation()
       } finally {
           processCameraProvider.unbindAll()
           cameraControl = null
           cameraExecutor.shutdown()
       }
   }

   fun tapToFocus(tapCoords: Offset) {
       val point = surfaceMeteringPointFactory?.createPoint(tapCoords.x, tapCoords.y)
       if (point != null) {
           val meteringAction = FocusMeteringAction.Builder(point).build()
           cameraControl?.startFocusAndMetering(meteringAction)
       }
   }
}

SurfaceRequest 发布到 Compose

这是 Compose 集成的核心:

kotlin 复制代码
setSurfaceProvider { newSurfaceRequest ->
    _surfaceRequest.update { newSurfaceRequest }
    surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory(
        newSurfaceRequest.resolution.width.toFloat(),
        newSurfaceRequest.resolution.height.toFloat()
    )
}

你无需为 CameraX 提供一个用于绘制的视图,而是通过 StateFlow 暴露 SurfaceRequest。UI 会收集它并将其传递给 CameraXViewfinder。这就是视图互操作的完整替代方案。

同时,你需要获取预览分辨率并构建一个 SurfaceOrientedMeteringPointFactory。这对于后续的点击对焦功能至关重要,因为测光点必须在 CameraX 期望的坐标空间中创建。

将预览和分析绑定在一起

bindToCamera(...) 中,你需要组合两个用例:

  • cameraPreviewUseCase 用于实时预览

  • imageAnalysis 用于二维码解码

分析用例配置为仅保留最新帧:

kotlin 复制代码
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)

这正是二维码扫描所需要的。解码过时的帧没有任何意义,而且你也不希望在用户移动手机时出现分析延迟。

然后,你附加一个分析器,并通过 onAnalyze 将结果返回:

kotlin 复制代码
setAnalyzer(cameraExecutor, QrCodeAnalyzer { qrCode ->
    qrCode.wifi?.let { wifi -> onAnalyze(wifi) }
})

注意这里的边界:分析器会生成一个二维码模型,并且只有在存在 Wi-Fi 有效负载时才转发它。这保持了 UI 契约的简洁性:onAnalyze 只接收 Barcode.WiFi

awaitCancellation() 和保证的清理

这种模式为你做了很多工作:

kotlin 复制代码
try {
    awaitCancellation()
} finally {
    processCameraProvider.unbindAll()
    cameraControl = null
    cameraExecutor.shutdown()
}

由于 bindToCamera 是从 LaunchedEffect 中调用的,因此当可组合对象离开组合时,它将被取消。awaitCancellation() 会一直挂起,直到取消发生。取消发生后,你会立即在 finally 中解除相机绑定并关闭执行器。

这意味着你可以获得可预测的清理,而无需第二个回调或显式的"停止相机"API。

点击聚焦

聚焦代码刻意保持简洁:

kotlin 复制代码
val point = surfaceMeteringPointFactory?.createPoint(tapCoords.x, tapCoords.y)
if (point != null) {
    val meteringAction = FocusMeteringAction.Builder(point).build()
    cameraControl?.startFocusAndMetering(meteringAction)
}

你只需要两个要素:

  • 基于预览表面分辨率创建的测光点工厂

  • 来自绑定相机的 CameraControl 引用

其他所有代码都保留在 Compose 输入处理中,这才是它们应该在的地方。

接下来,我们将深入探讨二维码解码本身:QrCodeAnalyzer 的预期功能、ML Kit 的集成方式,以及将原始条形码转换为 Wi-Fi 连接流程时需要注意的事项。

使用 ML Kit 和 QrCodeAnalyzer 进行二维码分析

到目前为止,我们一直在努力实现 Compose 原生预览并绑定正确的 CameraX 用例。二维码扫描部分在 ImageAnalysis 设置中仅通过一个很小的代码实现:

kotlin 复制代码
setAnalyzer(cameraExecutor, QrCodeAnalyzer { qrCode ->
    qrCode.wifi?.let { wifi -> onAnalyze(wifi) }
})

即使不查看 QrCodeAnalyzer 的实现,这行代码也足以说明设计思路。

你构建的契约非常清晰

你的 UI 不关心原始二维码有效载荷、二维码格式或条形码的一般内容。它只关心一件事:扫描的二维码是否包含 Wi-Fi 有效载荷?

ML Kit 的条形码 API 可以返回多种条形码类型和数据格式,但 Wi-Fi 二维码是 Barcode.WiFi 提供的首选支持类型。分析器负责完成繁重的计算工作,然后返回 wifi 存在或不存在的结果。

这样就形成了一个非常简洁的接口:

  • 输入摄像头帧

  • 输出二维码模型

  • 只将 Barcode.WiFi 传递给 onAnalyze

为什么 STRATEGY_KEEP_ONLY_LATEST 是正确的选择

你这样配置了图像分析:

kotlin 复制代码
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)

对于二维码扫描,这正是你所需要的。如果某些设备的解码时间稍长,你不希望存在旧帧队列。你希望在分析器准备就绪时,能够立即获取最新帧。Android 的 CameraX 文档明确指出,STRATEGY_KEEP_ONLY_LATEST 是用于分析的非阻塞方法。

QrCodeAnalyzer 的典型功能

你的分析器需要将 CameraX 的 ImageProxy 帧桥接到 ML Kit 的 BarcodeScanner 流水线中:

  • ImageProxy 转换为 ML Kit 的 InputImage

  • 调用 barcodeScanner.process(image)

  • 过滤掉 Wi-Fi 有效载荷

  • 可靠地关闭 ImageProxy,以避免分析停滞

ML Kit 的条形码扫描文档描述了如何通过 BarcodeScanning.getClient(...) 创建扫描器、创建 InputImage 以及通过扫描器处理图像。

可选的官方代码片段(ML Kit 设置)

你提供的代码中没有包含 QrCodeAnalyzer,因此我不会再编写它。为了更好地理解其内容,以下是官方文档中 ML Kit 的核心设置模式,仅供参考:

kotlin 复制代码
val scanner = BarcodeScanning.getClient()

ML Kit 还建议,在可以限制格式以获得更佳性能时,使用 getClient(BarcodeScannerOptions)

关于 Wi-Fi 二维码的实用说明

你之前的映射逻辑依赖于 ML Kit 的 Wi-Fi 加密类型常量:

  • Barcode.WiFi.TYPE_OPEN

  • Barcode.WiFi.TYPE_WEP

  • Barcode.WiFi.TYPE_WPA

这段逻辑的位置恰到好处:应该放在将扫描结果转换为应用程序行为的屏幕层,而不是相机层。

点击对焦和坐标转换

点击对焦功能听起来很简单,但真正上手之后就会发现并非如此。它需要同时处理至少三个坐标系:

  • 拍摄模式下的点击位置

  • 预览表面的坐标

  • 相机测光坐标系

如果使用 AndroidView,很多计算就只能靠猜测。你的实现很简洁,因为它明确地进行了坐标转换,并且每个步骤都与对应的代码紧密相关。

捕获取景器上的点击事件

你将指针输入直接附加到 CameraXViewfinder 并监听点击事件。

关键在于,你没有将原始的 tapCoords 传递给相机。你首先使用 MutableCoordinateTransformer 对其进行转换,该转换器专门用于在拍摄模式下的坐标和取景器使用的底层表面坐标之间进行转换。

基于表面分辨率构建测光点工厂

CameraPreviewViewModel 中,一旦 CameraX 向你发送 SurfaceRequest,你就需要创建 SurfaceOrientedMeteringPointFactory

kotlin 复制代码
CameraXViewfinder(
    surfaceRequest = request,
    coordinateTransformer = coordinateTransformer,
    modifier = Modifier.pointerInput(coordinateTransformer) {
        detectTapGestures { tapCoords ->
            with(coordinateTransformer) {
                currentOnTapToFocus(tapCoords.transform())
            }
            autofocusRequest = UUID.randomUUID() to tapCoords
        }
    }
)

这个工厂用于将点击位置转换为相机可用的 MeteringPoint。基于表面分辨率构建测光点是 CameraX 中点击对焦的常见模式。

使用 CameraControl 触发对焦和测光

获得测光点后,你需要创建一个 FocusMeteringAction 并将其传递给 cameraControl.startFocusAndMetering(...)

kotlin 复制代码
fun tapToFocus(tapCoords: Offset) {
    val point = surfaceMeteringPointFactory?.createPoint(tapCoords.x, tapCoords.y)
    if (point != null) {
        val meteringAction = FocusMeteringAction.Builder(point).build()
        cameraControl?.startFocusAndMetering(meteringAction)
    }
}

CameraX 文档明确指出,使用 FocusMeteringActionstartFocusAndMetering() 是实现点击对焦的基础。

developer.android.com/media/camer...

为什么这是一个可靠的"Compose优先"方案

  • Compose负责手势及其对应的UI状态。

  • 取景器负责坐标变换。

  • 视图模型负责相机控制和测光逻辑。

没有哪个组件"什么都做一点",而这通常是相机代码开始变得臃肿的原因。

文章中需要提及的一个小注意事项:对焦精度很大程度上取决于正确的坐标转换和预览缩放。Compose取景器和Transformer组件旨在处理这种转换,这也是为什么这种方法比手动连接要好得多的重要原因之一。

连接状态、用户体验和生产环境考量

一旦摄像头预览和分析流程正常运行,接下来的问题就不再是摄像头本身的问题,而是产品本身的问题了。

二维码扫描速度很快,但会产生大量噪声,而且重复性很高。摄像头画面会连续检测到相同的二维码,如果每次检测到都做出响应,很容易导致连接流程被频繁触发。

你的代码已经暗示了正确的方法:将扫描视为一个触发器,然后将用户界面移至一个受控的"连接尝试"状态。

连接时停止扫描

在你的用户界面层,你有一个清晰的门控机制:

kotlin 复制代码
if (connecting) {
    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
    val viewModel = remember { CameraPreviewViewModel() }
    CameraPreviewContent(viewModel, onAnalyze)
}

uiState.connectionState == ConnectionState.CONNECTING 时,摄像头预览甚至还没有被创建。这意味着绑定摄像头的 LaunchedEffect 会被取消,而 awaitCancellation() 会确保摄像头被解除绑定,并且执行器被关闭。这正是生产流程中所需要的:一旦获得有效的 Wi-Fi 有效载荷,扫描器就会失效,直到连接成功或失败。

这是演示扫描器和实际扫描器之间最大的区别之一。演示扫描器会一直扫描下去,而实际扫描器会切换到下一个用户意图。

正确处理"设置跳转"

你的生命周期观察器会在 ON_RESUME 时检查权限状态和连接状态:

kotlin 复制代码
if (event == Lifecycle.Event.ON_RESUME) {
    if (!permissionState.status.isGranted) {
        permissionState.launchPermissionRequest()
    }
    viewModel.isConnected(ssid, wifiManager)
}

这一点很重要,因为用户经常会跳转:

  • 他们拒绝相机权限

  • 你引导他们进入设置

  • 他们返回

  • 屏幕需要立即重新检查并继续流程

在恢复时执行此操作对于这些实际循环来说是最佳时机。

避免重复扫描导致的重复连接尝试

目前,你的分析器会转发它检测到的每个 Wi-Fi 有效负载:

kotlin 复制代码
qrCode.wifi?.let { wifi -> onAnalyze(wifi) }

这没问题,因为一旦状态变为 CONNECTING,你的 UI 层就会有效地禁用扫描。但仍然存在一个很小的例外情况:在 UI 状态切换之前,你可能会收到多个回调。

一种常见的生产模式是在处理 onAnalyze 的层中添加一个简单的节流或"首次有效扫描优先"的保护机制。你无需为此更改相机管道。你已经拥有的清晰边界使得后续添加变得容易。

如果你决定在文章中提及这一点,请这样表述:

  • 保持分析器的简洁性和速度

  • 在业务逻辑所在的位置控制扫描频率

保持分析的非阻塞性和响应性

你对以下代码的使用:

kotlin 复制代码
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)

是生产环境中友好的默认设置。 CameraX 将此描述为非阻塞方法,分析器会接收最新可用的帧,而不是积压帧。

对于二维码扫描,这正是用户所期望的行为。如果用户移动摄像头,扫描器应该对当前视野中的内容做出反应,而不是对半秒前视野中的内容做出反应。

使清理操作可预测

这一点很容易被忽略,直到遇到特定于设备的摄像头问题。你的清理操作是明确的,并且与取消操作相关联:

kotlin 复制代码
try {
    awaitCancellation()
} finally {
    processCameraProvider.unbindAll()
    cameraControl = null
    cameraExecutor.shutdown()
}

对于基于 Compose 的摄像头代码来说,这是一个可靠的模式。你不会泄漏执行器,也不会在导航后保持摄像头绑定。

预期存在不完美的二维码

Wi-Fi 二维码通常得到很好的支持,ML Kit 的 Barcode.WiFi 直接暴露了 ssidpasswordencryptionType

但实际上,你仍然会遇到以下情况:

  • 开放网络的空白密码

  • 第三方二维码生成器生成的缺失值或异常值

  • 无法正确映射的加密类型

你的逻辑已经考虑到了这些问题:

kotlin 复制代码
password.ifBlank { null }

以及

kotlin 复制代码
else -> SecurityType.UNKNOWN

这些小的防御措施可以防止扫描器变得脆弱。

结论

我希望这个功能实现的很简单:扫描 Wi-Fi 二维码,提取凭据,并帮助用户连接。真正令人沮丧的并非 ML Kit 或 CameraX 本身,而是现代 Compose 界面仍然需要使用 AndroidView 来实现像相机预览这样常见的功能。

现在情况终于改变了。

通过围绕 SurfaceRequest 构建预览并使用 CameraXViewfinder 进行渲染,相机不再是一个特殊情况,而是变成了一个可组合的 UI。手势和状态都保留在 Compose 中,生命周期清理也变得可预测,因为它由取消操作驱动,而不是临时的清理代码。

最终得到的界面更易于维护和理解。顶层可组合组件负责处理导航、权限和连接状态。相机层只负责一项工作:显示预览并输出扫描结果。而视图模型则承载着绑定用例、发布 Surface 请求以及支持点击聚焦所需的底层机制。

如果你想在实际项目中查看其集成效果,包括其随时间推移的演变过程,完整的实现代码位于我的一个开源项目中。本文中展示的二维码扫描器界面直接取自该代码库,你可以直接使用或根据自身需求进行修改:ScannerScreen

如果你之前因为 Compose 中的相机功能显得杂乱或不完善而一直避免使用,那么本文将为你呈现第一个真正契合的方案。无需互操作脚手架,无需担心生命​​周期冲突,也无需对视图层级结构做出任何妥协。它只是一个与你的其他 UI 元素完美兼容的相机预览。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
eric*16884 小时前
Android15 enableEdgeToEdge 全面屏沉浸式体验
android·edgetoedge
小智社群6 小时前
小米安卓真机ADB对硬件操作
android·adb
嗷o嗷o6 小时前
Android BLE 为什么连上了却收不到数据
android
pengyu6 小时前
【Kotlin 协程修仙录 · 炼气境 · 后阶】 | 划定疆域:CoroutineScope 与 Android 生命周期的绑定艺术
android·kotlin
朝星6 小时前
Android开发[5]:组件化之路由+注解
android·kotlin
随遇丿而安6 小时前
Android全功能终极创作
android
随遇丿而安6 小时前
第1周:别小看 `TextView`,它其实是 Android 页面里最常被低估的组件
android
summerkissyou198710 小时前
Android-基础-SystemClock.elapsedRealtime和System.currentTimeMillis区别
android
ian4u10 小时前
车载 Android C++ 完整技能路线:从基础到进阶
android·开发语言·c++