Prendre en charge les surfaces redimensionnables dans votre application d'appareil photo

1. Introduction

Dernière mise à jour : 27/10/2022

Pourquoi une surface redimensionnable ?

Par le passé, votre application pouvait être utilisée dans la même fenêtre pendant tout son cycle de vie.

Aujourd'hui, avec l'émergence de nouveaux facteurs de forme, tels que les appareils pliables, et de nouveaux modes d'affichage, tels que le mode multifenêtre et le mode multiécran, vous ne pouvez plus le présumer.

Nous allons examiner plus spécifiquement quelques-unes des considérations les plus importantes à prendre en compte lors du développement d'applications ciblant les grands écrans et les appareils pliables :

  • Ne partez pas du principe que votre application sera utilisée exclusivement en mode Portrait. La version Android 12L permet toujours de demander une orientation fixe, mais les fabricants d'appareils peuvent désormais ignorer la requête de l'application pour respecter d'autres préférences d'affichage.
  • Ne vous basez pas sur des dimensions ou un format fixes. Même si vous définissez resizeableActivity = "false", les grands écrans (600 dp et au-delà) peuvent toujours afficher votre application en mode multifenêtre au niveau d'API 31 ou supérieur.
  • Ne vous basez pas sur une relation fixe entre les orientations de l'écran et de l'appareil photo. Le document de définition de compatibilité Android spécifie qu'un capteur d'image de l'appareil photo "DOIT être orienté de sorte à faire correspondre la dimension longue de l'appareil photo à la dimension longue de l'écran". À partir du niveau d'API 32, les clients d'appareils photo qui interrogent l'orientation des appareils pliables peuvent recevoir une valeur susceptible de changer de manière dynamique, en fonction de l'état de l'appareil (position pliée ou non, etc.).
  • Ne vous basez pas sur une taille de l'encart fixe. La nouvelle barre des tâches est transmise aux applications comme un encart, mais lorsqu'elle est utilisée avec la navigation par gestes, la barre des tâches peut être affichée ou masquée de façon dynamique.
  • Ne partez pas du principe que votre application dispose d'un accès exclusif à l'appareil photo. Tant qu'elle est en mode multifenêtre, les autres applications peuvent avoir un accès exclusif aux ressources partagées, comme l'appareil photo et le micro.

Vous devez vous assurer que votre application d'appareil photo fonctionne correctement dans tous les scénarios. Pour cela, vous allez apprendre à transformer la sortie caméra afin qu'elle s'adapte aux surfaces redimensionnables, et à utiliser les API proposées par Android pour gérer différents cas d'utilisation.

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez créer une application simple qui affiche l'aperçu de l'appareil photo. Dans un premier temps, vous allez créer une application naïve qui verrouille l'orientation de l'appareil et se déclare non redimensionnable, et vous observerez son comportement sous Android 12L.

Ensuite, vous mettrez à jour le code source pour vous assurer que l'aperçu s'affiche correctement dans tous les scénarios. Vous obtiendrez une application d'appareil photo qui gère correctement les changements de configuration et transforme automatiquement la surface pour correspondre à l'aperçu.

1df0acf495b0a05a.png

Points abordés

  • Comment les aperçus Camera2 sont affichés sur les surfaces Android
  • La relation entre l'orientation du capteur, la rotation de l'écran et le format d'affichage
  • Comment transformer une surface pour qu'elle corresponde au format de l'aperçu de l'appareil photo et à la rotation de l'écran

Ce dont vous avez besoin

  • Une version récente d'Android Studio
  • Des connaissances de base du développement d'applications Android
  • Des connaissances de base des API Camera2
  • Un appareil ou un émulateur exécutant Android 12L

2. Configurer

Obtenir le code de démarrage

Pour comprendre le comportement sous Android 12L, vous commencerez par utiliser une application d'appareil photo qui verrouille l'orientation et se déclare non redimensionnable.

Si Git est installé, vous pouvez simplement exécuter la commande ci-dessous. Pour vérifier si Git est installé, saisissez git --version dans le terminal ou la ligne de commande, et vérifiez qu'il s'exécute correctement.

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

Si vous n'avez pas accès à Git, cliquez sur le bouton ci-dessous pour télécharger l'ensemble du code de cet atelier de programmation : .

Ouvrir le premier module

Dans Android Studio, ouvrez le premier module situé sous /step1.

Android Studio vous invite à spécifier le chemin d'accès au SDK. Si vous rencontrez des problèmes, vous pouvez suivre les recommandations concernant la mise à jour de l'IDE et des SDK Tools.

302f1fb5070208c7.png

Si vous êtes invité à utiliser la dernière version de Gradle, continuez et mettez-la à jour.

Préparer l'appareil

À la date de publication de cet atelier de programmation, seuls quelques appareils physiques peuvent exécuter Android 12L.

Vous trouverez la liste des appareils et des instructions pour installer la version 12L sur la page https://developer.android.com/about/versions/12/12L/get.

Dans la mesure du possible, utilisez un appareil physique pour tester les applications d'appareil photo. Toutefois, si vous souhaitez utiliser un émulateur, assurez-vous d'en créer un avec un grand écran (par exemple, Pixel C) et avec le niveau d'API 32.

Préparer un sujet pour la prise de vue

Lorsque vous travaillez avec des appareils photo, il peut être utile de disposer d'un sujet standard afin de pouvoir analyser les différences de paramétrage, d'orientation et de mise à l'échelle dans vos prises de vue.

Les exemples de cet atelier de programmation utiliseront une version imprimée de cette image carrée : 66e5d83317364e67.png

Si la flèche ne pointe pas vers le haut, ou si le carré est devenu une autre figure géométrique… vous avez un problème à résoudre !

3. Exécuter et observer

Positionnez l'appareil en mode Portrait et exécutez le code du module 1. Veillez à autoriser l'application Camera2 Codelab à prendre des photos et à enregistrer des vidéos lorsque vous l'utilisez. Comme vous pouvez le voir, l'aperçu s'affiche correctement et utilise efficacement l'espace de l'écran.

Ensuite, faites pivoter l'appareil en mode Paysage :

46f2d86b060dc15a.png

On est loin du résultat idéal. Cliquez sur le bouton d'actualisation en bas à droite.

b8fbd7a793cb6259.png

Vous devriez voir une amélioration, mais ce n'est pas encore optimal.

Ce comportement est dû au mode de compatibilité d'Android 12L. Les applications qui verrouillent leur orientation en mode Portrait peuvent passer au format letterbox lorsque l'appareil pivote en mode Paysage avec une densité d'écran supérieure à 600 dp.

Bien que ce mode préserve le format d'origine, il n'offre pas une expérience utilisateur optimale, car la majeure partie de la surface d'affichage reste inutilisée.

De plus, dans notre cas, la rotation de l'aperçu est décalée de 90 degrés.

Replacez l'appareil en mode Portrait, puis démarrez en mode Écran partagé.

Vous pouvez redimensionner la fenêtre en faisant glisser le séparateur central.

Explorez l'effet du redimensionnement sur l'aperçu de l'appareil photo. Est-il déformé ? Son format est-il préservé ?

4. Solution rapide

Étant donné que le mode de compatibilité n'est activé que pour les applications qui verrouillent l'orientation et ne sont pas redimensionnables, vous pourriez être tenté de modifier les indicateurs dans le fichier manifeste pour contourner le problème.

Essayez :

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>

Compilez l'application et exécutez-la à nouveau en mode Paysage. Le résultat devrait se présenter comme suit :

f5753af5a9e44d2f.png

La flèche ne pointe pas vers le haut, et votre carré n'en est plus un !

Comme l'application n'a pas été conçue pour fonctionner en mode multifenêtre ou dans des orientations différentes, elle ne gère pas les changements de taille de la fenêtre. C'est ce qui entraîne les problèmes que vous avez rencontrés.

5. Gérer les modifications de configuration

Commençons par indiquer au système que nous voulons gérer manuellement les modifications de configuration. Ouvrez step1/AndroidManifest.xml et ajoutez les lignes suivantes :

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>

À présent, mettez à jour step1/CameraActivity.kt pour recréer CameraCaptureSession chaque fois que la taille de la surface change.

Accédez à la ligne 232 et appelez la fonction createCaptureSession() :

step1/CameraActivity.kt

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

Il reste une faille dans la démarche : onSurfaceTextureSizeChanged n'est pas appelé en cas de rotation sur 180 degrés (car la taille ne change pas). Même constat pour onConfigurationChanged. La seule option consiste à instancier un DisplayListener pour vérifier les rotations de 180 degrés. Comme l'appareil permet quatre orientations (portrait, paysage, portrait inversé et paysage inversé) définies par les nombres entiers 0, 1, 2 et 3, nous devons vérifier une éventuelle différence de rotation de 2.

Ajoutez le code suivant :

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

Nous sommes désormais certains que la session de capture est recréée dans tous les scénarios. Passons maintenant à la relation cachée entre l'orientation de l'appareil photo et la rotation de l'écran.

6. Orientation des capteurs et rotation de l'écran

Le terme orientation naturelle désigne l'orientation dans laquelle l'appareil a tendance à être utilisé "naturellement". Par exemple, le mode Paysage est probablement l'orientation naturelle d'un ordinateur portable, tandis que le mode Portrait est l'orientation naturelle d'un téléphone. Pour les tablettes, il s'agit de l'une ou l'autre de ces options.

Nous pouvons partir de cette définition pour définir deux autres concepts.

1f9cf3248b95e534.png

Le terme orientation de la caméra désigne l'angle entre le capteur vidéo et l'orientation naturelle de l'appareil. Cet angle dépend probablement de la façon dont la caméra est physiquement installée sur l'appareil, le capteur étant supposé être toujours aligné avec le bord long de l'écran (voir le CDD).

Il peut être difficile de définir le bord long d'un appareil pliable, dont la géométrie physique se transforme. En conséquence, à partir du niveau d'API 32, ce champ n'est plus statique et peut être récupéré de manière dynamique à partir de l'objet CameraCharacteristics.

La rotation de l'appareil est un autre concept. Elle mesure la rotation physique de l'appareil par rapport à son orientation naturelle.

Comme nous ne voulons généralement gérer que quatre orientations différentes, nous pouvons nous limiter aux angles multiples de 90. Pour obtenir cette information, il suffit de multiplier par 90 la valeur renvoyée par Display.getRotation().

Par défaut, TextureView compense déjà l'orientation de l'appareil photo, mais ne gère pas la rotation de l'écran, ce qui peut entraîner une rotation incorrecte de l'aperçu.

Pour résoudre ce problème, il vous suffit de faire pivoter la SurfaceTexture cible. Mettons à jour la fonction CameraUtils.buildTargetTexture pour accepter le paramètre surfaceRotation: Int et appliquer la transformation à la surface :

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

Vous pouvez ensuite l'appeler en modifiant la ligne 138 de CameraActivity comme suit :

step1/CameraActivity.kt

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

Si vous exécutez l'application maintenant, l'aperçu devrait ressembler à ce qui suit :

1566c3f9e5089a35.png

La flèche pointe maintenant vers le haut, mais le carré est toujours déformé. Nous résoudrons ce problème à la dernière étape.

Mettre à l'échelle le viseur

La dernière étape consiste à redimensionner la surface pour qu'elle corresponde au format de sortie de l'appareil photo.

Le problème observé lors de l'étape précédente se produit, car TextureView adapte par défaut le contenu à la fenêtre entière. Le format de cette fenêtre peut être différent de celui de l'aperçu, qui se retrouve étiré ou déformé.

Ce problème peut être résolu en deux étapes :

  • Calculer les facteurs de scaling que TextureView s'applique par défaut, et inverser cette transformation
  • Calculer et appliquer le facteur de scaling approprié (qui doit être identique pour les axes X et Y)

Pour calculer le facteur de scaling approprié, nous devons prendre en compte la différence entre l'orientation de la caméra et celle de l'écran. Ouvrez step1/CameraUtils.kt et ajoutez la fonction suivante pour calculer l'écart relatif entre l'orientation du capteur et la rotation de l'écran :

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
}

Il est essentiel de connaître la valeur renvoyée par computeRelativeRotation, car elle permet de déterminer si l'aperçu d'origine a été pivoté avant le scaling.

Par exemple, dans le cas d'un téléphone dans son orientation naturelle, la sortie de l'appareil photo est en mode Paysage et pivote de 90 degrés avant d'être affichée à l'écran.

En revanche, dans le cas d'un Chromebook dans son orientation naturelle, la sortie de la caméra s'affiche directement sans rotation supplémentaire.

Examinez à nouveau les cas suivants :

4e3a61ea9796a914.png Dans le second cas (milieu), l'axe X de la sortie de l'appareil photo est affiché sur l'axe Y de l'écran, et inversement. Cela signifie que la largeur et la hauteur de la sortie de l'appareil photo sont inversées lors de la transformation. Dans les autres cas, elles restent identiques, bien qu'une rotation soit toujours requise dans le troisième scénario.

Nous pouvons généraliser ces cas avec la formule suivante :

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

Grâce à ces informations, nous pouvons désormais mettre à jour la fonction pour adapter la surface :

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

Compilez l'application, exécutez-la et profitez du super aperçu de votre appareil photo !

Bonus : Modifier l'animation par défaut

Si vous souhaitez éviter l'animation par défaut lors de la rotation, qui peut sembler atypique pour les applications d'appareil photo, vous pouvez la remplacer par une animation de transition plus fluide. Pour ce faire, ajoutez le code suivant à la méthode onCreate() de l'activité :

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

7. Félicitations

Connaissances acquises :

  • Comment les applications non optimisées se comportent sous Android 12L en mode de compatibilité
  • Comment gérer les changements de configuration
  • Quelles sont les différences entre des concepts tels que l'orientation de la caméra, la rotation de l'écran ou l'orientation naturelle de l'appareil
  • Comportement par défaut de TextureView
  • Comment faire pivoter et mettre à l'échelle la surface pour afficher correctement l'aperçu de l'appareil photo dans tous les scénarios

Complément d'informations

Documents de référence