Cicli di vita dello stato in Compose

In Jetpack Compose, le funzioni componibili spesso mantengono lo stato utilizzando la funzione remember. I valori memorizzati possono essere riutilizzati in più ricomposizioni, come spiegato in Stato e Jetpack Compose.

Anche se remember funge da strumento per rendere persistenti i valori tra le ricomposizioni, lo stato spesso deve esistere oltre la durata di una composizione. Questa pagina spiega la differenza tra le API remember, retain, rememberSaveable, e rememberSerializable, quando scegliere quale API e quali sono le best practice per la gestione dei valori memorizzati e conservati in Compose.

Scegliere la durata corretta

In Compose, esistono diverse funzioni che puoi utilizzare per mantenere lo stato tra le composizioni e non solo: remember, retain, rememberSaveable e rememberSerializable. Queste funzioni differiscono per durata e semantica e sono adatte per memorizzare tipi specifici di stato. Le differenze sono descritte nella tabella seguente:

remember

retain

rememberSaveable, rememberSerializable

I valori sopravvivono alle ricomposizioni?

I valori sopravvivono alle ricreazioni dell'attività?

Verrà sempre restituita la stessa istanza (===)

Verrà restituito un oggetto equivalente (==), possibilmente una copia deserializzata

I valori sopravvivono all'interruzione del processo?

Tipi di dati supportati

Tutte

Non deve fare riferimento a oggetti che verrebbero divulgati se l'attività viene eliminata

Deve essere serializzabile
(con un Saver personalizzato o con kotlinx.serialization)

Casi d'uso

  • Oggetti inclusi nella composizione
  • Oggetti di configurazione per i componenti combinabili
  • Stato che può essere ricreato senza perdere la fedeltà dell'interfaccia utente
  • Cache
  • Oggetti di lunga durata o "gestore"
  • Input utente
  • Stato che non può essere ricreato dall'app, inclusi l'input del campo di testo, lo stato di scorrimento, i pulsanti di attivazione/disattivazione e così via.

remember

remember è il modo più comune per memorizzare lo stato in Compose. Quando remember viene chiamato per la prima volta, il calcolo specificato viene eseguito e memorizzato, ovvero viene archiviato da Compose per essere riutilizzato in futuro dal composable. Quando un elemento componibile viene ricomposto, il suo codice viene eseguito di nuovo, ma tutte le chiamate a remember restituiscono i valori della composizione precedente anziché eseguire nuovamente il calcolo.

Ogni istanza di una funzione componibile ha il proprio insieme di valori memorizzati, denominato memorizzazione posizionale. Quando i valori memorizzati vengono memorizzati nella cache per l'utilizzo in più ricomposizioni, sono associati alla loro posizione nella gerarchia di composizione. Se un componente componibile viene utilizzato in posizioni diverse, ogni istanza nella gerarchia di composizione ha il proprio insieme di valori memorizzati.

Quando un valore memorizzato non viene più utilizzato, viene dimenticato e il relativo record viene eliminato. I valori memorizzati vengono dimenticati quando vengono rimossi dalla gerarchia di composizione (incluso quando un valore viene rimosso e aggiunto di nuovo per spostarlo in una posizione diversa senza l'utilizzo di key o MovableContent) o chiamati con parametri key diversi.

Tra le scelte disponibili, remember ha la durata più breve e dimentica i valori prima delle quattro funzioni di memorizzazione descritte in questa pagina. Per questo motivo, è più adatta a:

  • Creazione di oggetti di stato interni, ad esempio la posizione di scorrimento o lo stato dell'animazione
  • Evitare la ricreazione costosa degli oggetti a ogni ricomposizione

Tuttavia, dovresti evitare:

  • Memorizzare qualsiasi input dell'utente con remember, perché gli oggetti memorizzati vengono dimenticati in seguito alle modifiche alla configurazione dell'attività e all'interruzione del processo avviata dal sistema.

rememberSaveable e rememberSerializable

rememberSaveable e rememberSerializable si basano su remember. Hanno la durata più lunga tra le funzioni di memoizzazione descritte in questa guida. Oltre a memorizzare gli oggetti in base alla posizione nelle ricomposizioni, può anche salvare i valori in modo che possano essere ripristinati nelle ricreazioni delle attività, incluse quelle dovute a modifiche alla configurazione e all'interruzione del processo (quando il sistema interrompe il processo dell'app in background, di solito per liberare memoria per le app in primo piano o se l'utente revoca le autorizzazioni dell'app mentre è in esecuzione).

rememberSerializable funziona allo stesso modo di rememberSaveable, ma supporta automaticamente la persistenza di tipi complessi serializzabili con la libreria kotlinx.serialization. Scegli rememberSerializable se il tuo tipo è (o può essere) contrassegnato con @Serializable e rememberSaveable in tutti gli altri casi.

In questo modo, sia rememberSaveable che rememberSerializable sono candidati perfetti per memorizzare lo stato associato all'input dell'utente, inclusi l'inserimento nel campo di testo, la posizione di scorrimento, gli stati di attivazione/disattivazione e così via. Devi salvare questo stato per assicurarti che l'utente non perda mai la sua posizione. In generale, devi utilizzare rememberSaveable o rememberSerializable per memorizzare qualsiasi stato che la tua app non è in grado di recuperare da un'altra origine dati persistente, ad esempio un database.

Tieni presente che rememberSaveable e rememberSerializable salvano i valori memorizzati serializzandoli in un Bundle. Ciò ha due conseguenze:

  • I valori che memorizzi devono essere rappresentabili da uno o più dei seguenti tipi di dati: primitivi (inclusi Int, Long, Float, Double), String o array di uno qualsiasi di questi tipi.
  • Quando viene ripristinato un valore salvato, si tratta di una nuova istanza uguale a (==), ma non dello stesso riferimento (===) utilizzato in precedenza dalla composizione.

Per archiviare tipi di dati più complessi senza utilizzare kotlinx.serialization, puoi implementare un Saver personalizzato per serializzare e deserializzare l'oggetto nei tipi di dati supportati. Tieni presente che Compose comprende tipi di dati comuni come State, List, Map, Set e così via, e li converte automaticamente in tipi supportati per tuo conto. Di seguito è riportato un esempio di Saver per una classe Size. Viene implementato inserendo tutte le proprietà di Size in un elenco utilizzando listSaver.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

L'API retain si trova tra remember e rememberSaveable/rememberSerializable in termini di durata della memorizzazione dei valori. Il nome è diverso perché i valori conservati hanno un ciclo di vita diverso rispetto a quelli memorizzati.

Quando un valore viene conservato, viene memorizzato nella cache in base alla posizione e salvato in una struttura di dati secondaria con una durata separata, legata alla durata dell'app. Un valore conservato è in grado di sopravvivere alle modifiche alla configurazione senza essere serializzato, ma non può sopravvivere alla chiusura del processo. Se un valore non viene utilizzato dopo la ricreazione della gerarchia di composizione, il valore conservato viene ritirato (ovvero l'equivalente di essere dimenticato in retain).

In cambio di questo ciclo di vita più breve di rememberSaveable, retain è in grado di rendere persistenti i valori che non possono essere serializzati, come espressioni lambda, flussi e oggetti di grandi dimensioni come bitmap. Ad esempio, puoi utilizzare retain per gestire un lettore multimediale (come ExoPlayer) per evitare interruzioni della riproduzione multimediale durante una modifica della configurazione.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain contro ViewModel

Entrambi retain e ViewModel offrono funzionalità simili nella loro capacità più comunemente utilizzata di rendere persistenti le istanze degli oggetti tra le modifiche alla configurazione. La scelta di utilizzare retain o ViewModel dipende dal tipo di valore che vuoi rendere persistente, dal modo in cui deve essere definito l'ambito e se hai bisogno di funzionalità aggiuntive.

I ViewModel sono oggetti che in genere incapsulano la comunicazione tra i livelli UI e dati della tua app. Consentono di spostare la logica dalle funzioni componibili, il che migliora la testabilità. Gli ViewModel vengono gestiti come singleton all'interno di un ViewModelStore e hanno una durata diversa rispetto ai valori conservati. Un ViewModel rimarrà attivo finché il relativo ViewModelStore non verrà eliminato, mentre i valori conservati vengono ritirati quando i contenuti vengono rimossi definitivamente dalla composizione (ad esempio, per una modifica alla configurazione, un valore conservato viene ritirato se la gerarchia della UI viene ricreata e il valore conservato non è stato utilizzato dopo la ricreazione della composizione).

ViewModel include anche integrazioni predefinite per l'inserimento delle dipendenze con Dagger e Hilt, l'integrazione con SavedState e il supporto integrato delle coroutine per l'avvio di attività in background. Ciò rende ViewModel un luogo ideale per avviare attività in background e richieste di rete, interagire con altre origini dati nel tuo progetto e, facoltativamente, acquisire e rendere persistente lo stato dell'interfaccia utente mission-critical che deve essere mantenuto in caso di modifiche alla configurazione in ViewModel e sopravvivere all'interruzione del processo.

retain è più adatto a oggetti con ambito limitato a istanze di componenti componibili specifiche e che non richiedono il riutilizzo o la condivisione tra componenti componibili di pari livello. Mentre ViewModel è un buon posto per archiviare lo stato della UI ed eseguire attività in background, retain è un buon candidato per archiviare oggetti per l'infrastruttura della UI come cache, monitoraggio delle impressioni e analisi, dipendenze da AndroidView e altri oggetti che interagiscono con il sistema operativo Android o gestiscono librerie di terze parti come processori di pagamento o pubblicità.

Per gli utenti avanzati che progettano pattern di architettura delle app personalizzati al di fuori dei suggerimenti per l'architettura delle app per Android moderne: retain può essere utilizzato anche per creare un'API interna "simile a ViewModel". Sebbene il supporto per le coroutine e lo stato salvato non sia offerto immediatamente, retain può fungere da elemento di base per il ciclo di vita di ViewModel simili con queste funzionalità integrate. I dettagli su come progettare un componente di questo tipo non rientrano nell'ambito di questa guida.

retain

ViewModel

Scoping

Nessun valore condiviso; ogni valore viene conservato e associato a un punto specifico della gerarchia di composizione. Il mantenimento dello stesso tipo in una posizione diversa agisce sempre su una nuova istanza.

ViewModel sono singleton all'interno di un ViewModelStore

Distruzione

Quando esci definitivamente dalla gerarchia di composizione

Quando ViewModelStore viene cancellato o eliminato

Funzionalità aggiuntive

Può ricevere callback quando l'oggetto si trova nella gerarchia di composizione o meno

coroutineScope integrato, supporto per SavedStateHandle, può essere inserito utilizzando Hilt

Di proprietà di

RetainedValuesStore

ViewModelStore

Casi d'uso

  • Persistenza di valori specifici dell'interfaccia utente locali per singole istanze di componenti componibili
  • Monitoraggio delle impressioni, possibilmente tramite RetainedEffect
  • Componente di base per definire un'architettura personalizzata simile a ViewModel
  • Estrazione delle interazioni tra i livelli UI e dati in una classe separata, sia per l'organizzazione del codice sia per i test
  • Trasformazione di Flow in oggetti State e chiamata di funzioni di sospensione che non devono essere interrotte dalle modifiche alla configurazione
  • Condivisione degli stati su aree dell'interfaccia utente di grandi dimensioni, come intere schermate
  • Interoperabilità con View

Combina retain e rememberSaveable o rememberSerializable

A volte, un oggetto deve avere una durata ibrida sia di retained sia di rememberSaveable o rememberSerializable. Questo potrebbe indicare che il tuo oggetto dovrebbe essere un ViewModel, che può supportare lo stato salvato come descritto nella guida al modulo Saved State per ViewModel.

è possibile utilizzare retain e rememberSaveable o rememberSerializable contemporaneamente. La combinazione corretta di entrambi i cicli di vita aggiunge una complessità significativa. Ti consigliamo di utilizzare questo pattern nell'ambito di pattern di architettura più avanzati e personalizzati e solo quando si verificano tutte le seguenti condizioni:

  • Stai definendo un oggetto composto da un mix di valori che devono essere conservati o salvati (ad es. un oggetto che tiene traccia di un input utente e una cache in memoria che non può essere scritta su disco)
  • Il tuo stato è limitato a un composable e non è adatto all'ambito singleton o alla durata di ViewModel

Quando si verificano tutte queste condizioni, ti consigliamo di dividere la classe in tre parti: i dati salvati, i dati conservati e un oggetto "mediatore" che non ha uno stato proprio e delega agli oggetti conservati e salvati l'aggiornamento dello stato di conseguenza. Questo pattern assume la seguente forma:

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

Separando lo stato in base alla durata, la separazione delle responsabilità e dell'archiviazione diventa molto esplicita. È intenzionale che i dati di salvataggio non possano essere manipolati dai dati di conservazione, in quanto ciò impedisce uno scenario in cui viene tentato un aggiornamento dei dati di salvataggio quando il bundle savedInstanceState è già stato acquisito e non può essere aggiornato. Consente inoltre di testare scenari di ricreazione testando i costruttori senza chiamare Compose o simulare la ricreazione di un'attività.

Vedi l'esempio completo (RetainAndSaveSample.kt) per un esempio completo di come può essere implementato questo pattern.

Memorizzazione posizionale e layout adattivi

Le applicazioni Android possono supportare molti fattori di forma, tra cui smartphone, pieghevoli, tablet e computer. Le applicazioni devono spesso passare da un fattore di forma all'altro utilizzando layout adattivi. Ad esempio, un'app in esecuzione su un tablet potrebbe essere in grado di mostrare una visualizzazione elenco-dettagli a due colonne, ma potrebbe spostarsi tra un elenco e una pagina di dettagli se visualizzata sullo schermo più piccolo di uno smartphone.

Poiché i valori memorizzati e conservati vengono memorizzati nella cache in base alla posizione, vengono riutilizzati solo se vengono visualizzati nello stesso punto della gerarchia di composizione. Man mano che i layout si adattano a fattori di forma diversi, possono alterare la struttura della gerarchia di composizione e portare a valori dimenticati.

Per i componenti predefiniti come ListDetailPaneScaffold e NavDisplay (da Jetpack Navigation 3), questo non è un problema e lo stato verrà mantenuto durante le modifiche al layout. Per i componenti personalizzati che si adattano ai fattori di forma, assicurati che lo stato non sia interessato dalle modifiche al layout eseguendo una delle seguenti operazioni:

  • Assicurati che i composable stateful vengano sempre chiamati nello stesso punto della gerarchia di composizione. Implementa layout adattivi modificando la logica del layout anziché riposizionare gli oggetti nella gerarchia di composizione.
  • Utilizza MovableContent per riposizionare i componibili stateful in modo controllato. Le istanze di MovableContent sono in grado di spostare i valori memorizzati e conservati dalle vecchie alle nuove posizioni.

Ricorda le funzioni di fabbrica

Sebbene le UI di Compose siano costituite da funzioni componibili, molti oggetti vengono utilizzati per la creazione e l'organizzazione di una composizione. L'esempio più comune è quello di oggetti componibili complessi che definiscono il proprio stato, come LazyList, che accetta un LazyListState.

Quando definisci oggetti incentrati su Compose, ti consigliamo di creare una funzione remember per definire il comportamento di memorizzazione previsto, inclusi sia la durata che gli input chiave. In questo modo, i consumatori del tuo stato possono creare con sicurezza istanze nella gerarchia di composizione che sopravviveranno e verranno invalidate come previsto. Quando definisci una funzione di fabbrica componibile, segui queste linee guida:

  • Aggiungi il prefisso remember al nome della funzione. Se l'implementazione della funzione dipende dall'oggetto retained e l'API non si evolverà mai per fare affidamento su una variante diversa di remember, utilizza il prefisso retain.
  • Utilizza rememberSaveable o rememberSerializable se è stata scelta la persistenza dello stato ed è possibile scrivere un'implementazione Saver corretta.
  • Evita effetti collaterali o valori di inizializzazione basati su CompositionLocal che potrebbero non essere pertinenti all'utilizzo. Ricorda che il luogo in cui viene creato lo stato potrebbe non essere quello in cui viene utilizzato.

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}