La recomposition dans Jetpack Compose : ce qui la déclenche vraiment, et pourquoi j'ai arrêté de deviner
Un guide pratique de la recomposition Jetpack Compose en 2026 — stabilité des types, lambdas instables, clés LazyColumn, et comment lire le rapport de métriques du compilateur.
Un écran Compose qui rame n’a presque jamais de cause évidente. Pas de stack trace, pas de crash, pas d’avertissement du linter — juste une liste qui semble un cran plus lente qu’elle ne devrait, ou un champ de texte qui traîne d’une demi-frame derrière chaque frappe. Le coupable habituel, c’est la recomposition qui s’exécute sur une plus grande partie de l’arbre UI que nécessaire, et ce qui est frustrant, c’est que Compose ne vous prévient jamais quand cela arrive. Il fait simplement plus de travail que nécessaire, silencieusement, à chaque frame, jusqu’à ce que vous alliez chercher. Voici ce qui détermine réellement si un composable est ignoré ou réexécuté, et les deux outils qui remplacent les suppositions par de la lecture.
Ce qu’est réellement la recomposition
Compose suit les lectures d’état, pas les variables. Quand une fonction composable lit un State<T> — directement ou via remember { mutableStateOf(...) } — le runtime enregistre cette lecture pour la portée de composition en cours. Quand la valeur de l’état change, seules les portées qui l’ont réellement lue sont programmées pour se réexécuter. Tout le reste de l’arbre est laissé tranquille, en théorie. Le mot qui fait tout le travail dans cette phrase, c’est « portées qui l’ont réellement lue », et découvrir quelles sont ces portées est précisément là où les choses tournent mal.
La vraie question du compilateur : ce type est-il stable ?
Chaque paramètre de fonction composable est classé par le compilateur Compose comme stable ou instable. Un type stable est un type dont le compilateur peut prouver qu’il ne changera pas sans notifier la composition — donc si la valeur est égale (equals()) à celle de la dernière fois, Compose saute entièrement la réexécution de la fonction. Un type instable ne mérite pas cette confiance, donc le composable qui l’utilise se réexécute à chaque recomposition de son parent, que ce qu’il lit réellement ait changé ou non.
// Stable : chaque propriété est val, et String/Int sont stables par définition.
data class PantryItem(val id: Long, val name: String, val quantity: Int)
// Instable : une propriété var signifie que le compilateur ne peut pas prouver
// que cette instance ne mutera pas sous les pieds d'un composable qui la référence.
data class PantryItemDraft(var name: String, var quantity: Int)
La version avec var n’est pas une chicane de style — c’est la différence entre un composable que Compose peut ignorer et un qu’il ne peut pas. Le même problème apparaît avec les paramètres List<T> et Map<K, V> simples : le compilateur traite l’interface List comme instable, car rien n’empêche l’appelant de fournir une MutableList et de la modifier ensuite. Une data class pleine de champs val stables, une fois enveloppée dans une List simple, reste un paramètre instable — c’est l’endroit le plus courant où la stabilité se casse silencieusement dans une base de code réelle.
Où ça s’infiltre sans qu’on le remarque
Deux schémas expliquent la plupart des recompositions inutiles que j’ai tracées dans mes propres écrans :
Les lambdas non hissées (« hoisted »). Une lambda définie en ligne dans le corps d’un composable est un nouvel objet à chaque recomposition du parent, même si les valeurs qu’elle capture n’ont pas changé. Passée à un composable enfant, cette nouvelle instance échoue au test d’égalité et force l’enfant à se recomposer, que ce qu’il lit ait réellement changé ou non.
// Recréée à chaque recomposition de PantryScreen — annule le skip sur ItemRow.
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })
// Hissée une seule fois, stable à travers les recompositions de PantryScreen.
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)
Les collections transmises directement depuis un repository. Un Flow<List<PantryItem>> collecté avec collectAsStateWithLifecycle() vous donne une nouvelle instance de List à chaque émission, même si le contenu est identique — ce qui arrive couramment juste après qu’une requête Room se réexécute suite à une écriture sur une table sans rapport. Si cette liste est transmise à plusieurs composables enfants, ils se recomposent tous à chaque émission. Envelopper le côté lecture dans derivedStateOf corrige cela quand ce qui vous importe réellement est une valeur calculée, pas la liste brute elle-même :
val isEmpty by remember {
derivedStateOf { items.isEmpty() }
}
derivedStateOf ne déclenche une recomposition que lorsque sa sortie calculée change, pas lorsque ses entrées changent — donc une liste qui passe de 40 à 41 éléments ne redéclenche pas un composable qui se soucie uniquement de savoir si la liste est vide.
Listes : la clé qui limite la recomposition ligne par ligne
LazyColumn utilise par défaut la position de l’élément comme clé d’identité. Réorganisez, insérez ou supprimez un élément n’importe où sauf à la fin, et chaque ligne après cet index est traitée comme « modifiée » — parce que c’est la position, pas le contenu, que Compose surveille. Fournir une clé explicite et stable corrige cela en limitant la recomposition aux lignes dont le contenu a réellement changé :
LazyColumn {
items(items = pantryItems, key = { it.id }) { item ->
PantryItemRow(item)
}
}
C’est exactement le schéma derrière la liste du garde-manger de Stocky, qui contient régulièrement quelques centaines de lignes issues de scans de codes-barres et d’imports de tickets de caisse — sans clé stable, modifier la quantité d’un article recomposerait toute la liste visible au lieu de la seule ligne modifiée.
Lire le propre rapport du compilateur
Deviner quels composables sont instables est inutile — le compilateur Compose peut vous le dire directement. Ajouter un flag de métriques au build Gradle produit un rapport listant chaque fonction composable, si elle est « skippable », et exactement quel paramètre l’a rendue instable :
// build.gradle.kts
composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_metrics")
reportsDestination = layout.buildDirectory.dir("compose_metrics")
}
Le rapport *-composables.txt généré nomme le paramètre instable sur chaque fonction qu’il signale, ce qui transforme « pourquoi cet écran est-il lent » d’une session de profiling en un simple grep. Je lance ça avant de livrer tout écran contenant une liste ou une valeur qui se met à jour fréquemment — c’est peu coûteux, précis, et ça attrape l’erreur du var dans une data class en à peu près le temps qu’il faut pour ouvrir le fichier.
Ce qu’il faut retenir
La recomposition n’est pas intrinsèquement un problème de performance — Compose est conçu pour réexécuter des fonctions à moindre coût, et souvent. Le problème, c’est la recomposition non limitée : un écran entier qui se réexécute parce qu’un paramètre instable, une lambda non hissée, ou une clé de liste manquante ont dit au compilateur qu’il ne pouvait rien prouver de sûr à ignorer. Corrigez ces trois choses — types stables avec des champs val, lambdas hissées, et clés explicites sur chaque élément de LazyColumn — et la plupart de ce qui ressemble à un problème de performance Compose s’avère déjà résolu.
// À 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.