State e Jetpack Compose

Lo stato di un'app è qualsiasi valore che può cambiare nel tempo. Si tratta di una definizione molto ampia che comprende tutto, da un database Room a una variabile in una classe.

Tutte le app per Android mostrano lo stato all'utente. Ecco alcuni esempi di stato nelle app per Android:

  • Una snackbar che viene visualizzata quando non è possibile stabilire una connessione di rete.
  • Un blog post e i commenti associati.
  • Animazioni con effetto onda sui pulsanti che vengono riprodotte quando un utente fa clic.
  • Adesivi che un utente può disegnare sopra un'immagine.

Jetpack Compose ti aiuta a essere esplicito su dove e come archiviare e utilizzare lo stato in un'app per Android. Questa guida si concentra sulla connessione tra lo stato e i componibili e sulle API offerte da Jetpack Compose per lavorare con lo stato.

Stato e composizione

Compose è dichiarativo e, di conseguenza, l'unico modo per aggiornarlo è chiamare lo stesso componibile con nuovi argomenti. Questi argomenti sono rappresentazioni dello stato della UI. Ogni volta che uno stato viene aggiornato, viene eseguita una ricomposizione. Di conseguenza, elementi come TextField non si aggiornano automaticamente come avviene nelle visualizzazioni basate su XML imperativo. A un componibile deve essere comunicato esplicitamente il nuovo stato per poterlo aggiornare di conseguenza.

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

Se esegui questa operazione e provi a inserire del testo, vedrai che non succede nulla. Questo perché TextField non si aggiorna da solo, ma quando cambia il parametro value. Ciò è dovuto al modo in cui funzionano la composizione e la ricomposizione in Compose.

Composizione iniziale: creazione di una composizione eseguendo i componibili per la prima volta.

Ricomposizione: esecuzione di nuovo dei componibili per aggiornare la composizione quando i dati cambiano.

Per scoprire di più sulla composizione iniziale e sulla ricomposizione, consulta Pensare in Compose.

Stato negli elementi componibili

Le funzioni componibili possono utilizzare l'API remember per archiviare un oggetto in memoria. Un valore calcolato da remember viene archiviato nella composizione durante la composizione iniziale e il valore archiviato viene restituito durante la ricomposizione. remember può essere utilizzato per archiviare oggetti sia mutabili che immutabili.

mutableStateOf crea un osservabile MutableState<T>, che è un tipo osservabile integrato con il runtime di Compose.

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

Qualsiasi modifica a value pianifica la ricomposizione di tutte le funzioni componibili che leggono value.

Esistono tre modi per dichiarare un oggetto MutableState in un componibile:

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

Queste dichiarazioni sono equivalenti e vengono fornite come zucchero sintattico per diversi utilizzi dello stato. Devi scegliere quella che produce il codice più facile da leggere nel composable che stai scrivendo.

La sintassi del delegato by richiede le seguenti importazioni:

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

Puoi utilizzare il valore memorizzato come parametro per altri componibili o anche come logica nelle istruzioni per modificare i componibili visualizzati. Ad esempio, se non vuoi visualizzare il saluto se il nome è vuoto, utilizza lo stato in un'istruzione if:

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

Sebbene remember ti aiuti a conservare lo stato tra le ricomposizioni, lo stato non viene conservato tra le modifiche alla configurazione. Per farlo, devi utilizzare rememberSaveable. rememberSaveable salva automaticamente qualsiasi valore che può essere salvato in un Bundle. Per altri valori, puoi passare un oggetto saver personalizzato.

Altri tipi di stato supportati

Compose non richiede l'utilizzo di MutableState<T> per mantenere lo stato, ma supporta altri tipi osservabili. Prima di leggere un altro tipo osservabile in Compose, devi convertirlo in un State<T> in modo che i componibili possano ricomporsi automaticamente quando lo stato cambia.

Compose include funzioni per creare State<T> da tipi osservabili comuni utilizzati nelle app per Android. Prima di utilizzare queste integrazioni, aggiungi gli artefatti appropriati come segue:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() raccoglie i valori da un Flow in modo sensibile al ciclo di vita, consentendo alla tua app di conservare le risorse dell'app. Rappresenta l'ultimo valore emesso da Compose State. Utilizza questa API come metodo consigliato per raccogliere i flussi nelle app per Android.

    La seguente dipendenza è obbligatoria nel file build.gradle (deve essere 2.6.0-beta01 o versioni successive):

Kotlin

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

Alla moda

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

    collectAsState è simile a collectAsStateWithLifecycle, perché raccoglie anche i valori da un Flow e li trasforma in State di Compose.

    Utilizza collectAsState per il codice indipendente dalla piattaforma anziché collectAsStateWithLifecycle, che è solo per Android.

    Non sono necessarie dipendenze aggiuntive per collectAsState, perché è disponibile in compose-runtime.

  • LiveData: observeAsState()

    observeAsState() inizia a osservare questo LiveData e ne rappresenta i valori utilizzando State.

    La seguente dipendenza è obbligatoria nel file build.gradle:

Kotlin

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

Alla moda

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

Kotlin

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

Alla moda

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

Kotlin

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

Alla moda

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

Stateful e stateless

Un componibile che utilizza remember per archiviare un oggetto crea uno stato interno, rendendo il componibile stateful. HelloContent è un esempio di un componibile stateful perché contiene e modifica internamente il proprio stato name. Questo può essere utile in situazioni in cui un chiamante non ha bisogno di controllare lo stato e può utilizzarlo senza doverlo gestire autonomamente. Tuttavia, i componibili con stato interno tendono a essere meno riutilizzabili e più difficili da testare.

Un composable stateless è un composable che non contiene alcuno stato. Un modo comune per ottenere l'assenza di stato è utilizzare l'innalzamento dello stato.

Quando sviluppi composabili riutilizzabili, spesso vuoi esporre sia una versione stateful sia una versione stateless dello stesso composabile. La versione stateful è comoda per i chiamanti che non si preoccupano dello stato, mentre la versione stateless è necessaria per i chiamanti che devono controllare o eseguire l'hoisting dello stato.

Innalzamento dello stato

L'innalzamento dello stato in Compose è un pattern di spostamento dello stato al chiamante di un componibile per rendere quest'ultimo stateless. Il pattern generale per l'innalzamento dello stato in Jetpack Compose consiste nel sostituire la variabile di stato con due parametri:

  • value: T: il valore corrente da visualizzare
  • onValueChange: (T) -> Unit: un evento che richiede la modifica del valore, dove T è il nuovo valore proposto

Tuttavia, non hai l'obbligo di utilizzare onValueChange. Se per il composable sono appropriati eventi più specifici, devi definirli utilizzando le espressioni lambda.

Lo stato sottoposto a hoisting in questo modo ha alcune proprietà importanti:

  • Single Source Of Truth: spostando lo stato anziché duplicarlo, ci assicuriamo che esista un'unica fonte di verità. In questo modo, eviterai i bug.
  • Incapsulati:solo i componibili stateful possono modificare il proprio stato. È completamente interno.
  • Condivisibile: lo stato sottoposto a hoisting può essere condiviso con più componibili. Se volessi leggere name in un altro componibile, l'hoisting ti consentirebbe di farlo.
  • Intercettabili: i chiamanti dei componibili stateless possono decidere di ignorare o modificare gli eventi prima di cambiare lo stato.
  • Disaccoppiato: lo stato dei componibili stateless può essere archiviato ovunque. Ad esempio, ora è possibile spostare name in un ViewModel.

Nel caso di esempio, estrai name e onValueChange da HelloContent e li sposti verso l'alto nell'albero in un componibile HelloScreen che chiama HelloContent.

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

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

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

Se esegui l'hoisting dello stato da HelloContent, è più facile ragionare sul componibile, riutilizzarlo in situazioni diverse ed eseguire i test. HelloContent è disaccoppiato dal modo in cui viene archiviato il suo stato. Il disaccoppiamento significa che se modifichi o sostituisci HelloScreen, non devi cambiare il modo in cui viene implementato HelloContent.

Flusso di dati unidirezionale

Il pattern in cui lo stato scende e gli eventi salgono è chiamato flusso di dati unidirezionale. In questo caso, lo stato scende da HelloScreen a HelloContent e gli eventi salgono da HelloContent a HelloScreen. Seguendo il flusso di dati unidirezionale, puoi disaccoppiare i componibili che mostrano lo stato nella UI dalle parti dell'app che archiviano e modificano lo stato.

  1. Lo stato deve essere spostato almeno al livello del genitore comune più basso di tutti i componibili che utilizzano lo stato (lettura).
  2. Lo stato deve essere sottoposto a hoisting almeno al livello più alto in cui può essere modificato (scrittura).
  3. Se due stati cambiano in risposta agli stessi eventi, devono essere sollevati insieme.

Puoi eseguire l'hoisting dello stato più in alto di quanto richiesto da queste regole, ma l'underhoisting dello stato rende difficile o impossibile seguire il flusso di dati unidirezionale.

Per saperne di più, consulta la pagina Dove eseguire l'elevazione dello stato.

Ripristino dello stato in Compose

L'API rememberSaveable si comporta in modo simile a remember perché mantiene lo stato tra le ricomposizioni e anche tra la ricreazione di attività o processi utilizzando il meccanismo dello stato dell'istanza salvato. Ad esempio, questo accade quando lo schermo viene ruotato.

Modi per archiviare lo stato

Tutti i tipi di dati aggiunti alla Bundle vengono salvati automaticamente. Se vuoi salvare qualcosa che non può essere aggiunto alla Bundle, hai diverse opzioni.

Parcelize

La soluzione più semplice è aggiungere l'annotazione @Parcelize all'oggetto. L'oggetto diventa parcelable e può essere raggruppato. Ad esempio, questo codice crea un tipo di dati City parcelable e lo salva nello stato.

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

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

MapSaver

Se per qualche motivo @Parcelize non è adatto, puoi utilizzare mapSaver per definire la tua regola per convertire un oggetto in un insieme di valori che il sistema può salvare in Bundle.

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

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

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

ListSaver

Per evitare di dover definire le chiavi per la mappa, puoi anche utilizzare listSaver e utilizzare i relativi indici come chiavi:

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

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

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

Contenitori di stato in Compose

L'innalzamento dello stato semplice può essere gestito nelle funzioni componibili stesse. Tuttavia, se la quantità di stato da monitorare aumenta o se sorge la logica da eseguire nelle funzioni componibili, è buona norma delegare le responsabilità di logica e stato ad altre classi: i contenitori di stato.

Tieni presente che in altri materiali i contenitori di stato sono chiamati anche oggetti di stato sollevati.

Per scoprire di più, consulta la documentazione sull'innalzamento dello stato in Compose o, più in generale, la pagina Contenitori di stato e stato della UI nella guida all'architettura.

Attiva nuovamente i calcoli di remember quando le chiavi cambiano

L'API remember viene spesso utilizzata insieme a MutableState:

var name by remember { mutableStateOf("") }

In questo caso, l'utilizzo della funzione remember consente al valore MutableState di sopravvivere alle ricomposizioni.

In generale, remember accetta un parametro lambda calculation. Quando remember viene eseguita per la prima volta, richiama la funzione lambda calculation e ne memorizza il risultato. Durante la ricomposizione, remember restituisce l'ultimo valore memorizzato.

Oltre a memorizzare nella cache lo stato, puoi anche utilizzare remember per archiviare qualsiasi oggetto o risultato di un'operazione nella composizione che è costoso da inizializzare o calcolare. Potresti non voler ripetere questo calcolo in ogni ricomposizione. Un esempio è la creazione di questo oggetto ShaderBrush, che è un'operazione costosa:

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

remember memorizza il valore finché non esce dalla composizione. Tuttavia, esiste un modo per invalidare il valore memorizzato nella cache. L'API remember accetta anche un parametro key o keys. Se una di queste chiavi cambia, la volta successiva che la funzione viene ricomposta, remember invalida la cache ed esegue nuovamente il blocco lambda di calcolo. Questo meccanismo ti consente di controllare la durata di un oggetto nella composizione. Il calcolo rimane valido finché gli input non cambiano, anziché finché il valore memorizzato non lascia la composizione.

I seguenti esempi mostrano come funziona questo meccanismo.

In questo snippet, viene creato un ShaderBrush e utilizzato come colore di sfondo di un componibile Box. remember archivia l'istanza ShaderBrush perché è costoso ricrearla, come spiegato in precedenza. remember accetta avatarRes come parametro key1, che è l'immagine di sfondo selezionata. Se avatarRes cambia, il pennello si ricompone con la nuova immagine e viene riapplicato a Box. Questo può accadere quando l'utente seleziona un'altra immagine da utilizzare come sfondo da un selettore.

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

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

Nel prossimo snippet, lo stato viene sottoposto ad hoisting in una classe contenitore di stato semplice MyAppState. Espone una funzione rememberMyAppState per inizializzare un'istanza della classe utilizzando remember. Esporre queste funzioni per creare un'istanza che sopravvive alle ricomposizioni è un pattern comune in Compose. La funzione rememberMyAppState riceve windowSizeClass, che funge da parametro key per remember. Se questo parametro cambia, l'app deve ricreare la classe del contenitore di stato semplice con il valore più recente. Questo può accadere se, ad esempio, l'utente ruota il dispositivo.

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

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

Compose utilizza l'implementazione equals della classe per decidere se una chiave è cambiata e invalidare il valore memorizzato.

Archivia lo stato con chiavi oltre la ricomposizione

L'API rememberSaveable è un wrapper di remember che può archiviare i dati in un Bundle. Questa API consente allo stato di sopravvivere non solo alla ricomposizione, ma anche alla ricreazione dell'attività e all'interruzione del processo avviata dal sistema. rememberSaveable riceve i parametri input per lo stesso scopo per cui remember riceve keys. La cache viene annullata quando uno degli input cambia. La volta successiva che la funzione viene ricomposta, rememberSaveable esegue nuovamente il blocco lambda di calcolo.

Nell'esempio seguente, rememberSaveable memorizza userTypedQuery fino a quando typedQuery non cambia:

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

Scopri di più

Per scoprire di più sullo stato e su Jetpack Compose, consulta le seguenti risorse aggiuntive.

Esempi

Codelab

Video

Blog