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 dans les 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 perdurer 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 quelle API et quelles sont les bonnes pratiques pour gérer les valeurs mémorisées et conservées dans Compose.

Choisissez la bonne durée de vie

Dans Compose, vous pouvez utiliser plusieurs fonctions pour conserver l'état entre les 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 à la recréation d'activité ?

❌

✅

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

✅

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

Les valeurs sont-elles conservées après l'arrêt du processus ?

❌

❌

✅

Types de données acceptés

Tous

Ne doit pas référencer d'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 pouvant être recréé sans perdre la fidélité de l'UI
  • Caches
  • Objets persistants ou "gestionnaires"
  • Entrée utilisateur
  • État qui ne peut pas être recréé par l'application, y compris la saisie dans un champ de texte, l'état de défilement, les boutons d'activation/de désactivation, 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 mémorisé, ce qui signifie qu'il est stocké par Compose pour être réutilisé ultérieurement par le composable. Lorsqu'un composable est recomposé, il exécute à nouveau son code, mais tous les appels à remember renvoient leurs valeurs 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émorisation positionnelle. Lorsque les valeurs mémorisées sont mémorisées pour être utilisées dans les 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 de nouveau pour être déplacée vers un autre emplacement sans utiliser le composable key ou MovableContent), ou lorsqu'elles sont appelées avec des paramètres key différents.

Parmi les choix disponibles, remember a la durée de vie la plus courte et oublie les valeurs le plus tôt parmi les quatre fonctions de mémorisation décrites sur cette page. Il est donc particulièrement adapté aux cas suivants :

  • Créer des objets d'état interne, tels que la position de défilement ou l'état d'animation
  • Éviter de recréer des objets coûteux à chaque recomposition

Toutefois, vous devez éviter :

  • Stocker une entrée 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 d'un processus initié par le système.

rememberSaveable et rememberSerializable

rememberSaveable et rememberSerializable s'appuient sur remember. Elles ont la durée de vie la plus longue des fonctions de mémorisation décrites dans ce guide. En plus de mémoriser les objets de manière positionnelle lors des recompositions, il peut également enregistrer des valeurs afin qu'elles puissent être restaurées lors des recréations d'activités, y compris à partir des modifications de configuration et de l'arrêt du processus (lorsque le système arrête le processus de votre application lorsqu'elle est 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 accepte automatiquement la persistance des types complexes 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.

rememberSaveable et rememberSerializable sont donc parfaits pour stocker l'état associé aux entrées utilisateur, y compris la saisie dans un champ de texte, la position de défilement, les états d'activation/de désactivation, 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émoriser 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émorisées en les sérialisant dans un Bundle. Cela a deux conséquences :

  • Les valeurs que vous mémorisez doivent pouvoir être représentées 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 précédemment par la composition.

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. et les convertit automatiquement en types compatibles. Voici un exemple de Saver pour une classe Size. Elle est implémentée 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'API retain se situe entre remember et rememberSaveable/rememberSerializable en termes de durée de mémorisation de ses valeurs. Il porte un nom différent, car les valeurs conservées ont également un cycle de vie différent de celui de leurs équivalents mémorisés.

Lorsqu'une valeur est conservée, elle est à la fois mémorisé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 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 rememberSaveable, retain peut 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'un changement 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 contre ViewModel

À la base, retain et ViewModel offrent des fonctionnalités similaires dans leur capacité la plus couramment utilisée à conserver les instances d'objets lors des modifications de configuration. Le choix entre retain et ViewModel dépend du type de valeur que vous conservez, de la manière dont elle doit être limitée et de la nécessité ou non de fonctionnalités supplémentaires.

Les ViewModel sont des objets qui encapsulent généralement la communication entre l'UI de votre application et les couches de données. 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 supprimées lorsque le contenu est définitivement supprimé de la composition (par exemple, lors d'un changement de configuration, cela signifie qu'une valeur conservée est supprimée si la hiérarchie de l'interface utilisateur est recréée et que la valeur conservée n'a pas été utilisé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 compatibilité intégrée avec les coroutines pour lancer des tâches en arrière-plan. ViewModel est donc l'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 de votre projet, et éventuellement capturer et conserver l'état de l'UI essentiel à la mission, qui doit être conservé lors des modifications de configuration dans ViewModel et survivre à l'arrêt du processus.

retain est idéal pour les objets limités à des instances de composables spécifiques et qui ne nécessitent pas d'être réutilisés ou partagés entre des composables frères. ViewModel est un bon endroit pour stocker l'état de l'UI et effectuer des tâches en arrière-plan, tandis que retain est un bon candidat pour stocker des objets pour la plomberie de l'UI, comme les caches, l'outil de suivi des impressions et l'analyse de données, les dépendances sur les AndroidView et d'autres objets qui interagissent avec l'OS Android ou gèrent des bibliothèques tierces telles que les sociétés de traitement des paiements 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 compatibilité avec les coroutines et l'état enregistré ne soit pas proposée d'emblée, retain peut servir de composant de base pour le cycle de vie de ces éléments semblables à ViewModel avec ces fonctionnalités intégrées. La conception d'un tel composant n'entre pas dans le cadre de ce guide.

retain

ViewModel

Définition du champ d'application

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 dans un autre emplacement agit toujours sur une nouvelle instance.

Les ViewModel sont des singletons dans un ViewModelStore.

Destruction

Lorsqu'il quitte définitivement 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é, compatibilité avec SavedStateHandle, peut être injecté à l'aide de Hilt

Propriétaire

RetainedValuesStore

ViewModelStore

Cas d'utilisation

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

Combinez retain et rememberSaveable ou rememberSerializable

Parfois, un objet doit avoir une durée de vie hybride de 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 du 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é importante. 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 portée ou à la durée de vie du singleton 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 de sauvegarde ne puissent pas être manipulées par les données de conservation, car cela évite un scénario dans lequel une mise à jour des données de sauvegarde est tentée alors que 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é.

Consultez l'exemple complet (RetainAndSaveSample.kt) pour voir un exemple complet d'implémentation de ce modèle.

Mémorisation positionnelle et mises en page adaptatives

Les applications Android peuvent prendre en charge de nombreux facteurs de forme, y compris les téléphones, les 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 détaillée en liste sur deux colonnes, mais naviguer entre une liste et une page d'informations lorsqu'elle est présentée sur un écran de téléphone plus petit.

Comme les valeurs mémorisées et conservées sont mémorisées de manière positionnelle, elles ne sont réutilisées que si elles apparaissent au même point dans la hiérarchie de la composition. Lorsque 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 (de Jetpack Navigation 3), ce n'est pas un problème, et votre état sera conservé 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 effectuant l'une des opérations 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 les objets dans la hiérarchie de composition.
  • Utilisez MovableContent pour déplacer correctement les composables avec état. Les instances de MovableContent peuvent déplacer les valeurs mémorisées et conservées de leur ancien emplacement vers le nouvel emplacement.

Se souvenir des fonctions d'usine

Bien que les UI Compose soient constituées de fonctions composables, de nombreux objets entrent en jeu dans 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 de clé. Cela permet aux utilisateurs 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 :

  • Ajoutez le préfixe remember au nom de la fonction. 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, vous pouvez utiliser 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 ne sont peut-être pas pertinentes pour l'utilisation. N'oubliez pas que l'endroit où votre état est créé n'est pas forcément 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) }
    )
}