CameraX en 2026: enlazar Preview e ImageAnalysis sin dejar escapar una sesión de cámara
Una guía práctica de CameraX en Android: enlazar Preview e ImageAnalysis al ciclo de vida, la estrategia de backpressure correcta y el fallo que causa la rotación.
Dos de mis aplicaciones apuntan la cámara a algo y esperan una respuesta en menos de un segundo: Stocky escaneando un código de barras, Subly leyendo una factura. Los modelos de visión detrás de ambas — el escáner de códigos de barras de ML Kit y su reconocedor de texto — tienen sus propios artículos en otro lugar. Este trata sobre la capa que hay debajo de ambos: CameraX, la parte que tiene que permanecer abierta exactamente el mismo tiempo que la pantalla que muestra la vista previa esté viva, ni un fotograma más.
CameraX es fácil de poner en marcha y fácil de usar mal de forma sutil. El código de demostración enlaza una vista previa y un analizador en unas pocas líneas y funciona a la primera. Los errores aparecen después — al rotar, en un ir y venir rápido entre pantallas, en un teléfono que pausa la app a mitad de un escaneo. Esto es lo que realmente hay que hacer bien.
Enlazar a un ciclo de vida, no a una activity
La razón para usar CameraX en lugar de Camera2 puro no es la superficie de la API, es bindToLifecycle. Dale un LifecycleOwner junto con tus casos de uso, y CameraX arranca la cámara cuando ese owner llega a STARTED y la desmonta cuando deja de estarlo — sin cableado manual de onPause/onResume que se pueda hacer mal:
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
cameraProvider.unbindAll()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(analysisExecutor, ::analyzeFrame) }
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
}, ContextCompat.getMainExecutor(context))
Ese unbindAll() antes del enlace no es código defensivo de más — es la solución al fallo más común de CameraX. ProcessCameraProvider.getInstance() devuelve un ListenableFuture, así que el enlace ocurre de forma asíncrona, en un listener. Si la pantalla se recrea (rotación, un cambio de configuración, volver a una pantalla de escaneo que ya estaba enlazada) antes de que la sesión anterior se haya liberado limpiamente, terminas con dos conjuntos de casos de uso peleando por la misma cámara, o una IllegalStateException que dice que un caso de uso ya está enlazado a otro ciclo de vida. Llamar a unbindAll() primero hace que cada enlace sea idempotente: no importa cuántas veces hayas pasado por onResume.
La vista previa y el análisis son dos consumidores de los mismos fotogramas
Preview e ImageAnalysis no son etapas de un pipeline — son dos casos de uso independientes que la cámara alimenta simultáneamente. La vista previa le muestra al usuario un flujo en vivo en una PreviewView; el analizador recibe su propio ImageProxy por fotograma para ejecutar inferencia. Enlazar ambos a la vez es lo que convierte “cámara con overlay de escaneo en vivo” en una sola llamada en lugar de dos cámaras. También significa que la velocidad del analizador no afecta lo que el usuario ve en pantalla — un modelo lento no hace que la vista previa vaya con tirones, algo que importa más de lo que parece para lo “receptivo” que se siente un escáner.
La estrategia de backpressure no es un ajuste menor
ImageAnalysis usa por defecto STRATEGY_KEEP_ONLY_LATEST en las versiones más recientes de CameraX, pero vale la pena fijarla explícitamente, porque la alternativa — STRATEGY_BLOCK_PRODUCER — encola fotogramas cuando tu analizador no da abasto, y un modelo de código de barras u OCR rara vez le sigue el ritmo a una cámara de 30fps. KEEP_ONLY_LATEST descarta todos los fotogramas excepto el más reciente mientras el analizador sigue ocupado, que es exactamente el comportamiento que quieres: el usuario ve una vista previa en vivo y obtiene un resultado del fotograma que el modelo tuvo libre para mirar, en lugar de que el pipeline se atrase y procese fotogramas de hace un segundo.
Cierra cada ImageProxy, o el flujo se detiene en silencio
Este es el error más difícil de notar en pruebas porque no se cae — simplemente se detiene. ImageAnalysis no entregará un nuevo fotograma a tu analizador hasta que el ImageProxy anterior esté cerrado, incluso bajo KEEP_ONLY_LATEST. Un return temprano en una imagen nula, una excepción lanzada antes de tu finally, o una búsqueda que se ejecuta de forma asíncrona y cierra el proxy solo en su callback — cualquiera de estos puede dejar un ImageProxy abierto para siempre, y desde ese fotograma en adelante, el analizador simplemente se queda callado. Sin error, sin log, solo un escáner que dejó de escanear:
fun analyzeFrame(image: ImageProxy) {
try {
val media = image.image ?: return
val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
scanner.process(input).addOnSuccessListener { /* handle result */ }
} finally {
image.close()
}
}
Pon el cierre en un finally, no al final del camino feliz. Es la única línea de toda la configuración que es fácil de saltarse y cara de depurar después.
Comprueba el permiso antes de enlazar, no dentro del callback
CameraX no solicita el permiso de cámara por ti, y no falla con elegancia si enlazas sin él — bindToLifecycle lanza una SecurityException que hará caer la pantalla si CAMERA aún no se ha concedido. La solución es de orden: comprueba ContextCompat.checkSelfPermission y pasa ActivityResultContracts.RequestPermission antes de que se ejecute el listener del ProcessCameraProvider, no dentro de él. Una comprobación de permiso que solo se ejecuta después de que el usuario ya lo haya denegado una vez, o que compite con el future asíncrono del provider, es como terminas con un informe de fallo que solo se reproduce en una instalación nueva.
La conclusión
CameraX se gana su lugar frente a Camera2 puro de exactamente una manera: convierte “la gestión del ciclo de vida de la cámara” en un problema resuelto, siempre que realmente le dejes ser el dueño de ese ciclo de vida. Enlaza a través de un LifecycleOwner, llama a unbindAll() antes de cada enlace para que la rotación y la navegación nunca puedan enlazar dos veces un caso de uso, fija la estrategia de backpressure explícitamente en lugar de confiar en el valor por defecto, y cierra cada ImageProxy en un finally. Haz esas cuatro cosas bien y la cámara se vuelve aburrida — que, para una función que tiene que funcionar cada vez que un usuario apunta su teléfono a un código de barras o una factura, es exactamente lo que quieres que sea.
// Lecturas relacionadas
Más del diario
androidx.startup en 2026: ordenar la inicialización de librerías sin una pila de ContentProviders
Una guía práctica de la librería App Startup de Android — consolidar initializers en un único ContentProvider, declarar dependencias, y los casos de init perezosa que no puede sustituir.
La API de Actualización en la App de Android en 2026: flexible vs. inmediata, y cuándo forzar una
Una guía práctica de la API In-App Update de Google: flujos flexible e inmediato, prioridad de actualización, días de obsolescencia y la comprobación de reanudación que todos olvidan.
La API Photo Picker de Android en 2026: adjuntar una foto sin dar acceso a toda la galería
Una guía práctica de la API Photo Picker de Android: selección única y múltiple, filtrado por MIME, el respaldo para versiones previas a Android 13 y por qué supera a READ_MEDIA_IMAGES.