Durée de vie des états dans Compose

Dans Jetpack Compose, les fonctions composables conservent souvent l'état à l'aide de la fonction remember. Les valeurs mémorisées peuvent être réutilisées lors des recompositions, comme expliqué dans États et Jetpack Compose.

Bien que remember serve d'outil pour conserver les valeurs lors des recompositions, l'état doit souvent durer au-delà de la durée de vie d'une composition. Cette page explique la différence entre les API remember, retain, rememberSaveable, et rememberSerializable, quand choisir chaque API et quelles sont les bonnes pratiques pour gérer les valeurs mémorisées et conservées dans Compose.

Choisir la durée de vie appropriée

Dans Compose, vous pouvez utiliser plusieurs fonctions pour conserver l'état lors des compositions et au-delà : remember, retain, rememberSaveable et rememberSerializable. Ces fonctions diffèrent par leur durée de vie et leur sémantique, et chacune est adaptée au stockage de types d'état spécifiques. Les différences sont décrites dans le tableau suivant :

remember

retain

rememberSaveable, rememberSerializable

Les valeurs survivent-elles aux recompositions ?

Les valeurs survivent-elles aux recréations d'activité ?

La même instance (===) sera toujours renvoyée

Un objet équivalent (==) sera renvoyé, éventuellement une copie désérialisée

Les valeurs survivent-elles à l'arrêt du processus ?

Types de données acceptés

Tous

Ne doit pas faire référence à des objets qui seraient divulgués si l'activité était détruite

Doit être sérialisable
(avec un Saver personnalisé ou avec kotlinx.serialization)

Cas d'utilisation

  • Objets limités à la composition
  • Objets de configuration pour les composables
  • État qui pourrait être recréé sans perte de fidélité de l'UI
  • Caches
  • Objets "gestionnaires" ou à longue durée de vie
  • Entrée utilisateur
  • État qui ne peut pas être recréé par l'application, y compris l'entrée de champ de texte, l'état de défilement, les boutons à bascule, etc.

remember

remember est le moyen le plus courant de stocker l'état dans Compose. Lorsque remember est appelé pour la première fois, le calcul donné est exécuté et est mémorisé, ce qui signifie qu'il est stocké par Compose pour une utilisation ultérieure par le composable. Lorsqu'un composable est recomposé, il exécute à nouveau son code, mais tous les appels à remember renvoient leurs valeurs à partir de la composition précédente au lieu d'exécuter à nouveau le calcul.

Chaque instance d'une fonction composable possède son propre ensemble de valeurs mémorisées, appelé mémoïsation positionnelle. Lorsque les valeurs mémorisées sont mémoïsées pour être utilisées lors des recompositions, elles sont liées à leur position dans la hiérarchie de composition. Si un composable est utilisé à différents emplacements, chaque instance de la hiérarchie de composition possède son propre ensemble de valeurs mémorisées.

Lorsqu'une valeur mémorisée n'est plus utilisée, elle est oubliée et son enregistrement est supprimé. Les valeurs mémorisées sont oubliées lorsqu'elles sont supprimées de la hiérarchie de composition (y compris lorsqu'une valeur est supprimée et ajoutée à nouveau pour être déplacée vers un autre emplacement sans utiliser le composable key ou MovableContent), ou lorsqu'elles sont appelées avec différents paramètres key.

Parmi les choix disponibles, remember a la durée de vie la plus courte et oublie les valeurs le plus tôt des quatre fonctions de mémoïsation décrites sur cette page. Il est donc le mieux adapté pour :

  • Créer des objets d'état internes, tels que la position de défilement ou l'état d'animation
  • Éviter la recréation coûteuse d'objets lors de chaque recomposition

Toutefois, vous devez éviter :

  • de stocker des entrées utilisateur avec remember, car les objets mémorisés sont oubliés lors des modifications de configuration de l'activité et de l'arrêt du processus initié par le système.

rememberSaveable et rememberSerializable

rememberSaveable et rememberSerializable s'appuient sur remember. Ils ont la durée de vie la plus longue des fonctions de mémoïsation abordées dans ce guide. En plus de mémoïser les objets de manière positionnelle lors des recompositions, ils peuvent également enregistrer des valeurs afin qu'elles puissent être restaurées lors des recréations d'activité, y compris lors des modifications de configuration et de l'arrêt du processus (lorsque le système arrête le processus de votre application en arrière-plan, généralement pour libérer de la mémoire pour les applications au premier plan ou si l'utilisateur révoque les autorisations de votre application pendant son exécution).

rememberSerializable fonctionne de la même manière que rememberSaveable, mais il est automatiquement compatible avec la persistance des types complexes qui sont sérialisables avec la bibliothèque kotlinx.serialization. Choisissez rememberSerializable si votre type est (ou peut être) marqué avec @Serializable, et rememberSaveable dans tous les autres cas.

Cela fait de rememberSaveable et rememberSerializable des candidats parfaits pour stocker l'état associé à l'entrée utilisateur, y compris l'entrée de champ de texte, la position de défilement, les états de bouton à bascule, etc. Vous devez enregistrer cet état pour vous assurer que l'utilisateur ne perd jamais sa place. En général, vous devez utiliser rememberSaveable ou rememberSerializable pour mémoïser tout état que votre application ne peut pas récupérer à partir d'une autre source de données persistante, telle qu'une base de données.

Notez que rememberSaveable et rememberSerializable enregistrent leurs valeurs mémoïsées en les sérialisant dans un Bundle. Cela a deux conséquences :

  • Les valeurs que vous mémoïsez doivent être représentables par un ou plusieurs des types de données suivants : primitives (y compris Int, Long, Float, Double), String ou tableaux de l'un de ces types.
  • Lorsqu'une valeur enregistrée est restaurée, il s'agit d'une nouvelle instance égale à (==), mais pas de la même référence (===) que celle utilisée par la composition auparavant.

Pour stocker des types de données plus complexes sans utiliser kotlinx.serialization, vous pouvez implémenter un Saver personnalisé pour sérialiser et désérialiser votre objet dans des types de données compatibles. Notez que Compose comprend les types de données courants tels que State, List, Map, Set, etc. prêts à l'emploi, et les convertit automatiquement en types compatibles à votre place. Voici un exemple de Saver pour une classe Size. Il est implémenté en regroupant toutes les propriétés de Size dans une liste à l'aide de listSaver.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

L'retain API se situe entre remember et rememberSaveable/rememberSerializable en termes de durée de mémoïsation de ses valeurs. Son nom est différent, car les valeurs conservées ont également un cycle de vie différent de celui de leurs homologues mémorisés.

Lorsqu'une valeur est conservée, elle est à la fois mémoïsée de manière positionnelle et enregistrée dans une structure de données secondaire dont la durée de vie est distincte et liée à celle de l'application. Une valeur conservée peut survivre aux modifications de configuration sans être sérialisée, mais elle ne peut pas survivre à l'arrêt du processus. Si une valeur n'est pas utilisée après la recréation de la hiérarchie de composition, la valeur conservée est retirée (ce qui équivaut à être oubliée pour retain).

En échange de ce cycle de vie plus court que celui de rememberSaveable, retain est en mesure de conserver des valeurs qui ne peuvent pas être sérialisées, comme les expressions lambda, les flux et les objets volumineux tels que les bitmaps. Par exemple, vous pouvez utiliser retain pour gérer un lecteur multimédia (tel qu'ExoPlayer) afin d'éviter les interruptions de la lecture multimédia lors d'une modification de configuration.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain et ViewModel

Dans leur forme la plus élémentaire, retain et ViewModel offrent des fonctionnalités similaires dans leur capacité la plus couramment utilisée à conserver des instances d'objet lors des modifications de configuration. Le choix d'utiliser retain ou ViewModel dépend du type de valeur que vous conservez, de la manière dont elle doit être limitée et si vous avez besoin de fonctionnalités supplémentaires.

Les ViewModel sont des objets qui encapsulent généralement la communication entre l'UI et les couches de données de votre application. Ils vous permettent de déplacer la logique hors de vos fonctions composables, ce qui améliore la testabilité. Les ViewModel sont gérés en tant que singletons dans un ViewModelStore et ont une durée de vie différente de celle des valeurs conservées. Alors qu'un ViewModel reste actif jusqu'à ce que son ViewModelStore soit détruit, les valeurs conservées sont retirées lorsque le contenu est définitivement supprimé de la composition (par exemple, pour une modification de configuration, cela signifie qu' une valeur conservée est retirée si la hiérarchie de l'UI est recréée et que la valeur conservée n'a pas été consommée après la recréation de la composition).

ViewModel inclut également des intégrations prêtes à l'emploi pour l'injection de dépendances avec Dagger et Hilt, l'intégration avec SavedState et la prise en charge intégrée des coroutines pour le lancement de tâches en arrière-plan. Cela fait de ViewModel un endroit idéal pour lancer des tâches en arrière-plan et des requêtes réseau, interagir avec d'autres sources de données dans votre projet, et éventuellement capturer et conserver l'état de l'UI essentiel qui doit être conservé lors des modifications de configuration dans le ViewModel et survivre à l'arrêt du processus.

retain est mieux adapté aux objets limités à des instances composables spécifiques et qui ne nécessitent pas de réutilisation ni de partage entre des composables frères. Alors que ViewModel est un bon endroit pour stocker l'état de l'UI et effectuer des tâches en arrière-plan, retain est un bon candidat pour stocker des objets pour l'infrastructure de l'UI, tels que les caches, le suivi des impressions et les analyses, les dépendances sur les AndroidView et d'autres objets qui interagissent avec le système d'exploitation Android ou gèrent des bibliothèques tierces telles que les processeurs de paiement ou la publicité.

Pour les utilisateurs avancés qui conçoivent des modèles d'architecture d'application personnalisés en dehors des recommandations de l'architecture d'application Android moderne, retain peut également être utilisé pour créer une API interne de type ViewModel. Bien que la prise en charge des coroutines et de l'état enregistré ne soit pas proposée prête à l'emploi, retain peut servir de bloc de construction pour le cycle de vie de ces composants de type ViewModel avec ces fonctionnalités intégrées. Les spécificités de la conception d'un tel composant ne sont pas abordées dans ce guide.

retain

ViewModel

Limitation

Aucune valeur partagée ; chaque valeur est conservée et associée à un point spécifique de la hiérarchie de composition. La conservation du même type à un autre emplacement agit toujours sur une nouvelle instance.

ViewModel sont des singletons dans un ViewModelStore

Destruction

Lors du retrait définitif de la hiérarchie de composition

Lorsque le ViewModelStore est effacé ou détruit

Fonctionnalités supplémentaires

Peut recevoir des rappels lorsque l'objet se trouve ou non dans la hiérarchie de composition

coroutineScope intégré, prise en charge de SavedStateHandle, peut être injecté à l'aide de Hilt

Propriétaire :

RetainedValuesStore

ViewModelStore

Cas d'utilisation

  • Conservation des valeurs spécifiques à l'UI locales pour les instances composables individuelles
  • Suivi des impressions, éventuellement via RetainedEffect
  • Composant de base pour définir un composant d'architecture personnalisé de type "ViewModel"
  • Extraction des interactions entre l'UI et les couches de données dans une classe distincte, à la fois pour l'organisation du code et les tests
  • Transformation des Flow en objets State et appel de fonctions de suspension qui ne doivent pas être interrompues par des modifications de configuration
  • Partage d'états sur de grandes zones de l'UI, comme des écrans entiers
  • Interopérabilité avec View

Combiner retain et rememberSaveable ou rememberSerializable

Parfois, un objet doit avoir une durée de vie hybride à la fois retained et rememberSaveable ou rememberSerializable. Cela peut indiquer que votre objet doit être un ViewModel, qui peut prendre en charge l'état enregistré, comme décrit dans le guide Module Saved State pour ViewModel.

Il est possible d'utiliser retain et rememberSaveable ou rememberSerializable simultanément. La combinaison correcte des deux cycles de vie ajoute une complexité significative. Nous vous recommandons d'utiliser ce modèle dans le cadre de modèles d'architecture plus avancés et personnalisés, et uniquement lorsque toutes les conditions suivantes sont remplies :

  • Vous définissez un objet composé d'un mélange de valeurs qui doivent être conservées ou enregistrées (par exemple, un objet qui suit une entrée utilisateur et un cache en mémoire qui ne peut pas être écrit sur le disque).
  • Votre état est limité à un composable et ne convient pas à la limitation singleton ou à la durée de vie de ViewModel.

Dans ce cas, nous vous recommandons de diviser votre classe en trois parties : les données enregistrées, les données conservées et un objet "médiateur" qui n'a pas d'état propre et qui délègue aux objets conservés et enregistrés pour mettre à jour l'état en conséquence. Ce modèle se présente comme suit :

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

En séparant l'état par durée de vie, la séparation des responsabilités et du stockage devient très explicite. Il est intentionnel que les données d'enregistrement ne puissent pas être manipulées par les données de conservation, car cela évite un scénario dans lequel une tentative de mise à jour des données d'enregistrement est effectuée lorsque le bundle savedInstanceState a déjà été capturé et ne peut pas être mis à jour. Il permet également de tester des scénarios de recréation en testant vos constructeurs sans appeler Compose ni simuler une recréation d'activité.

Pour obtenir un exemple complet de la mise en œuvre de ce modèle, consultez l'exemple complet (RetainAndSaveSample.kt) pour un exemple complet de comment ce modèle peut être mis en œuvre.

Mémoïsation positionnelle et mises en page adaptatives

Les applications Android peuvent être compatibles avec de nombreux facteurs de forme, y compris les téléphones, les appareils pliables, les tablettes et les ordinateurs de bureau. Les applications doivent souvent passer d'un facteur de forme à un autre à l'aide de mises en page adaptatives. Par exemple, une application exécutée sur une tablette peut afficher une vue de liste-détails à deux colonnes, mais peut passer d'une liste à une page de détails lorsqu'elle est présentée sur un écran de téléphone plus petit.

Étant donné que les valeurs mémorisées et conservées sont mémoïsées de manière positionnelle, elles ne sont réutilisées que si elles apparaissent au même point dans la hiérarchie de composition. À mesure que vos mises en page s'adaptent à différents facteurs de forme, elles peuvent modifier la structure de votre hiérarchie de composition et entraîner l'oubli de valeurs.

Pour les composants prêts à l'emploi tels que ListDetailPaneScaffold et NavDisplay (à partir de Jetpack Navigation 3), cela ne pose pas de problème et votre état persiste lors des modifications de mise en page. Pour les composants personnalisés qui s'adaptent aux facteurs de forme, assurez-vous que l'état n'est pas affecté par les modifications de mise en page en procédant de l'une des manières suivantes :

  • Assurez-vous que les composables avec état sont toujours appelés au même endroit dans la hiérarchie de composition. Implémentez des mises en page adaptatives en modifiant la logique de mise en page au lieu de déplacer des objets dans la hiérarchie de composition.
  • Utilisez MovableContent pour déplacer les composables avec état de manière fluide. Les instances de MovableContent peuvent déplacer les valeurs mémorisées et conservées de leur ancien emplacement vers leur nouvel emplacement.

Mémoriser les fonctions de fabrique

Bien que les UI Compose soient composées de fonctions composables, de nombreux objets sont utilisés pour la création et l'organisation d'une composition. L'exemple le plus courant est celui des objets composables complexes qui définissent leur propre état, comme LazyList, qui accepte un LazyListState.

Lorsque vous définissez des objets axés sur Compose, nous vous recommandons de créer une fonction remember pour définir le comportement de mémorisation prévu, y compris la durée de vie et les entrées clés. Cela permet aux consommateurs de votre état de créer en toute confiance des instances dans la hiérarchie de composition qui survivront et seront invalidées comme prévu. Lorsque vous définissez une fonction de fabrique composable, suivez ces consignes :

  • Préfixez le nom de la fonction avec remember. Si l'implémentation de la fonction dépend de l'objet retained et que l'API n'évoluera jamais pour s'appuyer sur une autre variante de remember, utilisez plutôt le préfixe retain à la place.
  • Utilisez rememberSaveable ou rememberSerializable si la persistance de l'état est choisie et qu'il est possible d'écrire une implémentation Saver correcte.
  • Évitez les effets secondaires ou l'initialisation de valeurs basées sur des CompositionLocal qui pourraient ne pas être pertinentes pour l'utilisation. N'oubliez pas que l'endroit où votre état est créé n'est peut-être pas celui où il est consommé.

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}