wandres.dev
CÁMARA Y MULTIMEDIA · CameraX y Media3

CameraX y los casos de uso

Camera2 es una API correcta, completa y prácticamente imposible de usar bien en un ecosistema de veinte mil modelos distintos. Esta lección reconstruye por qué Google terminó construyendo una capa encima de su propia API de cámara, cómo el concepto de caso de uso sustituye a la gestión manual de sesiones y superficies, qué garantiza realmente el enlace al ciclo de vida, y por qué la combinación de `Preview`, `ImageCapture` e `ImageAnalysis` es el contrato central alrededor del que gira toda la biblioteca.

⏱ 24 min

Pocas áreas de Android han producido tanta cirugía defensiva como la cámara. Camera2 es, en el papel, una API ejemplar: expone el hardware casi sin mediación, permite controlar la exposición cuadro a cuadro, negociar formatos de salida y orquestar capturas en ráfaga con precisión de milisegundos. El problema es que esa exposición directa convierte cada diferencia de fabricante en un problema del desarrollador. Un teléfono acepta tres superficies simultáneas y otro dos; uno reporta una orientación del sensor coherente y otro miente; uno bloquea el enfoque automático cuando se lo pides y otro se queda en un estado intermedio del que nunca sale. La consecuencia histórica es conocida: cualquier aplicación seria con cámara acumulaba miles de líneas de condicionales por modelo, y aun así fallaba en dispositivos que nadie del equipo había visto nunca. CameraX no es una simplificación pedagógica de Camera2, sino una respuesta de ingeniería a un problema de distribución: es la capa donde Google concentra el conocimiento empírico obtenido de un laboratorio de dispositivos reales, para que ese conocimiento no tenga que reescribirse en cada aplicación del planeta.

🎯 Al terminar esta lección sabrás
  • Explicar qué problema estructural resuelve CameraX sobre Camera2 y por qué no es una mera envoltura de conveniencia.
  • Describir los tres casos de uso principales y las reglas de combinación que impone el hardware.
  • Enlazar la cámara a un ciclo de vida y razonar sobre qué garantiza y qué no garantiza ese enlace.
  • Elegir con criterio entre el controlador de alto nivel y el proveedor de cámara de bajo nivel.

Por qué existe una capa sobre Camera2

La raíz del problema es el nivel de soporte del hardware. Camera2 clasifica cada dispositivo en uno de cuatro niveles, de LEGACY a LEVEL_3, y esa clasificación determina literalmente qué es posible. Un dispositivo LEGACY es un teléfono con hardware de la era de Camera1 al que se le ha puesto por delante una capa de emulación: acepta la API moderna pero ignora buena parte de sus peticiones. Un LEVEL_3 permite capturas RAW y reprocesado. Entre ambos hay un espacio de comportamientos que ninguna documentación captura del todo, porque no depende de la versión de Android sino de la implementación del fabricante.

CameraX invierte el reparto de responsabilidades. En lugar de que tu código pregunte por capacidades y ramifique, declaras qué quieres conseguir y la biblioteca resuelve cómo conseguirlo en ese dispositivo concreto. Detrás hay un catálogo de correcciones específicas por modelo, mantenido a partir de una suite de pruebas automatizada que se ejecuta contra un laboratorio físico de teléfonos. Cuando un dispositivo popular devuelve la imagen girada noventa grados o necesita una espera adicional antes de disparar, la corrección entra en la biblioteca y llega a tu aplicación con una actualización de dependencia, no con una nota de la tienda.

🔧

Camera2

Control total, contrato mínimo. Tú gestionas sesiones, superficies, estados de enfoque y las particularidades de cada fabricante. Imprescindible para capacidades avanzadas, ruinoso como base de una aplicación normal.

📷

CameraX

Declaras casos de uso y la biblioteca negocia con el hardware. El conocimiento sobre dispositivos rotos vive en la dependencia, no en tu código, y se actualiza sin que toques nada.

Hay además un territorio que Camera2 nunca podría cubrir por sí solo: las extensiones del fabricante. Los modos de retrato, nocturno o alto rango dinámico que la aplicación de cámara preinstalada ofrece están implementados en el propio dispositivo y expuestos a través de una interfaz común. La biblioteca permite consultar cuáles están disponibles y activarlos sobre el selector de cámara, de modo que una aplicación de terceros puede aprovechar el procesamiento propietario del teléfono sin conocer nada de él.

Conviene entender que CameraX no sustituye a Camera2 sino que se apoya en él, y ofrece una vía de escape explícita: la interoperabilidad permite alcanzar controles del nivel inferior sobre la misma sesión que la biblioteca gestiona. Esa puerta trasera es la que hace defendible la elección por defecto, porque el coste de necesitar algo exótico deja de ser una reescritura completa.

Los tres casos de uso y sus reglas de combinación

Un caso de uso es una declaración de intención sobre un flujo de imágenes. Preview entrega cuadros a una superficie visible. ImageCapture produce fotografías de máxima calidad bajo demanda. ImageAnalysis entrega buffers a tu código para que hagas algo con ellos. Cada uno lleva asociada una configuración de resolución, formato y rotación, y la biblioteca traduce el conjunto a una configuración de sesión que el dispositivo pueda satisfacer.

val preview = Preview.Builder().build()

val captura = ImageCapture.Builder()
    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
    .build()

val analisis = ImageAnalysis.Builder()
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .build()

La restricción que más sorprende es que no puedes combinar casos de uso arbitrariamente. El hardware impone un número máximo de flujos simultáneos que depende del nivel de soporte, y CameraX garantiza únicamente un conjunto conocido de combinaciones. La tríada de vista previa, captura y análisis está garantizada en dispositivos de nivel LIMITED o superior, que son la inmensa mayoría; añadir grabación de vídeo a esa tríada ya exige LEVEL_3 o equivalente, y en el resto de los dispositivos la petición falla al enlazar.

La estrategia de resolución merece una mención aparte porque es donde se negocia el compromiso entre calidad y coste. Declarar una relación de aspecto preferida junto con una resolución objetivo y una regla de respaldo permite que la biblioteca elija de forma predecible en hardware desigual, en lugar de aceptar el primer tamaño que aparezca en la lista de capacidades.

val selector = ResolutionSelector.Builder()
    .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
    .setResolutionStrategy(
        ResolutionStrategy(
            Size(1280, 720),
            ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
        ),
    )
    .build()

val analisisAjustado = ImageAnalysis.Builder()
    .setResolutionSelector(selector)
    .build()

De ahí se deriva una regla de diseño que ahorra mucho sufrimiento: enlaza solo lo que necesitas en cada momento. Una pantalla que alterna entre modo foto y modo vídeo no debe mantener los cuatro casos de uso vivos, sino desenlazar y volver a enlazar el conjunto adecuado a cada modo. La operación es barata comparada con el fallo en dispositivos modestos.

⚠️
La resolución que pides no es la que recibes

Las peticiones de resolución son orientativas, no contractuales. CameraX elige el tamaño admitido más cercano según la estrategia que declares con ResolutionSelector, y ese tamaño puede diferir entre dispositivos e incluso entre combinaciones de casos de uso en el mismo dispositivo. Todo código que asuma dimensiones fijas para recortar, dibujar o correlacionar coordenadas está roto y solo espera al teléfono adecuado para demostrarlo. Lee siempre las dimensiones reales del cuadro que recibes.

El enlace al ciclo de vida

El segundo pilar de la biblioteca es que la cámara deja de ser un recurso que abres y cierras a mano. Enlazas los casos de uso a un LifecycleOwner y la biblioteca abre el dispositivo cuando ese propietario alcanza el estado iniciado y lo libera cuando lo abandona. La clase entera de errores consistente en dejar la cámara abierta al salir de la pantalla, y con ella el indicador de privacidad encendido y el hardware bloqueado para otras aplicaciones, desaparece por construcción.

val proveedor = ProcessCameraProvider.awaitInstance(contexto)

proveedor.unbindAll()
val camara = proveedor.bindToLifecycle(
    lifecycleOwner,
    CameraSelector.DEFAULT_BACK_CAMERA,
    preview,
    captura,
    analisis,
)

camara.cameraInfo.torchState.observe(lifecycleOwner) { estado -> pintarLinterna(estado) }

La llamada de enlace devuelve un objeto de cámara con dos mitades bien diferenciadas: la información, que expone estado observable como el nivel de zoom o el de la linterna, y el control, que expone acciones como fijar el zoom o disparar una secuencia de enfoque. Esa separación entre lectura reactiva y escritura imperativa es deliberada y conviene respetarla en la capa que construyas encima.

flowchart TD
A[Fragmento o pantalla iniciada] --> B[bindToLifecycle con casos de uso]
B --> C[CameraX abre el dispositivo]
C --> D[Preview e ImageAnalysis reciben cuadros]
D --> E[Pantalla detenida]
E --> F[CameraX cierra el dispositivo]
F --> G[Pantalla reiniciada]
G --> C

Queda por decidir el nivel de entrada. CameraController es la vía compacta: gestiona internamente el proveedor, ofrece gestos de pellizco y toque para enfocar ya cableados, y basta con habilitar los casos de uso que quieras. ProcessCameraProvider es la vía explícita: más código a cambio de control sobre la configuración de cada caso de uso. La recomendación práctica es empezar por el controlador y migrar al proveedor cuando aparezca la primera necesidad que el controlador no cubra, porque la migración es local y no contamina el resto de la aplicación.

Un detalle que conviene fijar desde el principio es que el proveedor se obtiene de forma asíncrona, porque su inicialización implica consultar el hardware disponible. En código con corrutinas la espera es una suspensión limpia; en código antiguo aparece como un futuro al que se le añade un oyente sobre el ejecutor del hilo principal. Cualquier arquitectura que trate la cámara como un recurso disponible de inmediato acabará con una condición de carrera entre la primera composición de la pantalla y la llegada del proveedor.

El visor, la rotación y el punto de integración con Compose

El caso de uso de vista previa necesita un destino donde volcar los cuadros, y ese destino es una superficie que la biblioteca solicita mediante un proveedor. En el mundo de vistas, PreviewView resuelve el asunto entero: elige internamente entre una vista de textura y una de superficie según lo que convenga al dispositivo, aplica las transformaciones de escalado y gestiona el recorte. En Compose, el artefacto de integración expone un visor que consume el mismo flujo de peticiones de superficie sin obligar a envolver una vista clásica.

@Composable
fun Visor(estadoSuperficie: SurfaceRequest?, modifier: Modifier = Modifier) {
    estadoSuperficie?.let { peticion ->
        CameraXViewfinder(surfaceRequest = peticion, modifier = modifier)
    }
}

La rotación es la fuente de errores más persistente de todo este terreno, y merece un modelo mental explícito. Existen tres orientaciones distintas que rara vez coinciden: la del sensor, fijada físicamente en el montaje del módulo; la de la pantalla, que cambia con el giro del dispositivo; y la de destino, que es la que tú declaras para indicar cómo quieres recibir el resultado. La biblioteca calcula la transformación necesaria entre ellas, pero solo si le comunicas la orientación de destino, y en dispositivos con rotación bloqueada por el usuario ese valor no cambia solo.

La solución robusta consiste en escuchar los cambios de orientación física con un OrientationEventListener y actualizar la rotación de destino de los casos de uso que la necesiten. Sin eso, una fotografía tomada con el teléfono en horizontal se guardará girada aunque la vista previa se viera perfecta, porque la vista previa se corrige con la pantalla y la captura no.

💡
Un solo sitio para el estado de la cámara

La tentación de guardar el estado de la cámara en la propia pantalla es fuerte y sale cara en cuanto aparecen cambios de configuración. El propietario del ciclo de vida al que enlazas puede ser la pantalla, pero la configuración deseada —cámara seleccionada, modo de flash, relación de aspecto— pertenece a un contenedor que sobreviva a la rotación. Enlazar de nuevo con la configuración correcta tras un cambio de configuración cuesta milisegundos; reconstruirla desde valores por defecto rompe la sensación de continuidad que el usuario espera de un visor.

La abstracción que compra tiempo contra la entropía del hardware

Merece la pena mirar CameraX como lo que es en términos de arquitectura de software: un experimento a escala planetaria sobre dónde debe vivir el conocimiento empírico de un sistema. Toda API que expone hardware directamente hace una apuesta implícita, la de que el hardware se comportará conforme a la especificación. Camera2 hizo esa apuesta y la perdió, no porque su diseño fuera malo sino porque la especificación era un documento y las implementaciones eran cientos de equipos distintos con calendarios, presupuestos y criterios de calidad incompatibles entre sí. Lo revelador es qué ocurrió con el conocimiento sobre esas divergencias: se acumuló, pero se acumuló en el peor sitio posible, replicado miles de veces dentro de aplicaciones individuales que no podían compartirlo, en forma de condicionales por marca y modelo escritos a partir de informes de fallos y comentarios de foros. Ese conocimiento era simultáneamente valiosísimo y perecedero, porque quedaba obsoleto con cada actualización de firmware sin que nadie se enterara. CameraX es la decisión de reubicarlo: extraerlo de las aplicaciones, verificarlo contra un laboratorio físico, versionarlo como una dependencia y distribuirlo con la cadencia de una biblioteca en lugar de la cadencia del sistema operativo. Esa reubicación tiene un precio real, que es una capa más de indirección y una pérdida de control fino, y quien construya una aplicación de fotografía profesional lo pagará y descenderá a Camera2. Pero la lección general trasciende la cámara y se aplica a cualquier frontera con un mundo heterogéneo: cuando la variabilidad de las implementaciones supera la capacidad de un equipo medio para catalogarla, la abstracción correcta no es la que oculta complejidad por elegancia, sino la que centraliza el mantenimiento del conocimiento sobre lo que está roto. Las bibliotecas de Jetpack son, casi todas, esta misma idea aplicada a fronteras distintas, y reconocerlo cambia la pregunta que uno se hace al evaluarlas: no si añaden capas, sino a quién le trasladan la carga de averiguar por qué un dispositivo concreto no funciona.

⚔️ Construye la base de tu cámara
  1. Enlaza Preview, ImageCapture e ImageAnalysis a la vez y comprueba en un dispositivo modesto que la combinación se acepta.
  2. Añade grabación de vídeo al conjunto anterior y documenta el error exacto que devuelve el enlace cuando el hardware no da para tanto.
  3. Registra en cada cuadro de análisis las dimensiones reales recibidas y contrástalas con la resolución que solicitaste.
  4. Implementa el cambio entre cámara frontal y trasera desenlazando y volviendo a enlazar, y mide cuánto tarda la transición.
  5. Migra una pantalla escrita con CameraController a ProcessCameraProvider sin cambiar nada fuera de esa pantalla.