Résoudre les problèmes de stabilité

Lorsque vous êtes confronté à une classe instable qui entraîne des problèmes de performances, vous devez optimiser la façon dont Compose la compare ou l'instancie. Ce document décrit plusieurs techniques que vous pouvez utiliser pour y parvenir.

Activer le mode de désactivation renforcée

Le mode de désactivation renforcée est activé par défaut à partir de Kotlin 2.0.20. Le mode de désactivation renforcée permet d'ignorer les composables avec des paramètres instables en comparant les paramètres instables à l'aide de l'égalité des instances (===), tandis que les paramètres stables continuent d'être comparés à l'aide de l'égalité structurelle (Object.equals()).

Si vous utilisez une version de Kotlin antérieure à la version 2.0.20, activez d'abord le mode de saut renforcé. Si le mode d'omission forcée est déjà activé et qu'un composable est toujours recomposé inutilement, vérifiez si de nouvelles instances de paramètres instables sont allouées à chaque passe de recomposition.

Pour en savoir plus, consultez Saut renforcé.

Rendre la classe immuable

Vous pouvez également rendre une classe instable complètement immuable afin que le compilateur puisse déduire automatiquement sa stabilité.

  • Immuable : indique un type dont la valeur des propriétés ne peut jamais changer après la construction d'une instance de ce type, et toutes les méthodes sont transparentes sur le plan référentiel.
    • Assurez-vous que toutes les propriétés de la classe sont de type val plutôt que var, et qu'elles sont de types immuables.
    • Les types primitifs tels que String, Int et Float sont toujours immuables.
    • Les types data class plats avec des propriétés primitives val ou String dans les modules créés avec le compilateur Compose sont déjà déduits comme stables et comparés automatiquement à .equals(). Vous n'avez pas besoin de les annoter avec @Immutable.
    • Si l'immuabilité est impossible, vous devez utiliser l'état Compose pour toutes les propriétés modifiables.
  • Stable : indique un type modifiable. L'environnement d'exécution Compose ne sait pas si et quand l'une des propriétés publiques ou le comportement de la méthode du type produira des résultats différents d'un appel précédent.

Collections et stabilité

Les collections sont une raison courante pour laquelle Compose considère une classe comme instable. Comme indiqué sur la page Diagnostiquer les problèmes de stabilité, le compilateur Compose ne peut pas être certain que les interfaces de collection telles que List, Map et Set sont réellement immuables. Il les marque donc comme instables.

Prenons l'exemple de cette classe du guide Diagnostiquer les problèmes de stabilité :

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

Lorsque le mode de désactivation forcée est activé (par défaut dans Kotlin 2.0.20 et versions ultérieures), les collections instables et les classes contenant des collections ignorent déjà la recomposition en utilisant l'égalité d'instance (===) chaque fois que la même référence est transmise.

Soyez prudent avant de remplacer kotlin.collections.List, Set ou Map par des collections immuables Kotlinx (ImmutableList, ImmutableSet, ImmutableMap) ou d'ajouter des règles @Immutable, @Stable ou stability_config.conf aux modèles contenant des collections. En mode d'omission renforcée, l'élément List standard ignore l'utilisation de l'égalité de référence (===), tandis que ImmutableList et les modèles de collection annotés exécutent des comparaisons .equals() élément par élément à chaque passe de recomposition.

Pour les collections volumineuses ou les mises en page différées (LazyColumn, LazyRow, LazyGrid), la vérification de .equals() sur chaque élément peut prendre plus de temps que la recomposition. Ce coût double si vous transmettez une propriété ImmutableList d'un data class à un composable enfant :

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

Au lieu d'imposer l'égalité structurelle sur les collections, conservez la référence d'instance de la collection entre les émissions (par exemple, en mettant en cache les transformations dans votre ViewModel ou avec remember) afin que Compose puisse ignorer l'utilisation de l'égalité d'instance (===). Envisagez d'utiliser ImmutableList ou des annotations de stabilité sur les modèles contenant des collections uniquement lorsque vous ne pouvez pas conserver la référence d'instance de la collection et que les benchmarks confirment que les vérifications d'égalité structurelle sont plus rapides que la recomposition. Si vous utilisez une version de Kotlin antérieure à la version 2.0.20, activez d'abord le mode de saut renforcé.

Annotez avec Stable ou Immutable.

Pour résoudre les problèmes de stabilité des petites classes avec des vérifications .equals() peu coûteuses, vous pouvez annoter les classes instables avec @Stable ou @Immutable.

Annoter une classe consiste à remplacer ce que le compilateur déduirait autrement de votre classe. Il est semblable à l'opérateur !! en Kotlin. Vous devez faire très attention à la façon dont vous utilisez ces annotations. Le remplacement du comportement du compilateur peut entraîner des bugs imprévus, par exemple si votre composable ne se recompose pas comme prévu.

Lorsque le mode de saut renforcé est activé, suivez ces consignes pour décider d'ajouter @Stable ou @Immutable :

  • N'annotez pas les classes déjà inférées comme stables : les classes avec des propriétés val de types stables dans les modules créés avec le compilateur Compose sont déjà inférées comme stables.
  • N'annotez les classes avec des vérifications .equals() peu coûteuses que lorsque les instances sont recréées : ajoutez @Stable ou @Immutable lorsque le compilateur ne peut pas déduire la stabilité (par exemple, les interfaces ou les classes de modules non Compose) et qu'une source de données alloue de nouvelles instances d'objets pour les éléments inchangés à chaque émission, à condition que la classe soit petite et qu'elle dispose d'une vérification .equals() peu coûteuse.
  • Soyez prudent avec les modèles contenant des collections : évitez d'ajouter @Stable ou @Immutable aux modèles de données contenant de grandes collections (List, Set, Map), en particulier pour les modèles utilisés dans les mises en page différées (LazyColumn, LazyRow, LazyGrid, Pager). En laissant ces modèles sans annotation, Compose peut les comparer à l'aide de l'égalité d'instance (===) au lieu d'exécuter des vérifications structurelles .equals() sur chaque élément.

L'extrait de code suivant fournit un exemple minimal de classe de données plate (sans propriétés de collection) annotée comme immuable lorsqu'elle est définie dans un module où le compilateur ne peut pas déduire la stabilité :

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

Que vous utilisiez l'annotation @Immutable ou @Stable, le compilateur Compose marque la classe SnackSummary comme stable et la compare à l'aide de Object.equals().

Classes annotées dans les collections

Prenons l'exemple d'un composable qui inclut un paramètre de type List<Snack> :

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

Même si Snack est stable ou annoté avec @Immutable, le compilateur Compose marque toujours le paramètre snacks dans HighlightedSnacks comme unstable, car List est une interface.

Lorsque le mode désactivation forcée est activé, HighlightedSnacks est toujours marqué comme skippable et ignore la recomposition en utilisant l'égalité d'instance (===) lorsque la même instance List est transmise. Vous pouvez rendre le paramètre snacks stable en encapsulant List<Snack> dans une classe wrapper @Immutable, en remplaçant List<Snack> par ImmutableList<Snack> ou en ajoutant kotlin.collections.* à votre fichier de configuration de la stabilité.

Toutefois, cela oblige Compose à effectuer une comparaison élément par élément (.equals()) à chaque passe de recomposition. N'utilisez ces approches que lorsque vous ne pouvez pas conserver la référence d'instance de la liste et que les benchmarks montrent un avantage.

Fichier de configuration de la stabilité

À partir de Compose Compiler 1.5.5, un fichier de configuration des classes à considérer comme stables peut être fourni au moment de la compilation. Cela permet de considérer comme stables les classes que vous ne contrôlez pas, telles que les classes de bibliothèque standards comme LocalDateTime.

Le fichier de configuration est un fichier en texte brut contenant une classe par ligne. Les commentaires, les caractères génériques simples et les caractères génériques doubles sont acceptés.

Exemple de configuration :

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

Pour activer cette fonctionnalité, transmettez le chemin d'accès du fichier de configuration au bloc d'options composeCompiler de la configuration du plug-in Gradle du compilateur Compose.

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Comme le compilateur Compose s'exécute séparément sur chaque module de votre projet, vous pouvez fournir des configurations différentes à différents modules si nécessaire. Vous pouvez également avoir une configuration au niveau racine de votre projet et transmettre ce chemin d'accès à chaque module.

Plusieurs modules

Un autre problème courant concerne l'architecture multimodule. Le compilateur Compose ne peut déduire si une classe est stable que si tous les types non primitifs qu'elle référence sont explicitement marqués comme stables ou se trouvent dans un module qui a également été créé avec le compilateur Compose.

Si votre couche de données se trouve dans un module distinct de votre couche d'interface utilisateur, ce qui est l'approche recommandée, vous pouvez rencontrer ce problème. En mode de saut strict, les classes des modules non Compose sont traitées comme instables et ignorent toujours la recomposition en utilisant l'égalité d'instance (===) lorsque la même instance est transmise. Toutefois, si votre couche de données alloue fréquemment de nouvelles instances de petits modèles plats avec des valeurs identiques à chaque émission, vous pouvez opter pour l'égalité structurelle (Object.equals()).

Solution

Pour résoudre ce problème, vous pouvez adopter l'une des approches suivantes :

  1. Ajoutez des classes petites et plates avec des vérifications .equals() peu coûteuses à votre fichier de configuration du compilateur.
  2. Activez le compilateur Compose sur vos modules de couche de données ou ajoutez les tags @Stable ou @Immutable à vos classes, le cas échéant.
    • Pour cela, vous devez ajouter une dépendance Compose à votre couche de données. Cependant, il s'agit uniquement de la dépendance pour l'environnement d'exécution Compose, et non pour Compose-UI.
  3. Dans votre module d'interface utilisateur, encapsulez vos classes de couche de données dans des classes de wrapper spécifiques à l'interface utilisateur.

Le même problème se produit également lors de l'utilisation de bibliothèques externes si elles n'utilisent pas le compilateur Compose.

Tous les composables ne doivent pas être ignorables

Lorsque vous essayez de résoudre des problèmes de stabilité, vous ne devez pas tenter de rendre chaque composable ignorable ni de forcer l'égalité structurelle sur chaque paramètre. Ignorer un composable n'est pas sans conséquence : chaque paramètre d'un composable pouvant être ignoré est stocké par l'environnement d'exécution Compose comme s'il était mémorisé, et Compose doit comparer chaque paramètre à sa valeur précédente chaque fois que la fonction est recomposée pour déterminer s'il doit être ignoré. Essayer de forcer l'omission partout peut entraîner une optimisation prématurée qui introduit plus de frais généraux qu'elle n'en économise.

Dans de nombreuses situations, forcer la stabilité structurelle n'apporte aucun avantage réel et peut dégrader les performances ou la facilité de maintenance. Exemple :

  • Un composable qui n'est pas recomposé souvent, voire pas du tout.
  • Un composable qui n'appelle que des composables ignorables.
  • Un composable avec un grand nombre de paramètres ou de modèles de collection avec des implémentations .equals() coûteuses. Dans ce cas, le coût de la vérification de la modification d'un paramètre peut l'emporter sur le coût d'une recomposition peu coûteuse.

Ne vous fiez pas uniquement au nombre de recompositions. Concentrez-vous sur la création de composables légers à exécuter, en reportant les lectures d'état qui changent rapidement aux phases de mise en page ou de dessin, et en vérifiant les modifications à l'aide de benchmarks. Lorsqu'un composable peut être ignoré, cela ajoute une petite surcharge qui n'en vaut peut-être pas la peine. Vous pouvez même annoter votre composable comme non redémarrable ou non désactivable si vous déterminez que le fait d'être redémarrable ou désactivable entraîne une surcharge inutile.