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 État et Jetpack Compose.

Bien que remember serve d'outil pour conserver les valeurs lors des recompositions, l'état doit souvent exister 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 l'une ou l'autre, 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 dans 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 indiquées 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é, peut-être une copie désérialisée.

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

Types de données compatibles

Tous

Ne doit faire référence à aucun objet qui serait divulgué si l'activité était détruite

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

Cas d'utilisation

  • Objets dont la portée est limitée à la composition
  • Objets de configuration pour les composables
  • État pouvant être recréé sans perte de fidélité de l'UI
  • Caches
  • Objets "gestionnaires" ou de longue durée
  • 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 la méthode la plus courante pour 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 une réutilisation ultérieure par le composable. Lorsqu'un composable se recompose, 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 mises en cache 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 endroits, 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 ni 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 des quatre fonctions de mémoïsation décrites sur cette page. Elle est donc particulièrement adaptée aux cas suivants :

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

Toutefois, vous devez éviter :

  • Stocker toute saisie 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 parmi les fonctions de mémoïsation abordées 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 de la recréation d'une activité, 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 lorsqu'elle est en cours d'exécution).

rememberSerializable fonctionne de la même manière que rememberSaveable, mais il permet de conserver automatiquement les 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.

Cela fait de rememberSaveable et rememberSerializable des candidats parfaits pour stocker l'état associé à l'entrée utilisateur, y compris l'entrée du champ de texte, la position de défilement, les états d'activation/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 persistantes, 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 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 pour vous. 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 mise en cache de ses valeurs. Son nom est différent, car les valeurs conservées ont également un cycle de vie différent de celui des valeurs mémorisées.

Lorsqu'une valeur est conservée, elle est à la fois mémorisée en position 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 changements 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 à l'oubli de retain).

En échange de ce cycle de vie plus court que rememberSaveable, retain peut conserver les valeurs qui ne peuvent pas être sérialisées, comme les expressions lambda, les flux et les grands objets 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

Dans leur forme la plus élémentaire, retain et ViewModel offrent des fonctionnalités similaires pour conserver les instances d'objet lors des changements 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 étendue et de la nécessité d'une fonctionnalité supplémentaire.

Les ViewModel sont des objets qui encapsulent généralement la communication entre les couches d'interface utilisateur et 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 restera 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 un changement 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 des coroutines intégrées pour le lancement de tâches en arrière-plan. ViewModel est donc 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 de votre projet, et éventuellement capturer et conserver l'état de l'UI essentiel qui doit être conservé lors des changements de configuration dans ViewModel et survivre à l'arrêt du processus.

retain est plus adapté aux objets dont la portée est limitée à des instances composables spécifiques et qui ne nécessitent pas d'être réutilisés ou partagés 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, comme les caches, le suivi des impressions et les données analytiques, les dépendances sur les AndroidView et d'autres objets qui interagissent avec l'OS Android ou gèrent des bibliothèques tierces comme 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 d'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 prête à l'emploi, retain peut servir de bloc de construction pour le cycle de vie de ces ViewModel-look-alikes avec ces fonctionnalités intégrées. Les détails de la conception d'un tel composant ne sont pas abordés dans ce guide.

retain

ViewModel

Définition du champ d'application

Aucune valeur n'est partagée. Chaque valeur est conservée et associée à un point spécifique de la hiérarchie de composition. Conserver le même type dans un emplacement différent agit toujours sur une nouvelle instance.

Les ViewModel sont des singletons dans un ViewModelStore.

Destruction

Lorsque vous quittez 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é, compatible avec SavedStateHandle, peut être injecté à l'aide de Hilt.

Propriétaire

RetainedValuesStore

ViewModelStore

Cas d'utilisation

  • Persistance des valeurs spécifiques à l'UI au niveau des instances composables individuelles
  • Suivi des impressions, éventuellement via RetainedEffect
  • Composant de base pour définir un composant d'architecture personnalisé de type "ViewModel"
  • Extraire les interactions entre les couches d'UI et de données dans une classe distincte, à la fois pour l'organisation du code et pour 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
  • Partager des états sur de grandes zones d'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 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 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 la mise à jour de 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é.

Pour obtenir un exemple complet d'implémentation de ce modèle, consultez l'exemple complet (RetainAndSaveSample.kt).

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 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 détaillée en liste à deux colonnes, mais peut naviguer entre une liste et 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 persistera tout au long 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 relocaliser 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.

Se souvenir des fonctions d'usine

Bien que les UI Compose soient constituées de fonctions composables, de nombreux objets entrent 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 souhaité, y compris la durée de vie et les entrées clés. Cela permet aux consommateurs de votre état de créer des instances en toute confiance 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, utilisez plutôt le préfixe retain.
  • 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 les valeurs d'initialisation 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 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) }
    )
}