Aller au contenu
Tous les articles

CameraX en 2026 : lier Preview et ImageAnalysis sans faire fuir une session caméra

Un guide pratique de CameraX sur Android : lier Preview et ImageAnalysis au cycle de vie, la bonne stratégie de backpressure, et le crash causé par la rotation.

MFKAPPS 5 min de lecture

Deux de mes applications pointent une caméra vers quelque chose et attendent une réponse en moins d’une seconde : Stocky qui scanne un code-barres, Subly qui lit une facture. Les modèles de vision derrière les deux — le scanner de codes-barres de ML Kit et son reconnaisseur de texte — ont leurs propres articles ailleurs. Celui-ci porte sur la couche en dessous des deux : CameraX, la partie qui doit rester ouverte exactement aussi longtemps que l’écran affichant l’aperçu est vivant, pas une image de plus.

CameraX est facile à faire fonctionner et facile à mal utiliser subtilement. Le code de démo lie un aperçu et un analyseur en quelques lignes et fonctionne au premier lancement. Les bugs apparaissent plus tard — lors d’une rotation, d’un aller-retour rapide entre écrans, sur un téléphone qui met l’application en pause en plein scan. Voici ce qui doit vraiment être fait correctement.

Se lier à un cycle de vie, pas à une activity

La raison d’utiliser CameraX plutôt que Camera2 brut n’est pas la surface d’API, c’est bindToLifecycle. Donnez-lui un LifecycleOwner avec vos cas d’usage, et CameraX démarre la caméra quand ce owner atteint STARTED et la démonte quand ce n’est plus le cas — aucune plomberie manuelle onPause/onResume à mal faire :

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

cameraProviderFuture.addListener({
    val cameraProvider = cameraProviderFuture.get()
    cameraProvider.unbindAll()

    val preview = Preview.Builder().build().also {
        it.surfaceProvider = previewView.surfaceProvider
    }

    val analysis = ImageAnalysis.Builder()
        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
        .build()
        .also { it.setAnalyzer(analysisExecutor, ::analyzeFrame) }

    cameraProvider.bindToLifecycle(
        lifecycleOwner,
        CameraSelector.DEFAULT_BACK_CAMERA,
        preview,
        analysis,
    )
}, ContextCompat.getMainExecutor(context))

Ce unbindAll() avant la liaison n’est pas du code défensif superflu — c’est le correctif du crash CameraX le plus courant. ProcessCameraProvider.getInstance() retourne un ListenableFuture, donc la liaison se fait de manière asynchrone, dans un listener. Si l’écran se recrée (rotation, changement de configuration, retour vers un écran de scan déjà lié) avant que l’ancienne session ait été proprement libérée, vous obtenez deux ensembles de cas d’usage qui se disputent la même caméra, ou une IllegalStateException indiquant qu’un cas d’usage est déjà lié à un autre cycle de vie. Appeler unbindAll() en premier rend chaque liaison idempotente : peu importe combien de fois vous êtes passé par onResume.

L’aperçu et l’analyse sont deux consommateurs des mêmes images

Preview et ImageAnalysis ne sont pas des étapes d’un pipeline — ce sont deux cas d’usage indépendants alimentés simultanément par la caméra. L’aperçu montre à l’utilisateur un flux en direct sur une PreviewView ; l’analyseur reçoit son propre ImageProxy par image pour y faire tourner l’inférence. Lier les deux ensemble est ce qui fait d’une « caméra avec overlay de scan en direct » un seul appel plutôt que deux caméras. Cela signifie aussi que la vitesse de l’analyseur n’affecte pas ce que l’utilisateur voit à l’écran — un modèle lent ne rend pas l’aperçu saccadé, ce qui compte plus qu’il n’y paraît pour la sensation de « réactivité » d’un scanner.

La stratégie de backpressure n’est pas un réglage mineur

ImageAnalysis utilise par défaut STRATEGY_KEEP_ONLY_LATEST dans les versions récentes de CameraX, mais cela vaut la peine de la définir explicitement, car l’alternative — STRATEGY_BLOCK_PRODUCER — met les images en file d’attente quand votre analyseur n’arrive pas à suivre, et un modèle de code-barres ou d’OCR suit rarement une caméra à 30 fps. KEEP_ONLY_LATEST supprime chaque image sauf la plus récente tant que l’analyseur est occupé, ce qui est exactement le comportement voulu : l’utilisateur voit un aperçu en direct et obtient un résultat basé sur l’image que le modèle a eu le temps de regarder, plutôt qu’un pipeline qui prend du retard et traite des images vieilles d’une seconde.

Fermez chaque ImageProxy, sinon le flux s’arrête silencieusement

C’est le bug le plus difficile à remarquer en test parce qu’il ne plante pas — il s’arrête, tout simplement. ImageAnalysis ne livre pas de nouvelle image à votre analyseur tant que le ImageProxy précédent n’est pas fermé, même sous KEEP_ONLY_LATEST. Un return prématuré sur une image nulle, une exception levée avant votre finally, ou une recherche qui s’exécute de manière asynchrone et ne ferme le proxy que dans son callback — n’importe lequel de ces cas peut laisser un ImageProxy ouvert pour toujours, et à partir de cette image, l’analyseur se tait tout simplement. Pas d’erreur, pas de log, juste un scanner qui a arrêté de scanner :

fun analyzeFrame(image: ImageProxy) {
    try {
        val media = image.image ?: return
        val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
        scanner.process(input).addOnSuccessListener { /* handle result */ }
    } finally {
        image.close()
    }
}

Mettez la fermeture dans un finally, pas à la fin du chemin nominal. C’est la seule ligne de toute la configuration qu’il est facile d’oublier et coûteuse à déboguer plus tard.

Vérifiez la permission avant de lier, pas dans le callback

CameraX ne demande pas la permission caméra à votre place, et il n’échoue pas gracieusement si vous liez sans elle — bindToLifecycle lève une SecurityException qui fera planter l’écran si CAMERA n’a pas encore été accordée. La solution est une question d’ordre : vérifiez ContextCompat.checkSelfPermission et passez ActivityResultContracts.RequestPermission avant que le listener du ProcessCameraProvider ne s’exécute, pas à l’intérieur. Une vérification de permission qui ne s’exécute qu’après un premier refus de l’utilisateur, ou qui entre en course avec le future asynchrone du provider, c’est ainsi qu’on obtient un rapport de crash qui ne se reproduit que sur une installation fraîche.

En résumé

CameraX justifie sa place par rapport à Camera2 brut d’une seule manière : il transforme « la gestion du cycle de vie de la caméra » en un problème résolu, à condition de vraiment le laisser gérer ce cycle de vie. Liez via un LifecycleOwner, appelez unbindAll() avant chaque liaison pour que rotation et navigation ne puissent jamais lier deux fois un cas d’usage, définissez explicitement la stratégie de backpressure plutôt que de compter sur la valeur par défaut, et fermez chaque ImageProxy dans un finally. Faites ces quatre choses correctement et la caméra devient ennuyeuse — ce qui, pour une fonctionnalité qui doit fonctionner à chaque fois qu’un utilisateur pointe son téléphone vers un code-barres ou une facture, est exactement ce qu’on veut.