Cómo brindar compatibilidad con superficies redimensionables en tu app de cámara

1. Introducción

Última actualización: 25 de abril de 2022

¿Por qué debería usarse una superficie que puede cambiar de tamaño?

Históricamente, tu app podría haber permanecido en la misma ventana durante todo su ciclo de vida.

Sin embargo, con la disponibilidad de nuevos factores de forma (como dispositivos plegables) y nuevos modos de visualización (como multiventana y varias pantallas), no puedes suponer que esto seguirá siendo así.

En particular, veamos algunas de las consideraciones más importantes para desarrollar una app orientada a dispositivos plegables y con pantallas grandes:

  • No supongas que tu app se alojará en una ventana con forma de retrato: 12L aún admite la solicitud de una orientación fija, pero ahora ofrecemos a los fabricantes de dispositivos la opción para anular la solicitud de la app para obtener una orientación preferida.
  • No des por sentado ninguna dimensión ni relación de aspecto fijas para tu app: Incluso si configuras resizableActivity como "falso", en pantallas grandes (más de 600 dp), la app podrá usarse en el modo multiventana.
  • No pretendas que la relación entre la orientación de la pantalla y la cámara sea fija. El documento de definición de compatibilidad de Android especifica que un sensor de imagen de la cámara "SE DEBE orientar para que la dimensión larga de la cámara se alinee con la dimensión larga de la pantalla". A partir del nivel de API 32, los clientes de cámaras que busquen la orientación en dispositivos plegables pueden recibir un valor que puede cambiar de forma dinámica según el estado del dispositivo o de la línea de plegado.
  • No asumas que el tamaño de la inserción no puede cambiar: La nueva barra de tareas se informa a las aplicaciones como una inserción y, cuando se usa con la navegación por gestos, se puede ocultar y mostrar de forma dinámica.
  • No des por sentado que tu app tiene acceso exclusivo a la cámara. Mientras tu app se encuentre en el modo multiventana, otras apps podrán acceder a recursos compartidos, como la cámara y el micrófono.

Es momento de asegurarte de que tu app de cámara funcione bien en todas las situaciones. Para ello, aprende a transformar la salida de la cámara para que se adapte a superficies que pueden cambiar de tamaño y a usar las APIs que ofrece Android para abordar diferentes casos de uso.

Qué compilarás

En este codelab, compilarás una app simple que mostrará la vista previa de la cámara. Comenzarás con una app de cámara simple que bloquea la orientación y se declara no ajustable, y verás su comportamiento en Android 12L.

Luego, actualizarás el código fuente para asegurarte de que la vista previa se muestre siempre bien en todas las situaciones. El resultado será una app de cámara que controlará correctamente los cambios de configuración y transformará automáticamente la superficie para que coincida con la vista previa.

1df0acf495b0a05a.png

Qué aprenderás

  • Cómo se muestran las vistas previas de Camera2 en las plataformas de Android
  • La relación entre la orientación del sensor, la rotación de la pantalla y la relación de aspecto
  • Cómo transformar una superficie para que coincida con la relación de aspecto de la vista previa de la cámara y la rotación de la pantalla

Requisitos

  • Una versión reciente de Android Studio
  • Conocimientos básicos sobre el desarrollo de aplicaciones para Android
  • Conocimientos básicos de las APIs de Camera2
  • Un dispositivo o emulador que ejecute Android 12L

2. Cómo prepararte

Obtén el código de inicio

Para comprender el comportamiento en Android 12L, comenzarás con una app de cámara que bloquea la orientación y se declara no ajustable.

Si tienes Git instalado, simplemente puedes ejecutar el comando que se indica abajo. Para comprobarlo, escribe git --version en la terminal o línea de comandos, y verifica que se ejecute correctamente.

git clone https://github.com/googlecodelabs/android-camera2-preview.git

Si no tienes Git, puedes hacer clic en el siguiente botón a fin de descargar todo el código de este codelab:

Abre el primer módulo

En Android Studio, abre el primer módulo ubicado en /step1.

Android Studio te pedirá que establezcas la ruta de acceso del SDK. Si tienes algún problema, te sugerimos que sigas las recomendaciones para actualizar las herramientas del IDE y el SDK.

302f1fb5070208c7.png

Si se te pide que uses la versión más reciente de Gradle, actualízala.

Prepara el dispositivo

A la fecha de publicación de este codelab, hay un conjunto limitado de dispositivos físicos que pueden ejecutar Android 12L.

Aquí encontrarás la lista de dispositivos y las instrucciones para instalar 12L: https://developer.android.com/about/versions/12/12L/get.

Te recomendamos que uses un dispositivo físico para probar las apps de la cámara, pero en caso de que quieras usar un emulador, asegúrate de crear uno con una pantalla grande (p. ej., Pixel C) y el nivel de API 32.

Prepara un sujeto para un encuadre

Al trabajar con cámaras, lo ideal es tener un sujeto estándar al que se pueda apuntar para apreciar las diferencias en la configuración, la orientación y el escalamiento.

En este codelab, usaremos una versión impresa de esta imagen cuadrada. 66e5d83317364e67.png

Si la flecha no apuntara al lado superior, el cuadrado se convertiría en otra figura geométrica. . . hay que corregir algo.

3. Ejecuta y observa

Coloca el dispositivo en modo vertical y ejecuta el código en el módulo 1. Asegúrate de permitir que la app de Camera2 Codelab tome fotos y grabe video mientras la usas. Como puedes ver, la vista previa se muestra correctamente y usa el espacio de la pantalla de manera eficiente.

Ahora, rota el dispositivo a la posición horizontal:

46f2d86b060dc15a.png

Definitivamente, no es la mejor opción. Ahora haz clic en el botón para actualizar ubicado en la esquina inferior derecha.

b8fbd7a793cb6259.png

Debería mejorar un poco, pero aún no es la condición óptima.

Lo que se ve es el comportamiento del modo de compatibilidad de Android 12L. Las apps que bloquean su orientación en el modo vertical pueden tener formato letterbox cuando se rota el dispositivo a la posición horizontal y la densidad de la pantalla es superior a 600 dp.

Si bien este modo conserva la relación de aspecto original, proporciona una experiencia del usuario poco óptima, ya que la mayor parte del espacio de la pantalla no se usa.

Además, en este caso, la vista previa se rotó 90 grados de forma incorrecta.

Vuelve a colocar el dispositivo en posición vertical e inicia el modo de pantalla dividida.

Puedes cambiar el tamaño de la ventana si arrastras la línea divisoria central.

Observa cómo el cambio de tamaño afecta la vista previa de la cámara. ¿Se ve distorsionada? ¿Mantiene la misma relación de aspecto?

4. La solución rápida

Dado que el modo de compatibilidad solo se activa para apps que bloquean la orientación y no pueden cambiar de tamaño, tal vez sientas la tentación de actualizar las marcas del manifiesto a fin de evitarlo.

Inténtalo:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

Ahora, compila la app y vuelve a ejecutarla en orientación horizontal. Deberías ver algo como esto:

f5753af5a9e44d2f.png

La flecha no apunta a la parte superior y eso no es un cuadrado.

Dado que la app no se diseñó para funcionar en el modo multiventana ni en diferentes orientaciones, no espera ningún cambio en el tamaño de la ventana, lo que genera los problemas que acabas de experimentar.

5. Administra los cambios en la configuración

Comencemos por decirle al sistema que queremos ocuparnos de los cambios de configuración por nuestra cuenta. Abre step1/AndroidManifest.xml y agrega las siguientes líneas:

step1/AndroidManifest.xml

<activity
    android:name=".CameraActivity"
    android:exported="true"
    android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
    android:resizeableActivity="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

Ahora también debes actualizar step1/CameraActivity.kt para recrear una CameraCaptureSession cada vez que cambie el tamaño de la superficie.

Ve a la línea 232 y llama a la función createCaptureSession():

step1/CameraActivity.kt

override fun onSurfaceTextureSizeChanged(
    surface: SurfaceTexture,
    width: Int,
    height: Int
) {
    createCaptureSession()
}

La salvedad es que no se llama a onSurfaceTextureSizeChanged después de una rotación de 180 grados (el tamaño no cambia). Tampoco activa onConfigurationChanged, por lo que la única opción disponible es crear una instancia de DisplayListener y verificar las rotaciones de 180 grados. Como el dispositivo tiene cuatro orientaciones (vertical, horizontal, vertical inverso y horizontal inverso) definidas por los números enteros 0, 1, 2 y 3, debemos comprobar si hay una diferencia de rotación de 2.

Agrega el siguiente código:

step1/CameraActivity.kt

/** [DisplayManager] to listen to display changes */
private val displayManager: DisplayManager by lazy {
    applicationContext.getSystemService(DISPLAY_SERVICE) as DisplayManager
}

/** Keeps track of display rotations */
private var displayRotation = 0

.
.
.

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    displayManager.registerDisplayListener(displayListener, mainLooperHandler)
}

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    displayManager.unregisterDisplayListener(displayListener)
}

private val displayListener = object : DisplayManager.DisplayListener {
    override fun onDisplayAdded(displayId: Int) {}
    override fun onDisplayRemoved(displayId: Int) {}
    override fun onDisplayChanged(displayId: Int) {
        val difference = displayManager.getDisplay(displayId).rotation - displayRotation
        displayRotation = displayManager.getDisplay(displayId).rotation

        if (difference == 2 || difference == -2) {
            createCaptureSession()
        }
    }
}

Ahora sabemos que la sesión de captura se recrea en cualquier caso. Es hora de aprender sobre la relación oculta entre las orientaciones de la cámara y las rotaciones de la pantalla.

6. Orientación del sensor y rotaciones de la pantalla

Nos referimos a la orientación natural como la orientación en la que los usuarios tienden a usar un dispositivo "de forma natural". Por ejemplo, es posible que la orientación natural sea horizontal para una laptop y vertical para un teléfono. En una tablet, puede ser cualquiera de los dos.

A partir de esta definición, podemos definir otros dos conceptos.

1f9cf3248b95e534.png

Llamamos orientación de la cámara al ángulo entre el sensor de la cámara y la orientación natural del dispositivo. Es probable que esta dependa de cómo la cámara esté montada físicamente en el dispositivo y de que se supone que el sensor siempre está alineado con el lado largo de la pantalla (CDD).

Teniendo en cuenta que puede ser difícil definir el lado largo de un dispositivo plegable, ya que puede transformar físicamente su geometría, a partir del nivel de API 32, este campo ya no es estático, pero se puede recuperar de manera dinámica desde el objeto CameraCharacteristics.

Otro concepto es la rotación del dispositivo. Mide cuánto rota el dispositivo físicamente desde su orientación natural.

Dado que normalmente solo deseamos manejar cuatro orientaciones diferentes, podemos considerar únicamente los ángulos que son múltiplos de 90 y obtener esta información si multiplicamos el valor que muestra Display.getRotation() por 90.

De manera predeterminada, TextureView ya compensa la orientación de la cámara, pero no controla la rotación de la pantalla, lo que genera vistas previas que se rotan de forma incorrecta.

Para solucionar este problema, basta con que se rote el objeto SurfaceTexture de destino. Actualicemos la función CameraUtils.buildTargetTexture para aceptar el parámetro surfaceRotation: Int y aplicar la transformación a la superficie:

step1/CameraUtils.kt

fun buildTargetTexture(
    containerView: TextureView,
    characteristics: CameraCharacteristics,
    surfaceRotation: Int
): SurfaceTexture? {

    val previewSize = findBestPreviewSize(Size(containerView.width, containerView.height), characteristics)

    val surfaceRotationDegrees = surfaceRotation * 90

    val halfWidth = containerView.width / 2f
    val halfHeight = containerView.height / 2f

    val matrix = Matrix()

    // Rotate to compensate display rotation
    matrix.postRotate(
        -surfaceRotationDegrees.toFloat(),
        halfWidth,
        halfHeight
    )

    containerView.setTransform(matrix)

    return containerView.surfaceTexture?.apply {
        setDefaultBufferSize(previewSize.width, previewSize.height)
    }
}

Luego, modifica la línea 142 de CameraActivity de la siguiente manera para realizar la llamada:

step1/CameraActivity.kt

val transformedTexture = CameraUtils.buildTargetTexture(
textureView, characteristics,
displayManager.getDisplay(Display.DEFAULT_DISPLAY).rotation
)

Cuando se ejecute la app, se mostrará una vista previa como la siguiente:

1566c3f9e5089a35.png

La flecha ahora apunta a la parte superior, pero el contenedor aún no es cuadrado. Veamos cómo solucionar este problema en el último paso.

Escala el visor

El último paso es ajustar la superficie para que coincida con la relación de aspecto de la salida de la cámara.

El problema del paso anterior se produce porque, de forma predeterminada, TextureView escala su contenido para que se ajuste a toda la ventana. Esta ventana puede tener una relación de aspecto diferente a la de la vista previa de la cámara, por lo que puede estar estirada o distorsionada.

Podemos corregir este problema en dos pasos:

  • Calcula los factores de escala que la TextureView se aplicó a sí misma de forma predeterminada y revierte esa transformación.
  • Calcula y aplica el factor de escala correcto (que debe ser el mismo para los ejes x e y).

Para calcular el factor de escala correcto, debemos tener en cuenta la diferencia entre la orientación de la cámara y la rotación de la pantalla. Abre step1/CameraUtils.kt y agrega la siguiente función para calcular la rotación relativa entre la orientación del sensor y la rotación de la pantalla:

step1/CameraUtils.kt

/**
 * Computes the relative rotation between the sensor orientation and the display rotation
 */
private fun computeRelativeRotation(
    characteristics: CameraCharacteristics,
    deviceOrientationDegrees: Int
): Int {
    val sensorOrientationDegrees =
        characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0

    // Reverse device orientation for front-facing cameras
    val sign = if (characteristics.get(CameraCharacteristics.LENS_FACING) ==
        CameraCharacteristics.LENS_FACING_FRONT
    ) 1 else -1

    return (sensorOrientationDegrees - (deviceOrientationDegrees * sign) + 360) % 360
}

Es fundamental conocer el valor que muestra computeRelativeRotation, ya que nos permite comprender si se rotó la vista previa original antes de escalar.

Por ejemplo, en el caso de un teléfono en su orientación natural, la salida de la cámara tiene orientación horizontal y gira 90 grados antes de mostrarse en pantalla.

Por otro lado, para una Chromebook en su orientación natural, la salida de la cámara se muestra directamente en la pantalla sin ninguna rotación adicional.

Vuelve a analizar los siguientes casos:

4e3a61ea9796a914.png En el segundo caso (centro), se muestra el eje x de salida de la cámara sobre el eje y de la pantalla, y viceversa, lo que significa que se invierten el ancho y la altura de salida de la cámara durante la transformación. En los otros casos, se mantienen iguales, aunque la rotación aún es necesaria en la tercera situación.

Podemos generalizarlos con la siguiente fórmula:

val isRotationRequired =
        computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

Con esta información, podemos actualizar la función para escalar la superficie:

step1/CameraUtils.kt

fun buildTargetTexture(
    containerView: TextureView,
    characteristics: CameraCharacteristics,
    surfaceRotation: Int
): SurfaceTexture? {

    val surfaceRotationDegrees = surfaceRotation * 90

    val previewSize = findBestPreviewSize(Size(containerView.width, containerView.height), characteristics)

    val isRotationRequired =
        computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

    /* Scale factor required to scale the preview to its original size on the x-axis */
    val scaleX =
        if (isRotationRequired) {
            containerView.width.toFloat() / previewSize.height
        } else {
            containerView.width.toFloat() / previewSize.width
        }
    /* Scale factor required to scale the preview to its original size on the y-axis */
    val scaleY =
        if (isRotationRequired) {
            containerView.height.toFloat() / previewSize.width
        } else {
            containerView.height.toFloat() / previewSize.height
        }

    /* Scale factor required to fit the preview to the TextureView size */
    val finalScale = max(scaleX, scaleY)
    val halfWidth = containerView.width / 2f
    val halfHeight = containerView.height / 2f

    val matrix = Matrix()

    if (isRotationRequired) {
        matrix.setScale(
            1 / scaleX * finalScale,
            1 / scaleY * finalScale,
            halfWidth,
            halfHeight
        )
    } else {
        matrix.setScale(
            containerView.height / containerView.width.toFloat() / scaleY * finalScale,
            containerView.width / containerView.height.toFloat() / scaleX * finalScale,
            halfWidth,
            halfHeight
        )
    }

    // Rotate to compensate display rotation
    matrix.postRotate(
        -surfaceRotationDegrees.toFloat(),
        halfWidth,
        halfHeight
    )

    containerView.setTransform(matrix)

    return containerView.surfaceTexture?.apply {
        setDefaultBufferSize(previewSize.width, previewSize.height)
    }
}

Compila la app, ejecútala y disfruta de una brillante vista previa de la cámara.

7. Felicitaciones

¡Felicitaciones!

Si llegaste hasta este punto, deberías haber aprendido lo siguiente:

  • Cómo se comportan las apps no optimizadas en Android 12L, en el modo de compatibilidad
  • Cómo controlar los cambios de configuración
  • La diferencia entre conceptos como orientación de la cámara, rotación de la pantalla y orientación natural del dispositivo
  • El comportamiento predeterminado de TextureView
  • Cómo ajustar y rotar la superficie para mostrar correctamente la vista previa de la cámara en cada situación

Lecturas adicionales

Documentos de referencia