Suivez les bonnes pratiques

Vous pouvez rencontrer des pièges courants de Compose. Ces erreurs peuvent vous donner du code qui semble fonctionner suffisamment bien, mais peuvent nuire aux performances de votre interface utilisateur. 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.

Une technique importante consiste à stocker les résultats des calculs avec remember. De cette façon, le calcul ne s'exécute qu'une seule fois, et vous pouvez récupérer les résultats chaque fois que vous en avez besoin.

Par exemple, voici du code qui affiche une liste triée de noms, mais qui effectue le tri d'une manière 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é, la liste complète des 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é chaque fois qu'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 regérant ou en les recompilant uniquement lorsqu'ils le doivent. 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, supposons que vous affichiez une liste de notes triées par date et heure de modification, avec la note la plus récente en haut.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

Il y a toutefois un problème avec ce code. Supposons que la note du bas 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 se rend pas compte que des éléments inchangés sont simplement déplacés dans la liste. Au lieu de cela, Compose pense 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 jusqu'à la fin. Résultat : Compose recompose chaque élément de la liste, même si un seul d'entre eux a été modifié.

La solution consiste à fournir des clés d'élément. Fournir une clé stable pour chaque élément permet à Compose d'éviter les recompositions inutiles. Dans ce cas, Compose peut déterminer que l'élément situé à l'emplacement 3 est le même qu'auparavant à l'emplacement 2. Comme aucune donnée n'a changé pour cet élément, 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. Par exemple, supposons que vous affichiez une liste déroulante. Vous examinez l'état de la liste pour identifier 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 le recomposer aussi souvent. Vous n'avez pas besoin de le recomposer tant qu'un nouvel élément ne s'affiche pas en bas de l'écran. 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 la recomposition. Dans ce cas, indiquez que vous vous souciez du moment où le premier élément visible change. Lorsque cette valeur d'état change, l'interface utilisateur doit se recomposer. Toutefois, si l'utilisateur n'a pas encore fait défiler suffisamment de pages pour placer un nouvel élément en haut de l'écran, il n'est pas nécessaire de se recomposer.

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 son écran d'informations. Pour comprendre pourquoi cette technique fonctionne, consultez l'article de blog Jetpack Compose: débogage de la recomposition.

Pour obtenir cet effet, le composable Title a besoin du décalage de défilement afin de se décaler à l'aide d'un Modifier. Voici une version simplifiée du code Jetsnack avant l'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. Il ne s'agit donc pas d'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 réellement, 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 lors de 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 lambda, dans ce cas, drawBehind. Cela signifie que l'état des couleurs n'est lu que pendant la phase de dessin. Par conséquent, Compose peut ignorer complètement les phases de composition et de mise en page. Lorsque la couleur change, Compose 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</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 verrez qu'après avoir cliqué sur le bouton, ce qui provoque une recomposition, le compteur augmente rapidement dans une boucle infinie à mesure que 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.

Autres ressources