Compatibilidade com superfícies redimensionáveis no seu app de câmera

1. Introdução

Última atualização: 27 de outubro de 2022

Por que uma superfície redimensionável?

Historicamente, o app pode ter passado todo o ciclo de vida na mesma janela.

No entanto, com a disponibilidade de novos formatos, como dispositivos dobráveis, e novos modos de exibição, como de várias janelas e telas, essa realidade não é mais a mesma.

Vamos conferir algumas das considerações mais importantes ao desenvolver um app destinado a dispositivos dobráveis e de tela grande:

  • Não presuma que o app vai ocupar uma janela em modo retrato. Ainda há suporte na atualização do Android 12L para solicitar uma orientação fixa, mas agora estamos dando aos fabricantes de dispositivos a opção de substituir a solicitação do app por uma orientação preferencial.
  • Não presuma nenhuma dimensão ou proporção fixa para o app. Mesmo que você defina resizeableActivity = "false", o app vai poder ser usado no modo de várias janelas em telas grandes (>=600 dp) no nível da API 31 ou mais recente.
  • Não presuma uma relação fixa entre a orientação da tela e a câmera. O Documento de definição de compatibilidade do Android especifica que um sensor de imagem de câmera "PRECISA estar orientado de forma que a dimensão longa da câmera se alinhe à dimensão longa da tela". No nível 32 da API e versões mais recentes, os clientes de câmera que consultam a orientação em dispositivos dobráveis podem receber um valor que pode mudar dinamicamente dependendo do estado do dispositivo ou da dobra.
  • Não presuma que o tamanho do recuo não possa ser alterado. A nova barra de tarefas é informada aos aplicativos como um recuo. Além disso, quando usada com a navegação por gestos, a barra de tarefas pode ser ocultada e mostrada dinamicamente.
  • Não espere que o app tenha acesso exclusivo à câmera. Enquanto ele estiver no modo de várias janelas, outros apps podem ter acesso exclusivo a recursos compartilhados, como a câmera e o microfone.

É hora de aprender a ajustar a saída da câmera a superfícies redimensionáveis e a usar as APIs oferecidas pelo Android para processar diferentes casos de uso, assim, vamos garantir que o app de câmera funcione bem em todos os cenários.

O que você vai criar

Neste codelab, você vai criar um app simples que exibe a visualização da câmera. Vamos começar com um app de câmera simples que bloqueia a orientação e se declara não redimensionável. Em seguida, vamos conferir como ele se comporta no Android 12L.

Depois, vamos atualizar o código-fonte para que a visualização seja sempre exibida corretamente em todos os cenários. O resultado é um app de câmera que processa corretamente as mudanças de configuração e transforma automaticamente a superfície para que ela corresponda à visualização.

1df0acf495b0a05a.png

O que você vai aprender

  • Como as visualizações da Camera2 são mostradas nas superfícies do Android.
  • A relação entre orientação do sensor, rotação da tela e proporção.
  • Como transformar uma superfície para que ela corresponda à proporção da visualização da câmera e da rotação da tela.

O que é necessário

  • Uma versão recente do Android Studio.
  • Conhecimento básico sobre o desenvolvimento de apps Android.
  • Conhecimento básico das APIs Camera2.
  • Um dispositivo ou emulador com o Android 12L.

2. Configurar

Fazer o download do código inicial

Para entender o comportamento no Android 12L, vamos começar com um app de câmera que bloqueia a orientação e se declara como não redimensionável.

Se você tiver o Git instalado, basta executar o comando abaixo. Para saber se o Git está instalado, digite git --version no terminal ou na linha de comando e confira se ele é executado corretamente.

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

Caso não tenha o Git, clique no botão a seguir para baixar todo o código deste codelab:

Abrir o primeiro módulo

No Android Studio, abra o primeiro módulo localizado em /step1.

O Android Studio vai solicitar que você defina o caminho do SDK. Se você encontrar problemas, siga as recomendações para atualizar as ferramentas do SDK e o ambiente de desenvolvimento integrado.

302f1fb5070208c7.png

Se for solicitado que você use a versão mais recente do Gradle, atualize-a.

Preparar o dispositivo

No momento da data de publicação deste codelab, há um conjunto limitado de dispositivos físicos que podem executar o Android 12L.

Confira a lista de dispositivos e as instruções de instalação do 12L em: https://developer.android.com/about/versions/12/12L/get

Sempre que possível, use um dispositivo físico para testar apps de câmera. No entanto, caso você queira usar um emulador, crie um com uma tela grande (por exemplo, Pixel C) e com o nível 32 da API.

Preparar o objeto para enquadramento

Ao trabalhar com câmeras, recomendamos ter um objeto padrão para o qual você possa apontar a câmera e entender as diferenças de configurações, orientações e escalonamento.

Neste codelab, vamos usar uma versão impressa desta imagem em formato quadrado. 66e5d83317364e67.png

Se por acaso a seta não apontar para o topo ou o quadrado se tornar outra figura geométrica, algo precisa ser corrigido.

3. Executar e observar

Coloque o dispositivo no modo retrato e execute o código no módulo 1. Permita que o app Camera2 Codelab tire fotos e grave vídeos enquanto estiver em uso. Como você pode ver, a visualização é exibida corretamente e usa o espaço da tela de forma eficiente.

Agora, gire o dispositivo para o modo paisagem:

46f2d86b060dc15a.png

Isso não é nada bom. Clique no botão Atualizar no canto direito de baixo.

b8fbd7a793cb6259.png

Deve melhorar um pouco, mas ainda não é o ideal.

Este é o comportamento do modo de compatibilidade do Android 12L. Quando o dispositivo for girado para o modo paisagem e a densidade da tela for maior que 600 dp, os apps que bloqueiam a orientação no modo retrato podem apresentar o efeito letterbox.

Embora esse modo preserve a proporção original, a experiência do usuário oferecida não é ideal, já que a maior parte da tela não é usada.

Além disso, nesse caso, a visualização foi girada incorretamente em 90 graus.

Coloque o dispositivo no modo retrato e inicie o modo de tela dividida.

É possível arrastar o divisor central para redimensionar a tela.

Saiba como o redimensionamento afeta a visualização da câmera. Ela está distorcida? Ela mantém a mesma proporção?

4. A solução rápida

Como o modo de compatibilidade é acionado apenas para apps que bloqueiam a orientação e não são redimensionáveis, pode ser tentador atualizar as sinalizações no manifesto para evitar esse comportamento.

Faça o seguinte:

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>

Agora, crie e execute o app novamente na orientação paisagem. Você vai ver algo como:

f5753af5a9e44d2f.png

A seta não está apontando para o topo, e isso não é um quadrado.

Como o app não foi projetado para funcionar no modo de várias janelas ou em diferentes orientações, nenhuma mudança no tamanho da janela era esperada, o que causa os problemas que você acabou de encontrar.

5. Processar mudanças de configuração

Vamos começar dizendo ao sistema que queremos processar as mudanças de configuração por conta própria. Abra step1/AndroidManifest.xml e adicione estas linhas de código:

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>

Agora, você também precisa atualizar step1/CameraActivity.kt para recriar uma CameraCaptureSession sempre que o tamanho da superfície mudar.

Vá para a linha 232 e chame a função createCaptureSession():

step1/CameraActivity.kt

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

Há uma ressalva aqui: a função onSurfaceTextureSizeChanged não é chamada após uma rotação de 180 graus (o tamanho não muda). A função onConfigurationChanged também não é acionada, então a única opção que temos é instanciar um DisplayListener para detectar rotações de 180 graus. Como o dispositivo tem quatro orientações (retrato, paisagem, retrato invertido e paisagem invertida) definidas pelos números inteiros 0, 1, 2 e 3, precisamos procurar uma diferença de rotação igual a 2.

Adicione o código abaixo:

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()
        }
    }
}

Agora, temos certeza de que a sessão de captura vai ser recriada em qualquer caso. É hora de aprender sobre o relacionamento oculto entre as orientações da câmera e as rotações da tela.

6. Orientação do sensor e rotações da tela

Chamamos de orientação natural aquela usada pelo usuário de forma "natural" em um dispositivo. Por exemplo, é muito provável que a orientação natural de um laptop seja paisagem e, para um smartphone, retrato. Em um tablet, pode ser qualquer uma das duas.

A partir disso, podemos definir dois outros conceitos.

1f9cf3248b95e534.png

Chamamos de orientação da câmera o ângulo entre o sensor da câmera e a orientação natural do dispositivo. É provável que isso dependa da instalação física da câmera no dispositivo e da posição do sensor que precisa estar sempre alinhado com o lado longo da tela (consulte o CDD).

Considerando que pode ser difícil definir o lado longo de um dispositivo dobrável, já que a geometria física desses dispositivos pode mudar na API 32 e em versões mais recentes, esse campo não é mais estático, mas pode ser extraído dinamicamente do objeto CameraCharacteristics.

Outro conceito é a rotação do dispositivo, que mede quanto o dispositivo é girado fisicamente em relação à orientação natural.

Como geralmente o ideal é processar apenas quatro orientações diferentes, podemos considerar apenas ângulos que sejam múltiplos de 90 e saber essas informações multiplicando o valor retornado de Display.getRotation() por 90.

Por padrão, a TextureView já compensa a orientação da câmera, mas não processa a rotação da tela, o que resulta em visualizações giradas incorretamente.

Isso pode ser resolvido pela rotação da SurfaceTexture de destino. Vamos atualizar a função CameraUtils.buildTargetTexture para aceitar o parâmetro surfaceRotation: Int e aplicar a transformação à superfície:

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)
    }
}

Em seguida, você pode chamar essa função modificando a linha 138 CameraActivity desta forma:

step1/CameraActivity.kt

val targetTexture = CameraUtils.buildTargetTexture(
textureView, cameraManager.getCameraCharacteristics(cameraID))

A execução do app agora resulta em uma visualização como esta:

1566c3f9e5089a35.png

Agora, a seta aponta para o topo, mas o contêiner ainda não é um quadrado. Vamos ver como corrigir isso na última etapa.

Escalonar o visor

A última etapa é escalonar a superfície para ajustá-la à proporção da saída da câmera.

O problema da etapa anterior está acontecendo porque, por padrão, a TextureView escalona o conteúdo para que caiba em toda a janela. Essa janela pode ter uma proporção diferente da visualização da câmera, o que pode esticar ou distorcer a imagem.

Isso pode ser corrigido em duas etapas:

  • Calcular os fatores de escalonamento que a TextureView aplicou por padrão e reverter essa transformação
  • Calcular e aplicar o fator de escalonamento correto (que precisa ser igual para os eixos X e Y)

Para calcular o fator de escalonamento correto, precisamos considerar a diferença entre a orientação da câmera e a rotação da tela. Abra step1/CameraUtils.kt e adicione a função abaixo para calcular a rotação relativa entre a orientação do sensor e a rotação da tela:

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
}

É essencial saber o valor retornado por computeRelativeRotation para entender se a visualização original foi girada antes de ser escalonada.

Por exemplo, para um smartphone na orientação natural, a saída da câmera tem formato paisagem e é girada em 90 graus antes de ser mostrada na tela.

Por outro lado, para um Chromebook na orientação natural, a saída da câmera é exibida diretamente na tela, sem qualquer rotação adicional.

Revise os casos abaixo:

4e3a61ea9796a914.png No segundo caso (no centro), o eixo x da saída da câmera é mostrado sobre o eixo y da tela e vice-versa. Isso significa que a largura e a altura da saída da câmera estão sendo invertidas durante a transformação. Nos outros casos, essas dimensões se mantêm as mesmas, ainda que a rotação seja necessária no terceiro cenário.

Podemos generalizar esses casos com a fórmula:

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

Com essas informações, agora podemos atualizar a função para escalonar a superfície:

step1/CameraUtils.kt

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

        val surfaceRotationDegrees = surfaceRotation * 90
        val windowSize = Size(containerView.width, containerView.height)
        val previewSize = findBestPreviewSize(windowSize, characteristics)
        val sensorOrientation =
            characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
        val isRotationRequired =
            computeRelativeRotation(characteristics, surfaceRotationDegrees) % 180 != 0

        /* Scale factor required to scale the preview to its original size on the x-axis */
        var scaleX = 1f
        /* Scale factor required to scale the preview to its original size on the y-axis */
        var scaleY = 1f

        if (sensorOrientation == 0) {
            scaleX =
                if (!isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (!isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        } else {
            scaleX =
                if (isRotationRequired) {
                    windowSize.width.toFloat() / previewSize.height
                } else {
                    windowSize.width.toFloat() / previewSize.width
                }

            scaleY =
                if (isRotationRequired) {
                    windowSize.height.toFloat() / previewSize.width
                } else {
                    windowSize.height.toFloat() / previewSize.height
                }
        }

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

        val matrix = Matrix()

        if (isRotationRequired) {
            matrix.setScale(
                1 / scaleX * finalScale,
                1 / scaleY * finalScale,
                halfWidth,
                halfHeight
            )
        } else {
            matrix.setScale(
                windowSize.height / windowSize.width.toFloat() / scaleY * finalScale,
                windowSize.width / windowSize.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)
        }
    }

Crie e execute o app para conferir como a visualização da câmera está ótima.

Bônus: mudar a animação padrão

Para evitar a animação padrão na rotação, que pode parecer atípica para apps de câmera, é possível mudá-la para que seja uma animação de corte, que funciona como uma transição mais suave. Basta adicionar o código abaixo ao método onCreate() da atividade:

val windowParams: WindowManager.LayoutParams = window.attributes
windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
window.attributes = windowParams

7. Parabéns

O que você aprendeu:

  • Como os apps não otimizados se comportam no modo de compatibilidade do Android 12L.
  • como processar mudanças de configuração;
  • A diferença entre conceitos como orientação da câmera, rotação da tela e orientação natural do dispositivo.
  • O comportamento padrão da TextureView.
  • Como escalonar e girar a superfície para mostrar corretamente a visualização da câmera em todos os cenários.

Leia mais

Documentos de referência