Capítulo 26: Programación de Cámara Asíncrona
Resumen
Vuelva a mirar el código que escribió para los Capítulos 7 a 9. CameraDevice.StateCallback anidado dentro de CameraManager.openCamera, con CameraCaptureSession.StateCallback anidado dentro de onOpened, con CaptureCallback anidado dentro de onConfigured, con ImageReader.OnImageAvailableListener disparándose en un HandlerThread que usted mismo creó y debe desmontar exactamente en el orden inverso en cada ruta de error. Esto es el infierno de las devoluciones de llamada (callback hell), con sabor a cámara. Cada nivel de sangría es una nueva clase de devolución de llamada. Cada error debe propagarse a través de cuatro capas de objetos anónimos. Cada close() omitido en la ruta de desmontaje filtra la cámara hasta el reinicio.
Este capítulo es la refactorización que tanto ha deseado. Convertimos todo el laberinto de devoluciones de llamada en código Kotlin limpio, lineal, cancelable y comprobable utilizando dos primitivas de corrutina: suspendCancellableCoroutine para operaciones de un solo disparo y callbackFlow + operadores Flow para flujos continuos. Aprenderá las reglas de seguridad de hilos para las corrutinas que interactúan con Camera2, por qué bloquear el hilo principal en cualquier llamada de cámara es un ANR a punto de ocurrir y por qué el patrón PipedOutputStream/PipedInputStream que quizás haya probado para los datos de ImageWriter produce bloqueos (deadlocks) que Flow evita de forma natural.
Como siempre, valide las capacidades a nivel de hardware a las que se dirige con Android Camera Parameters (Google Play, GitHub) para confirmar que las capacidades que su canalización asíncrona necesita (ráfaga repetitiva, resultados parciales, reprocesamiento YUV) realmente se incluyen en sus dispositivos de prueba.
Por qué las devoluciones de llamada anidadas son el "infierno de las devoluciones de llamada"
Visualicemos primero el problema. Esta es una estructura real (simplificada) de una aplicación Camera2 pura de producción antes de las corrutinas:
Cada devolución de llamada sombreada es una clase anónima separada. Cada una captura una referencia a recursos dos niveles superiores. Cada ruta de error debe burbujear desde J hasta A, cerrando imageReader → session → cameraDevice → handlerThread en orden inverso, y cualquier close() que falte en cualquiera de las 16 permutaciones de error produce una fuga permanente de la cámara hasta que el dispositivo se reinicie. Esta es la definición de libro de texto del infierno de las devoluciones de llamada.
El objetivo de este capítulo es convertir ese espagueti en esto:
Lineal. Componible. Comprobable. Cancelable cancelando el Job padre. Cada etapa es una función simple o un operador Flow. Las mismas cinco devoluciones de llamada ahora viven en una canalización lineal de 12 líneas.
Corrutinas de Kotlin para operaciones de un solo disparo: suspendCancellableCoroutine
El patrón central para envolver cualquier API basada en devoluciones de llamada como una función suspend es suspendCancellableCoroutine. La receta es siempre idéntica:
- Llamar a
suspendCancellableCoroutine { cont -> ... }para obtener unaCancellableContinuation<T>. - Llamar a la API real basada en devoluciones de llamada, pasándole una implementación de devolución de llamada anónima.
- En la ruta de éxito de la devolución de llamada, llamar a
cont.resume(value). - En cada ruta de error, llamar a
cont.resumeWithException(t). - En
cont.invokeOnCancellation { ... }, realizar la limpieza: cerrar la cámara, cancelar solicitudes pendientes, anular el registro de los listeners para que la devolución de llamada nunca se dispare después de que se cancelara la corrutina. - Envolver todo en
withTimeouten los lugares de llamada para que un HAL muerto no cuelgue su aplicación para siempre.
Ejemplo 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,
"Cámara $cameraId desconectada durante la apertura"
)
)
}
override fun onError(camera: CameraDevice, error: Int) {
cont.resumeWithException(
CameraAccessException(error, "Error en cámara $cameraId: $error")
)
}
}
cont.invokeOnCancellation {
try {
// Solución: openCamera() no expone un manejador cancelable
// en APIs anteriores a la 30. Cerrar el dispositivo si se abrió en la ventana de carrera.
} catch (_: Throwable) { /* ignorar */ }
}
openCamera(cameraId, callback, handler)
}
Por qué funciona. openCamera es de tipo "disparar y olvidar": usted lo llama y, en algún momento futuro, uno de los tres métodos de devolución de llamada se dispara exactamente una vez. Ese contrato ("se dispara exactamente una vez") es lo que nos permite mapearlo uno a uno en una continuación. Si la corrutina se cancela antes de que se dispare cualquier devolución de llamada, se ejecuta invokeOnCancellation y evita una fuga de recursos. Si se cancela después de resume, el bloque resume(value) { camera.close() } (el parámetro onCancellation de resume) cierra el dispositivo automáticamente.
Llamarlo con un tiempo de espera es trivial:
val cameraDevice: CameraDevice = withTimeoutOrNull(5_000L) {
cameraManager.openCameraAwait(cameraId, cameraHandler)
} ?: run {
Log.w(TAG, "La apertura de la cámara agotó el tiempo de espera después de 5s")
return@launch
}
Si el HAL está colgado (común en dispositivos LEGACY de gama baja después de una fuga de cámara de una aplicación anterior), esto falla rápida y limpiamente en lugar de presentar al usuario un diálogo de "La aplicación no responde".
Ejemplo 2: suspend fun createCaptureSessionAwait()
Mismo patrón, diferente devolución de llamada:
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(
"Fallo en la configuración de la sesión para el dispositivo ${this@createCaptureSessionAwait.id}"
)
)
}
}
cont.invokeOnCancellation {
// No se puede cancelar la creación de una sesión en curso en APIs antiguas.
// La sesión se cerrará si finalmente se completa a través del bloque
// resume onCancellation anterior.
}
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)
}
}
Esta es exactamente la misma forma. Las dos versiones de la API (createCaptureSession(surfaces, callback, handler) antes de S vs SessionConfiguration en S+) se manejan en un solo envoltorio. Los llamadores nunca necesitan saberlo.
Ejemplo 3: suspend fun awaitCaptureResult() para captura única
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(
"Captura fallida: motivo=${failure.reason} fotograma=${failure.frameNumber}"
)
)
}
}
cont.invokeOnCancellation {
try { abortCaptures() } catch (_: Throwable) { /* ignorar */ }
}
try {
capture(request, callback, handler)
} catch (t: Throwable) {
cont.resumeWithException(t)
}
}
Este es el bloque de construcción para el horquillado (bracketing) manual de múltiples fotogramas al estilo del Capítulo 19: encadene 7 llamadas a captureAwait(br[i]) en un bucle for con withTimeoutOrNull, recopile los 7 TotalCaptureResult y tendrá una secuencia completa de horquillado HDR con tiempo de espera por fotograma y aborto automático por cancelación. En el mundo de las devoluciones de llamada, esto suponía cientos de líneas de máquina de estados. Ahora es un bucle for de 12 líneas.
Flow para flujos continuos
Las operaciones de un solo disparo cubren la apertura de la cámara, la creación de la sesión y la captura única. Para las cosas repetitivas (cada fotograma de vista previa, cada TotalCaptureResult, cada Image de un ImageReader) queremos un Flow<T> para poder usar map, filter, debounce, combine y compartir flujos entre suscriptores.
Ejemplo 4: ImageReader → Flow<Image> a través de callbackFlow
fun ImageReader.imagesFlow(
lifecycleScope: CoroutineScope
): Flow<Image> = callbackFlow {
val listener = ImageReader.OnImageAvailableListener { reader ->
// acquireLatestImage() descarta los fotogramas antiguos si el consumidor es más lento que
// la producción de la cámara: obligatorio para evitar estancar el HAL.
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
trySend(image)
}
// Debe invocar onClose PRIMERO para que la cancelación siempre elimine el listener
// incluso si el propio setOnImageAvailableListener lanza excepción.
awaitClose {
setOnImageAvailableListener(null, null)
// NO cierre el ImageReader aquí; el llamador es el propietario de su ciclo de vida.
}
val handlerThread = HandlerThread("ImageReaderFlow").apply { start() }
val handler = Handler(handlerThread.looper)
setOnImageAvailableListener(listener, handler)
}.buffer(Channel.CONFLATED)
.onCompletion {
// Cualquier imagen no consumida por los recolectores de flujo es nuestra responsabilidad cerrarla,
// porque callbackFlow vuelve a lanzar los fallos después de emitir.
}
Decisiones de diseño críticas:
-
acquireLatestImage()frente aacquireNextImage(). Si el procesamiento de su imagen (inferencia de ML, detección de rostros) tarda 40 ms y la cámara dispara a 30 fps (~33 ms por fotograma), se quedará atrás.acquireNextImagelas pone en cola hasta que se quede sin búferes gralloc y la cámara se congele.acquireLatestImagese salta las antiguas y le da el fotograma más fresco. Esto es casi siempre lo que desea para el análisis de imágenes en el lado de la vista previa. -
buffer(Channel.CONFLATED). Un búfer combinado (conflated) mantiene solo el último valor. Combinado conacquireLatestImage, esta es una garantía absoluta de que nunca pondrá en cola fotogramas obsoletos. -
awaitClose { setOnImageAvailableListener(null, null) }. Este es el equivalente encallbackFlowacont.invokeOnCancellation. Cancele el alcance de la corrutina (por ejemplo, cuando el Fragmento pase poronDestroyView) y el listener se anulará automáticamente y elHandlerThreadse limpiará. Sin fugas.
Ejemplo 5: CaptureCallback → Flow<TotalCaptureResult>
Mismo patrón:
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
) {
// Si necesita resultados parciales, emítalos en un canal separado
// o envíe una clase sellada (sealed class).
}
}
awaitClose {
try {
stopRepeating()
abortCaptures()
} catch (_: Throwable) { /* ignorar */ }
}
setRepeatingRequest(repeatingRequest, callback, handler)
}
Ahora tiene un Flow<TotalCaptureResult> frío que inicia una solicitud repetitiva cuando se recolecta, se detiene por cancelación, emite cada resultado completado y funciona con cada operador estándar de Flow.
Ejemplo 6: combine(previewFlow, aeStateFlow) para una UI reactiva
El verdadero poder de Flow es la composición. Suponga que su interfaz de usuario muestra:
- FPS de vista previa en vivo
- Estado actual de AE (convergiendo / convergida / bloqueada)
- Un indicador de "Listo para disparar" que se pone verde solo cuando la AE ha convergido Y el AF ha convergido Y el AWB ha convergido.
Sin Flow, usted escribe a mano una máquina de estados que fusiona CaptureCallback con Choreographer. Con Flow son tres líneas:
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) // fuera del hilo principal, sin latencia de 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) // ventana rodante de 30 marcas de tiempo
}
.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) // solo actualiza la etiqueta de FPS cada 250ms, ahorra batería
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)
}
Recolecte uiState en el viewLifecycleOwner.lifecycleScope.launchWhenStarted de su Fragmento y pase cada emisión a su interfaz de usuario de Compose o viewBinding. Cada operador (map, runningFold, debounce, combine) es una primitiva de la biblioteca estándar. Sin máquina de estados personalizada. Sin condiciones de carrera. Sin eventos perdidos. Cancele el alcance y cada Flow ascendente, incluyendo la solicitud repetitiva y el listener del ImageReader, se detiene, cancela la suscripción y se limpia exactamente una vez.
Seguridad de hilos
Todo lo anterior no sirve de nada si viola las reglas de seguridad de hilos de Camera2. Aquí las tiene, extraídas de cientos de informes de errores de ANR:
-
Nunca llame a ninguna API de Camera2 desde el hilo principal.
cameraManager.openCamera()puede parecer rápido en un Pixel 7. En un dispositivo Android Go económico con un HALLEGACY, puede bloquearse durante 1.2s. Eso es un ANR instantáneo. Incluso las llamadas que parecen baratas, comoCameraCharacteristics.get(), pueden asignar varios KB de metadatos y copiarlos, lo que en el inicio de un proceso en frío mientras el usuario desliza entre Fragmentos es suficiente para perder 3 fotogramas. Envíe todo aDispatchers.Defaulto a un despachador de un solo hilo respaldado por unHandlerThread. -
HandlerThread vs
CoroutineDispatcher.DefaultvsDispatchers.IO.- Use un despachador de un solo hilo (por ejemplo,
HandlerThread("cam").asCoroutineDispatcher()) para las llamadas reales a la API de Camera2. La pila de cámara heredada en muchos dispositivosLEGACYtiene puntos de entrada de HAL con afinidad de hilos. Cambiar de hilo entreopenCameraycreateCaptureSessionactiva errores de HAL conocidos en Qualcomm msm8953 y anteriores. - Use
Dispatchers.Defaultpara el cálculo puro en los fotogramas capturados (fusión HDR, codificación JPEG, detección de rostros). Tiene tantos hilos como núcleos. - Use
Dispatchers.IOpara la E/S de disco (guardar el JPEG en MediaStore). Nunca useDefaultpara escrituras bloqueantes.
- Use un despachador de un solo hilo (por ejemplo,
-
El estado compartido mutable entre corrutinas y devoluciones de llamada debe estar protegido por un
Mutex. Si unCaptureCallbackescribelastResulty un clic de botón de Compose lo lee, envuelva ambos lados conmutex.withLock { ... }o useatomicfu/@Volatilepara tipos primitivos. NO confíe en que "solo toca un hilo". Las devoluciones de llamada de HAL en dispositivosLEGACYocasionalmente se disparan en hilos inesperados y, cuando lo hacen, se obtienen lecturas incompletas de valoresLongde 64 bits comoSENSOR_TIMESTAMP. -
Por qué Flow evita el bloqueo (deadlock) de
PipedOutputStream. La trampa delPipedOutputStreamdel documento de investigación merece un ejemplo concreto. Si hiciera esto:// NO HAGA ESTOval pos = PipedOutputStream()val pis = PipedInputStream(pos)lifecycleScope.launch(Dispatchers.Default) {while (true) { image.compressToJpeg(..., pos) }}lifecycleScope.launch(Dispatchers.IO) {// leer pis y escribir en archivo}Esto se bloquea en menos de 100 fotogramas porque
PipedInputStreamtiene un búfer predeterminado de 64 KB. Si el escritor produce más rápido de lo que el lector consume, el escritor se bloquea enpos.write()y el búfer se llena. Si el lector mientras tanto está bloqueado en otra cosa (por ejemplo, una transacción de inserción masiva en MediaStore), ambas corrutinas se bloquean para siempre: una espera circular clásica. Flow conbuffer(CONFLATED)obuffer(DROP_OLDEST)tiene una semántica de contrapresión explícita y nunca se bloquea. Pierda fotogramas, pero nunca se bloquee. Esa es la decisión correcta para la vista previa de la cámara.
Resumen
La API de Camera2 basada en devoluciones de llamada, cuando se compone de forma ingenua, produce un profundo infierno de devoluciones de llamada que es propenso a errores, fugas y es difícil de probar. Las corrutinas de Kotlin y Flow le brindan dos primitivas que simplifican todo el diseño: suspendCancellableCoroutine para operaciones de un solo disparo (openCamera, createCaptureSession, capture única) con soporte integrado para tiempo de espera y cancelación, y callbackFlow para flujos continuos (imágenes de ImageReader, devoluciones de llamada CaptureResult repetitivas) con contrapresión explícita. Los operadores estándar de Flow (map, filter, runningFold, debounce y el importantísimo combine) le permiten construir canalizaciones de estado de UI reactivas y seguras contra cancelaciones a partir de piezas componibles. Aplique la seguridad de hilos con un despachador de cámara dedicado, proteja el estado compartido con Mutex y reemplace cualquier tubería manual al estilo de PipedOutputStream con canales de Flow para evitar bloqueos.
Qué sigue
Ahora tiene las herramientas para escribir aplicaciones Camera2 robustas y de calidad de producción. Pero, ¿cómo verifica que su código funciona en los más de 24,000 modelos de dispositivos Android que existen actualmente y cómo validan los OEM sus HAL antes del envío? El Capítulo 27 cubre las pruebas de cámara: Camera ITS, CTS Verifier y pruebas de instrumentación que usan simulacros (mocks) para que pueda ejecutar su conjunto de pruebas de cámara en servidores de CI sin necesidad de hardware de cámara físico.