La concurrence structurée sur Android en 2026 : scopes, annulation et les fuites qu'elle évite
Un guide pratique de la concurrence structurée Kotlin sur Android — viewModelScope contre lifecycleScope, pourquoi l'annulation se propage, et les fuites de coroutines qu'elle élimine silencieusement.
La plupart des bugs de coroutines que j’ai débogués sur Android ne venaient pas d’une fonction suspend faisant la mauvaise chose — ils venaient d’une coroutine qui survivait à ce qui l’avait lancée. Un appel réseau se termine après la disparition de son écran et plante en essayant de mettre à jour une vue détruite. Un collecteur de Flow continue de tourner en arrière-plan, consommant de la batterie pour un écran que plus personne ne regarde. La concurrence structurée est la discipline qui élimine ces cas par construction, pas en se souvenant de nettoyer — et comprendre ce qu’elle garantit réellement vaut plus que de mémoriser quel Scope utiliser où.
L’idée centrale : une coroutine ne peut pas survivre à son scope
Chaque coroutine en Kotlin appartient à un CoroutineScope, et ce scope définit sa durée de vie. Lancez une coroutine dans un scope, et elle devient l’enfant du job de ce scope. Annulez le scope, et chaque enfant — aussi profondément imbriqué soit-il, quel que soit le nombre d’appels async qu’il a lancés — est annulé avec lui. C’est toute la garantie : vous ne pouvez pas laisser fuir accidentellement une coroutine hors du scope qui la possède, parce que le langage ne vous en donne pas les moyens.
class BudgetViewModel(private val repo: TransactionRepository) : ViewModel() {
fun refreshMonthlyTotal() {
viewModelScope.launch {
val total = repo.computeMonthlyTotal() // suspend ici
_monthlyTotal.value = total
}
}
}
Si l’utilisateur navigue ailleurs et que le ViewModel est nettoyé au milieu de computeMonthlyTotal(), viewModelScope est annulé automatiquement, et la coroutine ci-dessus s’arrête — elle n’atteint jamais la ligne qui écrit dans _monthlyTotal. Aucun indicateur manuel à vérifier, aucune garde isActive nécessaire avant l’écriture, parce que la coroutine ne reprend simplement pas après que l’annulation atteint un point de suspension.
viewModelScope, lifecycleScope, et choisir le bon
Android fournit deux scopes liés aux durées de vie des composants, et choisir le mauvais est l’erreur de concurrence structurée la plus courante que je vois :
viewModelScopevit aussi longtemps que leViewModel— il survit aux changements de configuration (rotation, bascule du mode sombre) et n’est annulé que lorsque leViewModelest nettoyé, typiquement quand l’utilisateur quitte définitivement l’écran. Utilisez-le pour un travail dont le résultat intéresse encore l’écran après une rotation : charger des données, écrire dans un repository, calculer une valeur dérivée.lifecycleScope, et spécifiquementrepeatOnLifecycle(Lifecycle.State.STARTED)à l’intérieur, est lié au cycle de vie de la vue de l’Activityou duFragment. Utilisez-le pour tout ce qui doit vraiment s’arrêter quand l’écran n’est pas visible — le plus souvent, collecter unFlowpour mettre à jour l’UI :
class ReminderListFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.reminders.collect { reminders ->
adapter.submitList(reminders)
}
}
}
}
}
Collecter directement dans viewModelScope à la place garderait le collecteur — et chaque requête Room qu’il déclenche — en cours d’exécution pendant que l’app est en arrière-plan, puisque viewModelScope ne sait pas que la vue a disparu. Ce n’est pas une fuite au sens d’un plantage ; c’est un coût plus silencieux, plus lent : CPU gaspillé, batterie gaspillée, et un Flow qui réémet vers une UI que personne ne peut voir. repeatOnLifecycle est la solution précisément parce qu’il redérive un scope frais lié à STARTED, annulant et redémarrant la collecte à mesure que le cycle de vie entre et sort de cet état.
La concurrence structurée rend la gestion des erreurs honnête
Avant la concurrence structurée, un motif courant était de lancer une coroutine nue avec GlobalScope.launch — sans scope, sans propriétaire, hors de toute supervision parente. Si elle levait une exception, celle-ci n’allait nulle part d’utile, et si l’écran avait disparu au moment où elle se terminait, il ne restait rien pour attraper le plantage. La concurrence structurée oblige chaque coroutine à avoir un parent, ce qui signifie que chaque exception a un endroit défini où se propager : en remontant la hiérarchie des jobs jusqu’à ce qui a installé un CoroutineExceptionHandler, ou vers l’annulation du scope lui-même si rien ne l’a fait.
private val handler = CoroutineExceptionHandler { _, throwable ->
_uiState.value = UiState.Error(throwable.message)
}
fun syncPantryFromReceipt(bitmap: Bitmap) {
viewModelScope.launch(handler) {
val items = ocrEngine.extract(bitmap) // peut lever une exception
repo.insertAll(items)
}
}
C’est le motif derrière la façon dont Stocky gère un scan de reçu échoué — l’étape OCR peut lever une exception pour une douzaine de raisons (mauvais éclairage, police illisible, un format de reçu jamais vu), et la concurrence structurée garantit que cet échec remonte à exactement un seul endroit au lieu de disparaître silencieusement dans une coroutine tirée-et-oubliée.
async/await : l’annulation fonctionne dans les deux sens
Les builders coroutineScope { } et async étendent la même garantie aux coroutines sœurs : si un enfant d’un coroutineScope lève une exception, tous les autres enfants sont annulés aussi, et l’exception ne se propage vers l’extérieur qu’une fois qu’ils se sont tous arrêtés. Cela compte chaque fois que vous répartissez un travail concurrent qui n’a de sens que pris ensemble :
suspend fun loadDashboard(): DashboardState = coroutineScope {
val totalDeferred = async { repo.computeMonthlyTotal() }
val trendDeferred = async { repo.computeSpendingTrend() }
DashboardState(totalDeferred.await(), trendDeferred.await())
}
Si computeSpendingTrend() lève une exception, computeMonthlyTotal() est annulé aussi — vous ne vous retrouvez jamais avec la moitié d’un tableau de bord affiché silencieusement à partir d’une requête partiellement échouée. Sans concurrence structurée, ce second async continuerait de tourner après le crash du premier, son résultat attendu par personne, son travail gaspillé.
À retenir
La concurrence structurée n’est pas une préférence de style — c’est ce qui transforme « ai-je oublié d’annuler ceci » d’un bug d’exécution en une impossibilité de compilation. La règle qui compte en pratique : lancez dans le scope qui correspond à la durée pendant laquelle le résultat sera nécessaire, pas dans le scope qui se trouve être à proximité. viewModelScope pour le travail dont l’écran se soucie encore après une rotation, lifecycleScope avec repeatOnLifecycle pour tout ce qui doit s’arrêter quand la vue n’est pas visible, et un CoroutineExceptionHandler partout où un échec a besoin d’un endroit défini où aller plutôt que nulle part.
// À lire aussi
D’autres notes du journal
L'API In-App Review d'Android : demander une note sans être pénible
Un guide pratique de l'API In-App Review de Google — comment elle fonctionne réellement, quand la déclencher, et pourquoi la popup classique « notez-nous » nuit discrètement à votre note sur le Play Store.
Raccourcis d'application et tuiles de Paramètres rapides sur Android : enregistrer de l'eau sans ouvrir l'application
Un guide pratique de l'API dynamique ShortcutManager et de TileService sur Android — comment permettre à une action en un tap de contourner complètement l'application, avec les pièges qui font trébucher la plupart des implémentations.
Les types de services au premier plan sur Android en 2026 : choisir celui auquel votre fonctionnalité correspond vraiment
Un guide pratique des restrictions de types de services au premier plan d'Android — dataSync, mediaPlayback, specialUse, shortService — et comment choisir le bon sans se faire tuer ou rejeter.