État avancé et effets secondaires dans Jetpack Compose

1. Introduction

Dans cet atelier de programmation, vous allez découvrir des concepts avancés liés aux API State (états) et Side Effect (effets secondaires) dans Jetpack Compose. Vous verrez comment créer un conteneur d'état pour des composables avec état à la logique complexe, comment créer des coroutines et appeler des fonctions de suspension à partir du code Compose, et comment déclencher des effets secondaires pour accomplir différents cas d'utilisation.

Pour obtenir de l'aide tout au long de cet atelier de programmation, reportez-vous au code suivant :

Points abordés

Ce dont vous avez besoin

Objectifs de l'atelier

Dans cet atelier de programmation, vous commencerez avec une application inachevée, l'application Crane, à laquelle vous ajouterez des fonctionnalités pour l'améliorer.

b2c6b8989f4332bb.gif

2. Configuration

Obtenir le code

Le code de cet atelier de programmation est disponible dans le dépôt GitHub android-compose-codelabs. Pour le cloner, exécutez la commande suivante :

$ git clone https://github.com/android/codelab-android-compose

Vous pouvez également télécharger le dépôt sous forme de fichier ZIP :

Découvrir l'application exemple

Le dépôt que vous venez de télécharger contient du code pour tous les ateliers de programmation traitant de Compose. Pour cet atelier, ouvrez le projet AdvancedStateAndSideEffectsCodelab dans Android Studio.

Nous vous recommandons de commencer par le code de la branche "main", puis de suivre l'atelier étape par étape, à votre propre rythme.

Au cours de cet atelier de programmation, vous découvrirez des extraits de code que vous devrez ajouter au projet. À certains endroits, vous devrez également supprimer le code qui est explicitement mentionné dans les commentaires sur les extraits de code.

Se familiariser avec le code et exécuter l'application exemple

Prenez quelques instants pour explorer la structure du projet et exécuter l'application.

162c42b19dafa701.png

Lorsque vous exécutez l'application à partir de la branche principale, vous verrez que certaines fonctionnalités telles que le panneau ou le chargement des destinations des vols ne fonctionnent pas. C'est ce sur quoi vous allez travailler dans les prochaines étapes de cet atelier de programmation.

b2c6b8989f4332bb.gif

Tests de l'interface utilisateur

L'application est couverte par des tests d'interface utilisateur très basiques disponibles dans le dossier androidTest, qui doivent toujours être transmis pour les branches main et end.

[Facultatif] Afficher la carte sur l'écran des détails

Il n'est pas du tout nécessaire d'afficher la carte de la ville sur l'écran des détails. Toutefois, si vous souhaitez la voir, vous devez obtenir une clé API personnelle, comme indiqué dans la documentation de Maps. Insérez cette clé dans le fichier local.properties comme suit :

// local.properties file
google.maps.key={insert_your_api_key_here}

Solution de l'atelier de programmation

Pour obtenir la branche end à l'aide de Git, exécutez la commande suivante :

$ git clone -b end https://github.com/android/codelab-android-compose

Vous pouvez également télécharger le code de solution ici :

Questions fréquentes

3. Pipeline de production de l'état de l'UI

Comme vous l'avez peut-être remarqué, lorsque vous exécutez l'application à partir de la branche main, la liste des destinations des vols est vide.

Pour résoudre ce problème, vous devez suivre deux étapes :

  • Ajoutez la logique dans ViewModel pour générer l'état de l'UI. Dans votre cas, il s'agit de la liste des destinations suggérées.
  • Utilisez l'état de l'UI, qui affiche l'interface utilisateur à l'écran.

Dans cette section, vous allez effectuer la première étape.

Dans le contexte des applications, une bonne architecture est organisée en couches afin de respecter les bonnes pratiques de base pour la conception du système, comme la séparation des tâches et la testabilité.

La production d'état de l'UI fait référence au processus par lequel l'application accède à la couche de données, applique des règles métier si nécessaire et expose l'état de l'UI à utiliser.

La couche de données de cette application est déjà implémentée. Vous allez maintenant créer l'état (la liste des destinations suggérées) afin que l'UI puisse l'utiliser.

Certaines API permettent de générer l'état de l'UI. Les alternatives sont résumées dans la documentation Types de sortie dans les pipelines de production d'état. En règle générale, il est recommandé d'utiliser StateFlow de Kotlin pour générer l'état de l'UI.

Pour générer l'état de l'UI, procédez comme suit :

  1. Ouvrez home/MainViewModel.kt.
  2. Définissez une variable _suggestedDestinations privée de type MutableStateFlow pour représenter la liste des suggestions de destinations, puis définissez une liste vide comme valeur de départ.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. Définissez une deuxième variable immuable suggestedDestinations de type StateFlow. Il s'agit de la variable publique en lecture seule qui peut être utilisée à partir de l'UI. Il est recommandé d'exposer une variable en lecture seule tout en utilisant la variable modifiable en interne. De cette manière, vous vous assurez que l'état de l'UI ne peut pas être modifié, sauf s'il est acheminé via ViewModel, ce qui en fait la source unique de vérité. La fonction d'extension asStateFlow convertit le flux de mutable en immuable.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. Dans le bloc "init" de ViewModel, ajoutez un appel à partir de destinationsRepository pour obtenir les destinations depuis la couche de données.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. Enfin, annulez la mise en commentaire des utilisations de la variable interne _suggestedDestinations que vous trouverez dans cette classe, afin qu'elle puisse être correctement mise à jour avec des événements provenant de l'UI.

Et voilà ! La première étape est terminée. Désormais, ViewModel peut produire l'état de l'UI. À l'étape suivante, vous utiliserez cet état à partir de l'UI.

4. Utiliser un flux de manière sécurisée à partir de ViewModel

La liste des destinations de vol est toujours vide. À l'étape précédente, vous avez généré l'état de l'UI dans MainViewModel. Vous allez maintenant utiliser l'état de l'UI exposé par MainViewModel pour qu'il s'affiche dans l'UI.

Ouvrez le fichier home/CraneHome.kt et examinez le composable CraneHomeContent.

Un commentaire "TODO" au-dessus de la définition de suggestedDestinations est associé à une liste vide mémorisée. Voici ce qui s'affiche à l'écran : une liste vide ! Au cours de cette étape, vous allez corriger et afficher les destinations suggérées par MainViewModel.

66ae2543faaf2e91.png

Ouvrez home/MainViewModel.kt et examinez StateFlow suggestedDestinations initialisé sur destinationsRepository.destinations et mis à jour lorsque les fonctions updatePeople ou toDestinationChanged sont appelées.

L'UI dans le composable CraneHomeContent doit être mise à jour chaque fois qu'un nouvel élément est émis dans le flux de données suggestedDestinations. Vous pouvez utiliser la fonction collectAsStateWithLifecycle(). collectAsStateWithLifecycle() collecte les valeurs de StateFlow et représente la dernière valeur via l'API State de Compose en tenant compte du cycle de vie. Le code Compose qui lit cette valeur d'état se base alors sur ces nouvelles valeurs.

Pour utiliser l'API collectAsStateWithLifecycle, commencez par ajouter la dépendance suivante dans app/build.gradle. La variable lifecycle_version est déjà définie dans le projet avec la version appropriée.

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}

Revenez au composable CraneHomeContent et remplacez la ligne qui attribue suggestedDestinations par un appel à collectAsStateWithLifecycle sur la propriété suggestedDestinations de ViewModel :

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

Si vous exécutez l'application, vous verrez que la liste des destinations s'affiche et qu'elle change dès que vous modifiez le nombre de passagers.

d656748c7c583eb8.gif

5. LaunchedEffect et rememberUpdatedState

Le projet contient un fichier home/LandingScreen.kt qui n'est pas utilisé pour le moment. Vous voulez ajouter un écran de destination à l'application, qui pourrait potentiellement être utilisé pour charger toutes les données nécessaires en arrière-plan.

L'écran de destination occupe la totalité de l'écran et le logo de l'application s'affiche au milieu. Idéalement, vous afficheriez l'écran, et, une fois les données chargées, indiqueriez à l'appelant que l'écran de destination peut être fermé à l'aide du rappel onTimeout.

Les coroutines Kotlin sont recommandées pour effectuer des opérations asynchrones dans Android. Une application utilise généralement des coroutines pour charger des éléments en arrière-plan au démarrage. Jetpack Compose propose des API qui permettent de sécuriser les coroutines dans la couche de l'UI. Comme cette application ne communique pas avec un backend, vous utiliserez la fonction delay des coroutines pour simuler le chargement d'éléments en arrière-plan.

Un effet secondaire dans Compose est un changement de l'état de l'application qui se produit en dehors du champ d'application d'une fonction modulable. La modification de l'état pour afficher/masquer l'écran de destination se produit dans le rappel onTimeout. De plus, puisqu'avant l'appel à onTimeout vous devez charger des éléments à l'aide de coroutines, le changement d'état doit se produire dans le contexte d'une coroutine !

Pour appeler des fonctions de suspension en toute sécurité depuis un composable, utilisez l'API LaunchedEffect, qui déclenche un effet secondaire au niveau de la coroutine dans Compose.

Lorsque LaunchedEffect entre dans la composition, il lance une coroutine avec le bloc de code transmis comme paramètre. La coroutine sera annulée si LaunchedEffect quitte la composition.

Voyons pourquoi le code suivant est incorrect et comment utiliser cette API malgré tout. Vous appellerez le composable LandingScreen plus tard dans cette étape.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Certaines API d'effets secondaires telles que LaunchedEffect utilisent un nombre variable de clés comme paramètre pour redémarrer l'effet chaque fois que l'une de ces clés change. Avez-vous repéré l'erreur ? Il n'est pas conseillé de redémarrer LaunchedEffect si les appelants de cette fonction modulable transmettent une valeur lambda onTimeout différente. Le delay pourrait alors redémarrer. et vous ne rempliriez plus les conditions requises.

Résolvons à présent ce problème. Pour déclencher l'effet secondaire une seule fois au cours du cycle de vie de ce composable, utilisez une constante comme clé, par exemple LaunchedEffect(Unit) { ... }. Toutefois, un autre problème se présente.

Si onTimeout change alors que l'effet secondaire est en cours, rien ne garantit que le dernier onTimeout sera appelé à la fin de l'effet. Pour vous assurer que le dernier onTimeout est appelé, n'oubliez pas onTimeout avec l'API rememberUpdatedState. Cette API capture et met à jour la valeur la plus récente :

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Vous devez utiliser rememberUpdatedState lorsqu'une expression lambda ou d'objet de longue durée fait référence à des paramètres ou à des valeurs calculées lors de la composition, ce qui peut être courant avec LaunchedEffect.

Afficher l'écran de destination

Vous devez maintenant afficher l'écran de destination lorsque l'application est ouverte. Ouvrez le fichier home/MainActivity.kt et vérifiez que MainScreen est le premier composable à être appelé.

Dans le composable MainScreen, vous pouvez simplement ajouter un état interne qui vérifie si l'atterrissage doit être affiché ou non :

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Si vous exécutez l'application maintenant, LandingScreen devrait apparaître et disparaître au bout de deux secondes.

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

Au cours de cette étape, vous allez faire fonctionner le panneau de navigation. Pour le moment, rien ne se passe lorsque vous appuyez sur le menu hamburger.

Ouvrez le fichier home/CraneHome.kt et consultez le composable CraneHome pour voir où vous devez ouvrir le panneau de navigation. Dans le rappel openDrawer !

Dans CraneHome, vous avez un scaffoldState contenant un DrawerState. DrawerState propose des méthodes pour programmer l'ouverture et la fermeture du panneau de navigation. Toutefois, un message d'erreur s'affichera si vous tentez de saisir scaffoldState.drawerState.open() dans le rappel openDrawer. En effet, la fonction open est une fonction de suspension. Nous sommes de nouveau dans le domaine des coroutines.

Hormis les API pour sécuriser les appels des coroutines depuis la couche de l'interface utilisateur, certaines API Compose sont des fonctions de suspension. L'API permettant d'ouvrir le panneau de navigation en est un exemple. Les fonctions de suspension peuvent non seulement exécuter du code asynchrone, mais également représenter des concepts qui se produisent avec le temps. Comme l'ouverture du tiroir nécessite un certain temps, mouvement et des animations potentielles, cela se reflète parfaitement dans la fonction de suspension, qui suspend l'exécution de la coroutine où elle est appelée jusqu'à la fin de l'ouverture et la reprise de l'exécution.

scaffoldState.drawerState.open() doit être appelé dans une coroutine. Ce que vous pouvez faire openDrawer est une fonction de rappel simple, ainsi :

  • Vous ne pouvez pas simplement appeler des fonctions de suspension, car openDrawer n'est pas exécuté dans le contexte d'une coroutine.
  • Vous ne pouvez pas utiliser LaunchedEffect comme vous l'aviez fait précédemment, car vous ne pouvez pas appeler de composables dans openDrawer. Nous ne sommes pas dans la composition.

Votre objectif est de pouvoir lancer une coroutine. Mais quel champ d'application utiliser ? Dans l'idéal, CoroutineScope doit suivre le cycle de vie de son site d'appel. L'API rememberCoroutineScope renvoie un CoroutineScope lié au point dans la composition où vous l'appelez. Le champ d'application sera automatiquement annulé lorsqu'il quittera la composition. Avec ce champ d'application, vous pouvez démarrer des coroutines lorsque vous ne vous trouvez pas dans la composition, par exemple dans le rappel openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Si vous exécutez l'application, vous verrez que le panneau de navigation s'ouvre lorsque vous appuyez sur l'icône du menu hamburger.

92957c04a35e91e3.gif

LaunchedEffect vs rememberCoroutineScope

Dans ce cas, utiliser LaunchedEffect était impossible, car vous deviez déclencher l'appel pour créer une coroutine dans un rappel standard qui se trouve en dehors de la composition.

Si vous revenez à l'étape de l'écran de destination qui utilisait LaunchedEffect, pourriez-vous utiliser rememberCoroutineScope et appeler scope.launch { delay(); onTimeout(); } au lieu de LaunchedEffect ?

Vous auriez pu faire ça et le code aurait semblé fonctionner, mais ce serait incorrect. Comme expliqué dans la documentation Réfléchir dans Compose, les composables peuvent être appelés par Compose à tout moment. LaunchedEffect garantit que l'effet secondaire sera exécuté lorsque l'appel à ce composable parviendra à la composition. Si vous utilisez rememberCoroutineScope et scope.launch dans le corps de LandingScreen, la coroutine sera exécutée chaque fois que LandingScreen est appelé par Compose, que cet appel fasse ou non partie de la composition. Par conséquent, vous gaspillerez des ressources et vous n'exécuterez pas cet effet secondaire dans un environnement contrôlé.

7. Créer un conteneur d'état

Avez-vous remarqué que si vous appuyez sur Choose Destination (Sélectionner une destination), vous pouvez modifier le champ et filtrer les villes en fonction de votre recherche ? Vous avez sans doute également remarqué que le style du texte change chaque fois que vous modifiez la section Choose Destination.

dde9ef06ca4e5191.gif

Ouvrez le fichier base/EditableUserInput.kt. Le composable avec état CraneEditableUserInput utilise des paramètres tels que hint et caption, qui correspond au texte préinséré à côté de l'icône. Par exemple, la mention caption To (À) s'affiche lorsque vous saisissez une destination.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Pourquoi ?

La logique permettant de mettre à jour l'élément textState et de déterminer si ce qui est affiché correspond au hint ou non, se trouve dans le corps du composable CraneEditableUserInput. Cela comporte quelques inconvénients :

  • La valeur de TextField n'est pas hissée et ne peut donc pas être contrôlée de l'extérieur, ce qui rend les tests plus difficiles.
  • La logique de ce composable pourrait devenir plus complexe et l'état interne pourrait être plus facilement désynchronisé.

En créant un conteneur d'état pour l'état interne de ce composable, vous pouvez centraliser toutes les modifications d'état au même endroit. Il est alors plus difficile pour l'état d'être désynchronisé, et la logique associée est regroupée dans une seule classe. De plus, cet état peut être facilement hissé et utilisé par les appelants de ce composable.

Dans ce cas, il est recommandé de hisser l'état, car il s'agit d'un composant d'interface utilisateur de bas niveau qui peut être réutilisé dans d'autres parties de l'application. Par conséquent, plus le composant est flexible et facile à contrôler, mieux c'est.

Créer le conteneur d'état

CraneEditableUserInput étant un composant réutilisable, créez une classe standard en tant que conteneur d'état intitulée EditableUserInputState dans le même fichier, qui se présente comme suit :

// base/EditableUserInput.kt file

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

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

La classe doit présenter les caractéristiques suivantes :

  • text est un état modifiable de type String, tout comme dans CraneEditableUserInput. Il est important d'utiliser mutableStateOf afin que Compose suive les modifications apportées à la valeur et se recompose lorsque des modifications se produisent.
  • text est une var, avec un set privé, et ne peut donc pas être transféré directement depuis l'extérieur de la classe. Au lieu de rendre cette variable publique, vous pouvez exposer un événement updateText pour la modifier, ce qui fait de la classe sa source unique de référence.
  • La classe utilise un initialText comme dépendance pour initialiser text.
  • La logique pour savoir si text correspond au hint ou se trouve au niveau de la propriété isHint qui effectue le contrôle à la demande.

Si la logique se complexifie à l'avenir, il vous suffira de modifier une seule classe : EditableUserInputState.

Enregistrer le conteneur d'état

Les conteneurs d'état doivent être stockés pour les conserver dans la composition et ne pas avoir à en un créer une autre à chaque fois. Nous vous recommandons de créer une méthode dans le même fichier afin de supprimer le code récurrent et d'éviter toute erreur. Dans le fichier base/EditableUserInput.kt, ajoutez le code suivant :

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Si vous n'enregistrez (remember) que cet état, il ne survivra pas à la recréation de l'activité. Pour ce faire, vous pouvez plutôt utiliser l'API rememberSaveable, qui se comporte de la même manière que remember, mais dont la valeur stockée survit également à la recréation de l'activité et du processus. En interne, l'API utilise le mécanisme enregistré de l'état de l'instance.

rememberSaveable effectue toutes ces opérations sans solliciter les objets, qui peuvent être stockés dans un Bundle. Ce n'est pas le cas pour la classe EditableUserInputState que vous avez créée dans votre projet. Vous devez donc indiquer à rememberSaveable comment enregistrer et restaurer une instance de cette classe à l'aide d'un Saver.

Créer un saver personnalisé

Un Saver décrit comment un objet peut être converti en quelque chose de Saveable (enregistrable). Les implémentations d'un Saver doivent forcer deux fonctions :

  • save pour convertir la valeur d'origine en valeur enregistrable.
  • restore pour convertir la valeur restaurée en instance de la classe d'origine.

Dans ce cas, au lieu de créer une implémentation personnalisée de Saver pour la classe EditableUserInputState, vous pouvez utiliser certaines des API Compose existantes, telles que listSaver ou mapSaver (qui stocke les valeurs à enregistrer dans une List ou une Map) afin d'avoir moins de code à écrire.

Nous vous recommandons de placer les définitions de Saver à proximité de la classe concernée. Étant donné qu'il doit être accessible de manière statique, ajoutez le Saver pour EditableUserInputState dans un companion object (objet compagnon). Dans le fichier base/EditableUserInput.kt, ajoutez l'implémentation de Saver :

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

Dans ce cas, vous utilisez un listSaver comme détail d'implémentation pour stocker et restaurer une instance de EditableUserInputState dans le saver.

Vous pouvez désormais utiliser ce saver dans rememberSaveable (au lieu de remember) dans la méthode rememberEditableUserInputState, créée précédemment :

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Ainsi, l'état mémorisé EditableUserInput survivra à la recréation de processus et d'activités.

Utiliser le conteneur d'état

Vous allez utiliser EditableUserInputState au lieu de text et de isHint, mais vous ne voulez pas l'utiliser uniquement comme état interne dans CraneEditableUserInput, car il n'y a aucun moyen pour l'appelant de composable de contrôler l'état. À la place, vous souhaitez hisser EditableUserInputState afin que les appelants puissent contrôler l'état de CraneEditableUserInput. Si vous hissez l'état, le composable peut être utilisé dans les aperçus et testé plus facilement, car vous pouvez modifier son état depuis l'appelant.

Pour ce faire, vous devez modifier les paramètres de la fonction modulable et lui attribuer une valeur par défaut si nécessaire. Puisque vous souhaitez peut-être autoriser CraneEditableUserInput avec des hints vides, ajoutez un argument par défaut :

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Vous avez probablement remarqué que le paramètre onInputChanged a disparu ! Étant donné que l'état peut être hissé, si les appelants veulent savoir si l'entrée a changé, ils peuvent contrôler l'état et le transmettre à cette fonction.

Vous devez ensuite modifier le corps de la fonction pour qu'il utilise l'état hissé plutôt que l'état interne utilisé précédemment. Après refactorisation, la fonction devrait se présenter comme suit :

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.updateText(it) },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Appelants de conteneurs d'états

Comme vous avez modifié l'API de CraneEditableUserInput, vous devez vérifier tous les endroits où elle est appelée pour vous assurer de transmettre les paramètres appropriés.

Dans le projet, cette API n'est appelée que dans le fichier home/SearchUserInput.kt. Ouvrez-le et accédez à la fonction modulable ToDestinationUserInput. Vous devriez voir une erreur de compilation. Le hint fait désormais partie du conteneur d'état, et vous souhaitez obtenir un hint personnalisé pour cette instance de CraneEditableUserInput dans la composition. Vous devez mémoriser l'état au niveau ToDestinationUserInput et le transmettre à CraneEditableUserInput :

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

Le code ci-dessus ne comporte pas de fonctionnalité permettant d'avertir l'appelant de ToDestinationUserInput lorsque l'entrée change. Compte tenu de la structure de l'application, vous ne souhaitez pas hisser EditableUserInputState plus haut dans la hiérarchie. Vous ne voulez pas associer les autres composables tels que FlySearchContent à cet état. Comment appeler le lambda onToDestinationChanged à partir de ToDestinationUserInput tout en conservant ce composable réutilisable ?

Vous pouvez déclencher un effet secondaire à l'aide de LaunchedEffect chaque fois que l'entrée change et appeler le lambda onToDestinationChanged :

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Vous avez déjà utilisé LaunchedEffect et rememberUpdatedState, mais le code ci-dessus utilise également une nouvelle API. L'API snapshotFlow convertit les objets State<T> Compose en un Flux. Lorsque l'état lu dans snapshotFlow est modifié, le flux émettra la nouvelle valeur pour le collecteur. Dans ce cas, vous convertissez l'état en flux pour utiliser la puissance des opérateurs de flux. Ensuite, vous utilisez un filtre (filter) lorsque text n'est pas hint, et vous récupérez (avec collect) les éléments émis pour avertir le parent que la destination actuelle a changé.

Cette étape de l'atelier de programmation n'a apporté aucune modification visuelle, mais vous avez amélioré la qualité de cette partie du code. Si vous exécutez l'application maintenant, vous devriez constater que tout fonctionne comme auparavant.

8. DisposableEffect

Lorsque vous appuyez sur une destination, l'écran des détails s'ouvre et vous pouvez voir où se trouve la ville sur la carte. Ce code se trouve dans le fichier details/DetailsActivity.kt. Dans le composable CityMapView, vous appelez la fonction rememberMapViewWithLifecycle. Si vous ouvrez cette fonction, qui se trouve dans le fichier details/MapViewUtils.kt, vous verrez qu'elle n'est connectée à aucun cycle de vie. Elle se souvient simplement d'un MapView et appelle onCreate dessus :

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Même si l'application fonctionne correctement, c'est un problème, car MapView ne suit pas le bon cycle de vie. Par conséquent, il ne saura pas quand l'application sera déplacée en arrière-plan, quand l'affichage devrait être mis en pause, etc. Trouvons une solution à ce problème !

Étant donné que MapView est une vue et non un composable, vous voulez qu'il suive le cycle de vie de l'activité où il est utilisé au lieu du cycle de vie de la composition. Vous devez donc créer un LifecycleEventObserver pour écouter les événements de cycle de vie et appeler les bonnes méthodes sur MapView. Vous devez ensuite ajouter cet observateur au cycle de vie de l'activité actuelle.

Commencez par créer une fonction qui renvoie un LifecycleEventObserver qui appelle les méthodes correspondantes dans un MapView en fonction d'un événement donné :

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Vous devez maintenant ajouter cet observateur au cycle de vie actuel, que vous pouvez obtenir à l'aide du LifecycleOwner actuel avec la composition LocalLifecycleOwner locale. Cependant, il ne suffit pas d'ajouter l'observateur. Vous devez aussi être en mesure de le supprimer. Vous avez besoin d'un effet secondaire qui vous indique quand l'effet quitte la composition afin que vous puissiez exécuter du code de nettoyage. L'API d'effets secondaires que vous recherchez est DisposableEffect.

DisposableEffect est destiné aux effets secondaires qui doivent être nettoyés après que les touches soient modifiées ou que le composable ait quitté la composition. C'est exactement ce que fait le code final rememberMapViewWithLifecycle. Ajoutez les lignes suivantes à votre projet :

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

L'observateur est ajouté au lifecycle (cycle de vie) actuel, et est supprimé chaque fois que le cycle de vie change ou que ce composable quitte la composition. Avec les key de DisposableEffect, si l'élément lifecycle ou mapView change, l'observateur sera supprimé et ajouté de nouveau au bon lifecycle.

Avec les modifications que vous venez d'apporter, MapView suivra toujours le lifecycle du LifecycleOwner actuel. Il aura le même comportement que s'il avait été utilisé dans une vue.

N'hésitez pas à exécuter l'application et à ouvrir l'écran d'informations pour vérifier que MapView s'affiche toujours correctement. Cette étape n'a apporté aucune modification visuelle.

9. produceState

Dans cette section, vous allez améliorer l'affichage de l'écran des détails. Le composable DetailsScreen dans le fichier details/DetailsActivity.kt récupère le cityDetails de manière synchrone à partir de ViewModel et appelle DetailsContent si le résultat aboutit.

Cependant, cityDetails pourrait se révéler plus coûteux à charger sur le thread UI et pourrait utiliser des coroutines pour déplacer le chargement des données vers un autre thread. Vous allez améliorer ce code pour ajouter un écran de chargement et afficher DetailsContent lorsque les données sont prêtes.

Pour modéliser l'état de l'écran, vous pouvez utiliser la classe suivante, qui couvre toutes les possibilités : les données à afficher à l'écran, ainsi que les signaux de chargement et d'erreur. Ajoutez la classe DetailsUiState au fichier DetailsActivity.kt :

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Vous pourriez mapper ce que l'écran doit afficher et l'UiState dans la couche ViewModel à l'aide d'un flux de données, un StateFlow de type DetailsUiState, que le ViewModel met à jour lorsque les informations sont prêtes et que Compose collecte avec l'API collectAsStateWithLifecycle() que vous connaissez déjà.

Toutefois, pour les besoins de cet exercice, vous allez implémenter une alternative. Si vous souhaitez déplacer la logique de mappage uiState vers l'environnement Compose, vous pouvez utiliser l'API produceState.

produceState vous permet de convertir un état autre que Compose en un état Compose. L'API lance une coroutine limitée à la composition qui peut transférer des valeurs dans le State (état) renvoyé à l'aide de la propriété value. Comme avec LaunchedEffect, produceState utilise également des clés pour annuler et redémarrer le calcul.

Pour votre cas d'utilisation, vous pouvez utiliser produceState pour émettre des mises à jour d'uiState avec une valeur initiale de DetailsUiState(isLoading = true) comme suit :

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

Ensuite, en fonction de uiState, vous affichez les données, l'écran de chargement ou les erreurs. Voici le code complet du composable DetailsScreen :

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Si vous exécutez l'application, vous verrez comment l'icône de chargement apparaît avant d'afficher les détails de la ville.

aa8fd1ac660266e9.gif

10. derivedStateOf

La dernière amélioration que vous allez apporter à Crane consiste à afficher un bouton Scroll to top (Faire défiler vers le haut) chaque fois que vous faites défiler la liste des destinations de vol après avoir dépassé le premier élément de l'écran. Appuyer sur le bouton vous permet d'accéder au premier élément de la liste.

2c112d73f48335e0.gif

Ouvrez le fichier base/ExploreSection.kt contenant ce code. Le composable ExploreSection correspond à ce que vous voyez en arrière-plan de l'échafaudage.

Pour déterminer si l'utilisateur a dépassé le premier élément, utilisez LazyListState de LazyColumn et vérifiez si listState.firstVisibleItemIndex > 0.

Une implémentation simple se présente comme suit :

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Cette solution n'est pas aussi efficace qu'elle pourrait l'être, car la fonction modulable qui lit showButton se recompose aussi souvent que firstVisibleItemIndex change, ce est fréquent en cas de défilement. L'objectif est de faire en sorte que la fonction se recompose uniquement lorsque la condition passe de true à false, et vice versa.

L'API derivedStateOf permet d'atteindre cet objectif.

listState est un état (State) Compose observable. Votre calcul, showButton, doit également être un State de Compose, car vous voulez que l'UI se recompose lorsque sa valeur change, et afficher ou masquer le bouton.

Utilisez derivedStateOf lorsque vous souhaitez un objet Compose State dérivé d'un autre élément State. Le bloc de calcul derivedStateOf est exécuté chaque fois que l'état interne change, mais la fonction modulable ne se recompose que lorsque le résultat du calcul est différent du précédent. Cela réduit le nombre de fois où les fonctions qui lisent showButton sont recomposées.

Dans ce cas, l'utilisation de l'API derivedStateOf est une alternative plus efficace. Vous allez également encapsuler l'appel avec l'API remember afin que la valeur calculée survive à la recomposition.

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

Vous devriez reconnaître le nouveau code du composable ExploreSection. Vous utilisez une Box pour placer l'élément Button affiché de manière conditionnelle en haut de ExploreList. Vous utilisez aussi rememberCoroutineScope pour appeler la fonction de suspension listState.scrollToItem dans le rappel onClick de Button.

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Si vous exécutez l'application, vous verrez que le bouton s'affiche en bas une fois que vous avez fait défiler le premier élément en dehors de l'écran.

11. Félicitations !

Félicitations, vous avez terminé cet atelier de programmation et appris les concepts avancés des API d'état et d'effets secondaires dans une application Jetpack Compose !

Vous avez appris à créer des conteneurs d'état et des API d'effets secondaires tels que LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState et derivedStateOf, et comment utiliser les coroutines dans Jetpack Compose.

Et maintenant ?

Consultez les autres ateliers de programmation du parcours Compose, ainsi que d'autres exemples de code, dont celui de Crane.

Documentation

Pour en savoir plus et obtenir des conseils à ce sujet, consultez la documentation suivante :