Saltar al contenido
Todas las entradas

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.

MFKAPPS 5 min de lectura

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.