Appliquer les bonnes pratiques

Vous pouvez rencontrer des 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'UI. Suivez les bonnes pratiques pour optimiser votre application sur Compose.

Utiliser remember pour réduire les calculs coûteux

Les fonctions composables peuvent s'exécuter très fréquemment, aussi souvent que pour chaque frame 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, comparator) {
        contacts.sortedWith(comparator)
    }

    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 efficacement les éléments, en les recréant ou en les recompilant uniquement lorsque cela est nécessaire. Toutefois, vous pouvez optimiser les mises en page différées pour la recomposition.

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éé 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 déterminer 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 un état dérivé. L'état dérivé vous permet d'indiquer à Compose quels changements d'état doivent réellement déclencher une recomposition. Dans ce cas, spécifiez 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. Pour référence, consultez l'implémentation dans l'application exemple Jetsnack. Jetsnack implémente 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 Jetpack Compose : Déboguer une recomposition.

Pour obtenir cet effet, le composable Title a besoin du 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 invalide le champ d'application de recomposition parent le plus proche. Dans ce cas, le champ d'application le plus proche est le composable SnackDetail. Notez que Box est une fonction intégrée et n'est donc pas un champ d'application de recomposition. Compose recompose donc SnackDetail et 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, utilisez un modificateur basé sur le 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 { mutableIntStateOf(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</b>
}

Ce code met à jour le nombre à la fin du composable après l'avoir lu à la ligne précédente. 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.

Ressources supplémentaires