跳到主要内容

第 26 章:异步相机编程

摘要

回头看看你在第 7 章到第 9 章中编写的代码:CameraDevice.StateCallback 嵌套在 CameraManager.openCamera 内部,CameraCaptureSession.StateCallback 又嵌套在 onOpened 内部,CaptureCallback 嵌套在 onConfigured 内部,ImageReader.OnImageAvailableListener 在你手动开启的 HandlerThread 上触发,且在每条错误路径上都必须按完全相反的顺序进行拆除。这就是典型的相机版回调地狱。每个缩进层级都是一个新的回调类。每个错误都必须穿过四层匿名对象进行传递。在拆除路径上漏掉任何一个 close() 调用都会导致相机泄漏,直到设备重启。

本章就是你渴望已久的重构。我们使用两个协程原语将整个回调丛林转换为整洁、线性、可取消且可测试的 Kotlin 代码:使用 suspendCancellableCoroutine 处理单次操作,使用 callbackFlow + Flow 操作符处理连续流。你将学习协程与 Camera2 交互时的线程安全规则,了解为什么在任何相机调用上阻塞主线程都是在等待 ANR 发生,以及为什么你可能尝试过用于 ImageWriter 数据的 PipedOutputStream/PipedInputStream 模式会导致死锁,而 Flow 却能自然地避免这一问题。

一如既往,请通过 Android Camera Parameters (Google Play, GitHub) 验证你的目标硬件性能,以确认你的异步管线所需的性能(重复连拍、部分结果、YUV 重处理)在你的测试设备上确实有提供。


为什么嵌套回调是"回调地狱"

让我们先将问题视觉化。这是在使用协程之前,一个真实的(经过简化的)生产级 RAW Camera2 应用的结构:

每一个阴影部分的回调都是一个独立的匿名类。每一个类都捕获了向上两层资源的引用。每一个错误路径都必须从 J 冒泡回 A,按相反顺序关闭 imageReader → session → cameraDevice → handlerThread,在 16 种错误排列组合中只要漏掉任何一个 close() 调用,都会产生永久的相机泄漏直到设备重启。这就是回调地狱的教科书式定义。

本章的目标就是将这团乱麻变成这样:

线性、可组合、可测试。通过取消父 Job 即可实现取消。每个阶段都是一个普通的函数或 Flow 操作符。同样的五个回调现在活在一个 12 行的线性管线中。


用于单次操作的 Kotlin 协程:suspendCancellableCoroutine

将任何基于回调的 API 包装为 suspend 函数的核心模式是 suspendCancellableCoroutine。步骤始终一致:

  1. 调用 suspendCancellableCoroutine { cont -> ... } 获取一个 CancellableContinuation<T>
  2. 调用真实基于回调的 API,并向其传递一个匿名的回调实现。
  3. 在回调的成功路径中,调用 cont.resume(value)
  4. 在每一条错误路径中,调用 cont.resumeWithException(t)
  5. cont.invokeOnCancellation { ... } 中执行清理工作:关闭相机、取消挂起的请求、注销监听器,确保回调永远不会在协程取消之后触发。
  6. 在调用点使用 withTimeout 包装,以免死掉的 HAL 导致你的应用永远挂起。

示例 1:suspend fun openCameraAwait()

suspend fun CameraManager.openCameraAwait(
cameraId: String,
handler: Handler
): CameraDevice = suspendCancellableCoroutine { cont ->
val callback = object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cont.resume(camera) {
camera.close()
}
}
override fun onDisconnected(camera: CameraDevice) {
cont.resumeWithException(
CameraAccessException(
CameraAccessException.CAMERA_DISCONNECTED,
"相机 $cameraId 在打开过程中断开连接"
)
)
}
override fun onError(camera: CameraDevice, error: Int) {
cont.resumeWithException(
CameraAccessException(error, "相机 $cameraId 错误: $error")
)
}
}

cont.invokeOnCancellation {
try {
// 变通方案:在 API 30 之前 openCamera() 并不暴露可取消的句柄。
// 如果在竞争窗口内打开了设备,则关闭它。
} catch (_: Throwable) { /* 忽略 */ }
}

openCamera(cameraId, callback, handler)
}

为什么这有效。 openCamera 是"触发后即忘"的:你调用它,将来某个时刻三个回调方法之一会恰好触发一次。这种契约("恰好触发一次")正是我们可以将其一对一映射到 continuation 的原因。如果协程在任何回调触发之前被取消,invokeOnCancellation 就会运行并防止资源泄漏。如果在 resume 之后被取消,resume(value) { camera.close() } 块 —— 即 resumeonCancellation 参数 —— 会自动关闭设备。

带超时调用它非常简单:

val cameraDevice: CameraDevice = withTimeoutOrNull(5_000L) {
cameraManager.openCameraAwait(cameraId, cameraHandler)
} ?: run {
Log.w(TAG, "相机打开在 5s 后超时")
return@launch
}

如果 HAL 挂死(在之前的应用导致相机泄漏后,在低端 LEGACY 设备上很常见),这会快速且干净地失败,而不会向用户呈现"应用无响应"对话框。

示例 2:suspend fun createCaptureSessionAwait()

同样的模式,不同的回调:

suspend fun CameraDevice.createCaptureSessionAwait(
outputs: List<OutputConfiguration>,
handler: Handler
): CameraCaptureSession = suspendCancellableCoroutine { cont ->
val callback = object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
cont.resume(session) { session.close() }
}
override fun onConfigureFailed(session: CameraCaptureSession) {
cont.resumeWithException(
IllegalStateException(
"设备 ${this@createCaptureSessionAwait.id} 的会话配置失败"
)
)
}
}

cont.invokeOnCancellation {
// 在旧版 API 上无法取消正在进行的会话创建。
// 会话如果最终通过上面的 resume onCancellation 块完成,则会自动关闭。
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val config = SessionConfiguration(
SessionConfiguration.SESSION_REGULAR,
outputs,
{ r -> handler.post(r) },
callback
)
createCaptureSession(config)
} else {
@Suppress("DEPRECATION")
createCaptureSession(outputs.map { it.surface }, callback, handler)
}
}

这是完全相同的结构。两个 API 版本(S 之前的 createCaptureSession(surfaces, callback, handler) 对比 S 之后的 SessionConfiguration)被封装在一个包装器中。调用者无需关心细节。

示例 3:用于单次捕获的 suspend fun awaitCaptureResult()

suspend fun CameraCaptureSession.captureAwait(
request: CaptureRequest,
handler: Handler
): TotalCaptureResult = suspendCancellableCoroutine { cont ->
val callback = object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
cont.resume(result)
}
override fun onCaptureFailed(
session: CameraCaptureSession,
request: CaptureRequest,
failure: CaptureFailure
) {
cont.resumeWithException(
CaptureFailureException(
"捕获失败: 原因=${failure.reason} 帧=${failure.frameNumber}"
)
)
}
}

cont.invokeOnCancellation {
try { abortCaptures() } catch (_: Throwable) { /* 忽略 */ }
}

try {
capture(request, callback, handler)
} catch (t: Throwable) {
cont.resumeWithException(t)
}
}

这是第 19 章式手动多帧包围曝光的基础 —— 在 for 循环中使用 withTimeoutOrNull 串联 7 个 captureAwait(br[i]) 调用,收集所有 7 个 TotalCaptureResult,你就拥有了一个完整的 HDR 包围序列,具备逐帧超时和取消时自动中止的功能。在回调世界中,这曾是数百行代码的状态机。现在,它只是一个 12 行的 for 循环。


用于连续流的 Flow

单次操作涵盖了相机打开、会话创建和单次捕获。对于重复的操作 —— 每一帧预览、每一个 TotalCaptureResult、来自 ImageReader 的每一个 Image —— 我们希望得到 Flow<T>,以便我们可以使用 mapfilterdebouncecombine 并在订阅者之间共享流。

示例 4:通过 callbackFlow 实现 ImageReader → Flow<Image>

fun ImageReader.imagesFlow(
lifecycleScope: CoroutineScope
): Flow<Image> = callbackFlow {
val listener = ImageReader.OnImageAvailableListener { reader ->
// acquireLatestImage() 会在消费者速度慢于相机生产速度时丢弃旧帧
// 这是避免阻塞 HAL 的强制要求。
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
trySend(image)
}

// 必须首先调用 invokeOnClose,这样即使 setOnImageAvailableListener 本身抛出异常,
// 取消操作也总能移除监听器。
awaitClose {
setOnImageAvailableListener(null, null)
// 此处不要关闭 ImageReader —— 调用者拥有其生命周期。
}

val handlerThread = HandlerThread("ImageReaderFlow").apply { start() }
val handler = Handler(handlerThread.looper)
setOnImageAvailableListener(listener, handler)
}.buffer(Channel.CONFLATED)
.onCompletion {
// 由于 callbackFlow 会在发射后重新抛出失败,
// 任何未被下游收集器消费的 Image 都由我们负责关闭。
}

关键设计选择:

  1. 优先使用 acquireLatestImage() 而非 acquireNextImage()。如果你的图像处理(机器学习推理、人脸检测)耗时 40ms,而相机以 30fps(每帧约 33ms)运行,你肯定会落后。acquireNextImage 会将它们排队直到 gralloc 缓冲区耗尽,相机随之冻结。acquireLatestImage 会跳过旧帧,给你最新鲜的一帧。这几乎总是预览侧图像分析所需要的。

  2. buffer(Channel.CONFLATED)。Conflated 缓冲区仅保留最新值。结合 acquireLatestImage,这是你永远不会排队陈旧帧的硬性保证。

  3. awaitClose { setOnImageAvailableListener(null, null) }。这是 callbackFlow 版的 cont.invokeOnCancellation。取消协程作用域(例如当 Fragment 经历 onDestroyView 时),监听器会自动注销,HandlerThread 也会被清理。绝无泄漏。

示例 5:CaptureCallback → Flow<TotalCaptureResult>

同样的模式:

fun CameraCaptureSession.repeatingResultsFlow(
repeatingRequest: CaptureRequest,
handler: Handler
): Flow<TotalCaptureResult> = callbackFlow {
val callback = object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
trySend(result)
}
override fun onCaptureProgressed(
session: CameraCaptureSession,
request: CaptureRequest,
partial: CaptureResult
) {
// 如果你需要部分结果,可以在单独的通道发射它们
// 或者发送一个密封类。
}
}

awaitClose {
try {
stopRepeating()
abortCaptures()
} catch (_: Throwable) { /* 忽略 */ }
}

setRepeatingRequest(repeatingRequest, callback, handler)
}

现在你拥有了一个冷流 Flow<TotalCaptureResult>,它在被收集时启动重复请求,在取消时停止,发射每一个完成的结果,并兼容所有标准的 Flow 操作符。

示例 6:用于响应式 UI 的 combine(previewFlow, aeStateFlow)

Flow 的真正威力在于组合。假设你的 UI 显示:

  • 实时预览 FPS
  • 当前 AE 状态(正在收敛 / 已收敛 / 已锁定)
  • "准备好拍摄"指示器,仅当 AE 已收敛且 AF 已收敛且 AWB 已收敛时才显示为绿色。

如果不使用 Flow,你需要手写一个合并了 CaptureCallbackChoreographer 的状态机。有了 Flow,只需三行:

data class UiCameraState(
val aeState: Int,
val afState: Int,
val awbState: Int,
val fps: Int,
val ready: Boolean
)

val resultFlow: Flow<TotalCaptureResult> = session
.repeatingResultsFlow(previewRequest, cameraHandler)
.flowOn(cameraDispatcher) // 在主线程之外,不产生 UI 卡顿

val aeStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AE_STATE] ?: CaptureResult.CONTROL_AE_STATE_INACTIVE
}
val afStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AF_STATE] ?: CaptureResult.CONTROL_AF_STATE_INACTIVE
}
val awbStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AWB_STATE] ?: CaptureResult.CONTROL_AWB_STATE_INACTIVE
}
val fpsFlow = resultFlow
.map { it[CaptureResult.SENSOR_TIMESTAMP] }
.runningFold(emptyList<Long>()) { acc, ts ->
(acc + ts).takeLast(30) // 滚动的 30 个时间戳窗口
}
.map { timestamps ->
if (timestamps.size < 2) 0 else {
val windowNs = timestamps.last() - timestamps.first()
1_000_000_000 * (timestamps.size - 1) / windowNs.toInt()
}
}
.debounce(250) // 仅每 250ms 更新一次 FPS 标签,节省电量

val uiState: Flow<UiCameraState> = combine(
aeStateFlow, afStateFlow, awbStateFlow, fpsFlow
) { ae, af, awb, fps ->
val aeConverged = ae in arrayOf(
CaptureResult.CONTROL_AE_STATE_CONVERGED,
CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED,
CaptureResult.CONTROL_AE_STATE_LOCKED
)
val afConverged = af in arrayOf(
CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED,
CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED
)
val awbConverged = awb in arrayOf(
CaptureResult.CONTROL_AWB_STATE_CONVERGED,
CaptureResult.CONTROL_AWB_STATE_LOCKED
)
UiCameraState(ae, af, awb, fps, ready = aeConverged && afConverged && awbConverged)
}

在 Fragment 的 viewLifecycleOwner.lifecycleScope.launchWhenStarted 中收集 uiState,并将每次发射传递给你的 Compose UI 或 viewBinding。每一个操作符 —— maprunningFolddebouncecombine —— 都是标准库原语。无需自定义状态机。无竞态条件。无遗漏事件。取消作用域,上游的每一个 Flow —— 包括重复请求和 ImageReader 监听器 —— 都会恰好停止、取消订阅并清理一次。


线程安全

如果违反了 Camera2 的线程安全规则,上述一切都毫无价值。这里有几条从数百份 ANR 错误报告中提炼出的规则:

  1. 绝对不要从主线程调用任何 Camera2 API。 cameraManager.openCamera() 在 Pixel 7 上看起来可能很快。但在带有 LEGACY HAL 的低成本 Android Go 设备上,它可能阻塞 1.2s。那会导致瞬间的 ANR。甚至像 CameraCharacteristics.get() 这样看起来开销很小的调用,也可能会分配几 KB 的元数据并进行拷贝 —— 在用户切换 Fragment 时的冷启动过程中,这足以导致掉 3 帧。请将一切分发给 Dispatchers.Default 或由 HandlerThread 支持的专用单线程分发器。

  2. HandlerThread 对比 CoroutineDispatcher.Default 对比 Dispatchers.IO

    • 使用单线程分发器(例如 HandlerThread("cam").asCoroutineDispatcher())执行实际的 Camera2 API 调用。许多 LEGACY 设备上的旧版相机堆栈具有线程亲和的 HAL 入口点。在 openCameracreateCaptureSession 之间切换线程会触发高通 msm8953 及更早型号上的已知 HAL bug。
    • 对捕获帧的纯计算(HDR 合并、JPEG 编码、人脸检测)使用 Dispatchers.Default。它的线程数与核心数一致。
    • 对磁盘 I/O(将 JPEG 保存到 MediaStore)使用 Dispatchers.IO。绝对不要在阻塞式写入中使用 Default
  3. 协程与回调之间的共享可变状态必须受 Mutex 保护。 如果 CaptureCallback 写入 lastResult 而 Compose 按钮点击读取它,请在两端都使用 mutex.withLock { ... } 包装,或者对原始类型使用 atomicfu / @Volatile。不要依赖"它只触及一个线程"。LEGACY 设备上的 HAL 回调偶尔会在意想不到的线程上触发,当发生这种情况时,你会遇到 SENSOR_TIMESTAMP 等 64 位 Long 值的读取撕裂。

  4. 为什么 Flow 能避免 PipedOutputStream 死锁。 研究文档中的 PipedOutputStream 陷阱值得举一个具体的例子。如果你这样做:

    // 绝对不要这样做
    val pos = PipedOutputStream()
    val pis = PipedInputStream(pos)
    lifecycleScope.launch(Dispatchers.Default) {
    while (true) { image.compressToJpeg(..., pos) }
    }
    lifecycleScope.launch(Dispatchers.IO) {
    // 读取 pis 并写入文件
    }

    这会在 100 帧内发生死锁,因为 PipedInputStream 具有默认 64KB 的缓冲区。如果写入者的生产速度快于读者的消费速度,写入者就会在 pos.write() 上阻塞,缓冲区随之填满。如果此时读者恰好阻塞在其他事情上(例如 MediaStore 批量插入事务),两个协程都会永远阻塞 —— 典型的循环等待。具有 buffer(CONFLATED)buffer(DROP_OLDEST) 的 Flow 具有显式的背压语义,永远不会死锁。宁可丢帧,也不要死锁。这才是相机预览的正确权衡。


小结

Camera2 基于回调的 API 如果组合不当,会产生深层嵌套的回调地狱,极易出错、产生泄漏且不可测试。Kotlin 协程和 Flow 提供了两个原语,可以瓦解整个架构:用于单次操作 (openCameracreateCaptureSession、单次 capture) 且内置超时和取消支持的 suspendCancellableCoroutine,以及用于连续流 (ImageReader 图像、重复 CaptureResult 回调) 且具有显式背压的 callbackFlow。标准的 Flow 操作符 —— mapfilterrunningFolddebounce 以及最重要的 combine —— 让你能用可组合的部件构建响应式、取消安全的 UI 状态管线。通过专用相机分发器强化线程安全,使用 Mutex 保护共享状态,并使用 Flow 通道替换任何 PipedOutputStream 式的手动管道以避免死锁。

下一章

你现在拥有了编写稳健、生产级 Camera2 应用的工具。但如何验证你的代码能在目前野外存在的 24,000 多种 Android 设备型号上正常工作,以及 OEM 在出货前如何验证其 HAL?第 27 章涵盖了相机测试:相机 ITS、CTS 验证程序以及使用 mock 对象的插桩测试,这样你就可以在没有任何物理硬件的 CI 服务器上运行你的相机测试套件。