État et Jetpack Compose

Dans une application, l'état correspond à toute valeur susceptible de changer au fil du temps. C'est une définition très large qui recouvre aussi bien une base de données Room qu'une variable dans une classe.

Toutes les applications Android présentent des états à l'utilisateur. Voici quelques exemples d'états dans les applications Android :

  • Un snackbar qui indique quand une connexion réseau ne peut être établie.
  • Un article de blog et les commentaires associés.
  • Des animations de boutons produisant un effet d'ondes en cas d'activation par l'utilisateur.
  • Des autocollants qu'un utilisateur peut superposer à une image.

Jetpack Compose vous permet de préciser où et comment vous stockez et utilisez l'état dans une application Android. Ce guide se concentre sur la connexion entre l'état et les composables, ainsi que sur les API proposées par Jetpack Compose pour gérer plus facilement l'état.

État et composition

Compose est déclaratif. Le seul moyen de le mettre à jour est donc d'appeler le même composable avec de nouveaux arguments. Ces arguments sont des représentations de l'état de l'interface utilisateur. Chaque fois qu'un état est mis à jour, une recomposition se produit. Par conséquent, des éléments tels que TextField ne sont pas automatiquement mis à jour comme dans les vues XML essentielles. Un composable doit être explicitement informé du nouvel état pour être mis à jour en conséquence.

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

Si vous exécutez cette commande et essayez de saisir du texte, vous verrez que rien ne se passe. En effet, TextField ne se met pas à jour, mais lorsque son paramètre value change. Cela est dû au fonctionnement de la composition et de la recomposition dans Compose.

Pour en savoir plus sur la première composition et la recomposition, consultez Approche dans Compose.

État dans les composables

Les fonctions modulables peuvent utiliser l'API remember pour stocker un objet en mémoire. Une valeur calculée par remember est stockée dans la composition lors de la première composition, et la valeur stockée est renvoyée lors de la recomposition. remember peut être utilisé pour stocker à la fois des objets modifiables et immuables.

mutableStateOf crée un MutableState<T> observable, qui est un type observable intégré à l'environnement d'exécution Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Toute modification apportée à value programme la recomposition de toutes les fonctions modulables qui lisent value.

Il existe trois façons de déclarer un objet MutableState dans un composable :

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Ces déclarations sont équivalentes et sont fournies sous forme de sucre syntaxique pour différentes utilisations de l'état. Dans le composable que vous écrivez, vous devez choisir la déclaration qui génère le code le plus lisible possible.

La syntaxe by déléguée nécessite les importations suivantes :

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Vous pouvez utiliser la valeur mémorisée comme paramètre pour d'autres composables ou même en tant que logique dans des instructions pour modifier les composables à afficher. Par exemple, si vous ne souhaitez pas afficher le message d'accueil si le nom est vide, utilisez l'état dans une instruction if :

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

Bien que remember vous aide à conserver l'état lors des recompositions, l'état n'est pas conservé en cas de modification de la configuration. Pour cela, vous devez utiliser rememberSaveable. rememberSaveable enregistre automatiquement toutes les valeurs susceptibles d'être enregistrées dans un Bundle. Pour les autres valeurs, vous pouvez transmettre un objet Saver personnalisé.

Autres types d'états acceptés

Compose ne nécessite pas que vous utilisiez MutableState<T> pour conserver l'état. Il est compatible avec d'autres types observables. Avant de lire un autre type observable dans Compose, vous devez le convertir en State<T> afin que les composables puissent se recomposer automatiquement en cas de changement d'état.

Compose dispose de fonctions permettant de créer des State<T> à partir de types observables courants utilisés dans les applications Android. Avant d'utiliser ces intégrations, ajoutez le ou les artefacts appropriés comme décrit ci-dessous :

  • Flow : collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() collecte les valeurs d'un Flow en tenant compte du cycle de vie, ce qui permet d'économiser des ressources d'application. Il représente la dernière valeur émise à partir du conteneur State Compose. Il est recommandé d'utiliser cette API pour collecter des flux sur les applications Android.

    La dépendance suivante est requise dans le fichier build.gradle (elle doit être 2.6.0-beta01 ou plus récente) :

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
}
  • Flow : collectAsState()

    collectAsState est semblable à collectAsStateWithLifecycle, car il collecte également les valeurs d'un Flow et les convertit en State Compose.

    Utilisez collectAsState pour un code indépendant de la plate-forme à la place de collectAsStateWithLifecycle (Android uniquement).

    collectAsState étant disponible dans compose-runtime, aucune dépendance supplémentaire n'est requise.

  • LiveData : observeAsState()

    observeAsState() commence à observer cette LiveData et représente ses valeurs via State.

    La dépendance suivante est requise dans le fichier build.gradle :

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.7.5"
}

Avec état et sans état

Un composable qui utilise remember pour stocker un objet crée un état interne et devient un composable avec état. HelloContent est un exemple de composable avec état, car il contient et modifie son état name en interne. Cette fonctionnalité peut être utile lorsqu'un appelant n'a pas besoin de contrôler l'état et peut l'utiliser sans avoir à gérer l'état lui-même. Toutefois, les composables dotés d'un état interne ont tendance à être moins réutilisables et plus difficiles à tester.

Un composable sans état est un composable qui ne contient aucun état. Un moyen simple de réaliser une configuration sans état consiste à hisser un état.

Lorsque vous développez des composables réutilisables, vous souhaitez souvent présenter à la fois une version avec état et une version sans état du même composable. La version avec état est pratique pour les appelants qui ne se préoccupent pas de l'état, tandis que la version sans état est requise pour les appelants qui doivent contrôler ou hisser l'état.

Hisser un état

Le hissage d'état dans Compose est un modèle qui consiste à faire remonter un état vers l'appelant d'un composable pour obtenir un composable sans état. Le modèle général du hissage d'état dans Jetpack Compose consiste à remplacer la variable d'état par deux paramètres :

  • value: T : valeur actuelle à afficher
  • onValueChange: (T) -> Unit : événement qui demande la valeur à modifier et où T est la nouvelle valeur proposée

Vous n'êtes cependant pas limité à onValueChange. Si des événements plus spécifiques correspondent au composable, vous devez les définir à l'aide de lambdas.

L'état hissé selon cette méthode présente plusieurs propriétés importantes :

  • Source fiable unique : en déplaçant l'état au lieu de le copier, nous garantissons qu'il n'y a qu'une seule source fiable. Cela permet d'éviter les bugs.
  • Encapsulé : seuls les composables avec état peuvent modifier leur état. Le processus s'effectue entièrement en interne.
  • Partageable : un état hissé peut être partagé avec plusieurs composables. Si vous souhaitez lire name dans un autre composable, le hissage vous le permet.
  • Interceptable : les appelants des composables sans état peuvent décider d'ignorer ou de modifier les événements avant de modifier l'état.
  • Dissociation:l'état des composables sans état peut être stocké n'importe où. Par exemple, il est maintenant possible de déplacer name dans un ViewModel.

Dans l'exemple suivant, on extrait name et onValueChange de HelloContent, puis on les déplace vers le haut de l'arborescence vers un composable HelloScreen qui appelle HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

En hissant l'état de HelloContent, il est plus facile de déduire le composable, de le réutiliser dans différentes situations et de le tester. HelloContent est dissocié du mode de stockage de son état. La dissociation signifie que si vous modifiez ou remplacez HelloScreen, vous n'avez pas besoin de modifier la façon dont HelloContent est intégré.

Le modèle où l'état descend et les événements remontent est appelé flux de données unidirectionnel. Dans ce cas, l'état passe de HelloScreen à HelloContent et les événements passent de HelloContent à HelloScreen. En suivant un flux de données unidirectionnel, vous pouvez dissocier les composables qui affichent l'état dans l'interface utilisateur des parties de votre application qui stockent et modifient l'état.

Pour en savoir plus, consultez la page Où hisser l'état.

Restaurer l'état dans Compose

L'API rememberSaveable se comporte de la même manière que remember, car elle conserve l'état lors des recompositions, mais aussi lors de la recréation d'une activité ou d'un processus à l'aide du mécanisme d'enregistrement de l'état d'instance. Cela se produit, par exemple, lorsque l'écran est pivoté.

Comment stocker l'état

Tous les types de données ajoutés à Bundle sont enregistrés automatiquement. Plusieurs options s'offrent à vous si vous souhaitez enregistrer un élément qui ne peut pas être ajouté à Bundle.

Parcelize

La solution la plus simple consiste à ajouter l'annotation @Parcelize à l'objet. L'objet peut être scindé et regroupé. Par exemple, ce code crée un type de données City scindable qui est enregistré dans l'état.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Si, pour une raison quelconque, @Parcelize ne convient pas, vous pouvez utiliser mapSaver pour définir votre propre règle de conversion d'un objet en un ensemble de valeurs que le système peut enregistrer dans le Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Pour éviter de devoir définir les clés de la carte, vous pouvez également utiliser listSaver et utiliser ses index comme clés :

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Conteneurs d'état dans Compose

Un hissage d'état simple peut être géré dans les fonctions modulables. Si toutefois le nombre d'états à suivre est élevé ou si la logique d'exécution des fonctions modulables se produit, il est recommandé de déléguer les responsabilités logiques et d'état à d'autres classes : les conteneurs d'états.

Pour en savoir plus, consultez la documentation sur le hissage d'état dans Compose ou, plus généralement, la page Conteneurs d'états et état de l'interface utilisateur dans le guide sur l'architecture.

Relancer des calculs de mémorisation en cas de changement de touche

L'API remember est souvent utilisée conjointement avec MutableState :

var name by remember { mutableStateOf("") }

Ici, l'utilisation de la fonction remember permet à la valeur MutableState de survivre aux recompositions.

En général, remember utilise un paramètre lambda calculation. Lorsque remember est exécuté pour la première fois, il appelle le lambda calculation et stocke son résultat. Lors de la recomposition, remember renvoie la valeur qui a été stockée pour la dernière fois.

Outre l'état de mise en cache, vous pouvez également utiliser remember pour stocker tout objet ou résultat d'une opération de la composition dont l'initialisation ou le calcul est coûteux. Vous préférez peut-être ne pas avoir à répéter ce calcul à chaque recomposition. La création de l'objet ShaderBrush, qui est une opération coûteuse, est un bon exemple :

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember stocke la valeur jusqu'à ce qu'il quitte la composition. Cependant, il existe un moyen d'invalider la valeur mise en cache. L'API remember utilise également un paramètre key ou keys. Si l'une de ces touches change, la prochaine fois que la fonction recomposera, remember invalidera le cache et exécutera à nouveau le bloc lambda de calcul. Ce mécanisme vous permet de contrôler la durée de vie d'un objet dans la composition. Le calcul reste valide jusqu'à ce que les entrées changent, plutôt que jusqu'à ce que la valeur mémorisée quitte la composition.

Les exemples suivants montrent comment ce mécanisme fonctionne.

Dans cet extrait, un élément ShaderBrush est créé et utilisé en tant que peinture d'arrière-plan d'un composable Box. remember stocke l'instance ShaderBrush, car il est coûteux de la recréer, comme expliqué précédemment. remember utilise avatarRes comme paramètre key1, qui est l'image de fond sélectionnée. Si avatarRes change, le pinceau se recompose avec la nouvelle image et s'applique de nouveau à l'élément Box. Cela peut se produire lorsque l'utilisateur sélectionne une autre image comme arrière-plan d'un sélecteur.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

Dans l'extrait suivant, l'état est hissé dans une classe de conteneur d'état simple MyAppState. Il expose une fonction rememberMyAppState pour initialiser une instance de la classe à l'aide de remember. L'exposition de ces fonctions pour créer une instance qui survit aux recompositions est un modèle courant dans Compose. La fonction rememberMyAppState reçoit windowSizeClass, qui sert de paramètre key pour remember. Si ce paramètre change, l'application doit recréer la classe de conteneur d'état simple avec la dernière valeur. Cela peut se produire si, par exemple, l'utilisateur fait pivoter l'appareil.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose utilise l'implémentation de la classe equals pour décider si une touche a changé et invalider la valeur stockée.

Stocker l'état avec des touches au-delà de la recomposition

L'API rememberSaveable est un wrapper lié à remember qui peut stocker des données dans un Bundle. Cette API permet à l'état de survivre non seulement en cas de recomposition, mais également de recréation d'activité et d'arrêt de processus initié par le système. rememberSaveable reçoit les paramètres input de la même manière que remember reçoit keys. Le cache n'est pas valide lorsqu'une des entrées change. La prochaine fois que la fonction se recomposera, rememberSaveable réexécutera le bloc lambda de calcul.

Dans l'exemple suivant, rememberSaveable stocke userTypedQuery jusqu'à ce que typedQuery change :

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

En savoir plus

Pour en savoir plus sur l'état et Jetpack Compose, consultez les ressources supplémentaires suivantes.

Exemples

Ateliers de programmation

Vidéos

Blogs