Nozioni di base sulle app

Porta lo stato di Compose oltre la UI con Jetpack AppState

Altoparlanti

  • Ben Sagmoe

    Developer Relations Engineer

  • Elif Bilgin Morris

    Software Engineer

Informazioni

Informazioni sulla presentazione

Il runtime di snapshot, il monitoraggio automatico delle dipendenze e gli aggiornamenti atomici di Compose non devono fermarsi al livello UI. Jetpack AppState ti consente di spostare lo stato di Compose a monte, semplificando ciò che raggiunge le schermate tramite il sollevamento e la derivazione dello stato con il sistema di snapshot prima che raggiunga la UI.

In questa sessione scoprirai le due librerie fondamentali di AppState. Esamineremo StateStore per l'hoisting dello stato e Transform per l'utilizzo della composizione per derivare lo stato con il semplice flusso di controllo di Kotlin, eliminando le complesse catene di combinazione di Flow e mantenendo la UI sincronizzata senza sforzo.

Riepilogo

  • Compose Runtime funziona come un motore di gestione dello stato per uso generico senza l'interfaccia utente di Compose. Il suo sistema di snapshot monitora le letture in modo dinamico e mantiene coerenti gli aggiornamenti simultanei tra le proprietà.
  • Jetpack AppState Transform calcola ed espone lo stato di Compose da blocchi componibili headless. Sostituisce le catene di operatori Flow complesse come combine, supportando al contempo i ritorni anticipati e le derivazioni di stato nidificate.
  • Transform utilizza un clock di frame headless eager per eseguire le trasformazioni al di fuori del thread dell'interfaccia utente. Il clock si blocca ogni volta che gli stati controllati rimangono invariati, evitando di sprecare lavoro.
  • Jetpack AppState StateStore condivide lo stato di Compose tra componenti distinti come attività e servizi in background. L'integrazione con DataStore consente alle singole chiavi di sopravvivere all'interruzione del processo senza boilerplate personalizzato.

Risorse

Gemini Notebook

Argomenti

  • jetpack-compose
  • jetpack-appstate
  • gestione dello stato
  • android-architecture
  • compose-runtime

Trascrizione

Scarica la trascrizione

Introduzione

  • Ciao e ti diamo il benvenuto a "Porta lo stato di Compose oltre l'UI con Jetpack AppState".

  • Mi chiamo Ben e sono un Developer Relations Engineer che lavora alle librerie di architettura Jetpack.

  • Sono Elif e sono una degli ingegneri che lavorano alle librerie di architettura delle app.

  • Oggi siamo qui per parlarvi di un futuro in cui Compose va oltre la UI e si estende ai livelli di dati e dominio delle applicazioni.

  • Come ormai saprete, Compose è il toolkit consigliato per lo sviluppo della UI su Android.

  • Dai telefoni e tablet ai widget, agli smartwatch, alle TV e ai dispositivi XR, Compose è alla base di tutto.

  • Tuttavia, man mano che la crescita nel settore app si estende su queste piattaforme UI e componenti in background, la gestione dello stato diventa più difficile, in particolare per quanto riguarda due pain point comuni.

  • La prima è la derivazione di uno stato complesso.

  • La combinazione di più stream di dati, input della UI locale e stato ambientale, come la connettività di rete, spesso si trasforma in un groviglio di operatori come combine, flatMapLatest e distinctUntilChanged.

  • Il secondo è la condivisione dello stato tra i componenti: mantenere sincronizzati un'attività, un servizio, un widget dell'app e altro ancora e assicurarsi che lo stato possa sopravvivere all'interruzione del processo in background, se necessario.

  • Quando abbiamo fatto un passo indietro per esaminare queste sfide, abbiamo iniziato a osservare come è stato creato Compose.

  • La maggior parte delle persone pensa a Compose come a un toolkit per l'interfaccia utente, ma in realtà è composto da due livelli distinti: Compose UI in alto e Compose Runtime in basso.

  • Mentre l'interfaccia utente di Compose gestisce il layout, il disegno e l'input, il runtime di Compose è un motore per uso generico per la gestione dello stato e la ricomposizione.

  • Non ha alcuna dipendenza dalla UI.

  • Questa consapevolezza ci ha portato a creare Jetpack AppState.

  • Si tratta di una famiglia di librerie incentrate su come sfruttare al meglio lo stato di Compose oltre la UI.

Che cos'è lo stato di Compose?

  • Ma prima di approfondire AppState, assicuriamoci di essere sulla stessa lunghezza d'onda quando parliamo di stato di Compose.

  • La risposta è 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
    }
  • E l'interfaccia che probabilmente conosci meglio, 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
    }
  • In sostanza, si tratta di container di base.

  • Ciò che li fa funzionare è il sistema di snapshot di Compose.

  • Quindi, quando parliamo di stato di Compose, in realtà intendiamo lo stato di Snapshot.

  • Quindi, quando chiami mutableStateOf, crei un container che indirizza le letture e le scritture attraverso il sistema di snapshot.

  • Inoltre, ogni volta che il codice viene eseguito all'interno di un osservatore di istantanee, come una composizione, ottieni due proprietà che uno stream come un Flow non ti offre.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
    }
  • La prima è il monitoraggio automatico e dinamico della lettura.

    @Composable
    fun Counter() {
        val count = remember { mutableStateOf(0) }
        Column {
            Text("Count: ${count.value}")
            AddButton(onClick = { count.value++ })
        }
    }
  • Quindi, automatico significa che non è necessario iscriversi o raccogliere dati.

  • Just reading .value is the subscription.

  • Quindi, se guardiamo Counter qui, la lettura di count.value direttamente all'interno del composable segnala la lettura alla composizione e registra Counter nel suo elenco di lettori.

  • Ora guarda 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")
        }
    }
  • Scrive solo per conteggiare il clic.

  • Quindi non è un lettore.

  • Quando un utente fa clic su AddButton e modifica il conteggio, il sistema di istantanee monitora la scrittura, il che consente alla composizione di sapere che deve invalidare Counter, ma non AddButton.

  • "Dinamico" significa che il monitoraggio delle letture avviene in fase di runtime in base ai percorsi del codice effettivamente eseguiti.

    @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++ })
        }
    }
  • In questo caso, proteggiamo il testo del contatore con un flag isVisible.

  • Quando isVisible è true, vengono letti sia isVisible che count.

  • Pertanto, Counter è registrato come lettore di entrambi.

  • E quando isVisible è false, se il ramo if non viene eseguito, count non viene letto, quindi Counter viene registrato solo come lettore di isVisible, non di 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++ })
        }
    }
  • Quindi, quando AddButton scrive su count, non c'è più nulla da invalidare.

  • La seconda proprietà è che non devi scegliere tra coerenza e granularità.

  • La coerenza significa che quando aggiorni più valori in uno snapshot, queste modifiche rimangono isolate durante l'esecuzione del codice, anche se un altro thread sta scrivendo contemporaneamente.

  • Questi aggiornamenti diventano visibili tutti insieme al termine dello snapshot.

  • Granularità significa che le proprietà separate possono essere dichiarate e monitorate singolarmente, soprattutto quando non sono correlate.

  • Pertanto, l'aggiornamento di una proprietà invalida solo i lettori che dipendono effettivamente da questa.

  • Vediamo come funziona in un codice reale.

  • Ad esempio, esamineremo un gioco con tre elementi di stato: un nome di livello, il tempo di sopravvivenza e il punteggio.

    val levelName: String = "Level 1 - Stay Alive!"
    val timeSurvived: Int = 0
    val score: Double = 0.0
  • Per iniziare, esaminiamo un'implementazione di Flow che sceglie la coerenza a scapito della granularità raggruppando tutto in una classe GameUiState.

    data class GameUiState(
        val levelName: String = "Level 1 - Stay Alive!",
        val timeSurvived: Int = 0,
        val score: Double = 0.0
    )
  • Ciò significa che ogni volta che viene eseguito updateScore, copi l'intero GameUiState in modo che sia timeSurvived sia il punteggio possano essere aggiornati insieme in sincronia.

    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
            )
        }
    }
  • Nell'interfaccia utente, GameScreen raccoglie lo stato dell'interfaccia utente e lo legge per trasmettere le proprietà pertinenti a Header e 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
            )
        }
    }
  • Poiché GameScreen legge ogni stato, ogni chiamata a updateScore invalida GameScreen anche se i dati dell'intestazione non sono mai cambiati.

  • Ora potresti suddividere ogni proprietà nel proprio flusso per ottenere granularità, ma perderesti la coerenza perché un altro thread potrebbe aggiornare un valore mentre l'altro blocco di aggiornamento è in esecuzione.

  • Ora consideriamo un'implementazione alternativa basata sullo stato.

    @Stable
    class GameUiState {
        var levelName by mutableStateOf("Level 1 - Stay Alive!")
        var timeSurvived by mutableStateOf(0)
        var score by mutableStateOf(0.0)
    }
  • In questo caso, utilizziamo di nuovo un GameUiState.

  • In questo caso, però, è un po' diverso perché ogni proprietà delega a mutableStateOf.

  • In questo caso, updateScore racchiude le sue scritture in withMutableSnapshot, il che ci garantisce la coerenza tra le scritture.

    @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
        }
    }
  • Poi, nell'interfaccia utente, GameScreen passa lo stato direttamente a Header e 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)
        }
    }
  • Non legge nessuna delle proprietà, quindi quando viene eseguito updateScore, GameScreen non è un lettore.

  • Solo il componente effettivo, in questo caso ScoreBadge, viene invalidato.

  • Infine, tieni presente che nulla di tutto ciò è legato alla UI.

  • La UI è solo uno dei punti in cui queste proprietà si distinguono davvero.

  • Grazie per aver partecipato al nostro intervento.

  • Ok, scherzi a parte, lascio la parola a Elif, che ci illustrerà la prima libreria, Transform.

Introduzione a Transform

  • Ok.

  • Grazie, Ben.

  • Come ha appena osservato Ben, lo stato degli snapshot di Compose ci offre monitoraggio automatico e dinamico delle letture, coerenza e granularità, il che mi porta alla domanda: e se potessimo avere i nostri flussi di dati guidati anche da uno snapshot di stato di Compose, uno stato di snapshot?

  • E se lo stato è il nostro modello di dati, come facciamo a calcolare e trasformare uno stato per tutti i suoi consumer?

  • È qui che entra in gioco la nuova libreria Jetpack AppState Transform.

  • Prima di approfondire la libreria stessa, dobbiamo parlare di come è nata.

  • Quindi, l'idea di sfruttare il motore di Compose, ovvero il runtime e il compilatore, per produrre valori di stato in una composizione headless non è nuova.

  • Molti di voi potrebbero già utilizzare o conoscere la libreria Molecule.

  • In questo caso, per headless intendiamo che non esiste un'interfaccia utente.

  • Molecule è stato un pioniere nel dimostrare la potenza dell'utilizzo di Compose Runtime e lo ha fatto mostrandoci in azione che una funzione componibile non deve necessariamente emettere nodi UI e può essere effettivamente utilizzata per scrivere una funzione componibile che restituisce un modello di dati puro.

  • Nel caso di Molecule, si trattava di un flusso Kotlin o di un StateFlow.

  • Ciò significa che, invece di concatenare operatori reattivi come combine, puoi scrivere codice Kotlin ordinario.

  • Ma invece di gestire i flussi, cosa succederebbe se il tuo modello di dati fosse State, dato che la tua UI è già in Compose?

  • Con Transform, il tuo modello di dati è lo stato.

  • Diamo un'occhiata all'API effettiva.

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

  • È molto semplice, una sola funzione.

  • Restituisce uno stato osservabile semplice, non un Flow, non un StateFlow.

  • E chiunque lo utilizzi non sa o non si preoccupa che sia stato prodotto da una trasformazione.

  • La cosa davvero interessante qui è questo blocco.

  • È componibile.

  • Ciò significa che tutto ciò che già conosci funziona al suo interno.

  • Ricorda che LaunchedEffect, collectAsState, i ritorni anticipati, le istruzioni if semplici e tutto ciò che puoi leggere all'interno di un Composable possono essere derivati in una trasformazione.

  • Tieni presente che tutte le API che vediamo oggi in questa presentazione sono in versione alpha e sono soggette a modifiche, ma diamo comunque un'occhiata a un esempio.

  • Prendiamo ad esempio un'app per il timer di concentrazione molto semplice e di piccole dimensioni.

  • Scegli un'attività, imposti un timer di 25 minuti e il tempo scorre.

  • Ogni volta che crei una singola schermata, anche quando ne crei una semplice come questa, probabilmente dovrai combinare e gestire più stream di dati indipendenti per assemblare lo stato dietro le quinte.

  • Per la nostra schermata di concentrazione, diciamo che ne abbiamo due: la sessione di concentrazione stessa, che sarà la fase in cui ci troviamo, quindi in pausa o meno, quanti secondi rimangono, e tutto questo proverrà da un motore di timer come StateFlow che ticchetterà, diciamo, ogni secondo.

  • L'altro stream di dati sarà l'attività corrente su cui l'utente sta lavorando, che verrà recuperata da un database Room come Flow.

  • Quindi, due stream di dati e uno stato della schermata alla fine.

  • ViewModel è il punto in cui vengono uniti e il suo output, che chiameremo riepilogo, sarà il codice che scriveremo in seguito.

  • In genere, questo significa che nel tuo ViewModel dovrai combinare questi flussi utilizzando operatori concatenati.

    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,
    )
    }
  • Quindi, unisci i due flussi in un unico flusso, la ramificazione all'interno della funzione lambda, quindi l'istruzione if else proprio lì e infine uno stato alla fine.

  • Con Transform, possiamo eliminare l'intero codice boilerplate.

    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(...)
  • Questa chiamata sembra familiare perché abbiamo un valore iniziale in un ambito che viene passato, ma non c'è nulla a monte da concatenare, la derivazione è il blocco stesso.

  • E a differenza di stateIn, in cui probabilmente dovresti configurare un timeout WhileSubscribed per mettere in pausa e riprendere gli stream upstream, una trasformazione viene eseguita continuamente per tutta la durata di questo ambito.

  • Anche in questo caso, le nostre origini sono i flussi e i flussi sono benvenuti.

    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(...)
            }
    }
  • Collegheremo ciascuno con collectAsState e la sessione è un StateFlow, quindi non abbiamo bisogno di un valore iniziale, ma il flusso della stanza è freddo, quindi ne diamo uno.

  • E questo è tutto ciò che c'è da sapere sugli abbonamenti.

  • La lettura è ciò che ci iscrive.

  • Si tratta del monitoraggio automatico della lettura di cui Ben parlava prima nell'esempio che abbiamo visto, ma in un ViewModel.

  • Possiamo anche introdurre ritorni anticipati come questo quando siamo in uno stato di inattività.

  • Supponiamo di non avere nuove attività, non elaboreremo nemmeno il resto.

  • Anche in questo caso, il monitoraggio dinamico delle letture si rivela utile.

  • Durante l'inattività, il flusso della stanza non viene raccolto affatto.

  • Infine, emettiamo il riepilogo.

  • Quindi, se l'utente mette in pausa l'attività o il timer avanza, tutto viene aggiornato automaticamente.

Trasformazioni nidificate

  • Le cose si complicano un po' quando usciamo dall'esempio di un singolo ViewModel.

  • Supponiamo che questa stessa sessione debba essere visualizzata su altre due piattaforme: una barra del mini timer che si trova nella parte inferiore dello schermo dell'app su ogni schermata e un widget della schermata Home.

  • Entrambi avranno bisogno delle stesse informazioni derivate sul riepilogo della sessione, sulla fase, se è in pausa o meno, su quanto tempo rimane nel timer.

  • Ciò significa che se ogni singola piattaforma dovesse assemblare la propria copia, si avrebbe la stessa derivazione tre volte e in tre posizioni diverse che devono essere tutte in accordo tra loro.

  • Quindi, ciò che dovremo fare è ricavare il riepilogo una volta nell'ambito dell'applicazione e poi tutto ciò che si trova a valle lo utilizzerà.

  • Quindi, come facciamo a implementare una soluzione di questo tipo?

  • La nostra implementazione probabilmente avrà bisogno di tre parti.

    class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
        ...
    }
    
    class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
        ...
    }
    
    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                ...
            }
        }
    }
  • Quindi, un gestore di sessioni nell'ambito dell'applicazione, che sarà il punto in cui viene ricavato il riepilogo, un mini-timer ViewModel e un widget Riepilogo, entrambi i quali utilizzeranno il riepilogo ricavato in questo gestore di sessioni di concentrazione.

  • Creiamo ciascuna di queste funzionalità prima nel modo tradizionale e poi con Transform.

  • Quindi, tradizionalmente, inizieremmo ricavando il riepilogo una volta nell'ambito dell'applicazione come 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
    )
    }
  • Si tratta delle stesse chiamate combine e stateIn che abbiamo visto in precedenza nell'esempio ViewModel, ma spostate di un livello.

  • Con Transform, possiamo anche riutilizzare la stessa dichiarazione di ViewModel che abbiamo visto in precedenza.

    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(...)
            }
    }
  • L'unica cosa che è cambiata è che ora si trova nell'ambito dell'applicazione.

  • Poi abbiamo il ViewModel del mini timer.

    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 } }
    }
  • Supponiamo di voler aggiungere un po' di sfida anche qui.

  • Supponiamo che nel mini timer, oltre ai due stream di dati che avevamo già, StateFlow e Room Flow, vogliamo monitorare anche se la barra del timer è espansa o meno.

  • A questo scopo utilizziamo un MutableStateFlow.

  • Per farlo, dovremo utilizzare un altro combine per monitorare il flusso di riepilogo e isExpanded StateFlow.

  • E otteniamo un secondo stato.

  • Con Transform, non dobbiamo più utilizzare nessuno di questi operatori concatenati per ottenere la sincronizzazione tra i flussi di dati.

    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 } }
    }
  • Quello che possiamo fare, ed è davvero interessante, è eseguire trasformazioni nidificate.

  • In questo caso, ViewModel legge il riepilogo, che è l'output della trasformazione upstream, proprio come qualsiasi altro stato, e poi raccoglie isExpanded con collectAsState.

  • Quindi, possiamo combinare uno stato upstream e un flusso downstream in StateFlow nello stesso blocco.

  • E ricorda la granularità.

  • Quindi, se attivi/disattivi isExpanded, verrà eseguito nuovamente solo questo blocco.

  • Il blocco a livello di app e il widget non devono nemmeno accorgersene.

  • Infine, abbiamo il widget.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                    .collectAsState()
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Poiché Glance è Compose, possiamo raccogliere StateFlow con collectAsState all'interno di un provideContent come qualsiasi altro Composable.

  • Con Transform, questo è ancora più semplice.

    class FocusWidget : GlanceAppWidget() {
        override suspend fun provideGlance(
            context: Context,
            id: GlanceId
        ) {
            val focusManager = ...
            provideContent {
                val summary by focusManager.summary
                GlanceTheme { FocusWidgetContent(summary) }
            }
        }
    }
  • Il nostro gestore dello stato attivo ci fornisce uno stato di Compose, quindi non dobbiamo convertire nulla qui.

  • Infine, la barra del mini timer e il widget della schermata Home non possono mai essere in disaccordo perché leggono lo stesso stato.

  • Inoltre, qualsiasi modifica apportata a questo stato si rifletterà su tutti i consumer di questo stato.

  • Che cosa significa?

  • Quindi, se dovessi mettere in pausa il widget qui, l'azione passerebbe attraverso il gestore nello stream della sessione, tornerebbe indietro attraverso il riepilogo e tutte e tre queste schermate verrebbero aggiornate in sincronia.

  • Ora che abbiamo visto un po' come utilizzare Trasforma, diamo un Approfondimento al funzionamento.

  • L'API Transform che abbiamo visto in precedenza è in realtà una funzione helper utilizzata per creare una trasformazione gestita di 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
    }
  • In realtà, utilizza una funzione di estensione TransformScope e launchTransform, che avvia una trasformazione componibile in questo TransformScope e restituisce uno stato monouso.

  • Se lo leggi dall'alto verso il basso, vedrai che fa tutto per te.

  • Crea TransformScope e avvia la trasformazione, che sarà lo stato che riceverai.

  • Inoltre, avvia l'esecuzione in TransformScope sul dispatcher che hai fornito, in modo che la durata dell'ambito e il thread appartengano a te.

  • Infine, elimina la trasformazione quando l'ambito termina.

  • Questa è la storia dell'intero ciclo di vita di Transform.

  • Diamo un'occhiata molto rapida all'interfaccia di 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()
    }
  • Possiamo vedere che ha una funzione createTransform, che creerà una trasformazione associata a questo TransformScope.

  • Inoltre, ha una funzione di esecuzione in sospensione, che avvia TransformScope e il processo di aggiornamento delle trasformazioni dello stato attuale.

  • Che cosa significa?

  • Quindi, run configura effettivamente il Recomposer e la composizione.

  • Inoltre, ogni Transform nell'ambito si trova all'interno di quella composizione e ha il proprio ambito di ricomposizione.

  • Ecco perché vogliamo condividere l'ambito e perché abbiamo TransformScope.

  • In questo modo, ogni volta che uno stato cambia, vengono eseguite nuovamente solo le trasformazioni che lo leggono.

  • Come abbiamo visto in precedenza, run è una funzione di sospensione.

    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
            }
        }
  • Pertanto, viene eseguito su qualsiasi coroutine da cui lo chiami finché la coroutine non viene annullata.

  • Ora, potresti chiederti: un Recomposer ha bisogno di un frame clock per sapere quando deve produrre un frame.

  • In Compose UI, si tratta della visualizzazione, ma come accennato in precedenza, ci troviamo in un ambiente headless, quindi non esiste un'interfaccia utente.

  • Quindi, a meno che tu non ci fornisca un orologio, ne creeremo uno noi.

  • Scollegando la ricomposizione dal clock del frame di visualizzazione, Transform esegue tutte le sue trasformazioni e derivazioni di stato al di fuori del thread dell'interfaccia utente.

  • Per consentire ciò, abbiamo creato un HeadlessTransformClock dedicato.

  • Orologio con cornice trasformabile.

  • Poiché un widget, un servizio o un'altra trasformazione downstream potrebbe essere in attesa di un valore da una trasformazione, il nostro orologio è eager.

  • Ciò significa che nel momento in cui cambia uno stato utilizzato da un Transform, viene prodotto immediatamente un nuovo frame.

  • Si tratta di una piccola macchina a stati senza blocchi, quindi la richiesta di un frame è una semplice operazione di confronto e impostazione.

  • Quando non cambia nulla nello stato, il clock si ferma.

  • Pertanto, una trasformazione che utilizza uno stato che non è stato modificato non comporta alcun costo.

  • Prima di passare la parola a Ben, dobbiamo anche sottolineare che Transform ora supporta rememberSaveable per garantire che i dati vengano salvati durante le modifiche alla configurazione.

  • Lo fa tramite la nuova API SavedStateContainer, che ora è l'elemento di base su cui sono basati SaveableStateRegistry, SavedStateHandle e SavedStateRegistry di Compose.

  • Puoi trasformare uno di questi in un SavedStateContainer o creare container secondari e passarli alla trasformazione per assicurarti che i dati vengano salvati.

  • Infine, ti ricordo che tutte le API di cui parliamo oggi sono in versione alpha, quindi sono soggette a modifiche.

  • Passo la parola a Ben.

Componi lo stato al di fuori della UI

  • Okay.

  • Ok, grazie, Elif.

  • Ora che abbiamo visto quanto è potente lo stato di Compose e come Transform ci consente di derivarlo al di fuori della UI, una cosa che potresti aver notato nell'esempio finora è che i nostri input erano Flow che abbiamo collegato utilizzando collectAsState.

  • Ma è sempre necessario?

  • Perché non utilizzare lo stato di Compose direttamente nei nostri contenitori di stato?

  • Per rispondere a questa domanda, trovo utile pensare allo stato all'interno di tre grandi categorie.

  • Il primo è lo stato attivo, che viene memorizzato solo nella memoria.

  • Ad esempio, lo stato della connessione di rete o Bluetooth.

  • Non è consigliabile utilizzare un valore persistente.

  • Il secondo è lo stato temporaneo che dovrebbe sopravvivere all'interruzione del processo, ma non deve essere archiviato a tempo indeterminato.

  • Ad esempio, lo stato della UI, ad esempio se una scheda è espansa o meno, e lo stato della sessione, come il back stack di navigazione o una bozza di modulo.

  • Infine, il terzo è lo stato permanente, che viene archiviato a tempo indeterminato.

  • Pensa a elementi come le preferenze utente o una raccolta multimediale che risiede in un database.

  • Iniziamo con il modo in cui puoi utilizzare lo stato di Compose sia per lo stato attivo che per quello temporaneo.

  • Finora, le nostre indicazioni sull'architettura mantenevano lo stato di Compose nel livello UI o, a volte, nei ViewModel, e ti abbiamo detto di utilizzare Flow ovunque.

  • Ma perché?

  • Questo perché, non appena hai avuto bisogno di combinare una proprietà di stato con un Flow, hai dovuto riconvertirla utilizzando snapshotFlow.

  • Tuttavia, con Transform questa conversione non è necessaria.

  • Vediamo quindi cosa significa, a partire da un ViewModel.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(
        private val tasksRepository: TasksRepository,
        private val networkMonitor: NetworkMonitor,
        savedStateHandle: SavedStateHandle,
    ) : ViewModel() {
        ...
    }
  • Per gli input della schermata locale, come selectedTaskIds qui, puoi dichiarare la proprietà mutableStateOf semplice anziché MutableStateFlow.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    }
  • E poiché si tratta di un elemento che non vuoi necessariamente mantenere dopo l'interruzione del processo, va bene che non sia così e che utilizzi solo mutableStateOf.

  • Per gli elementi che vuoi che sopravvivano all'interruzione del processo, come la query di ricerca, puoi supportare direttamente il relativo stato modificabile con savedStateHandle.saveable.

    @HiltViewModel
    class TaskListViewModel @Inject constructor(...) : ViewModel() {
        var selectedTaskIds by mutableStateOf(emptySet<String>())
            private set
    
        var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
            private set
    }
  • Inoltre, proprio come updateScore nell'esempio di gioco precedente, quando un metodo aggiorna più valori di stato contemporaneamente, l'inserimento delle scritture in withMutableSnapshot garantisce la coerenza.

    @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()
            }
        }
    }
  • Poi, per combinare searchQuery con un flusso di stanze, basta leggerlo direttamente all'interno della trasformazione, mentre raccogli il flusso.

    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)
    }
  • E per quanto riguarda lo stato in tempo reale, come la connettività di rete?

    @Singleton
    class NetworkMonitor @Inject constructor(
        @ApplicationContext context: Context,
    ) {
        private val connectivityManager =
            context.getSystemService(ConnectivityManager::class.java)
        ...
    }
  • In genere, questo si trova in un Singleton perché viene letto in più schermate o anche da più componenti dell'app, come i servizi.

  • Tuttavia, ciò non significa che non puoi utilizzare Compose State per archiviarlo.

  • In effetti, spesso è più semplice di una versione basata sul flusso.

  • Non è necessario un flusso di callback o altro per eseguire l'adattamento, basta aggiornare il valore del punto direttamente nei callback.

    @Singleton
    class NetworkMonitor @Inject constructor(...) {
        ...
        private val _isOnline = mutableStateOf(checkCurrentConnectivity())
        val isOnline: State<Boolean> = _isOnline
    }
  • Inoltre, se un consumer ha ancora bisogno di un flusso, puoi utilizzare snapshotFlow per tornare indietro.

    @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)
        }
    }
  • Vediamo come appare nella ViewModel.

  • Puoi leggere isOnline direttamente in un Transform e verrà ricomposto quando il valore cambia.

    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)
    }
  • E tutto questo avviene direttamente, non c'è un ambito dell'applicazione o un ambito della coroutine che devi fornire per aggiornare lo stato, è solo un aggiornamento diretto e sincrono.

  • Quindi, con Transform, il tuo ViewModel può combinare lo stato dei gestori Singleton, lo stato in un ViewModel e i flussi dai repository, il tutto in un unico blocco Transform.

  • Ora torniamo allo stato transitorio.

  • In precedenza, ho menzionato due tipi: stato della UI e stato della sessione.

  • Per lo stato della UI e persino per lo stato della sessione che si trova all'interno di una singola attività, ViewModel e SavedState funzionano perfettamente.

  • È proprio per questo che sono stati progettati.

  • Tuttavia, le cose iniziano a complicarsi quando lo stato della sessione deve essere condiviso tra diverse UI e componenti dell'app.

  • Quando lo stato esce da un ViewModel, le nostre API e linee guida esistenti lasciano un po' di spazio.

  • Innanzitutto, le API SavedState salvano in un bundle di un'attività, ma questo non è disponibile in altri componenti dell'app, come un servizio.

  • In secondo luogo, mutableStateOf in un Singleton, che risiede solo in memoria, quindi non sopravvive all'interruzione del processo.

Introduzione a StateStore

  • Ed è proprio questa lacuna che Jetpack AppState, StateStore, colma.

  • Si tratta di una mappa con sicurezza dei tipi di stato di Compose che può essere condivisa tra i componenti dell'app e supportata da DataStore per sopravvivere all'interruzione del processo.

  • Potresti collegare DataStore a ogni Singleton manualmente, ma questo significa scrivere codice boilerplate di salvataggio e ripristino per ogni proprietà.

  • Con l'integrazione di StateStore DataStore, colleghi DataStore una volta e poi attivi ogni piccolo stato che vuoi mantenere.

  • Quindi, creiamo questa funzionalità nella nostra app Timer per la concentrazione.

  • Inizieremo con la modalità in memoria e poi aggiungeremo la persistenza di DataStore.

  • Per preparare il terreno, modelliamo un FocusSession come interfaccia sealed con gli stati idle, running e paused.

    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
    }
  • Poi, per archiviare questo stato in StateStore, abbiamo definito un StateStoreKey con il relativo tipo, FocusSession, e un valore predefinito, idle.

    sealed interface FocusSession { ... }
    
    object FocusSessionKey :
        StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
  • Poi, forniamo un Singleton StateStore in Hilt che possiamo inserire in diversi componenti dove ne abbiamo bisogno, come Focus Session Manager.

    @Module
    @InstallIn(SingletonComponent::class)
    object AppModule {
        @Provides
        @Singleton
        fun provideStateStore(): StateStore = StateStore()
    }
    
    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        ...
    }
  • In modo simile a come ViewModel inserisce SavedStateHandle.

  • Ok.

  • Ora abbiamo un StateStore, ma come lo utilizziamo?

  • Per fortuna, è un'API di piccole dimensioni.

  • Ha solo tre metodi, quindi li esamineremo uno per uno.

  • Il primo è setState, che scrive un nuovo valore per una chiave.

    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)
    }
  • Quando una sessione inizia, la impostiamo su In esecuzione e, quando termina, su Inattiva.

  • È tutto.

  • In secondo luogo, abbiamo ancora updateState, che consente di calcolare un nuovo valore in base al valore corrente, ad esempio calcolare il tempo rimanente quando si passa da un'attività in esecuzione a una in pausa.

    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)
        }
    }
  • Infine, c'è getState, che restituisce uno stato di sola lettura per una chiave e offre l'opzione di eseguire l'override del valore predefinito, se vuoi modificarlo in un sito di chiamata specifico.

    @Singleton
    class FocusSessionManager @Inject constructor(
        private val stateStore: StateStore,
    ) {
        val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
        ...
    }
  • Inoltre, l'esposizione dello stato FocusSession in questo gestore significa che i consumer non devono mai accedere direttamente a StateStore o alle relative chiavi.

  • Puoi mantenere l'incapsulamento di scrittura e lettura.

  • E poi, invece dei consumer, inseriscono semplicemente Focus Session Manager, che probabilmente hanno già perché devono controllare la sessione, e poi possono leggere lo stato.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() { ... }
    
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface WidgetEntryPoint {
        fun focusSessionManager(): FocusSessionManager
    }
  • Questi lettori possono riesporre direttamente lo stato o utilizzare Transform per creare un nuovo stato aggiornato, un po' come abbiamo visto prima con l'esempio del mini timer ViewModel.

    @HiltViewModel
    class MiniTimerViewModel @Inject constructor(
        focusSessionManager: FocusSessionManager,
    ) : ViewModel() {
        val session: State<FocusSession> = focusSessionManager.session
    }
  • In seguito, i composabili dell'interfaccia utente possono leggere lo stato come qualsiasi altro stato di Compose ed eseguire la ricomposizione quando necessario.

    @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)
        }
    }
  • Ma finora, FocusSession è sempre rimasto solo nella memoria.

  • Vediamo quindi cosa serve per sopravvivere all'interruzione del processo.

  • Per prima cosa, dobbiamo eseguire una configurazione una tantum avviando syncToDataStore quando creiamo lo store.

    @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) }
        }
    }
  • Dopodiché, attiviamo ogni chiave che vogliamo mantenere.

    @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
  • Tutto ciò che dobbiamo fare è assicurarci che il tipo sia serializzabile e che la chiave implementi l'interfaccia PersistToDataStore.

  • A questo punto, potresti pensare che sia facile.

  • Quindi, cosa fa effettivamente per rendere persistente la chiave?

  • Quindi, in background, syncToDataStore utilizza l'API listener di Transform, che è praticamente la stessa cosa di Transform, tranne per il fatto che non restituisce nulla, viene utilizzata solo per l'osservazione e gli effetti collaterali.

    suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
        listener { … }
    }
  • All'interno di questo listener, legge ogni singola chiave nello store e controlla se una è una chiave 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
                    }
                }
            }
        }
    }
  • In questo caso, utilizza LaunchedEffect per sincronizzare questi valori con DataStore ogni volta che cambiano.

  • Inoltre, voglio sottolineare che avere un container standard come StateStore è ciò che ci consente di fornire queste integrazioni drop-in.

  • DataStore è solo il primo che stiamo esaminando.

Riepilogo delle categorie di stato di Scrivi

  • Tornando alle nostre categorie di stato, ora abbiamo un quadro abbastanza chiaro di come puoi utilizzare Compose State nell'intera applicazione.

  • Da mutableStateOf per lo stato live, alle API SavedState per lo stato di presentazione locale dello schermo e StateStore per lo stato della sessione tra componenti e, infine, abbiamo collectAsState per l'interoperabilità con i flussi.

  • In realtà, l'interoperabilità non è limitata a una singola categoria, puoi semplicemente utilizzare questi helper come collectAsState ogni volta che devi collegare i tipi osservabili allo stato di Compose.

  • Ma per lo stato permanente, un'altra cosa che stiamo esaminando sono i modi per lavorare con Compose o per DataStore e Room per esporre direttamente Compose State, in modo da non dover scrivere la tua sintassi più bella per farlo.

  • E con questo, sono felice di annunciare che Transform, StateStore e l'integrazione di DataStore sono tutti disponibili in versione alpha oggi e che abbiamo anche pubblicato tutta la documentazione su developer.android.com, oltre ad avere un repository AppState Recipes su GitHub che mostra esempi di utilizzo sia di StateStore che di 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" }
  • E sì, provali nella tua app.

  • Inviaci un feedback.

  • Siamo in fase alpha, quindi è il momento in cui possiamo davvero eseguire l'iterazione e apportare modifiche che garantiranno il funzionamento per i tuoi casi d'uso.

Codice

Snippet di codice

Snippet di Kotlin al minuto 01:56 in "What is Compose 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
}
Snippet di Kotlin al minuto 02:00 in "What is Compose State?"
/**
 * 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
}
Snippet di Kotlin al minuto 02:23 in "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
}
Snippet di Kotlin al minuto 02:32 in "What is Compose State?"
@Composable
fun Counter() {
    val count = remember { mutableStateOf(0) }
    Column {
        Text("Count: ${count.value}")
        AddButton(onClick = { count.value++ })
    }
}
Snippet di Kotlin al minuto 02:52 in "What is Compose State?"
@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")
    }
}
Snippet Kotlin al minuto 03:08 in "What is Compose State?"
@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++ })
    }
}
Snippet di Kotlin al minuto 03:26 in "What is Compose State?"
@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++ })
    }
}
Snippet di Kotlin al minuto 04:15 in "What is Compose State?"
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0
Snippet di Kotlin al minuto 04:23 in "What is Compose State?"
data class GameUiState(
    val levelName: String = "Level 1 - Stay Alive!",
    val timeSurvived: Int = 0,
    val score: Double = 0.0
)
Snippet di Kotlin al minuto 04:33 in "What is Compose State?"
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
        )
    }
}
Snippet di Kotlin al minuto 04:42 in "What is Compose State?"
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
        )
    }
}
Snippet di Kotlin al minuto 05:09 in "What is Compose State?"
@Stable
class GameUiState {
    var levelName by mutableStateOf("Level 1 - Stay Alive!")
    var timeSurvived by mutableStateOf(0)
    var score by mutableStateOf(0.0)
}
Snippet di Kotlin al minuto 05:21 in "What is Compose State?"
@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
    }
}
Snippet di Kotlin al minuto 05:30 in "What is Compose State?"
@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)
    }
}
Snippet Kotlin al minuto 07:35 in "Introducing Transform"
public fun <R> transform(
    initialValue: R,
    scope: CoroutineScope,
    onUpdate: @Composable (R) -> R,
): State<R>
Snippet Kotlin al minuto 09:20 in "Introducing 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,
)
}
Snippet di Kotlin alle 09:37 in "Introducing 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(...)
Snippet di Kotlin al minuto 10:01 in "Introducing 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(...)
        }
}
Snippet di Kotlin al minuto 11:39 in "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 {
            ...
        }
    }
}
Snippet Kotlin alle 12:03 in "Nested transforms"
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
)
}
Snippet Kotlin al minuto 12:17 in "Nested transforms"
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(...)
        }
}
Snippet Kotlin al minuto 12:28 in "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 } }
}
Snippet Kotlin alle 12:56 in "Nested transforms"
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 } }
}
Snippet di Kotlin alle 13:37 in "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
                .collectAsState()
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Snippet di Kotlin al minuto 13:47 in "Nested transforms"
class FocusWidget : GlanceAppWidget() {
    override suspend fun provideGlance(
        context: Context,
        id: GlanceId
    ) {
        val focusManager = ...
        provideContent {
            val summary by focusManager.summary
            GlanceTheme { FocusWidgetContent(summary) }
        }
    }
}
Snippet di Kotlin al minuto 14:33 in "Nested transforms"
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
}
Snippet Kotlin alle 15:18 in "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()
}
Snippet di Kotlin al minuto 16:02 in "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
        }
    }
Snippet di Kotlin alle 19:12 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    private val networkMonitor: NetworkMonitor,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    ...
}
Snippet Kotlin al minuto 19:17 in "Compose State outside of the UI"
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
    var selectedTaskIds by mutableStateOf(emptySet<String>())
        private set
}
Snippet di Kotlin al minuto 19:33 in "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
}
Snippet Kotlin alle 19:41 in "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()
        }
    }
}
Snippet di Kotlin al minuto 19:52 in "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)
}
Snippet Kotlin alle 20:00 in "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(
    @ApplicationContext context: Context,
) {
    private val connectivityManager =
        context.getSystemService(ConnectivityManager::class.java)
    ...
}
Snippet di Kotlin al minuto 20:17 in "Compose State outside of the UI"
@Singleton
class NetworkMonitor @Inject constructor(...) {
    ...
    private val _isOnline = mutableStateOf(checkCurrentConnectivity())
    val isOnline: State<Boolean> = _isOnline
}
Snippet di Kotlin al minuto 20:23 in "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)
    }
}
Snippet di Kotlin al minuto 20:32 in "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)
}
Snippet di Kotlin al minuto 22:07 in "Introducing 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
}
Snippet Kotlin alle 22:14 in "Introducing StateStore"
sealed interface FocusSession { ... }

object FocusSessionKey :
    StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)
Snippet di Kotlin al minuto 22:23 in "Introducing StateStore"
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideStateStore(): StateStore = StateStore()
}

@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    ...
}
Snippet di Kotlin al minuto 22:42 in "Introducing 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)
}
Snippet di Kotlin al minuto 22:52 in "Introducing 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)
    }
}
Snippet di Kotlin al minuto 23:03 in "Introducing StateStore"
@Singleton
class FocusSessionManager @Inject constructor(
    private val stateStore: StateStore,
) {
    val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
    ...
}
Snippet di Kotlin al minuto 23:22 in "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }

@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
    fun focusSessionManager(): FocusSessionManager
}
Snippet di Kotlin al minuto 23:33 in "Introducing StateStore"
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
    focusSessionManager: FocusSessionManager,
) : ViewModel() {
    val session: State<FocusSession> = focusSessionManager.session
}
Snippet di Kotlin al minuto 23:45 in "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)
    }
}
Snippet di Kotlin al minuto 23:58 in "Introducing 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) }
    }
}
Snippet Kotlin al minuto 24:04 in "Introducing 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
Snippet di Kotlin al minuto 24:22 in "Introducing StateStore"
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
    listener { … }
}
Snippet di Kotlin al minuto 24:33 in "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
                }
            }
        }
    }
}
Snippet TOML al minuto 25:39 in "Summary of Compose State categories"
[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" }