L’essentiel en 30 secondes
- Des clés stables (
key) et uncontentTypepermettent à Compose de reconnaître et de réutiliser les éléments d’une liste. - Remontez l’état au plus petit ancêtre commun et exposez des collections observables, jamais une
MutableListnue. - Chargez les images avec Coil (
AsyncImage) et réservez leur taille pour éviter les recompositions en cascade. - Pour les grandes collections, associez
LazyColumnà Paging 3, et mesurez toujours en Release avec R8.
Les listes sont partout dans les applications Android : historique de conversations, catalogue de produits, bibliothèque musicale, fil d’actualité, résultats de recherche, notifications ou liste de fichiers. Elles constituent souvent l’une des principales interfaces de navigation d’une application.
Avec Jetpack Compose, créer une liste ne demande que quelques lignes de Kotlin : un LazyColumn, un modèle de données et un composable pour chaque élément suffisent à obtenir un premier résultat. Mais faire défiler dix éléments sans ralentissement est une chose. Maintenir une expérience parfaitement fluide avec plusieurs milliers d’entrées, des images distantes, des états individuels et des mises à jour fréquentes en est une autre.
La difficulté ne réside pas uniquement dans le nombre d’éléments. Elle dépend de la manière dont Compose identifie les éléments, de l’organisation des états, du coût des recompositions, du chargement des images et du travail effectué sur le thread principal. Voici sept bonnes pratiques pour concevoir des listes plus performantes, plus prévisibles et plus faciles à maintenir.
Comprendre ce qui ralentit réellement une liste Compose
Avant d’optimiser le code, il faut comprendre ce qui se passe lorsqu’un utilisateur fait défiler une liste. Jetpack Compose repose sur trois grandes phases de traitement :
- Composition : Compose détermine quels éléments de l’interface doivent être créés ou mis à jour.
- Mise en page : le moteur calcule la taille et la position des éléments.
- Dessin : les éléments sont effectivement rendus à l’écran.
Ces phases sont décrites dans la documentation officielle sur les phases et les performances de Compose. Lorsqu’une propriété change, Compose peut éviter certaines phases si elles ne sont pas nécessaires. En revanche, une lecture d’état mal placée ou un calcul coûteux peut déclencher davantage de travail que prévu.
Imaginons une application musicale affichant 500 morceaux. Chaque ligne contient une pochette, un titre, un artiste, un bouton de lecture et une commande de favori. Si le défilement entraîne inutilement le recalcul de données, le rechargement de ressources ou la reconstruction de nombreuses lignes, le thread principal risque de ne pas terminer son travail à temps.
Sur un écran à 60 Hz, une nouvelle image doit être produite environ toutes les 16,7 ms ; à 120 Hz, cette fenêtre descend à environ 8,3 ms. Le résultat d’un dépassement est visible : le mouvement perd sa régularité, les animations accrochent, le défilement paraît lourd.
1. Choisir le bon conteneur : LazyColumn n’est pas une obligation universelle
Pourquoi les listes différées sont importantes
LazyColumn et LazyRow ne composent et ne mettent en page que les éléments nécessaires à la fenêtre d’affichage, ainsi que ceux utiles à l’anticipation du défilement. C’est le principe de la composition différée. Avec une Column classique, une boucle forEach produit tous les éléments dans la composition, même ceux très loin hors de l’écran.
@Composable
fun TrackList(
tracks: List<Track>
) {
Column {
tracks.forEach { track ->
TrackRow(track = track)
}
}
}Cette approche est acceptable pour une liste courte, statique et bornée. Elle l’est beaucoup moins lorsque la collection grandit ou que sa taille n’est pas connue à l’avance. La version différée s’écrit ainsi :
@Composable
fun TrackList(
tracks: List<Track>
) {
LazyColumn {
items(tracks) { track ->
TrackRow(track = track)
}
}
}La différence est particulièrement importante pour les historiques, les bibliothèques musicales ou les flux alimentés par un serveur. La documentation Android recommande les composants différés pour les collections importantes ou de taille inconnue (guide « Listes et grilles différées »).
Faut-il toujours utiliser LazyColumn ?
Non. Le mot « Lazy » ne constitue pas une garantie de performance universelle. Pour trois boutons, quatre options de configuration ou cinq catégories fixes, une Column classique est plus simple et suffisante : la composition différée implique elle-même un travail de gestion, de mesure et d’anticipation. La règle pratique :
- Quelques éléments fixes : privilégier
RowouColumn. - Liste longue ou nombre d’éléments variable : privilégier
LazyColumnouLazyRow. - Grille de contenu : utiliser
LazyVerticalGridouLazyHorizontalGrid. - Collection potentiellement immense provenant d’un serveur : associer un conteneur différé à une stratégie de pagination.
Éviter la fausse liste différée
Un autre piège consiste à calculer la collection complète à chaque recomposition :
@Composable
fun TrackList(
tracks: List<Track>
) {
val sortedTracks = tracks.sortedBy { it.title }
LazyColumn {
items(sortedTracks) { track ->
TrackRow(track = track)
}
}
}Même si LazyColumn ne compose que les lignes nécessaires, le tri est effectué avant la création du contenu différé et peut donc être répété à chaque recomposition du parent. Pour plusieurs milliers d’éléments, les transformations coûteuses méritent d’être effectuées dans la couche appropriée : ViewModel, dépôt de données ou base de données.
2. Définir des clés stables et des types de contenu pour chaque élément
Si une seule optimisation devait être systématiquement vérifiée dans une liste dynamique, ce serait celle-ci : les clés stables.
Le problème des éléments identifiés uniquement par leur position
Par défaut, Compose peut s’appuyer sur la position des éléments pour les distinguer. Imaginons une liste de trois messages : « Bonjour » (position 0), « Comment vas-tu ? » (1) et « À bientôt » (2). L’utilisateur reçoit un nouveau message au début : il prend la position 0, et les trois anciens passent aux positions 1, 2 et 3.
Les positions ont changé alors que les trois anciens messages sont toujours les mêmes. Si la position est le seul repère, Compose a moins d’informations pour reconnaître l’identité des éléments après l’insertion, ce qui peut perturber la conservation des états locaux et provoquer du travail supplémentaire. La solution : fournir une clé stable, unique et liée à l’identité réelle de chaque élément.
@Composable
fun MessageList(
messages: List<Message>
) {
LazyColumn {
items(
items = messages,
key = { message -> message.id }
) { message ->
MessageRow(message = message)
}
}
}Ici, message.id est l’identifiant persistant du message. Si le message change de position, son identité reste la même : Compose fait mieux correspondre les éléments entre les versions de la liste, préserve les états sauvegardables et peut gérer certaines animations de déplacement.
Les trois erreurs à éviter avec les clés
1. Utiliser l’index comme identifiant. itemsIndexed n’est pas mauvais en soi : il est utile quand l’index est nécessaire au contenu. Mais cet index ne doit pas servir d’identité durable à un élément susceptible d’être inséré, supprimé ou déplacé.
itemsIndexed(messages) { index, message ->
MessageRow(message = message)
}2. Utiliser hashCode() comme clé universelle. Un hash n’est pas garanti unique : deux éléments différents peuvent produire la même valeur, et il peut changer si son calcul dépend de propriétés modifiables.
key = { it.hashCode() }3. Utiliser une clé différente à chaque composition. Une clé aléatoire ou recréée arbitrairement empêche Compose de reconnaître la continuité d’un élément.
Les clés doivent être déterministes et uniques dans la liste. Pour que l’état d’un élément puisse être restauré par les mécanismes de sauvegarde Android, privilégiez des types compatibles avec un Bundle : Long, Int ou String.
L’intérêt de contentType
Depuis Compose 1.2, l’API des listes différées propose aussi contentType, qui précise la famille visuelle de chaque élément. Prenons un fil d’actualité mêlant articles textuels, cartes vidéo, bannières promotionnelles et en-têtes de section : tout est dans la même liste, mais la structure visuelle diffère.
LazyColumn {
items(
items = feedItems,
key = { item -> item.id },
contentType = { item -> item.type }
) { item ->
when (item.type) {
"article" -> ArticleRow(item)
"video" -> VideoRow(item)
"banner" -> BannerRow(item)
"header" -> SectionHeader(item)
}
}
}En indiquant les types de contenu, vous aidez Compose à réutiliser les compositions entre des éléments de structure comparable. Pour une liste homogène, ce paramètre est moins déterminant ; pour une liste hétérogène, il mérite une attention particulière.
3. Remonter les états intelligemment sans transformer chaque ligne en petit écran autonome
Dans Compose, une valeur observable peut entraîner la recomposition des composables qui la lisent. Le résultat dépend donc non seulement de la valeur modifiée, mais aussi de l’endroit où elle est lue. Pour une liste, il faut éviter deux extrêmes : conserver tous les états dans une énorme fonction, ou déplacer inutilement chaque petit état dans un ViewModel.
Comprendre le state hoisting
Le *state hoisting*, ou remontée de l’état, consiste à déplacer l’état vers un composant appelant afin de séparer les données de leur représentation. Prenons une ligne de bibliothèque musicale :
@Composable
fun TrackRow(
track: Track,
onToggleFavorite: () -> Unit
) {
Row {
Text(track.title)
IconButton(
onClick = onToggleFavorite
) {
// Icône représentant l'état du favori.
}
}
}Cette ligne ne décide pas elle-même de la manière dont le favori est enregistré. Le parent l’utilise ainsi :
@Composable
fun LibraryScreen(
tracks: List<Track>,
onToggleFavorite: (Long) -> Unit
) {
LazyColumn {
items(
items = tracks,
key = { it.id }
) { track ->
TrackRow(
track = track,
onToggleFavorite = {
onToggleFavorite(track.id)
}
)
}
}
}La logique de modification des favoris appartient à la couche chargée des données, tandis que TrackRow se concentre sur l’affichage et l’interaction. Cette séparation facilite les tests, la réutilisation des lignes et la compréhension du comportement de la liste.
Pourquoi ne pas tout remonter dans le ViewModel ?
Parce que tous les états n’ont pas besoin de vivre au même niveau :
- Liste des morceaux et statut des favoris :
ViewModelou couche métier appropriée. - Position de défilement et interaction avec la liste : état d’interface, par exemple
LazyListState. - Animation locale ou détail développé dans une ligne : état local au composable, si aucun autre composant n’en dépend.
Un état simple, utilisé et modifié uniquement dans une ligne, peut parfaitement rester local. La bonne pratique consiste à hisser l’état vers le plus petit ancêtre commun des composants qui doivent le lire ou le modifier, comme le recommande la documentation Android sur la remontée de l’état.
Éviter les collections mutables qui échappent à Compose
Une erreur fréquente consiste à modifier directement une collection Kotlin ordinaire :
val tracks = mutableListOf<Track>()
tracks.add(newTrack)Une MutableList classique n’est pas, à elle seule, un état observable par Compose : sa modification ne garantit pas la mise à jour de l’interface. Préférez un état observable ou un nouvel état immuable émis depuis votre source de données :
private val _tracks =
MutableStateFlow<List<Track>>(emptyList())
val tracks: StateFlow<List<Track>> =
_tracks.asStateFlow()
fun addTrack(track: Track) {
_tracks.update { currentTracks ->
currentTracks + track
}
}La mise à jour produit une nouvelle liste au lieu de modifier silencieusement l’existante. Dans le composable d’écran, collectez ensuite l’état avec collectAsStateWithLifecycle(), fourni par les bibliothèques AndroidX Lifecycle Compose. Le bénéfice ne se limite pas à la performance : la traçabilité des changements et la cohérence de l’interface en sortent améliorées.
4. Charger les images de manière asynchrone et réserver leur espace dès le départ
Dans les listes modernes, les images sont souvent le principal facteur de coût graphique. Chacune implique potentiellement une requête réseau, un décodage, une allocation mémoire et un travail de rendu. Le problème ne vient donc pas uniquement du téléchargement, mais aussi de ce qu’il se passe une fois les octets récupérés.
Pourquoi painterResource n’est pas le bon outil pour toutes les images
painterResource() convient parfaitement aux ressources incluses dans l’application, notamment les icônes vectorielles et les petites illustrations d’interface. Pour de grandes images matricielles dans une liste défilante, il est moins approprié : le décodage peut être effectué pendant la composition sur le thread principal, et la taille de décodage n’est pas forcément optimisée pour la petite zone d’affichage. Une photo compressée de quelques centaines de ko peut devenir un bitmap de plusieurs Mo en mémoire. Une bibliothèque de chargement d’images est généralement un meilleur choix.
Utiliser AsyncImage avec Coil
Pour Kotlin et Jetpack Compose, Coil fournit le composable AsyncImage, conçu pour charger et afficher des images de manière asynchrone. La dépendance documentée pour Coil 3 :
dependencies {
implementation("io.coil-kt.coil3:coil-compose:3.6.3")
}Adaptez la version à celle de votre projet et à la compatibilité de vos autres dépendances. Une ligne de bibliothèque affiche ensuite sa pochette ainsi :
@Composable
fun TrackArtwork(
imageUrl: String,
modifier: Modifier = Modifier
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}Le traitement asynchrone, le cache et les possibilités d’adaptation du chargement évitent de réimplémenter manuellement la gestion des images. Le guide Android « Optimiser les images bitmap » recommande d’ailleurs d’utiliser des bibliothèques adaptées, comme Coil ou Glide.
Réserver la taille avant l’arrivée de l’image
Ce détail est particulièrement important dans une LazyColumn. Si une ligne n’a aucune hauteur tant que son image n’est pas chargée, le conteneur différé peut considérer qu’un grand nombre d’éléments de hauteur nulle tiennent dans la zone visible. Quand les images arrivent et que les lignes prennent leur taille définitive, Compose doit revoir la disposition et a pu composer bien plus d’éléments que nécessaire. Ce comportement est explicitement signalé dans la documentation des listes différées. La solution : définir une taille ou des contraintes réalistes, même pendant le chargement.
@Composable
fun ArtworkPlaceholder() {
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}Le même principe s’applique aux lignes complètes : leur hauteur doit rester cohérente même lorsque les images ou informations secondaires ne sont pas encore disponibles.
Ne pas charger une image de 4 000 pixels pour l’afficher sur 56 dp
Avec un service distant, demandez une miniature adaptée à la taille de l’interface plutôt que l’original : moins de données transférées, moins de décodage, moins de mémoire. Une bibliothèque de chargement peut sous-échantillonner quand les conditions s’y prêtent, mais il reste préférable de prévoir des ressources adaptées dès la conception de l’API.
Prévoyez aussi un comportement cohérent en cas d’échec : image de remplacement, indicateur de chargement si nécessaire, dimensions conservées. Enfin, évitez de générer une URL artificiellement différente à chaque recomposition : le cache n’est efficace que si l’application fournit des demandes et des clés de cache cohérentes.
5. Réduire le travail effectué pendant les recompositions
Compose est conçu pour recomposer efficacement : toute recomposition n’est pas un problème. En revanche, la fonction d’un composable peut être exécutée très fréquemment, notamment pendant certaines animations. Une ligne de liste doit donc éviter les calculs coûteux, les transformations répétées et les lectures d’état qui concernent inutilement une vaste partie de l’interface.
Déplacer les calculs coûteux hors des composables
@Composable
fun ContactList(
contacts: List<Contact>
) {
val sortedContacts = contacts
.sortedBy { it.name.lowercase() }
LazyColumn {
items(
items = sortedContacts,
key = { it.id }
) { contact ->
ContactRow(contact)
}
}
}Le tri est exécuté à chaque fois que le corps du composable est réévalué. Si ce travail est important et que la liste change rarement, le refaire est un gaspillage. Première solution : calculer la liste préparée dans le ViewModel ou la couche de données. Seconde solution : mémoriser le résultat lorsque le calcul dépend de paramètres identifiables.
@Composable
fun ContactList(
contacts: List<Contact>
) {
val sortedContacts = remember(contacts) {
contacts.sortedBy {
it.name.lowercase()
}
}
LazyColumn {
items(
items = sortedContacts,
key = { it.id }
) { contact ->
ContactRow(contact)
}
}
}remember(contacts) conserve le résultat tant que la clé contacts reste égale selon les règles de comparaison de Compose. Cela suppose que les changements pertinents produisent une collection correctement actualisée : une liste mutable modifiée silencieusement rendrait le résultat mémorisé obsolète. Évitez aussi de tout placer dans remember : pour une opération peu coûteuse, la mémorisation ajoute plus de complexité qu’elle n’en retire.
Exploiter derivedStateOf pour les états qui changent plus vite que leur résultat
Prenons un bouton « Remonter en haut » qui apparaît dès que l’utilisateur quitte le début de la liste.
@Composable
fun TrackScreen(
tracks: List<Track>
) {
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
Box {
LazyColumn(
state = listState
) {
items(
items = tracks,
key = { it.id }
) { track ->
TrackRow(track)
}
}
if (showScrollToTop) {
ScrollToTopButton(
onClick = {
// Faire défiler la liste vers le début.
}
)
}
}
}La position de défilement évolue très fréquemment, mais le résultat qui intéresse le bouton est un simple booléen : sommes-nous au début de la liste ou non ? derivedStateOf limite les notifications dépendant de ce calcul lorsque son résultat ne change pas, même si l’état source évolue. Ne l’appliquez pas à toutes les expressions : si le résultat change aussi souvent que la source, il ajoute du travail sans bénéfice.
Reporter les lectures d’état au moment où elles sont nécessaires
La documentation des performances Compose présente un principe important : différer les lectures d’état pour éviter de relancer inutilement la composition. Lorsqu’une valeur changeante sert uniquement à modifier une position graphique, un modificateur acceptant une lambda permet de lire cette valeur plus tard, pendant la phase de mise en page. Au lieu de :
Modifier.offset(
x = 0.dp,
y = offsetY
)on peut, dans les cas adaptés, utiliser la forme différée :
Modifier.offset {
IntOffset(
x = 0,
y = offsetYPx
)
}offsetYPx doit ici être exprimé en pixels. La lambda est évaluée au moment de la mise en page, ce qui diffère la lecture par rapport à la composition. Ce principe est utile pour des décalages ou des propriétés de dessin qui changent souvent, mais il ne faut pas remplacer mécaniquement tous les modificateurs par leur variante à lambda : l’intérêt dépend de la manière dont l’état est consommé.
Garder les composables simples
Une ligne idéale reçoit ses données, affiche son contenu et expose des événements. Elle ne devrait pas, à chaque recomposition, effectuer une requête réseau, reconstruire une base intermédiaire ou lancer un traitement massif. Les opérations avec effets de bord se déclenchent via les mécanismes appropriés de Compose : événements utilisateur ou API d’effets. La documentation officielle « Suivre les bonnes pratiques » détaille les calculs coûteux, les lectures d’état différées et la réduction des recompositions inutiles.
6. Adopter Paging pour les grandes collections et concevoir de vrais états de chargement
LazyColumn améliore le rendu de grandes collections, mais ne règle pas à elle seule leur acquisition. Une application qui récupère 50 000 messages depuis une API, les conserve tous en mémoire puis les transmet à une liste différée peut encore souffrir de problèmes de temps de réponse, de mémoire ou de volume de données. Il faut distinguer deux mécanismes complémentaires :
- Le rendu différé : ne composer que les éléments nécessaires à la fenêtre d’affichage.
- La pagination des données : récupérer progressivement une collection depuis une source de données.
Utiliser Paging 3 lorsque les données arrivent par pages
La bibliothèque AndroidX Paging fournit un chargement progressif des données. La version 3 s’intègre à Jetpack Compose via androidx.paging:paging-compose. Voici un exemple simplifié de rendu :
@Composable
fun MessageList(
pager: Flow<PagingData<Message>>
) {
val messages = pager.collectAsLazyPagingItems()
LazyColumn {
items(
count = messages.itemCount,
key = messages.itemKey { it.id }
) { index ->
val message = messages[index]
if (message != null) {
MessageRow(message)
} else {
MessagePlaceholder()
}
}
}
}Paging fournit progressivement les éléments nécessaires et Compose les affiche via une liste différée. L’exemple illustre volontairement la partie visuelle : dans une application complète, le Flow<PagingData<Message>> provient d’un ViewModel ou d’une couche adaptée, et la gestion des erreurs, du rafraîchissement et de l’état de chargement dépend des exigences de l’écran.
Avec un RemoteMediator, une base locale et une API distante, la conception des pages et des espaces réservés demande une attention supplémentaire : les placeholders doivent avoir des dimensions réalistes, sans quoi l’application risque de charger plus de données que nécessaire pour remplir la zone visible. La documentation Android propose un guide détaillé pour charger progressivement des listes avec Paging.
Concevoir des placeholders qui préservent la géométrie
Un état de chargement n’est pas qu’un détail esthétique. Un écran vide qui voit soudainement apparaître des dizaines de lignes donne une impression de lenteur ou d’instabilité. Un placeholder bien conçu préserve la structure visuelle avant l’arrivée des données :
@Composable
fun MessagePlaceholder() {
Row(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.surfaceVariant
)
)
Spacer(
modifier = Modifier.width(12.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.width(180.dp)
.height(12.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant
)
)
Box(
modifier = Modifier
.width(120.dp)
.height(10.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
}Ce squelette reprend une structure et des dimensions déterminées : une fois le contenu reçu, la vraie ligne remplace le placeholder sans variation importante de hauteur. Dans une application aboutie, prévoyez aussi un état vide, un état d’erreur avec une action de réessai et un indicateur de chargement pour les pages supplémentaires.
Ne pas confondre anticipation et téléchargement massif
Les listes différées peuvent anticiper certains éléments pour préparer le défilement, mais cela ne signifie pas qu’il faut télécharger toutes les données ou toutes les images de la collection. Une stratégie efficace charge ce qui est utile, s’appuie sur la pagination quand c’est pertinent et reste prévisible sur un réseau lent.
Restez aussi prudent avec les listes imbriquées. Une liste horizontale dans une liste verticale est un cas courant et légitime ; en revanche, plusieurs conteneurs concurrents qui défilent dans la même direction compliquent la gestion des contraintes et des gestes si leur architecture est mal définie.
7. Mesurer les performances dans des conditions réelles plutôt que compter les recompositions
La dernière bonne pratique est aussi l’une des plus négligées : mesurer avant et après chaque optimisation importante. Une interface peut être fluide sur un appareil haut de gamme et saccadée sur un téléphone plus ancien. Inversement, une optimisation visible dans des statistiques de recomposition peut n’apporter aucune amélioration mesurable au défilement. L’objectif est de vérifier l’expérience réelle.
Tester en version Release
Les builds Debug servent au développement et au diagnostic, mais ajoutent des coûts qui modifient le comportement de l’application. La documentation Android recommande de mesurer les listes différées dans une version Release avec les optimisations R8 activées. Un défilement laborieux en build de développement ne prouve pas à lui seul que le code de production est trop lent ; à l’inverse, un test rapide sur un seul appareil ne garantit rien pour tout le parc matériel.
Utiliser Macrobenchmark pour reproduire le défilement
La bibliothèque AndroidX Macrobenchmark mesure des parcours larges de l’application, notamment le démarrage et les interactions complexes. Pour une liste, un scénario peut reproduire un défilement, mesurer les temps de rendu et aider à identifier les images qui dépassent le budget disponible. Voici un exemple schématique :
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollLibrary() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()),
iterations = 5,
startupMode = StartupMode.WARM
) {
startActivityAndWait()
val list = device.findObject(
By.res("my_lazy_list")
)
list.fling(Direction.DOWN)
}Cet extrait suppose que le module de benchmark possède les dépendances et la configuration UI Automator adaptées, que l’application testée expose la liste avec l’identifiant de ressource attendu et que les classes nécessaires sont importées. Dans Compose, exposez un testTag sur le conteneur et activez testTagsAsResourceId au niveau approprié de l’arbre sémantique lorsque la configuration de test le nécessite. La documentation officielle « Écrire un macrobenchmark » explique comment préparer ce type de scénario.
Examiner les temps de rendu plutôt que les seuls compteurs de recomposition
Lorsqu’une liste saccade, observez plusieurs éléments :
- Les temps de rendu et les images en retard.
- Les phases de composition et de mise en page trop longues.
- Le décodage des images et les allocations mémoire.
- Les tâches effectuées sur le thread principal.
- Le comportement lors des insertions, suppressions et mises à jour.
- Les différences entre le premier affichage, le défilement initial et un défilement après mise en cache.
Les outils de profilage d’Android Studio et les traces de performance aident à déterminer si le problème vient de la composition, du chargement des ressources, de la préparation des données ou d’ailleurs. Testez aussi les cas où les optimisations sont réellement sollicitées : première ouverture, réseau lent, images froides en cache, navigation rapide, mises à jour fréquentes.
Comprendre la stabilité et le Strong Skipping
Compose utilise les informations de stabilité des paramètres pour déterminer si certains composables peuvent être ignorés lorsque leurs entrées n’ont pas changé. Avec Kotlin 2.0.20 et les versions suivantes concernées, le mode Strong Skipping est activé par défaut : il rend ignorables davantage de composables ayant des paramètres instables, en comparant les instances selon les règles de ce mode.
Cela ne signifie pas qu’il faut ajouter @Stable ou @Immutable partout. Ces annotations ont un rôle précis et doivent respecter les garanties réelles du type : déclarer à tort qu’un objet est stable peut compromettre l’invalidation correcte de l’interface. Chercher à rendre tous les composables ignorables n’est pas non plus nécessairement bénéfique : les comparaisons ont un coût, et certaines fonctions sont déjà assez légères. La bonne approche : diagnostiquer, identifier les paramètres ou calculs réellement coûteux, puis vérifier l’amélioration par la mesure. Pour approfondir, consultez le guide Android sur la stabilité dans Jetpack Compose.
Aller plus loin avec les Baseline Profiles
Les Baseline Profiles précompilent certains chemins de code essentiels afin de réduire le coût de leur exécution initiale. Compose bénéficie déjà d’un profil fourni par la bibliothèque, mais un profil personnalisé peut couvrir les parcours propres à votre application : ouverture d’une bibliothèque musicale, défilement des résultats, accès à une conversation. Ils ne remplacent pas une liste bien conçue : c’est une optimisation complémentaire.
Un exemple de liste optimisée qui rassemble les bonnes pratiques
Voici une implémentation de référence réunissant plusieurs principes : clé stable, type de contenu, état de défilement conservé, ligne visuelle indépendante et chargement asynchrone de la pochette. Le modèle reste volontairement simple :
data class Track(
val id: Long,
val title: String,
val artist: String,
val artworkUrl: String,
val isFavorite: Boolean
)Le composable de la liste s’écrit ensuite ainsi :
@Composable
fun TrackLibrary(
tracks: List<Track>,
onTrackClick: (Long) -> Unit,
onFavoriteClick: (Long) -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier,
state = listState,
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 8.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = tracks,
key = { track -> track.id },
contentType = { "track" }
) { track ->
TrackRow(
track = track,
onClick = {
onTrackClick(track.id)
},
onFavoriteClick = {
onFavoriteClick(track.id)
}
)
}
}
}TrackRow reçoit uniquement les informations nécessaires à son affichage :
@Composable
fun TrackRow(
track: Track,
onClick: () -> Unit,
onFavoriteClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = track.artworkUrl,
contentDescription = null,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
Spacer(
modifier = Modifier.width(12.dp)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = track.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.artist,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
IconButton(
onClick = onFavoriteClick
) {
Icon(
imageVector = if (track.isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Outlined.FavoriteBorder
},
contentDescription = if (track.isFavorite) {
"Retirer des favoris"
} else {
"Ajouter aux favoris"
}
)
}
}
}Les imports des composants Material 3, des icônes, des modificateurs et des types graphiques ne sont pas reproduits intégralement ici. Cette implémentation est une bonne base, mais ne doit pas être considérée comme universellement optimale sans mesure.
Puisque tous les éléments sont des morceaux, le contentType est identique : il devient intéressant quand la liste combine plusieurs structures visuelles. De même, une bibliothèque très volumineuse provenant d’un serveur gagnera à intégrer Paging plutôt qu’à charger toutes ses données en mémoire. Enfin, une bonne LazyColumn ne compense pas un flux de données qui recrée continuellement toutes les instances ou un traitement excessif à chaque changement d’état.
Checklist : les points à vérifier avant de livrer une liste Compose
Structure et identité
- Le choix entre
Column,LazyColumnet une grille correspond au volume de données. - Chaque élément dynamique possède une clé unique, stable et adaptée à sa nature.
- Les listes hétérogènes précisent un
contentTypepertinent. - Aucun calcul coûteux de recherche ou de tri n’est répété inutilement à chaque recomposition.
État et composition
- Les composants visuels reçoivent uniquement les données et les actions nécessaires.
- Les états partagés sont remontés au niveau approprié.
- Les petits états locaux restent proches de leur utilisation lorsqu’ils n’ont pas besoin d’être partagés.
- Les collections changent au moyen d’un mécanisme observable.
- Les calculs coûteux sont déplacés ou mémorisés lorsque cela est pertinent.
derivedStateOfest utilisé lorsque le résultat doit évoluer moins souvent que son état source.
Images et données
- Les images distantes sont chargées avec une bibliothèque adaptée.
- Les dimensions des éléments sont définies avant l’arrivée des ressources.
- La résolution des images correspond autant que possible à leur taille d’affichage.
- Les erreurs et les états de chargement conservent une disposition cohérente.
- Les très grandes collections utilisent une stratégie de pagination appropriée.
Mesure et livraison
- Les performances ont été testées en version Release avec R8.
- Le défilement a été mesuré sur des appareils représentatifs.
- Les images manquantes en cache et les mises à jour fréquentes ont été testées.
- Les saccades ont été diagnostiquées plutôt que supposées.
- Les Baseline Profiles ont été envisagés pour les parcours les plus importants.
Conclusion : la fluidité se construit dans les détails
Jetpack Compose fournit des outils puissants pour créer des interfaces Android déclaratives, évolutives et agréables à utiliser, mais la fluidité d’une liste ne dépend jamais d’un unique paramètre magique. Elle résulte d’un ensemble de décisions cohérentes : choisir le bon conteneur, fournir des clés stables, organiser les états de manière prévisible, éviter les calculs superflus, charger les images intelligemment et alimenter les collections volumineuses progressivement. La mesure complète cette démarche : elle distingue les optimisations réellement efficaces des changements qui compliquent simplement le code.
Une bonne liste Compose n’est pas nécessairement celle qui recompose le moins. C’est celle qui effectue le travail utile au bon moment, conserve l’identité de ses éléments et reste réactive lorsque les données et les interactions évoluent.
La fluidité n’est pas une finition que l’on ajoute à la fin du développement. C’est une propriété que l’on construit dès les premières lignes de code.
Ressources officielles pour aller plus loin
- Listes et grilles différées (developer.android.com/develop/ui/compose/lists)
- Bonnes pratiques de performance Compose (developer.android.com/develop/ui/compose/performance/bestpractices)
- Remontée de l’état (developer.android.com/develop/ui/compose/state-hoisting)
- Optimisation des images bitmap (developer.android.com/develop/ui/compose/graphics/images/optimization)
- Documentation de Coil pour Compose (coil-kt.github.io/coil/compose)
- Performance de Jetpack Compose (developer.android.com/develop/ui/compose/performance)
- Macrobenchmark (developer.android.com/topic/performance/benchmarking/macrobenchmark-overview)
- Baseline Profiles (developer.android.com/develop/ui/compose/performance/baseline-profiles)
Commentaires
Aucun commentaire pour le moment. Soyez le premier à réagir !