Principes de base des applications

Utiliser l'état Compose au-delà de l'UI avec Jetpack AppState

Haut-parleurs

  • Ben Sagmoe

    Ingénieur Relations avec les développeurs

  • Elif Bilgin Morris

    Ingénieur logiciel

À propos

À propos de l'intervention

L'environnement d'exécution d'instantanés, le suivi automatique des dépendances et les mises à jour atomiques de Compose ne doivent pas s'arrêter à la couche d'interface utilisateur. Jetpack AppState vous permet de déplacer l'état Compose en amont, ce qui simplifie ce qui s'affiche sur vos écrans en hissant et en dérivant l'état avec le système d'instantanés avant qu'il n'atteigne l'UI.

Dans cette session, vous allez découvrir les deux bibliothèques fondamentales d'AppState. Nous allons nous intéresser à StateStore pour le hissage d'état et à Transform pour utiliser la composition afin de dériver l'état avec un flux de contrôle Kotlin simple, ce qui élimine les chaînes de combinaison Flow complexes et permet de synchroniser votre UI sans effort.

Résumé

  • Compose Runtime fonctionne comme un moteur de gestion d'état à usage général sans l'UI Compose. Son système d'instantanés suit les lectures de manière dynamique et assure la cohérence des mises à jour simultanées entre les propriétés.
  • Jetpack AppState Transform calcule et expose l'état Compose à partir de blocs composables sans interface graphique. Il remplace les chaînes d'opérateurs Flow complexes telles que combine, tout en permettant les retours anticipés et les dérivations d'état imbriquées.
  • Transform utilise une horloge de frame headless hâtive pour exécuter les transformations en dehors du thread UI. L'horloge se met en pause chaque fois que les états surveillés restent inchangés, ce qui évite les tâches inutiles.
  • StateStore de Jetpack AppState partage l'état Compose entre différents composants, tels que les activités et les services d'arrière-plan. L'intégrer à DataStore permet aux clés individuelles de survivre à l'arrêt du processus sans code récurrent personnalisé.

Ressources

Gemini Notebook

Thèmes

  • jetpack-compose
  • jetpack-appstate
  • state-management
  • android-architecture
  • compose-runtime

Transcription

Introduction

  • Bonjour et bienvenue dans "Utiliser l'état Compose au-delà de l'UI avec Jetpack AppState".

  • Je m'appelle Ben et je suis ingénieur DevRel. Je travaille sur les bibliothèques d'architecture Jetpack.

  • Je m'appelle Elif et je suis l'une des ingénieures qui travaillent sur les bibliothèques d'architecture d'application.

  • Aujourd'hui, nous allons vous parler d'un avenir où Compose va au-delà de l'UI et s'étend aux couches de données et de domaine des applications.

  • Comme vous le savez déjà, Compose est le kit d'outils recommandé pour le développement d'UI sur Android.

  • Des téléphones et tablettes aux widgets, montres, téléviseurs et appareils XR, Compose est partout.

  • Toutefois, à mesure que votre application se développe sur ces surfaces d'interface utilisateur et ces composants d'arrière-plan, la gestion de l'état sous-jacent devient plus difficile, en particulier pour deux problèmes courants.

  • La première consiste à dériver un état complexe.

  • La combinaison de plusieurs flux de données, d'entrées d'UI locales et d'un état ambiant, comme la connectivité réseau, se transforme souvent en un enchevêtrement d'opérateurs tels que combine, flatMapLatest et distinctUntilChanged.

  • Le second est le partage d'état entre les composants : maintenir une activité, un service, un widget d'application et d'autres éléments synchronisés, et s'assurer que cet état peut survivre à l'arrêt du processus si nécessaire.

  • En prenant du recul pour examiner ces défis, nous avons examiné la façon dont Compose est conçu.

  • La plupart des gens considèrent Compose comme un kit d'interface utilisateur, mais il s'agit en réalité de deux couches distinctes : l'UI Compose en haut et l'environnement d'exécution Compose en bas.

  • Alors que Compose UI gère la mise en page, le dessin et la saisie, Compose Runtime est un moteur à usage général pour la gestion de l'état et la recomposition.

  • Il n'a aucune dépendance vis-à-vis de l'UI.

  • C'est ce constat qui nous a amenés à créer Jetpack AppState.

  • Il s'agit d'une famille de bibliothèques conçues pour exploiter au mieux l'état de Compose au-delà de l'UI.

Qu'est-ce que l'état de Compose ?

  • Mais avant de nous intéresser à AppState, assurons-nous que nous sommes sur la même longueur d'onde lorsque nous parlons de l'état de Compose.

  • La réponse est androidx.compose.runtime.State.

    /**
     * A value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value.
     */
    @Stable
    public interface State<out T> {
        public val value: T
    }
  • Et l'interface que vous connaissez probablement mieux, MutableState.

    /**
     * A mutable value holder where reads to the [value] property during the execution of a [Composable]
     * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
     * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
     * will be scheduled. If [value] is written to with the same value, no recompositions will be
     * scheduled.
     */
    @Stable
    public interface MutableState<T> : State<T> {
        override var value: T
        public operator fun component1(): T
        public operator fun component2(): (T) -> Unit
    }
  • En gros, il s'agit de conteneurs de base.

  • Ce qui les fait réellement fonctionner, c'est le système d'instantanés de Compose.

  • Ainsi, lorsque nous parlons d'état Compose, nous faisons généralement référence à l'état de l'instantané.

  • Ainsi, lorsque vous appelez mutableStateOf, vous créez un conteneur qui achemine les lectures et les écritures via le système d'instantanés.

  • Et chaque fois que votre code s'exécute dans un observateur d'instantané, comme une composition, vous obtenez deux propriétés qu'un flux comme Flow ne vous donne pas.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • La première est le suivi automatique et dynamique des lectures.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • "Automatique" signifie qu'il n'y a pas d'abonnement ni de collecte.

  • La lecture de .value est l'abonnement.

  • Si nous examinons le composant Counter, la lecture de count.value directement dans le composable indique cette lecture à la composition, qui enregistre Counter dans sa liste de lecteurs.

  • Et maintenant, regardez AddButton.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
    
    @Composable
    fun AddButton(onClick: () -> Unit) {
        Button(onClick = onClick) {
            Text("Add")
        }
    }
  • Il écrit uniquement pour comptabiliser le clic.

  • Il ne s'agit donc pas d'un lecteur.

  • Lorsqu'un utilisateur clique sur AddButton et modifie le nombre, le système d'instantanés suit cette écriture, ce qui permet à la composition de savoir qu'elle doit invalider Counter, mais pas AddButton.

  • "Dynamique" signifie que le suivi des lectures s'effectue au moment de l'exécution, en fonction des chemins de code réellement exécutés.

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(true) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • Ici, nous protégeons le texte du compteur derrière un indicateur isVisible.

  • Lorsque isVisible est défini sur "true", isVisible et le nombre sont tous deux lus.

  • "Counter" est donc enregistré en tant que lecteur des deux.

  • Et lorsque isVisible est faux, si la branche "if" ne s'exécute pas, "count" n'est pas lu. "Counter" n'est donc enregistré qu'en tant que lecteur de isVisible, et non de "count".

    @Composable
    fun Counter() {
        val isVisible = remember { mutableStateOf(false) }
        val count = remember { mutableStateOf(0) }
        Column {
            if (isVisible.value) {
                Text("Count: ${count.value}")
            } else {
                Text("Hidden")
            }
            AddButton(onClick = { count.value++ })
        }
    }
  • Ainsi, lorsque AddButton écrit dans "count", il ne reste rien à invalider.

  • La deuxième propriété est de ne pas avoir à choisir entre cohérence et précision.

  • La cohérence signifie que lorsque vous mettez à jour plusieurs valeurs dans un instantané, ces modifications restent isolées pendant l'exécution de votre code, même si un autre thread les écrit en même temps.

  • Ces mises à jour deviennent visibles toutes en même temps lorsque l'instantané est terminé.

  • La précision signifie que des propriétés distinctes peuvent être déclarées et suivies individuellement, en particulier lorsqu'elles ne sont pas liées.

  • Ainsi, la mise à jour d'une propriété n'invalide que les lecteurs qui en dépendent réellement.

  • Voyons comment cela se traduit dans du code réel.

  • Par exemple, nous allons examiner un jeu qui comporte trois éléments d'état : un nom de niveau, le temps de survie et le score.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • Pour commencer, examinons une implémentation de Flow qui privilégie la cohérence au détriment de la précision en regroupant tout dans une classe GameUiState.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • Cela signifie que chaque fois que updateScore s'exécute, vous copiez l'intégralité de GameUiState afin que timeSurvived et le score puissent être mis à jour de manière synchronisée.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
  • Dans l'UI, GameScreen collecte l'état de l'UI et le lit pour transmettre les propriétés pertinentes à Header et ScoreBadge.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
    
    class GameViewModel : ViewModel() {
        val uiState = MutableStateFlow(GameUiState())
        fun updateScore() = uiState.update {
            it.copy(
                timeSurvived = it.timeSurvived + 1,
                score = it.score + 10 + .01 * it.timeSurvived
            )
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state by viewModel.uiState.collectAsStateWithLifecycle()
        Column {
            Header(levelName = state.levelName)
            ScoreBadge(
                timeSurvived = state.timeSurvived,
                score = state.score
            )
        }
    }
  • Comme GameScreen lit chaque état, chaque appel à updateScore invalide GameScreen, même si les données de l'en-tête n'ont jamais changé.

  • Vous pourriez diviser chaque propriété en un flux distinct pour obtenir une précision optimale, mais vous perdriez en cohérence, car un autre thread pourrait mettre à jour une valeur pendant l'exécution de l'autre bloc de mise à jour.

  • Examinons maintenant une autre implémentation basée sur l'état.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • Dans celui-ci, nous utilisons à nouveau un GameUiState.

  • Mais dans celui-ci, c'est un peu différent, car chaque propriété délègue à mutableStateOf.

  • Ici, updateScore encapsule ses écritures dans withMutableSnapshot, ce qui nous permet d'assurer la cohérence entre les écritures.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
  • Ensuite, dans l'UI, GameScreen transmet l'état directement à l'en-tête et à ScoreBadge.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
    
    class GameViewModel : ViewModel() {
        val uiState = GameUiState()
        fun updateScore() = Snapshot.withMutableSnapshot {
            uiState.timeSurvived += 1
            uiState.score += 10 + .01 * uiState.timeSurvived
        }
    }
    
    @Composable
    fun GameScreen(viewModel: GameViewModel = viewModel()) {
        val state = viewModel.uiState
        Column {
            Header(state = state)
            ScoreBadge(state = state)
        }
    }
  • Il ne lit aucune des propriétés. Par conséquent, lorsque updateScore s'exécute, GameScreen n'est pas un lecteur.

  • Seul le composant réel, dans ce cas ScoreBadge, est invalidé.

  • Enfin, notez que rien de tout cela n'est lié à l'interface utilisateur.

  • L'interface utilisateur est l'un des endroits où ces propriétés sont particulièrement utiles.

  • Merci d'avoir assisté à notre présentation.

  • Bon, blague à part, je vais laisser la parole à Elif, qui va nous présenter la première bibliothèque, Transform.

Présentation de Transform

  • OK.

  • Merci, Ben.

  • Comme Ben vient de le souligner, l'état de l'instantané Compose nous offre un suivi, une cohérence et une précision de lecture automatiques et dynamiques. Cela m'amène à la question suivante : et si nos flux de données pouvaient également être pilotés par un instantané d'état Compose ?

  • Et si l'état est notre modèle de données, comment calculer et transformer un état pour tous ses consommateurs ?

  • C'est là que la nouvelle bibliothèque Jetpack AppState Transform entre en jeu.

  • Avant de nous intéresser à la bibliothèque elle-même, nous devons parler de sa création.

  • L'idée d'exploiter le moteur Compose, qui est l'environnement d'exécution et le compilateur, pour produire des valeurs d'état dans une composition sans interface graphique n'est donc pas nouvelle.

  • Beaucoup d'entre vous utilisent peut-être déjà la bibliothèque Molecule ou la connaissent déjà.

  • "Sans interface graphique" signifie ici qu'il n'y a pas d'UI.

  • Molecule a donc été un pionnier en démontrant la puissance de l'utilisation de l'environnement d'exécution Compose. Il l'a fait en nous montrant en action qu'une fonction composable n'a pas besoin d'émettre des nœuds d'interface utilisateur et qu'elle peut en fait être utilisée pour écrire une fonction composable qui renvoie un modèle de données pur.

  • Dans le cas de Molecule, il s'agissait d'un flux Kotlin ou d'un StateFlow.

  • Cela signifie qu'au lieu d'enchaîner des opérateurs réactifs comme "combine", vous pouvez écrire du code Kotlin ordinaire.

  • Mais au lieu de gérer des flux, que se passerait-il si votre modèle de données était un état, puisque votre UI est déjà dans Compose ?

  • Avec Transform, votre modèle de données est l'état.

  • Examinons l'API elle-même.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R>
  • C'est l'API complète.

  • C'est très simple, une seule fonction.

  • Elle renvoie un State observable simple, et non un Flow ni un StateFlow.

  • Et l'utilisateur ne sait pas ou ne se soucie pas de savoir qu'une transformation l'a produit.

  • Ce qui est vraiment intéressant ici, c'est ce bloc.

  • Il est composable.

  • Cela signifie que tout ce que vous savez déjà fonctionne dans ce contexte.

  • N'oubliez pas que vous pouvez dériver dans une transformation tout ce que vous pouvez lire depuis un composable : LaunchedEffect, collectAsState, les retours anticipés, les instructions "if" simples, etc.

  • Notez que toutes les API que nous voyons aujourd'hui dans cette présentation sont en version alpha et sont susceptibles d'être modifiées. Quoi qu'il en soit, examinons un exemple.

  • Prenons l'exemple d'une petite application de minuteur de concentration très simple.

  • Vous choisissez une tâche, vous réglez un minuteur sur 25 minutes et le compte à rebours commence.

  • Chaque fois que vous créez un écran, même un écran simple comme celui-ci, vous devrez probablement combiner et gérer plusieurs flux de données indépendants pour assembler votre état en arrière-plan.

  • Pour notre écran de concentration, supposons que nous en ayons deux : la session de concentration elle-même, qui correspond à la phase dans laquelle nous nous trouvons (en pause ou non), le nombre de secondes restantes, et tout cela proviendra d'un moteur de minuteur sous la forme d'un StateFlow qui se déclenchera, disons, toutes les secondes.

  • L'autre flux de données sera la tâche en cours sur laquelle l'utilisateur travaille, qui proviendra d'une base de données Room sous forme de flux.

  • Nous avons donc deux flux de données et un état d'écran à la fin.

  • ViewModel est l'endroit où ils seront assemblés, et sa sortie, que nous appellerons le résumé, sera le morceau de code que nous allons écrire ensuite.

  • En temps normal, cela signifie que vous devez combiner ces flux dans votre ViewModel à l'aide d'opérateurs enchaînés.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: StateFlow<FocusSummary> =
            combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find { it.id == session.activeTaskId }
                    FocusSummary.Active(...)
                }
            }.stateIn(
                    scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = FocusSummary.Idle,
    )
    }
  • Nous allons donc combiner les deux flux en un seul, ajouter une branche dans la fonction lambda (avec une instruction if-else) et un état à la fin.

  • Avec Transform, nous pouvons supprimer tout ce code récurrent.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
    initialValue = FocusSummary.Idle,
    scope = viewModelScope,
    container = handle.getOrCreateContainer("transform")
    ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
  • Cet appel semble familier, car nous avons une valeur initiale dans un champ d'application transmis, mais il n'y a rien en amont à enchaîner. La dérivation est le bloc lui-même.

  • Contrairement à stateIn, où vous devriez probablement configurer un délai avant expiration WhileSubscribed pour mettre en pause et reprendre les flux en amont, une transformation s'exécute en continu pendant la durée de vie de ce champ d'application.

  • Encore une fois, nos sources sont des flux, et les flux sont les bienvenus.

    class FocusScreenViewModel(
        focusManager: FocusSessionManager,
        taskRepository: TaskRepository
    ) : ViewModel() {
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
                initialValue = FocusSummary.Idle,
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val session by focusManager.sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                         FocusSummary.Active(...)
            }
    }
  • Nous allons relier chacun d'eux avec collectAsState. La session est un StateFlow, nous n'avons donc pas besoin de valeur initiale, mais le flux Room est froid, nous lui en donnons donc une.

  • Et voilà, vous savez tout sur les abonnements.

  • La lecture est ce qui nous abonne.

  • Il s'agit du suivi automatique des lectures dont Ben parlait plus tôt dans l'exemple que nous avons vu, mais dans un ViewModel.

  • Nous pouvons même introduire des retours anticipés comme celui-ci lorsque nous sommes dans un état inactif.

  • Imaginons que nous n'ayons pas de nouvelles tâches. Nous ne traiterons même pas le reste.

  • Là encore, c'est le suivi dynamique de la lecture qui porte ses fruits.

  • Lorsque nous sommes inactifs, le débit de la pièce n'est pas du tout collecté.

  • Enfin, nous émettons le résumé.

  • Ainsi, si l'utilisateur met en pause la tâche ou que le minuteur s'écoule, tout est automatiquement mis à jour.

Transformations imbriquées

  • Les choses se compliquent un peu lorsque nous sortons de l'exemple d'un seul ViewModel.

  • Imaginons que cette même session doive s'afficher sur deux surfaces supplémentaires : une barre de minuteur miniature qui se trouve en bas de l'écran de l'application sur chaque écran et un widget sur l'écran d'accueil.

  • Ces deux éléments auront besoin des mêmes informations dérivées sur le résumé de la session, la phase, l'état de pause ou non, et le temps restant sur le minuteur.

  • Si chaque surface devait assembler sa propre copie, cela signifierait que la même dérivation serait effectuée trois fois à trois endroits différents, qui devraient tous être en accord les uns avec les autres.

  • Nous devrons donc dériver le résumé une fois dans le champ d'application, puis tout ce qui se trouve en aval l'utilisera.

  • Alors, comment implémenter concrètement une telle solution ?

  • Notre implémentation aura probablement besoin de trois éléments.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • Nous avons donc un gestionnaire de session dans le champ d'application, qui va dériver le résumé, un ViewModel de mini-minuteur et un widget Glance, qui vont tous deux consommer le résumé dérivé dans ce gestionnaire de session de concentration.

  • Créons-en un de manière traditionnelle, puis avec Transform.

  • Traditionnellement, nous commencerions par dériver le résumé une fois dans le champ d'application en tant que StateFlow.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: StateFlow<FocusSummary> =
            combine(
                sessionFlow,
                taskRepository.tasksFlow
            ) { session, tasks ->
                if (session.phase == FocusPhase.Idle) {
                    FocusSummary.Idle
                } else {
                    val task = tasks.find {
                        it.id == session.activeTaskId
                    }
                    FocusSummary.Active(...)
                }
            }.stateIn(
    scope = appScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = FocusSummary.Idle
    )
    }
  • Il s'agit des mêmes appels combine et stateIn que ceux que nous avons vus précédemment dans l'exemple ViewModel, mais ils ont été déplacés d'un niveau.

  • Avec Transform, nous pouvons également réutiliser la même déclaration de la ViewModel que nous avons vue précédemment.

    class FocusSessionManager(
        taskRepository: TaskRepository,
        scope: CoroutineScope
    ) {
        val sessionFlow: StateFlow<FocusSession> = ...
        val summary: State<FocusSummary> =
            transform<FocusSummary>(
               initialValue = FocusSummary.Idle,
    scope = scope,
    container = handle.getOrCreateContainer("transform")
            ) {
                val session by sessionFlow.collectAsState()
                if (session.phase == FocusPhase.Idle)
                    return@transform FocusSummary.Idle
                val tasks by taskRepository.tasksFlow
                    .collectAsState(emptyList())
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
    }
  • La seule chose qui a changé, c'est qu'il se trouve désormais dans le champ d'application.

  • Ensuite, nous avons le ViewModel du mini-minuteur.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: StateFlow<MiniTimerUiState> =
            combine(
                focusManager.summary,
                isExpanded
            ) { summary, expanded ->
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = MiniTimerUiState()
    )
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Imaginons que nous voulions nous lancer un petit défi ici aussi.

  • Imaginons que dans le mini-minuteur, en plus des deux flux de données que nous avions déjà, StateFlow et Room Flow, nous voulions également savoir si la barre du minuteur est développée ou non.

  • Pour cela, nous utilisons un MutableStateFlow.

  • Pour ce faire, nous allons devoir utiliser un autre opérateur "combine" pour suivre le flux récapitulatif et le isExpanded StateFlow.

  • et nous obtenons un deuxième état.

  • Avec Transform, nous n'avons plus besoin d'utiliser ces opérateurs enchaînés pour synchroniser les flux de données.

    class MiniTimerViewModel(
        focusManager: FocusSessionManager
    ) : ViewModel() {
        val isExpanded = MutableStateFlow(false)
    
        val uiState: State<MiniTimerUiState> =
            transform(
                initialValue = MiniTimerUiState(),
                scope = viewModelScope,
                       container = handle.getOrCreateContainer("transform")
            ) {
                val summary by focusManager.summary
                       val expanded by isExpanded.collectAsState()
                MiniTimerUiState(
        summary = summary,
        showControls = expanded && summary is FocusSummary.Active
    )
            }
    
        fun toggleExpanded() { isExpanded.update { !it } }
    }
  • Ce que nous pouvons faire, et c'est vraiment génial, c'est imbriquer des transformations.

  • Ici, ViewModel lit le résumé, qui est le résultat de la transformation en amont, comme n'importe quel autre état, puis collecte isExpanded avec collectAsState.

  • Nous pouvons donc combiner un état en amont et un flux en aval dans le StateFlow au sein du même bloc.

  • Et n'oubliez pas la précision.

  • Ainsi, si vous activez isExpanded, seul ce bloc sera réexécuté.

  • Le bloc au niveau de l'application et le widget n'ont même pas besoin de le savoir.

  • Enfin, nous avons le widget.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Comme Glance est Compose, nous pouvons collecter le StateFlow avec collectAsState dans un provideContent comme n'importe quel autre composable.

  • Avec Transform, c'est encore plus simple.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Notre gestionnaire de sélection nous fournit un état Compose. Nous n'avons donc rien à convertir ici.

  • Enfin, la barre du mini-minuteur et le widget de l'écran d'accueil ne peuvent jamais être en désaccord, car ils lisent le même état.

  • De plus, toute modification apportée à cet état sera répercutée sur tous les consommateurs de cet état.

  • Qu'est-ce que cela signifie ?

  • Si vous appuyez sur "Pause" dans le widget, l'action passe par le gestionnaire pour accéder au flux de session, puis revient par le résumé, et les trois écrans sont mis à jour de manière synchronisée.

  • Maintenant que nous avons vu comment utiliser Transform, examinons-le plus en détail.

  • L'API Transform que nous avons vue précédemment est en fait une fonction d'assistance utilisée pour créer une transformation gérée de base.

    public fun <R> transform(
        initialValue: R,
        scope: CoroutineScope,
        onUpdate: @Composable (R) -> R,
    ): State<R> {
            val transformScope = TransformScope()
        val transform = transformScope
    .launchTransform(initialValue, onUpdate)
        scope.launch(
    dispatcher,
    start = CoroutineStart.UNDISPATCHED
     ) {
            try { transformScope.run() } finally {
                transform.dispose()
            }
        }
        return transform
    }
  • En réalité, il utilise une fonction d'extension TransformScope et launchTransform, qui lance une transformation composable dans ce TransformScope et renvoie un état jetable.

  • En le lisant de haut en bas, vous verrez qu'il fait tout pour vous.

  • Il crée le TransformScope et y lance votre transformation, qui sera l'état que vous récupérerez.

  • Il lance également l'exécution dans le TransformScope sur le dispatcher que vous avez fourni, de sorte que la durée de vie du champ d'application et le thread vous appartiennent.

  • Enfin, il supprime votre transformation lorsque le champ d'application se termine.

  • Voilà pour le cycle de vie de Transform.

  • Examinons rapidement l'interface TransformScope.

    public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
    
    public abstract class TransformScope {
            public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
    public abstract suspend fun run()
    }
    
    public fun <T> TransformScope.launchTransform(
    initialState: T,
    onUpdate: @Composable (T) -> T,
    ): DisposableState<T>
    
    public interface DisposableState<T> : State<T> {
    public fun dispose()
    }
  • Nous pouvons voir qu'il comporte une fonction createTransform, qui va créer une transformation associée à ce TransformScope.

  • Il dispose également d'une fonction d'exécution de suspension, qui démarre le TransformScope et lance le processus de mise à jour des transformations d'état actuelles.

  • Qu'est-ce que cela signifie ?

  • "run" configure donc le Recomposer et la composition.

  • Chaque transformation du champ d'application se trouve dans cette composition et possède son propre champ d'application de recomposition.

  • C'est pourquoi nous voulons partager le champ d'application et pourquoi nous avons TransformScope.

  • Ainsi, chaque fois qu'un état change, seules les transformations qui le lisent sont réexécutées.

  • Comme nous l'avons vu précédemment, "run" est une fonction de suspension.

    override suspend fun run() {
            val context = currentCoroutineContext()
            val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
            ...
            val recomposer = Recomposer(context)
            val composition = Composition(UnitApplier, recomposer)
            try {
                withContext(clock) {
                    if (clock is HeadlessTransformClock) {
                        launch(start = CoroutineStart.UNDISPATCHED) {
                            clock.runClock()
                        }
                    }
                    ...
                }
            } finally {
                // dispose of composition, cancel the clock, close the recomposer
            }
        }
  • Il s'exécute donc sur la coroutine à partir de laquelle vous l'appelez, jusqu'à ce que cette coroutine soit annulée.

  • Vous vous demandez peut-être comment un Recomposer sait quand il doit produire une frame. Il a besoin d'une horloge de frame.

  • Dans l'UI Compose, il s'agit de l'affichage, mais comme nous l'avons mentionné précédemment, nous sommes dans un environnement sans interface graphique, il n'y a donc pas d'UI.

  • Donc, à moins que vous ne nous fournissiez une horloge, nous allons créer la nôtre.

  • En dissociant la recomposition de l'horloge de l'image d'affichage, Transform exécute toutes ses transformations et dérivations d'état en dehors du thread UI.

  • Pour ce faire, nous avons créé un HeadlessTransformClock dédié.

  • Horloge de transformation (cadre).

  • Étant donné qu'un widget, un service ou une autre transformation en aval peut attendre une valeur d'une transformation, notre horloge est impatiente.

  • Cela signifie que dès qu'un état utilisé par une transformation change, une nouvelle image est immédiatement produite.

  • Il s'agit d'une petite machine d'état sans verrou. Par conséquent, demander une image est une simple opération de comparaison et de définition.

  • Et quand rien n'a changé dans l'état, l'horloge se met en pause.

  • Ainsi, une transformation qui consomme un état qui n'a pas été modifié ne vous coûte rien.

  • Avant de repasser la main à Ben, nous devons également noter que Transform est désormais compatible avec rememberSaveable pour garantir que vos données sont enregistrées lors des modifications de configuration.

  • Pour ce faire, elle utilise la nouvelle API SavedStateContainer, qui est désormais le composant de base sur lequel reposent SaveableStateRegistry, SavedStateHandle et SavedStateRegistry de Compose.

  • Vous pouvez transformer n'importe lequel de ces éléments en SavedStateContainer ou créer des conteneurs enfants et les transmettre à votre transformation pour vous assurer que vos données sont enregistrées.

  • Enfin, nous vous rappelons que toutes les API dont nous parlons aujourd'hui sont en version alpha et sont donc susceptibles d'être modifiées.

  • Je repasse maintenant la parole à Ben.

État Compose en dehors de l'UI

  • Très bien.

  • Merci, Elif.

  • Maintenant que nous avons vu à quel point l'état Compose est puissant et comment Transform nous permet de le dériver en dehors de l'UI, vous avez peut-être remarqué que, dans l'exemple jusqu'à présent, nos entrées étaient des flux que nous avons reliés à l'aide de collectAsState.

  • Mais est-ce toujours nécessaire ?

  • Pourquoi ne pas utiliser l'état Compose directement dans nos conteneurs d'état ?

  • Pour répondre à cette question, je trouve utile de classer l'état dans trois grandes catégories.

  • Le premier est l'état actif, qui n'est stocké qu'en mémoire.

  • Par exemple, l'état de la connexion réseau ou Bluetooth.

  • Vous ne voudriez pas utiliser une valeur persistante.

  • Le second est un état temporaire qui doit survivre à l'arrêt du processus, mais qui n'a pas besoin d'être stocké indéfiniment.

  • Il peut s'agir de l'état de l'interface utilisateur (par exemple, si une fiche est développée ou non) et de l'état de la session (par exemple, la pile de navigation ou un brouillon de formulaire).

  • Enfin, le troisième est l'état permanent, qui est stocké indéfiniment.

  • Par exemple, les préférences utilisateur ou une bibliothèque multimédia stockée dans une base de données.

  • Commençons par voir comment utiliser l'état Compose pour les états actifs et transitoires.

  • Jusqu'à présent, nos conseils d'architecture consistaient à conserver l'état Compose dans la couche d'interface utilisateur ou, parfois, dans les ViewModels, et nous vous recommandions d'utiliser Flow partout ailleurs.

  • Mais pourquoi ?

  • En effet, dès que vous avez besoin de combiner une propriété d'état avec un flux, vous devez la reconvertir à l'aide de snapshotFlow.

  • Mais avec Transform, cette conversion n'est pas nécessaire.

  • Voyons ce que cela signifie, en commençant par un ViewModel.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • Pour les entrées d'écran locales, comme selectedTaskIds ici, vous pouvez déclarer une propriété mutableStateOf simple au lieu de MutableStateFlow.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • Comme il s'agit d'un élément que vous ne souhaitez pas nécessairement conserver après l'arrêt du processus, vous pouvez utiliser mutableStateOf.

  • Pour les éléments que vous souhaitez conserver après l'arrêt du processus, comme la requête de recherche, vous pouvez sauvegarder son état modifiable directement avec savedStateHandle.saveable.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • Ensuite, tout comme updateScore dans l'exemple de jeu précédent, lorsqu'une méthode met à jour plusieurs valeurs d'état à la fois, l'encapsulation des écritures dans withMutableSnapshot garantit la cohérence.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    
        fun updateSearchQuery(query: String) {
            Snapshot.withMutableSnapshot {
                searchQuery = query
                selectedTaskIds = emptySet()
            }
        }
    }
  • Ensuite, pour combiner searchQuery avec un flux Room, il vous suffit de le lire directement dans la transformation, tout en collectant le flux.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • Qu'en est-il de l'état en direct, comme la connectivité réseau ?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • Il se trouve généralement dans un Singleton, car il est lu sur plusieurs écrans ou même par plusieurs composants d'application, comme des services.

  • Cela ne signifie pas pour autant que vous ne pouvez pas utiliser l'état Compose pour le stocker.

  • En fait, elle est souvent plus simple qu'une version basée sur un flux.

  • Vous n'avez pas besoin d'un flux de rappel ni de quoi que ce soit d'autre pour effectuer l'adaptation. Il vous suffit de mettre à jour la valeur du point directement dans les rappels.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • Si un consommateur a toujours besoin d'un flux, vous pouvez utiliser snapshotFlow pour revenir en arrière.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    
        private val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) { _isOnline.value = true }
            override fun onLost(network: Network) { _isOnline.value = false }
        }
    
        init {
            connectivityManager.registerDefaultNetworkCallback(callback)
        }
    }
  • Voyons ce que cela donne dans ViewModel.

  • Vous pouvez lire isOnline directement dans un Transform, et il se recomposera lorsque la valeur changera.

    val tasksState: State<TaskListState> = transform(
        initialValue = TaskListState.Loading,
        scope = viewModelScope,
    ) {
        if (!networkMonitor.isOnline.value) {
            return@transform TaskListState.Offline
        }
    
        val tasks = remember(searchQuery) {
            tasksRepository.getTasks(searchQuery)
        }.collectAsState(initial = null).value
            ?: return@transform TaskListState.Loading
    
        TaskListState.Success(tasks = tasks)
    }
  • Et tout cela se produit directement. Vous n'avez pas besoin de fournir de champ d'application ni de champ d'application de coroutine pour mettre à jour l'état. Il s'agit simplement d'une mise à jour synchrone directe.

  • Ainsi, avec Transform, votre ViewModel peut combiner l'état des gestionnaires Singleton, l'état dans un ViewModel et les flux des dépôts, le tout dans un seul bloc Transform.

  • Revenons maintenant à l'état transitoire.

  • Plus tôt, j'ai mentionné deux types d'états : l'état de l'UI et l'état de la session.

  • Pour l'état de l'UI, et même l'état de session qui se trouve dans une seule activité, ViewModel et SavedState fonctionnent très bien.

  • C'est précisément pour cela qu'ils ont été conçus.

  • Mais les choses se compliquent lorsque cet état de session doit être partagé entre plusieurs interfaces utilisateur et composants d'application.

  • Une fois que cet état sort d'un ViewModel, nos API et conseils existants laissent un vide.

  • Tout d'abord, les API SavedState enregistrent les données dans le bundle d'une activité, mais celui-ci n'est pas disponible dans les autres composants de l'application, comme un service.

  • Deuxièmement, mutableStateOf dans un Singleton, qui ne réside qu'en mémoire et ne survit donc pas à l'arrêt du processus.

Présentation de StateStore

  • C'est là qu'intervient Jetpack AppState, StateStore.

  • Il s'agit d'un mappage avec sûreté du typage de l'état Compose qui peut être partagé entre les composants de l'application et sauvegardé par DataStore pour survivre à l'arrêt du processus.

  • Vous pouvez intégrer vous-même DataStore dans chaque Singleton, mais cela signifie que vous devez écrire du code récurrent d'enregistrement et de restauration pour chaque propriété.

  • Avec l'intégration StateStore DataStore, vous associez DataStore une fois, puis vous activez chaque élément d'état que vous souhaitez conserver.

  • Nous allons donc l'intégrer à notre application de minuteur de concentration.

  • Nous allons commencer par la mémoire, puis nous ajouterons la persistance DataStore.

  • Pour planter le décor, nous modélisons un FocusSession en tant qu'interface scellée avec des états inactif, en cours d'exécution et en pause.

    sealed interface FocusSession {
        data object Idle : FocusSession
        data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
  • Ensuite, pour stocker cet état dans StateStore, nous avons défini un StateStoreKey avec son type, FocusSession, et une valeur par défaut, idle.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • Ensuite, nous fournissons un singleton StateStore dans Hilt que nous pouvons injecter dans différents composants où nous en avons besoin, comme Focus Session Manager.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • De la même manière que ViewModel injecte SavedStateHandle.

  • OK.

  • Nous avons maintenant un StateStore, mais comment l'utiliser ?

  • Heureusement, il s'agit d'une petite API.

  • Il ne comporte que trois méthodes, que nous allons examiner une par une.

  • La première est setState, qui écrit une nouvelle valeur pour une clé.

    fun startFocus(taskId: String, durationMinutes: Int = 25) {
        val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
        stateStore.setState(
            FocusSessionKey,
            FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
        )
    }
    
    fun endFocus() {
        stateStore.setState(FocusSessionKey, FocusSession.Idle)
    }
  • Lorsqu'une session démarre, nous la définissons sur "running" (en cours d'exécution), et lorsqu'elle se termine, nous la définissons sur "idle" (inactive).

  • Et voilà !

  • Ensuite, nous avons toujours updateState, qui vous permet de calculer une nouvelle valeur en fonction de la valeur actuelle, par exemple pour calculer le temps restant lors du passage de l'état "en cours d'exécution" à l'état "en pause".

    fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Running) return@updateState session
            FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
        }
    }
    
    fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
        stateStore.updateState(FocusSessionKey) { session ->
            if (session !is FocusSession.Paused) return@updateState session
            FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
        }
    }
  • Enfin, getState renvoie un état en lecture seule pour une clé, avec la possibilité de remplacer la valeur par défaut si vous souhaitez la modifier au niveau d'un site d'appel spécifique.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • De plus, l'exposition de l'état FocusSession dans ce gestionnaire signifie que les consommateurs n'ont jamais besoin d'accéder directement à StateStore ou à ses clés.

  • Vous pouvez maintenir l'encapsulation en lecture et en écriture.

  • Ensuite, au lieu des consommateurs, ils injectent simplement Focus Session Manager, qu'ils ont probablement déjà, car ils doivent contrôler la session, puis ils peuvent lire l'état.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • Ensuite, ces lecteurs peuvent soit réexposer directement l'état, soit utiliser Transform pour créer un nouvel état mis à jour, comme nous l'avons vu précédemment avec l'exemple de minuteur ViewModel.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • Les composables d'UI peuvent ensuite lire cet état comme n'importe quel autre état Compose et se recomposer si nécessaire.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
    
    @Composable
    fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
        val session by viewModel.session
        if (session !is FocusSession.Idle) {
            TimerReadout(session = session)
        }
    }
  • Mais jusqu'à présent, le FocusSession n'existe que dans la mémoire.

  • Voyons ce qu'il faut faire pour survivre à l'arrêt du processus.

  • Nous devons d'abord effectuer une configuration unique en lançant syncToDataStore lorsque nous créons le magasin.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(
            @ApplicationContext context: Context,
            @ApplicationScope scope: CoroutineScope,
        ): StateStore = StateStore().apply {
            val path = context.dataStoreFile("app_state.json").absolutePath
            scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
        }
    }
  • Ensuite, nous activons chaque clé que nous voulons conserver.

    @Serializable
    sealed interface FocusSession {
        @Serializable data object Idle : FocusSession
        @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
        @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
    }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
        PersistToDataStore
  • Il nous suffit de nous assurer que le type est sérialisable et que la clé implémente l'interface PersistToDataStore.

  • Vous vous dites peut-être que ça a l'air facile.

  • Alors, comment fait-il pour conserver la clé ?

  • En arrière-plan, syncToDataStore utilise l'API d'écouteur de Transform, qui est fondamentalement identique à Transform, sauf qu'elle ne renvoie rien et n'est utilisée que pour l'observation et les effets secondaires.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • Ensuite, dans cet écouteur, il lit chaque clé du magasin et vérifie si l'une d'elles est une clé PersistToDataStore.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener {
            for (key in keys) {
                if (key is PersistToDataStore) {
                    val value = getState(key).value
    
                    LaunchedEffect(key) {
                        // Restore saved value from dataStore on first access
                    }
    
                    LaunchedEffect(key, value) {
                        // Flush updated value to dataStore on change
                    }
                }
            }
        }
    }
  • Dans ce cas, il utilise LaunchedEffect pour synchroniser ces valeurs avec DataStore chaque fois qu'elles changent.

  • Ce que je veux souligner ici, c'est que le fait d'avoir un conteneur standard comme StateStore nous permet de fournir ces intégrations prêtes à l'emploi.

  • DataStore n'est que le premier que nous examinons.

Résumé des catégories d'état de Compose

  • Si nous revenons à nos catégories d'état, nous avons maintenant une idée assez claire de la façon dont vous pouvez utiliser l'état Compose dans l'ensemble de votre application.

  • mutableStateOf pour l'état actif, les API SavedState pour l'état de présentation local de l'écran, StateStore pour l'état de session entre composants et enfin collectAsState pour l'interopérabilité avec les flux.

  • Mais en réalité, l'interopérabilité n'est pas limitée à une seule catégorie. Vous pouvez simplement utiliser ces assistants comme collectAsState chaque fois que vous devez relier des types observables à l'état Compose.

  • Mais pour l'état permanent, nous cherchons également des moyens de travailler avec Compose ou de faire en sorte que DataStore et Room exposent directement l'état Compose, afin que vous n'ayez pas à écrire votre propre syntaxe plus agréable pour ce faire.

  • Je suis donc ravi d'annoncer que Transform, StateStore et l'intégration de DataStore sont tous disponibles en version alpha dès aujourd'hui. Nous avons également publié toute la documentation sur developer.android.com, et nous avons un dépôt AppState Recipes sur GitHub qui montre des exemples d'utilisation de StateStore et de Transform.

    [versions]
    appstate = "1.0.0-alpha01"
    ...
    
    [libraries]
    ...
    transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
    transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
    statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
    statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }
  • Et n'hésitez pas à les essayer dans votre application.

  • Envoyez-nous vos commentaires.

  • Comme nous sommes en phase alpha, nous pouvons vraiment itérer et apporter des modifications pour nous assurer qu'elles fonctionnent pour vos cas d'utilisation.

Code

Extraits de code

Extrait de code Kotlin à 01:56 dans "Qu'est-ce que l'état Compose ?"
/**
 * A value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value.
 */
@Stable
public interface State<out T> {
    public val value: T
}
Extrait Kotlin à 02:00 dans "Qu'est-ce que l'état Compose ?"
/**
 * A mutable value holder where reads to the [value] property during the execution of a [Composable]
 * function, the current [RecomposeScope] will be subscribed to changes of that value. When the
 * [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
 * will be scheduled. If [value] is written to with the same value, no recompositions will be
 * scheduled.
 */
@Stable
public interface MutableState<T> : State<T> {
    override var value: T
    public operator fun component1(): T
    public operator fun component2(): (T) -> Unit
}
Extrait de code Kotlin à 02:23 dans "Qu'est-ce que l'état Compose ?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
Extrait de code Kotlin à 02:32 dans "Qu'est-ce que l'état Compose ?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
Extrait de code Kotlin à 02:52 dans "Qu'est-ce que l'état Compose ?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}

@Composable
fun AddButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Add")
    }
}
Extrait de code Kotlin à 03:08 dans "Qu'est-ce que l'état Compose ?"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(true) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Extrait de code Kotlin à 03:26 dans "Qu'est-ce que l'état Compose ?"
@Composable
fun Counter() {
    val isVisible = remember { mutableStateOf(false) }
    val count = remember { mutableStateOf(0) }
    Column {
        if (isVisible.value) {
            Text("Count: ${count.value}")
        } else {
            Text("Hidden")
        }
        AddButton(onClick = { count.value++ })
    }
}
Extrait de code Kotlin à 04:15 dans "Qu'est-ce que l'état Compose ?"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
Extrait de code Kotlin à 04:23 dans "Qu'est-ce que l'état Compose ?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
Extrait de code Kotlin à 04:33 dans "Qu'est-ce que l'état Compose ?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}
Extrait de code Kotlin à 04:42 dans "Qu'est-ce que l'état Compose ?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)

class GameViewModel : ViewModel() {
    val uiState = MutableStateFlow(GameUiState())
    fun updateScore() = uiState.update {
        it.copy(
            timeSurvived = it.timeSurvived + 1,
            score = it.score + 10 + .01 * it.timeSurvived
        )
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Column {
        Header(levelName = state.levelName)
        ScoreBadge(
            timeSurvived = state.timeSurvived,
            score = state.score
        )
    }
}
Extrait de code Kotlin à 05:09 dans "Qu'est-ce que l'état Compose ?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
Extrait de code Kotlin à 05:21 dans "Qu'est-ce que l'état Compose ?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}
Extrait de code Kotlin à 05:30 dans "Qu'est-ce que l'état Compose ?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}

class GameViewModel : ViewModel() {
    val uiState = GameUiState()
    fun updateScore() = Snapshot.withMutableSnapshot {
        uiState.timeSurvived += 1
        uiState.score += 10 + .01 * uiState.timeSurvived
    }
}

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state = viewModel.uiState
    Column {
        Header(state = state)
        ScoreBadge(state = state)
    }
}
Extrait Kotlin à 07:35 dans "Présentation de Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
Extrait Kotlin à 09:20 dans "Présentation de Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: StateFlow<FocusSummary> =
        combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find { it.id == session.activeTaskId }
                FocusSummary.Active(...)
            }
        }.stateIn(
                scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FocusSummary.Idle,
)
}
Extrait Kotlin à 09:37 dans "Présentation de Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find { it.id == session.activeTaskId }
            FocusSummary.Active(...)
Extrait Kotlin à 10:01 dans "Présentation de Transform"
class FocusScreenViewModel(
    focusManager: FocusSessionManager,
    taskRepository: TaskRepository
) : ViewModel() {
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
            initialValue = FocusSummary.Idle,
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val session by focusManager.sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
                     FocusSummary.Active(...)
        }
}
Extrait de code Kotlin à 11:39 dans "Nested transforms"
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
    ...
}

class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
    ...
}

class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            ...
        }
    }
}
Extrait de code Kotlin à 12:03 dans "Transformations imbriquées"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: StateFlow<FocusSummary> =
        combine(
            sessionFlow,
            taskRepository.tasksFlow
        ) { session, tasks ->
            if (session.phase == FocusPhase.Idle) {
                FocusSummary.Idle
            } else {
                val task = tasks.find {
                    it.id == session.activeTaskId
                }
                FocusSummary.Active(...)
            }
        }.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}
Extrait Kotlin à 12:17 dans "Transformations imbriquées"
class FocusSessionManager(
    taskRepository: TaskRepository,
    scope: CoroutineScope
) {
    val sessionFlow: StateFlow<FocusSession> = ...
    val summary: State<FocusSummary> =
        transform<FocusSummary>(
           initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
        ) {
            val session by sessionFlow.collectAsState()
            if (session.phase == FocusPhase.Idle)
                return@transform FocusSummary.Idle
            val tasks by taskRepository.tasksFlow
                .collectAsState(emptyList())
            val task = tasks.find {
                it.id == session.activeTaskId
            }
            FocusSummary.Active(...)
        }
}
Extrait Kotlin à 12:28 dans "Nested transforms"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: StateFlow<MiniTimerUiState> =
        combine(
            focusManager.summary,
            isExpanded
        ) { summary, expanded ->
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = MiniTimerUiState()
)

    fun toggleExpanded() { isExpanded.update { !it } }
}
Extrait de code Kotlin à 12:56 dans "Transformations imbriquées"
class MiniTimerViewModel(
    focusManager: FocusSessionManager
) : ViewModel() {
    val isExpanded = MutableStateFlow(false)

    val uiState: State<MiniTimerUiState> =
        transform(
            initialValue = MiniTimerUiState(),
            scope = viewModelScope,
                   container = handle.getOrCreateContainer("transform")
        ) {
            val summary by focusManager.summary
                   val expanded by isExpanded.collectAsState()
            MiniTimerUiState(
    summary = summary,
    showControls = expanded && summary is FocusSummary.Active
)
        }

    fun toggleExpanded() { isExpanded.update { !it } }
}
Extrait Kotlin à 13:37 dans "Transformations imbriquées"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Extrait de code Kotlin à 13:47 dans "Transformations imbriquées"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Extrait Kotlin à 14:33 dans "Transformations imbriquées"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R> {
        val transformScope = TransformScope()
    val transform = transformScope
.launchTransform(initialValue, onUpdate)
    scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
 ) {
        try { transformScope.run() } finally {
            transform.dispose()
        }
    }
    return transform
}
Extrait Kotlin à 15:18 dans "Nested transforms"
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope

public abstract class TransformScope {
        public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}

public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>

public interface DisposableState<T> : State<T> {
public fun dispose()
}
Extrait Kotlin à 16:02 dans "Nested transforms"
override suspend fun run() {
        val context = currentCoroutineContext()
        val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
        ...
        val recomposer = Recomposer(context)
        val composition = Composition(UnitApplier, recomposer)
        try {
            withContext(clock) {
                if (clock is HeadlessTransformClock) {
                    launch(start = CoroutineStart.UNDISPATCHED) {
                        clock.runClock()
                    }
                }
                ...
            }
        } finally {
            // dispose of composition, cancel the clock, close the recomposer
        }
    }
Extrait de code Kotlin à 19:12 dans "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
Extrait Kotlin à 19:17 dans "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
Extrait de code Kotlin à 19:33 dans "État de Compose en dehors de l'UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set
}
Extrait Kotlin à 19:41 dans "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set

    var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun updateSearchQuery(query: String) {
        Snapshot.withMutableSnapshot {
            searchQuery = query
            selectedTaskIds = emptySet()
        }
    }
}
Extrait de code Kotlin à 19:52 dans "Compose State outside of the UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Extrait Kotlin à 20:00 dans "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
Extrait de code Kotlin à 20:17 dans "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
Extrait de code Kotlin à 20:23 dans "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline

    private val callback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) { _isOnline.value = true }
        override fun onLost(network: Network) { _isOnline.value = false }
    }

    init {
        connectivityManager.registerDefaultNetworkCallback(callback)
    }
}
Extrait de code Kotlin à 20:32 dans "Compose State outside of the UI"
val tasksState: State<TaskListState> = transform(
    initialValue = TaskListState.Loading,
    scope = viewModelScope,
) {
    if (!networkMonitor.isOnline.value) {
        return@transform TaskListState.Offline
    }

    val tasks = remember(searchQuery) {
        tasksRepository.getTasks(searchQuery)
    }.collectAsState(initial = null).value
        ?: return@transform TaskListState.Loading

    TaskListState.Success(tasks = tasks)
}
Extrait Kotlin à 22:07 dans "Présentation de StateStore"
sealed interface FocusSession {
    data object Idle : FocusSession
    data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
Extrait de code Kotlin à 22:14 dans "Présentation de StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
Extrait Kotlin à 22:23 dans "Présentation de StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
Extrait Kotlin à 22:42 dans "Présentation de StateStore"
fun startFocus(taskId: String, durationMinutes: Int = 25) {
    val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
    stateStore.setState(
        FocusSessionKey,
        FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
    )
}

fun endFocus() {
    stateStore.setState(FocusSessionKey, FocusSession.Idle)
}
Extrait Kotlin à 22:52 dans "Présentation de StateStore"
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Running) return@updateState session
        FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
    }
}

fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
    stateStore.updateState(FocusSessionKey) { session ->
        if (session !is FocusSession.Paused) return@updateState session
        FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
    }
}
Extrait Kotlin à 23:03 dans "Présentation de StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
Extrait Kotlin à 23:22 dans "Présentation de StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
Extrait Kotlin à 23:33 dans "Présentation de StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
Extrait Kotlin à 23:45 dans "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}

@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
    val session by viewModel.session
    if (session !is FocusSession.Idle) {
        TimerReadout(session = session)
    }
}
Extrait Kotlin à 23:58 dans "Présentation de StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(
        @ApplicationContext context: Context,
        @ApplicationScope scope: CoroutineScope,
    ): StateStore = StateStore().apply {
        val path = context.dataStoreFile("app_state.json").absolutePath
        scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
    }
}
Extrait de code Kotlin à 24:04 dans "Présentation de StateStore"
@Serializable
sealed interface FocusSession {
    @Serializable data object Idle : FocusSession
    @Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
    @Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
    PersistToDataStore
Extrait Kotlin à 24:22 dans "Présentation de StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
Extrait de code Kotlin à 24:33 dans "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener {
        for (key in keys) {
            if (key is PersistToDataStore) {
                val value = getState(key).value

                LaunchedEffect(key) {
                    // Restore saved value from dataStore on first access
                }

                LaunchedEffect(key, value) {
                    // Flush updated value to dataStore on change
                }
            }
        }
    }
}
Extrait TOML à 25:39 dans "Résumé des catégories d'état Compose"
[versions]
appstate = "1.0.0-alpha01"
...

[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }