Voici quelques pièges courants de Compose. Ces erreurs peuvent générer un code qui semble s'exécuter correctement, mais qui peut nuire aux performances de l'interface utilisateur. Cette section présente quelques bonnes pratiques pour vous aider à éviter ces problèmes.
Utiliser remember
pour réduire les calculs coûteux
Les fonctions modulables peuvent s'exécuter très fréquemment, aussi souvent que pour chaque image d'une animation. Par conséquent, nous vous conseillons de réduire le nombre de calculs au minimum dans le corps de votre composable.
L'une des principales techniques consiste à stocker les résultats des calculs avec remember
.
Ainsi, le calcul ne s'effectue qu'une seule fois, et vous pouvez récupérer les résultats chaque fois qu'ils sont nécessaires.
Voici par exemple un code qui affiche une liste de noms triés, mais dont le tri s'effectue de façon très coûteuse :
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
Chaque fois que ContactsList
est recomposé, l'intégralité de la liste de contacts est triée à nouveau, même si elle n'a pas changé. Si l'utilisateur fait défiler la liste, le composable est recomposé lorsqu'une nouvelle ligne apparaît.
Pour résoudre ce problème, triez la liste en dehors de LazyColumn
et stockez la liste triée avec remember
:
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
Désormais, la liste est triée une fois, lors de la première composition de ContactList
. Si les contacts ou le comparateur changent, la liste triée est générée une nouvelle fois. Sinon, le composable peut continuer à utiliser la liste triée en cache.
Utiliser des clés de mise en page différée
Les mises en page différées réutilisent au mieux les éléments, en les recréant ou en les recompilant uniquement lorsque cela est nécessaire. Vous pouvez toutefois les aider à prendre les meilleures décisions.
Supposons qu'une opération utilisateur entraîne le déplacement d'un élément de la liste. Par exemple, imaginons que vous affichiez une liste de notes triées par heure de modification, avec la dernière note modifiée en haut de la liste.
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
Il y a un problème avec ce code. Supposons que la note en bas de la liste soit modifiée. Il s'agit désormais de la dernière note modifiée. Elle est donc placée en haut de la liste, et toutes les autres notes sont déplacées d'une ligne vers le bas.
Sans votre aide, Compose ne comprend pas que des éléments non modifiés sont simplement déplacés dans la liste. Au lieu de cela, Compose considère que l'ancien "élément 2" a été supprimé et qu'un autre a été créé, et ainsi de suite pour l'élément 3, l'élément 4, et ainsi de suite jusqu'à la fin de la liste. Résultat, Compose recompose chaque élément de la liste, même si un seul d'entre eux a réellement changé.
La solution consiste ici à fournir des clés d'élément. Fournir une clé stable pour chaque élément permet à Compose d'éviter les recompositions inutiles. Dans le cas en question, Compose peut voir que l'élément situé à l'emplacement 3 est le même qu'auparavant à l'emplacement 2. Comme aucune des données de cet élément n'a changé, Compose n'a pas besoin de le recomposer.
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// Return a stable, unique key for the note
note.id
}
) { note ->
NoteRow(note)
}
}
}
Utiliser derivedStateOf
pour limiter les recompositions
L'un des risques liés à l'utilisation de l'état dans vos compositions est que, si l'état change rapidement, votre UI risque d'être recomposée plus que nécessaire. Supposons par exemple que vous affichiez une liste déroulante. Vous examinez l'état de la liste pour déterminer le premier élément visible sur la liste :
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
Le problème est le suivant : si l'utilisateur fait défiler la liste, listState
change constamment, au fur et à mesure que l'utilisateur la fait défiler. Cela signifie que la liste est constamment recomposée. Cependant, vous n'avez pas besoin de la recomposer aussi souvent. Vous n'avez besoin de la recomposer que si un nouvel élément apparaît au bas de la liste. Cela représente beaucoup de calculs supplémentaires, ce qui nuit aux performances de votre UI.
La solution consiste à utiliser l'état dérivé. L'état dérivé vous permet d'indiquer à Compose quels changements d'état doivent réellement déclencher une recomposition. Dans le cas présent, indiquez que ce qui vous intéresse, c'est le moment où le premier élément visible change. Lorsque cette valeur d'état change, l'UI doit se recomposer. Mais si l'utilisateur n'a pas encore suffisamment fait défiler la page pour afficher un nouvel élément au début, une recomposition est inutile.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
Reporter les lectures le plus longtemps possible
Lorsqu'un problème de performances a été identifié, il peut être utile de reporter les lectures de l'état. Le report des lectures de l'état garantit que Compose réexécute le minimum de code lors de la recomposition. Par exemple, si l'état de votre UI est hissé en haut de l'arborescence composable et que vous lisez cet état dans un composable enfant, vous pouvez encapsuler la lecture d'état dans une fonction lambda. Ainsi, la lecture ne se produit que lorsqu'elle est réellement nécessaire. Vous pouvez voir comment nous avons appliqué cette approche à l'exemple d'application Jetsnack. Jetsnack utilise un effet semblable à une barre d'outils qui se réduit sur l'écran de détails. Pour comprendre pourquoi cette technique fonctionne, consultez l'article de blog : Debugging Recomposition (Déboguer une recomposition).
Pour obtenir cet effet, le composable Title
doit connaître le décalage de défilement pour pouvoir se décaler à l'aide d'un Modifier
. Voici une version simplifiée du code Jetsnack, avant optimisation :
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value)
// ...
} // Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
Lorsque l'état de défilement change, Compose recherche le champ d'application de recomposition parent le plus proche et l'invalide. Dans ce cas, le champ d'application le plus proche est le composable SnackDetail
. Remarque : Box est une fonction intégrée. Elle n'agit donc pas comme un champ d'application de recomposition.
Compose recompose donc SnackDetail
ainsi que tous les composables dans SnackDetail
.
Si vous modifiez votre code pour ne lire que l'état dans lequel vous l'utilisez, vous pouvez réduire le nombre d'éléments à recomposer.
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value }
// ...
} // Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
Le paramètre de défilement est désormais un lambda. Cela signifie que Title
peut toujours référencer l'état hissé, mais que la valeur n'est lue que dans Title
, où elle est réellement nécessaire. Par conséquent, lorsque la valeur de défilement change, le champ d'application de recomposition le plus proche est à présent le composable Title
. Compose n'a plus besoin de recomposer l'intégralité de Box
.
C'est une nette amélioration, mais vous pouvez faire mieux. Vous devriez vous poser des questions si une recomposition est effectuée uniquement pour réagencer ou redessiner un composable. Dans ce cas, il vous suffit de modifier le décalage du composable Title
, ce qui peut être fait dans la phase de mise en page.
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(x = 0, y = scrollProvider()) }
) {
// ...
}
}
Auparavant, le code utilisait Modifier.offset(x: Dp, y:
Dp)
, qui utilise le décalage comme paramètre. En passant à la version lambda du modificateur, vous pouvez vous assurer que la fonction lit l'état de défilement pendant la phase de mise en page. Ainsi, lorsque l'état de défilement change, Compose peut ignorer la phase de composition et passer directement à la phase de mise en page. Lorsque vous transmettez des variables d'état qui changent fréquemment à des modificateurs, utilisez les versions lambda des modificateurs dans la mesure du possible.
Voici un autre exemple de cette approche. Ce code n'a pas encore été optimisé :
// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))
Ici, l'arrière-plan de la zone passe rapidement d'une couleur à l'autre. Cet état change donc très fréquemment. Le composable lit ensuite cet état dans le modificateur d'arrière-plan. Par conséquent, la zone doit recomposer sur chaque image, car la couleur change à chaque image.
Pour améliorer cela, nous pouvons utiliser un modificateur basé sur la version lambda, dans ce cas, drawBehind
.
Cela signifie que l'état des couleurs n'est lu que pendant la phase de dessin. Compose peut donc ignorer complètement les phases de composition et de mise en page. Lorsque la couleur change, il passe directement à la phase de dessin.
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
}
)
Éviter les rétroécritures
Compose repose sur une hypothèse fondamentale : vous n'écrivez jamais dans un état déjà lu. Ce procédé de rétroécriture peut entraîner une recomposition sans fin à chaque image.
Le composable suivant montre un exemple de ce type d'erreur.
@Composable fun BadComposable() { var count by remember { mutableStateOf(0) } // Causes recomposition on click Button(onClick = { count++ }, Modifier.wrapContentSize()) { Text("Recompose") } Text("$count") count++ // Backwards write, writing to state after it has been read }
Ce code met à jour le nombre à la fin du composable après l'avoir lu à la ligne au-dessus. Si vous exécutez ce code, vous constaterez qu'une fois que vous avez cliqué sur le bouton, ce qui entraîne une recomposition. Le compteur augmente rapidement dans une boucle infinie, car Compose recompose ce composable, voit une lecture d'état obsolète et planifie donc une autre recomposition.
Vous pouvez éviter les rétroécritures en n'écrivant jamais dans un état dans la composition. Si possible, écrivez toujours dans un état en réponse à un événement et dans un lambda, comme dans l'exemple onClick
précédent.