Chapitre 26 : Programmation asynchrone de la caméra
Résumé
Revenez en arrière et regardez le code que vous avez écrit pour les chapitres 7 à 9. CameraDevice.StateCallback imbriqué dans CameraManager.openCamera, avec CameraCaptureSession.StateCallback imbriqué dans onOpened, avec CaptureCallback imbriqué dans onConfigured, avec ImageReader.OnImageAvailableListener se déclenchant sur un HandlerThread que vous avez lancé à la main et que vous devez démonter exactement dans l'ordre inverse sur chaque chemin d'erreur. C'est l'enfer des rappels, version caméra. Chaque niveau d'indentation est une nouvelle classe de rappel. Chaque erreur doit se propager à travers quatre couches d'objets anonymes. Chaque close() oublié sur le chemin du démontage fait fuiter la caméra jusqu'au redémarrage.
Ce chapitre est la refonte que vous attendiez. Nous convertissons toute la jungle des rappels en un code Kotlin propre, linéaire, annulable et testable en utilisant deux primitives de coroutines : suspendCancellableCoroutine pour les opérations ponctuelles, et callbackFlow + les opérateurs Flow pour les flux continus. Vous apprendrez les règles de sécurité des threads pour les coroutines interagissant avec Camera2, pourquoi bloquer le thread principal sur n'importe quel appel de caméra est un ANR garanti, et pourquoi le modèle PipedOutputStream/PipedInputStream que vous avez peut-être essayé pour les données ImageWriter produit des blocages que Flow évite naturellement.
Comme toujours, validez les capacités de niveau matériel que vous ciblez avec Android Camera Parameters (Google Play, GitHub) pour confirmer que les capacités dont votre pipeline asynchrone a besoin (rafale répétée, résultats partiels, retraitement YUV) sont réellement présentes sur vos appareils de test.
Pourquoi les rappels imbriqués sont un "enfer des rappels"
Visualisons d'abord le problème. Voici une structure réelle (simplifiée) provenant d'une application Camera2 brute en production avant les coroutines :
Chaque rappel ombré est une classe anonyme distincte. Chacun capture une référence à des ressources situées deux niveaux plus haut. Chaque chemin d'erreur doit remonter de J vers A, fermant imageReader → session → cameraDevice → handlerThread dans l'ordre inverse, et toute omission d'un close() dans l'une des 16 permutations d'erreurs produit une fuite permanente de la caméra jusqu'au redémarrage de l'appareil. C'est la définition même de l'enfer des rappels.
L'objectif de ce chapitre est de transformer ces spaghettis en ceci :
Linéaire. Composable. Testable. Annulable par l'annulation du Job parent. Chaque étape est une fonction simple ou un opérateur Flow. Les cinq mêmes rappels vivent désormais dans un pipeline linéaire de 12 lignes.
Coroutines Kotlin pour les opérations ponctuelles : suspendCancellableCoroutine
Le modèle de base pour envelopper toute API basée sur des rappels en tant que fonction suspend est suspendCancellableCoroutine. La recette est toujours identique :
- Appeler
suspendCancellableCoroutine { cont -> ... }pour obtenir uneCancellableContinuation<T>. - Appeler l'API réelle basée sur des rappels, en lui passant une implémentation de rappel anonyme.
- Dans le chemin de succès du rappel, appeler
cont.resume(valeur). - Dans chaque chemin d'erreur, appeler
cont.resumeWithException(t). - Dans
cont.invokeOnCancellation { ... }, effectuer le nettoyage : fermer la caméra, annuler les requêtes en attente, désinscrire les écouteurs pour que le rappel ne se déclenche jamais après l'annulation de la coroutine. - Envelopper le tout dans un
withTimeoutaux sites d'appel afin qu'un HAL bloqué ne puisse pas figer votre application indéfiniment.
Exemple 1 : suspend fun openCameraAwait()
suspend fun CameraManager.openCameraAwait(
cameraId: String,
handler: Handler
): CameraDevice = suspendCancellableCoroutine { cont ->
val callback = object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cont.resume(camera) {
camera.close()
}
}
override fun onDisconnected(camera: CameraDevice) {
cont.resumeWithException(
CameraAccessException(
CameraAccessException.CAMERA_DISCONNECTED,
"Caméra $cameraId déconnectée pendant l'ouverture"
)
)
}
override fun onError(camera: CameraDevice, error: Int) {
cont.resumeWithException(
CameraAccessException(error, "Erreur caméra $cameraId : $error")
)
}
}
cont.invokeOnCancellation {
try {
// Solution de contournement : openCamera() n'expose pas de handle annulable
// avant l'API 30. Fermer l'appareil s'il a été ouvert dans la fenêtre de course.
} catch (_: Throwable) { /* ignorer */ }
}
openCamera(cameraId, callback, handler)
}
Pourquoi cela fonctionne. openCamera est de type "déclencher et oublier" : vous l'appelez, et à un moment futur, l'une des trois méthodes de rappel se déclenche exactement une fois. Ce contrat ("se déclenche exactement une fois") est ce qui nous permet de le mapper un pour un sur une continuation. Si la coroutine est annulée avant qu'un rappel ne se déclenche, invokeOnCancellation s'exécute et empêche une fuite de ressource. Si elle est annulée après le resume, le bloc resume(valeur) { camera.close() } — le paramètre onCancellation de resume — ferme automatiquement l'appareil.
L'appeler avec un timeout est trivial :
val cameraDevice: CameraDevice = withTimeoutOrNull(5_000L) {
cameraManager.openCameraAwait(cameraId, cameraHandler)
} ?: run {
Log.w(TAG, "L'ouverture de la caméra a expiré après 5s")
return@launch
}
Si le HAL est bloqué (courant sur les appareils LEGACY bas de gamme après une fuite de caméra d'une application précédente), cela échoue rapidement et proprement au lieu de présenter à l'utilisateur une boîte de dialogue "L'application ne répond pas".
Exemple 2 : suspend fun createCaptureSessionAwait()
Même modèle, rappel différent :
suspend fun CameraDevice.createCaptureSessionAwait(
outputs: List<OutputConfiguration>,
handler: Handler
): CameraCaptureSession = suspendCancellableCoroutine { cont ->
val callback = object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
cont.resume(session) { session.close() }
}
override fun onConfigureFailed(session: CameraCaptureSession) {
cont.resumeWithException(
IllegalStateException(
"Échec de la configuration de la session pour l'appareil ${this@createCaptureSessionAwait.id}"
)
)
}
}
cont.invokeOnCancellation {
// Impossible d'annuler la création de session en cours sur les anciennes API.
// La session se fermera si elle finit par aboutir via le bloc de reprise
// onCancellation ci-dessus.
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val config = SessionConfiguration(
SessionConfiguration.SESSION_REGULAR,
outputs,
{ r -> handler.post(r) },
callback
)
createCaptureSession(config)
} else {
@Suppress("DEPRECATION")
createCaptureSession(outputs.map { it.surface }, callback, handler)
}
}
C'est exactement la même forme. Les deux versions de l'API (createCaptureSession(surfaces, callback, handler) avant S vs SessionConfiguration S+) sont gérées dans un seul wrapper. Les appelants n'ont jamais besoin de le savoir.
Exemple 3 : suspend fun awaitCaptureResult() pour une capture unique
suspend fun CameraCaptureSession.captureAwait(
request: CaptureRequest,
handler: Handler
): TotalCaptureResult = suspendCancellableCoroutine { cont ->
val callback = object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
cont.resume(result)
}
override fun onCaptureFailed(
session: CameraCaptureSession,
request: CaptureRequest,
failure: CaptureFailure
) {
cont.resumeWithException(
CaptureFailureException(
"Capture échouée : raison=${failure.reason} image=${failure.frameNumber}"
)
)
}
}
cont.invokeOnCancellation {
try { abortCaptures() } catch (_: Throwable) { /* ignorer */ }
}
try {
capture(request, callback, handler)
} catch (t: Throwable) {
cont.resumeWithException(t)
}
}
C'est la brique de base pour le bracketing manuel multi-images de style Chapitre 19 — enchaînez 7 appels captureAwait(br[i]) dans une boucle for avec withTimeoutOrNull, collectez les 7 TotalCaptureResult, et vous avez une séquence de bracketing HDR complète avec timeout par image et annulation automatique. Dans le monde des rappels, c'était des centaines de lignes de machine à états. C'est maintenant une boucle for de 12 lignes.
Flow pour les flux continus
Les opérations ponctuelles couvrent l'ouverture de la caméra, la création de session et la capture unique. Pour les éléments répétitifs — chaque image d'aperçu, chaque rappel TotalCaptureResult, chaque Image d'un ImageReader — nous voulons un Flow<T> afin de pouvoir utiliser map, filter, debounce, combine et partager les flux entre les abonnés.
Exemple 4 : ImageReader → Flow<Image> via callbackFlow
fun ImageReader.imagesFlow(
lifecycleScope: CoroutineScope
): Flow<Image> = callbackFlow {
val listener = ImageReader.OnImageAvailableListener { reader ->
// acquireLatestImage() ignore les anciennes images si le consommateur est plus lent que
// la caméra — obligatoire pour éviter de bloquer le HAL.
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
trySend(image)
}
// Doit appeler invokeOnClose en PREMIER pour que l'annulation supprime toujours le listener
// même si setOnImageAvailableListener lui-même lève une exception.
awaitClose {
setOnImageAvailableListener(null, null)
// Ne PAS fermer l'ImageReader ici — l'appelant possède son cycle de vie.
}
val handlerThread = HandlerThread("ImageReaderFlow").apply { start() }
val handler = Handler(handlerThread.looper)
setOnImageAvailableListener(listener, handler)
}.buffer(Channel.CONFLATED)
.onCompletion {
// Toute image non consommée par les collecteurs en aval est à nous de fermer,
// car callbackFlow renvoie les échecs après émission.
}
Choix de conception critiques :
-
acquireLatestImage()plutôt queacquireNextImage(). Si votre traitement d'image (inférence ML, détection de visage) prend 40 ms et que la caméra filme à 30 fps (~33 ms par image), vous allez prendre du retard.acquireNextImageles accumule jusqu'à ce que vous manquiez de tampons gralloc et que la caméra se fige.acquireLatestImagesaute les anciennes et vous donne l'image la plus fraîche. C'est presque toujours ce que vous voulez pour l'analyse d'image côté aperçu. -
buffer(Channel.CONFLATED). Un tampon "conflated" ne conserve que la dernière valeur. Combiné avecacquireLatestImage, c'est une garantie absolue que vous ne mettez jamais en file d'attente des images obsolètes. -
awaitClose { setOnImageAvailableListener(null, null) }. C'est l'équivalentcallbackFlowdecont.invokeOnCancellation. Annulez la portée de la coroutine (par exemple, lorsque le Fragment passe paronDestroyView) et l'écouteur est automatiquement désinscrit et leHandlerThreadnettoyé. Aucune fuite.
Exemple 5 : CaptureCallback → Flow<TotalCaptureResult>
Même modèle :
fun CameraCaptureSession.repeatingResultsFlow(
repeatingRequest: CaptureRequest,
handler: Handler
): Flow<TotalCaptureResult> = callbackFlow {
val callback = object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
trySend(result)
}
override fun onCaptureProgressed(
session: CameraCaptureSession,
request: CaptureRequest,
partial: CaptureResult
) {
// Si vous avez besoin des résultats partiels, émettez-les sur un canal séparé
// ou envoyez une classe scellée (sealed class).
}
}
awaitClose {
try {
stopRepeating()
abortCaptures()
} catch (_: Throwable) { /* ignorer */ }
}
setRepeatingRequest(repeatingRequest, callback, handler)
}
Vous avez maintenant un Flow<TotalCaptureResult> froid qui démarre une requête répétée lorsqu'il est collecté, l'arrête lors de l'annulation, émet chaque résultat terminé et fonctionne avec chaque opérateur Flow standard.
Exemple 6 : combine(previewFlow, aeStateFlow) pour une UI réactive
La véritable puissance de Flow réside dans la composition. Supposons que votre UI affiche :
- Les FPS de l'aperçu en direct
- L'état AE actuel (en convergence / convergé / verrouillé)
- Un indicateur "Prêt à photographier" qui est vert seulement quand l'AE est convergé ET l'AF est convergé ET l'AWB est convergé.
Sans Flow, vous écrivez manuellement une machine à états fusionnant CaptureCallback avec Choreographer. Avec Flow, cela prend trois lignes :
data class UiCameraState(
val aeState: Int,
val afState: Int,
val awbState: Int,
val fps: Int,
val ready: Boolean
)
val resultFlow: Flow<TotalCaptureResult> = session
.repeatingResultsFlow(previewRequest, cameraHandler)
.flowOn(cameraDispatcher) // hors thread principal, pas de saccade UI
val aeStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AE_STATE] ?: CaptureResult.CONTROL_AE_STATE_INACTIVE
}
val afStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AF_STATE] ?: CaptureResult.CONTROL_AF_STATE_INACTIVE
}
val awbStateFlow = resultFlow.map {
it[CaptureResult.CONTROL_AWB_STATE] ?: CaptureResult.CONTROL_AWB_STATE_INACTIVE
}
val fpsFlow = resultFlow
.map { it[CaptureResult.SENSOR_TIMESTAMP] }
.runningFold(emptyList<Long>()) { acc, ts ->
(acc + ts).takeLast(30) // fenêtre glissante de 30 horodatages
}
.map { timestamps ->
if (timestamps.size < 2) 0 else {
val windowNs = timestamps.last() - timestamps.first()
1_000_000_000 * (timestamps.size - 1) / windowNs.toInt()
}
}
.debounce(250) // mettre à jour l'étiquette FPS seulement toutes les 250ms, économise la batterie
val uiState: Flow<UiCameraState> = combine(
aeStateFlow, afStateFlow, awbStateFlow, fpsFlow
) { ae, af, awb, fps ->
val aeConverged = ae in arrayOf(
CaptureResult.CONTROL_AE_STATE_CONVERGED,
CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED,
CaptureResult.CONTROL_AE_STATE_LOCKED
)
val afConverged = af in arrayOf(
CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED,
CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED
)
val awbConverged = awb in arrayOf(
CaptureResult.CONTROL_AWB_STATE_CONVERGED,
CaptureResult.CONTROL_AWB_STATE_LOCKED
)
UiCameraState(ae, af, awb, fps, ready = aeConverged && afConverged && awbConverged)
}
Collectez uiState dans le viewLifecycleOwner.lifecycleScope.launchWhenStarted de votre Fragment et passez chaque émission à votre UI Compose ou viewBinding. Chaque opérateur — map, runningFold, debounce, combine — est une primitive de la bibliothèque standard. Pas de machine à états personnalisée. Pas de conditions de concurrence. Pas d'événements manqués. Annulez la portée et chaque Flow en amont — y compris la requête répétée et l'écouteur ImageReader — s'arrête, se désabonne et se nettoie exactement une fois.
Sécurité des threads
Tout ce qui précède est inutile si vous violez les règles de sécurité des threads de Camera2. Les voici, distillées à partir de centaines de rapports de bugs ANR :
-
N'appelez jamais d'API Camera2 depuis le thread principal.
cameraManager.openCamera()peut sembler rapide sur un Pixel 7. Sur un appareil Android Go à petit budget avec un HALLEGACY, il peut bloquer pendant 1,2 s. C'est un ANR instantané. Même les appels qui paraissent peu coûteux, commeCameraCharacteristics.get(), peuvent allouer plusieurs Ko de métadonnées et les copier — ce qui, lors d'un démarrage à froid du processus pendant que l'utilisateur bascule entre les Fragments, suffit à faire perdre 3 images. Répartissez tout versDispatchers.Defaultou un dispatcher dédié à un seul thread soutenu par unHandlerThread. -
HandlerThread vs
CoroutineDispatcher.DefaultvsDispatchers.IO.- Utilisez un dispatcher à thread unique (ex :
HandlerThread("cam").asCoroutineDispatcher()) pour les appels réels à l'API Camera2. La pile de caméras héritée sur de nombreux appareilsLEGACYpossède des points d'entrée HAL affines au thread. Changer de thread entreopenCameraetcreateCaptureSessiondéclenche des bugs de HAL connus sur Qualcomm msm8953 et antérieurs. - Utilisez
Dispatchers.Defaultpour le calcul pur sur les images capturées (fusion HDR, encodage JPEG, détection de visage). Il possède autant de threads que de cœurs. - Utilisez
Dispatchers.IOpour les E/S disque (enregistrement du JPEG dans MediaStore). N'utilisez jamaisDefaultpour les écritures bloquantes.
- Utilisez un dispatcher à thread unique (ex :
-
L'état partagé mutable entre les coroutines et les rappels doit être protégé par un
Mutex. Si unCaptureCallbackécritlastResultet qu'un clic sur un bouton Compose le lit, enveloppez les deux côtés avecmutex.withLock { ... }ou utilisezatomicfu/@Volatilepour les types primitifs. Ne vous fiez PAS au fait que "cela ne touche jamais qu'un seul thread". Les rappels de HAL sur les appareilsLEGACYse déclenchent occasionnellement sur des threads inattendus, et quand c'est le cas, vous obtenez des lectures tronquées de valeursLong64 bits commeSENSOR_TIMESTAMP. -
Pourquoi Flow évite le blocage
PipedOutputStream. Le piège duPipedOutputStreammentionné dans le doc de recherche mérite un exemple concret. Si vous faisiez ceci :// NE FAITES PAS CECIval pos = PipedOutputStream()val pis = PipedInputStream(pos)lifecycleScope.launch(Dispatchers.Default) {while (true) { image.compressToJpeg(..., pos) }}lifecycleScope.launch(Dispatchers.IO) {// lire pis et écrire dans le fichier}Cela se bloque en moins de 100 images car
PipedInputStreampossède un tampon par défaut de 64 Ko. Si le producteur produit plus vite que le consommateur ne consomme, le producteur bloque surpos.write()et le tampon se remplit. Si pendant ce temps le lecteur est bloqué sur autre chose (ex : transaction d'insertion groupée MediaStore), les deux coroutines sont bloquées pour toujours — une attente circulaire classique. Flow avecbuffer(CONFLATED)oubuffer(DROP_OLDEST)possède une sémantique de contre-pression explicite et ne bloque jamais. Perdez des images, mais ne bloquez jamais. C'est le bon compromis pour l'aperçu de la caméra.
Résumé
L'API de Camera2 basée sur des rappels, lorsqu'elle est composée naïvement, produit un enfer de rappels profondément imbriqués, sujet aux erreurs, aux fuites et impossible à tester. Les coroutines Kotlin et Flow vous donnent deux primitives qui simplifient toute la conception : suspendCancellableCoroutine pour les opérations ponctuelles (openCamera, createCaptureSession, capture unique) avec support intégré du timeout et de l'annulation, et callbackFlow pour les flux continus (images d'ImageReader, rappels CaptureResult répétés) avec une contre-pression explicite. Les opérateurs Flow standards — map, filter, runningFold, debounce et le très important combine — vous permettent de construire des pipelines d'état UI réactifs et sûrs contre l'annulation à partir de pièces composables. Imposez la sécurité des threads avec un dispatcher de caméra dédié, protégez l'état partagé avec un Mutex, et remplacez tout piping manuel de type PipedOutputStream par des canaux Flow pour éviter les blocages.
Et ensuite ?
Vous avez maintenant les outils pour écrire des applications Camera2 robustes et de qualité production. Mais comment vérifier que votre code fonctionne sur les plus de 24 000 modèles d'appareils Android actuellement en circulation, et comment les OEM valident-ils leurs HAL avant l'expédition ? Le chapitre 27 traite des tests de caméra : Camera ITS, CTS Verifier et les tests d'instrumentation utilisant des mocks pour que vous puissiez exécuter votre suite de tests de caméra sur des serveurs CI sans aucun matériel physique.