CameraX, la nouvelle librairie photo pour Android

Annoncée à la Google I/O 2019, CameraX fut présentée comme étant l’API permettant d’améliorer et de simplifier considérablement le développement photo dans le monde Android.
Cette librairie est, à l'heure où j'écris cet article, en alpha, mais elle présente déjà un certain nombre de nouveautés assez puissantes sur lesquelles nous allons nous intéresser ici.
Suite au Meetup Android (organisé par le GDG Android Nantes) que j’ai co-animé avec Thomas BOUTIN le mardi 10 septembre 2019 dans nos nouveaux locaux nantais, et dans lequel je présente CameraX, j’ai décidé d’en faire un article afin de vous en parler de façon plus approfondie.

Petit état des lieux de l’avant CameraX

Si vous vous êtes déjà frottés au développement photo dans une application Android, que ce soit avec Camera2 ou même Camera1, vous avez déjà dû constater que ce n’est pas une mince affaire.

Camera2 est une API relativement puissante mais qui nécessite une connaissance assez pointue et donc une prise en main assez longue et fastidieuse. Le code au final est lourd et difficilement maintenable. Jugez par vous-même avec ce petit tutoriel disponible sur Internet.
Vous remarquerez que nous devons utiliser différents objets tels que CameraManager, CameraDevice, CaptureRequest, CameraCaptureSession, etc., et j’en passe.

D’autre part, vous devrez gérer vous-même le cycle de vie de Camera2 : ce sera à vous de définir quand éteindre la caméra, quand la démarrer et quand notamment gérer le cas de la rotation d’écran (un cas qui peut facilement faire planter votre application si vous n’y faites pas attention). Pour cela, vous devrez surcharger les fonctions de cycle de vie de l’Activity ou du Fragment que vous utiliserez, par exemple onStart(), onResume(), onDestroy(), etc.

Ensuite, vous devrez faire en sorte que votre code fonctionne avec la multitude de terminaux Android présents sur le marché, ce qui représente 80% des devices sur le marché, sans compter les différentes versions Android présentes (au contraire d’Apple qui maîtrise ses devices de A à Z). Autant de choses que vous devrez prendre en compte pour avoir une application photo la plus fiable et la plus robuste possible, il n’y a plus qu’à vous souhaiter bon courage ! :D

Enfin, on notera également que les téléphones de façon générale ont connu une évolution importante dans le domaine de la photo depuis plusieurs années : on est passé de 1 à 2, 3 voire même 5 objectifs à l’arrière (cf. le Nokia 9 PureView), et d’autre part, de nouveaux modes “logiciels” plus orientés professionnels sont venus s’ajouter au monde Android, notamment le mode HDR, le mode nuit ou encore le mode portrait. Tout cela améliorant considérablement la qualité de nos prises de vues.

Tout ça pour simplement récupérer un flux photo et faire une capture, alors pour des fonctionnalités plus poussées telles que de la reconnaissance d’image par exemple, je vous laisse imaginer.
Vous l’aurez convenu, difficile donc de réaliser une application orientée photo et avec des fonctionnalités un minimum poussées avec du code propre, simple et facilement compréhensible.

Et Google présenta CameraX...

Rapide introduction

Si vous avez suivi de près la Google I/O de mai 2019, vous aurez repéré qu’une présentation de 30 minutes est consacrée à CameraX.
Cette librairie est présentée comme ajoutant une couche d’abstraction à Camera2. En effet, CameraX apporte une nouvelle façon de développer une application photo en reprenant l’ensemble des fonctionnalités de Camera2, mais l’énorme avantage est que toute la partie hardware est complètement transparente pour le développeur. Fini les problèmes de compatibilité ou les gestions selon les constructeurs :

if (Build.MANUFACTURER.equalsIgnoreCase("samsung")) {
    ... // This code will make you die a little inside.
}

Pour l’instant, l’API est en version alpha, cela signifie que Google est en pleine phase de prise de feedbacks de la part des développeurs qui auront pu jouer avec la librairie.

Enfin, CameraX fait partie du programme Android Jetpack dont nous allons faire un petit rappel dans la partie suivante.

Android Jetpack

Android Jetpack est un programme lancé lors de la Google I/O 2018. Avant ce programme, chaque développeur Android était plus ou moins livré à lui-même car il n’y avait pas de bonnes pratiques de définies par Google. Chacun appliquait donc ses propres recommandations quant à l’architecture à utiliser notamment.
Google a voulu répondre à cette problématique en sortant Android Jetpack. Il s’agit d’un programme qui comprend un ensemble de librairies et d’outils visant à aider les développeurs à écrire des apps Android de meilleure qualité, avec une architecture cohérente, maintenue et performante, et avec du code plus fiable et permettant de simplifier les tâches complexes.
Jetpack comprend notamment les Android extension libraries (autrement appelées AndroidX), des librairies visant à gérer les problèmes de rétrocompatibilité longuement présents dans le monde Android.
CameraX vient s’inscrire dans ce programme, plus précisément dans la partie Behavior, une partie composée de librairies permettant d’intégrer les différents services Android (notifications, permissions, médias, etc.).

Le schéma de Jetpack est articulé de la façon suivante (remis à jour avec les derniers composants) :

Schema-Android-Jetpack2-1

La catégorie qui va nous intéresser ici est Architecture. Cette catégorie regroupe des librairies dont le but est de développer des applications robustes, facilement testables et facilement maintenables. Ces librairies permettent notamment de gérer le cycle de vie des composants ainsi que la persistance des données. Entre autre, les composants tels que les Activity ou les Fragments seront “conscients” du cycle de vie qu’ils contiennent (ils sont LifecycleOwner) et pourront ainsi l’exposer. Cela va permettre à d’autres composants qui observent (LifecycleObserver), et qui sont donc lifecycle aware, de pouvoir gérer leurs actions selon les cycles de vie des LifecycleOwner.

Si les Android Architecture Components sont quelque chose de nouveau pour vous, vous pouvez lire cet article de Thomas BOUTIN, qui présente de façon claire les différents avantages des librairies. Si vous souhaitez approfondir vos connaissances, vous pourrez ensuite suivre une mise en pratique composée de 7 articles (en commençant ici) afin de maîtriser les AAC jusqu’au bout des doigts. ;-)

Revenons à CameraX. La raison pour laquelle j’ai pointé la partie Architecture du doigt est relativement simple : dans l’état des lieux de l’avant CameraX, j’énonçais le fait qu’il fallait gérer soi-même l’ouverture et la fermeture de la caméra en se calquant sur le cycle de vie du composant qui l’intègre. Et bien avec CameraX, vous n’aurez plus ce problème ! Et ce grâce à une seule et unique fonction qui gèrera tout à votre place (et qui fera même le café), elle s’appelle bindToLifecycle(). Littéralement, on peut traduire ça par “brancher au cycle de vie”. Cela signifie qu’une fois votre composant CameraX configuré, vous n’aurez plus qu’à le brancher au cycle de vie exposé par votre LifecycleOwner.
Ainsi, vous n’aurez plus à surcharger les méthodes onStart(), onResume(), etc., puisque la fonction gèrera tout cela et ce sera totalement transparent pour vous.
Imaginez donc le nombre de lignes de code en moins rien qu’avec cette fonction. ;-)
Nous verrons comment cela s’implémente concrètement dans la partie technique.

Mais dis-moi Jamy, comment ça marche CameraX ?

Nous venons de voir comment CameraX gérait les cycles de vie, nouveauté somme toute, plutôt puissante.
Nous allons voir ici comment cette librairie s’implémente concrètement.

CameraX est une API basée sur des use cases. Le but est de pouvoir nous concentrer sur ce que nous souhaitons que l’application fasse, et d’arrêter de passer du temps à gérer les différences des téléphones niveau hardware.
Voici les use cases basiques, sur ce schéma repris directement de la Google I/O :

Captureandroid2-1

En premier, on aura le use case Preview, il s’agira simplement d’afficher le flux vidéo sur notre écran.
Ensuite, on aura Image analysis. Comme son nom l’indique, ce use case va permettre de faire de l’analyse d’image en extrayant des informations sur la luminosité par exemple, ou en les envoyant notamment à des algorithmes. Un cas intéressant est l’utilisation de MLKit qui va nous permettre de faire de la reconnaissance d’image par exemple.
Enfin, Capture va nous permettre de sauvegarder simplement la photo (ou la vidéo).

Dans ces 3 use cases, on va pouvoir définir une configuration plus ou moins commune. On va pouvoir par exemple définir la résolution, le ratio, l’utilisation du flash, l’objectif avant ou arrière, la qualité de l’image, etc. Nous verrons tout cela tout à l’heure dans la démo.
Ainsi, on se détache totalement des problèmes rencontrés dans les versions précédentes de l’API Camera, et vous verrez qu’en quelques dizaines de minutes, vous pourrez développer une application photo un minimum fonctionnelle.

Il est aussi important d’ajouter que Google a prévu les fonctionnalités avancées telles que le mode HDR, le mode nuit, le mode beauté ou encore le mode portrait. Cela est accessible grâce à la librairie appelée Vendor Extensions.

Maintenant, passons à la pratique !

Présentation du projet

Avant toute chose, sachez que je vais pas m’attarder sur ce qui ne concerne pas CameraX. Il existe une multitude de tutoriels vous permettant de débuter une application Android.
Sachez en tout cas que je vais m’appuyer sur un sample que j’ai développé et qui est présent sur mon GitHub à cette adresse : https://github.com/yannickj10/CameraX-Sample. Ce sample reprend les 3 use cases que nous avons vu tout à l’heure.

Pour expliquer rapidement l’architecture du projet, nous avons :

  • un MainActivity.kt qui lance le premier fragment (PermissionFragment.kt) selon le navigation graph (nav_graph.xml présent dans les resources). Pour en savoir plus sur les Navigation components (composants aussi issus de Jetpack), vous pouvez retrouver plus d’informations sur la documentation Android ;
  • un PermissionFragment.kt qui s’occupe de demander la permission à l’utilisateur d’utiliser la caméra (nous n’allons pas nous attarder dessus) ;
  • un ImagePreviewFragment.kt qui s’occupe d’afficher un aperçu de l’image lorsqu’une capture a été réalisée (nous n’allons pas non plus passer de temps dessus) ;
  • un CameraConfiguration.kt qui regroupe l’ensemble de la configuration utilisée dans nos 3 use cases ;
  • un CameraFragment.kt qui va nous intéresser car il implémente les 3 use cases. Ce sont essentiellement des morceaux de code de ce fragment que je présenterai et expliquerai ici ;
  • enfin, le layout fragment_camera.xml (présent dans les resources) qui contient entre autre la TextureView, le composant permettant d’afficher le flux vidéo.

Explication du code et démonstration

Si vous ouvrez le fragmentcamera.xml, vous aurez une ContraintLayout qui englobe la TextureView et un bouton. Le bouton sert simplement à prendre la photo (avec le use case Capture). La TextureView est définie de façon relativement simple :

<TextureView
            android:id="@+id/view_finder"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

On va simplement lui définir un ID et faire en sorte qu’il utilise toute la place de son parent, c’est-à-dire le layout, qui lui-même utilise l’écran en entier. On aura donc un flux vidéo en plein écran.

Maintenant, ouvrons le CameraFragment.kt. Comme je l’ai précisé plus haut, les 3 use cases sont regroupés ici, dans les fonctions buildPreviewUseCase(), buildImageCaptureUseCase() et buildImageAnalysisUseCase().
Dans un premier temps, regardons de plus près la fonction setupCamera(). Celle-ci va récupérer les dimensions de l’écran de notre téléphone et les utiliser pour instancier l’objet CameraConfiguration en lui donnant un ratio, une rotation et une résolution. Cet objet sera ensuite utilisé pour les 3 use cases :

val metrics = DisplayMetrics().also { view_finder.display.getRealMetrics(it) }
        config = CameraConfiguration(
            aspectRatio = Rational(metrics.widthPixels, metrics.heightPixels),
            rotation = view_finder.display.rotation,
            resolution = Size(metrics.widthPixels, metrics.heightPixels)
        )

Si vous allez dans le CameraConfiguration.kt, ce ne sont rien d’autre que des variables. Cela nous évite simplement d’avoir à redéfinir la configuration à chaque instanciation des use cases.

Ensuite, vous remarquerez l’appel de la fameuse fonction magique bindToLifecycle() qui reçoit en paramètre le this, c’est-à-dire le fragment et les 3 use cases. Tout simplement, on branche le cycle de vie des use cases au cycle de vie du fragment. C’est le seul morceau de code utile à gérer les problèmes de cycle de vie présents dans Camera2 et Camera.
On peut maintenant se concentrer entièrement sur la configuration et l’utilisation des use cases.

Image Preview

La fonction concernant le premier use case est définie ici :

    private fun buildPreviewUseCase(): Preview {
        val previewConfig = PreviewConfig.Builder()
            .setTargetAspectRatio(config.aspectRatio)
            .setTargetRotation(config.rotation)
            .setTargetResolution(config.resolution)
            .setLensFacing(config.lensFacing)
            .build()
        val preview = Preview(previewConfig)

        preview.setOnPreviewOutputUpdateListener { previewOutput ->
            val parent = view_finder.parent as ViewGroup
            parent.removeView(view_finder)
            parent.addView(view_finder, 0)

            view_finder.surfaceTexture = previewOutput.surfaceTexture
        }

        return preview
    }

Dans un premier temps, on définit une configuration avec PreviewConfig dans laquelle on définit différentes valeurs récupérées dans notre CameraConfiguration. On pourra aussi définir quel objectif utiliser (avant ou arrière).

Il est important de préciser que si aucune configuration n’est définie, CameraX sera capable de trouver une configuration par défaut selon la taille et le nombre de pixels de votre écran, le nombre de pixels de vos objectifs photo, et de faire un choix optimal.

On instancie ensuite notre Preview.
Ensuite, Preview met à disposition une méthode pour définir un listener. A chaque fois que la preview sera active, elle va fournir un previewOuput. On doit alors mettre à jour notre viewFinder puis attacher la surfaceTexture de la previewOutput à la surfaceView de notre viewFinder. (J'espère que vous suivez toujours :D )
Voilà, notre preview est prête et est branchée au cycle de vie du composant père. Si vous lancez l’appli en commentant les 2 autres use cases, vous verrez ainsi le flux vidéo.

Image Analysis

Ce second use case est utilisé, comme son nom l’indique, pour de l’analyse d’image. Voici la fonction :

    private fun buildImageAnalysisUseCase(): ImageAnalysis {

        val analysisConfig = ImageAnalysisConfig.Builder()
            .setImageReaderMode(config.readerMode)
            .setImageQueueDepth(config.queueDepth)
            .build()
        val analysis = ImageAnalysis(analysisConfig)

        analysis.setAnalyzer { image, rotationDegrees ->
            val buffer = image.planes[0].buffer
            // Extract image data from callback object
            val data = buffer.toByteArray()
            // Convert the data into an array of pixel values
            val pixels = data.map { it.toInt() and 0xFF }
            // Compute average luminance for the image
            val luma = pixels.average()
            Log.d("CameraFragment", "Luminance: $luma")
        }

        return analysis
    }

La configuration est définie sur le même principe que Preview : on a un objet Config auquel on ajoute des attributs. Ici, on va spécifier que l’on souhaite récupérer la dernière image de la file d'attente (composée de 5 images), en éliminant les plus anciennes.
Si on augmente le nombre d’images dans la file d’attente, l’application paraîtra plus fluide mais l’utilisation de la mémoire sera plus importante.

On va ensuite définir un analyzer qui va nous fournir un ImageProxy. Il s’agit d’un objet regroupant l’ensemble des informations de l’image que l’on souhaite analyser. Il contient par exemple la luminance, le contraste, etc. Dans notre exemple, on extrait la luminance que l’on affiche dans les logs de notre IDE. Plus l’image à l’écran sera éclairée, plus la valeur sera élevée.
Un exemple très intéressant que j’ai d’ailleurs présenté lors du Meetup est la reconnaissance d’image : l’idée est tout simplement d’extraire les infos nécessaires et de les envoyer à l’outil ML Kit. Il est ainsi capable, avec une certaine probabilité, de reconnaître les objets de la Preview. J’avais repris l’application développée dans cet article.

Image Capture

Le dernier use case est donc Capture. Il s’agira de créer une capture de notre flux et de l’enregistrer :

    private fun buildImageCaptureUseCase(): ImageCapture {
        val captureConfig = ImageCaptureConfig.Builder()
            .setTargetAspectRatio(config.aspectRatio)
            .setTargetRotation(config.rotation)
            .setTargetResolution(config.resolution)
            .setFlashMode(config.flashMode)
            .setCaptureMode(config.captureMode)
            .build()
        val capture = ImageCapture(captureConfig)

        camera_capture_button.setOnClickListener {
            val fileName = "myPhoto"
            val fileFormat = ".jpg"
            val imageFile = createTempFile(fileName, fileFormat)

            capture.takePicture(imageFile, object : ImageCapture.OnImageSavedListener {
                override fun onImageSaved(file: File) {

                    val arguments = ImagePreviewFragment.arguments(file.absolutePath)
                    Navigation.findNavController(requireActivity(), R.id.mainContent)
                        .navigate(R.id.imagePreviewFragment, arguments)

                    Toast.makeText(requireContext(), "Image saved", Toast.LENGTH_LONG).show()
                }

                override fun onError(useCaseError: ImageCapture.UseCaseError, message: String, cause: Throwable?) {

                    Toast.makeText(requireContext(), "Error: $message", Toast.LENGTH_LONG).show()
                    Log.e("CameraFragment", "Capture error $useCaseError: $message", cause)
                }
            })
        }

        return capture
    }

Je ne vais pas repasser sur la configuration puisqu’elle est similaire à celle de la Preview. Une fois notre ImageCapture instancié, on va pouvoir définir un listener sur notre bouton qui s’exécutera à chaque clic. On crée un fichier temporaire, puis on appelle la fonction takePicture() mise à disposition par l’ImageCapture. Elle prend en paramètre notre fichier nouvellement créé. On va pouvoir ensuite surcharger onImageSaved() et onError(). Dans onImageSaved(), on passe le chemin du fichier créé au ImagePreviewFragment puis on affiche un petit Toast. En cas d’erreur, on affiche simplement son origine.
Vous n’avez rien de plus à faire.

Conclusion

Nous venons de voir au travers de cet article et de la démonstration qu’il est totalement possible d’avoir une application un minimum fonctionnelle en quelques lignes de code. Bien évidemment, elle est basique, et il vous faudra donc lire la documentation afin d’en développer une plus étoffée selon vos besoins, notamment si vous voulez ajouter les fonctionnalités avancées avec les Vendor Extensions.

La simplification du code a permis, selon les premiers retours présentés à la Google I/O, de réduire de 70% le code par rapport à Camera2, ce qui est plutôt considérable.

D’autre part, Google est en ce moment dans une phase d’amélioration de cette API, notamment grâce à un laboratoire appelé Automated CameraX test lab dans lequel ils regroupent plusieurs téléphones, de différents constructeurs et sous différentes versions d’Android et sur lesquels ils réalisent différents tests (tests fonctionnels, tests de performance, etc.). Ils ont ainsi pu déjà corriger des problèmes de crashs d’application ou d’orientation du téléphone par exemple.
A cela s’ajoute le fait qu’ils sont en pleine phase de recueillement des feedbacks de la part des développeurs. Ils ont donc une réelle volonté d’amélioration, et c’est plutôt prometteur.

Enfin, à l’heure où j’écris cet article, le support vidéo n’est pas encore disponible, il n’y a pas de documentation à ce sujet, mais on peut déjà trouver avec quelques recherches un objet appelé VideoCapture. La configuration est similaire à l’ImageCapture, et la prise en main ne devrait donc pas poser problème.

Grâce à cette application, Google souhaite donc “redorer” l’image du développement photo sous Android, et effacer les points pénibles que l’on pouvait rencontrer avec les précédentes APIs, à l’image de ce qui était présenté dans Jetpack, et c’est une très bonne chose !

Sources

Merci à notre Graphic Designer Ippon pour l'illustration !