Produzione stato UI

Le UI moderne sono raramente statiche. Lo stato della UI cambia quando l'utente interagisce con la UI o quando l'app deve mostrare nuovi dati.

Questo documento definisce le linee guida per la produzione e la gestione dello stato dell'interfaccia utente. Al termine devi:

  • Sapere quali API dovresti usare per generare lo stato dell'interfaccia utente. Ciò dipende dalla natura delle origini del cambiamento di stato disponibili nei tuoi stati titolari, seguendo i principi del flusso di dati unidirezionale.
  • Scopri come definire l'ambito della produzione dello stato dell'interfaccia utente per essere consapevole delle risorse di sistema.
  • Scopri come esporre lo stato dell'interfaccia utente per l'utilizzo.

Fondamentalmente, la produzione statale è l'applicazione incrementale di queste modifiche allo stato dell'interfaccia utente. Lo stato esiste sempre e cambia in base agli eventi. Le differenze tra gli eventi e lo stato sono riassunte nella tabella di seguito:

Eventi Stato
Sono temporanei, imprevedibili e esistono per un periodo limitato. Esiste sempre.
Gli input della produzione statale. L'output della produzione di stato.
Il prodotto dell'interfaccia utente o di altre origini. Viene utilizzato dall'interfaccia utente.

Un ottimo termine mnemonico per riassumere quanto detto è stato è: eventi. Lo schema riportato di seguito consente di visualizzare le modifiche dello stato man mano che si verificano eventi in una sequenza temporale. Ogni evento viene elaborato dal titolare dello stato appropriato e comporta un cambiamento di stato:

Confronto tra eventi e stato
Figura 1: gli eventi causano la modifica dello stato

Gli eventi possono provenire da:

  • Utenti: mentre interagiscono con l'interfaccia utente dell'app.
  • Altre origini del cambiamento di stato: API che presentano dati delle app provenienti, rispettivamente, da interfaccia utente, dominio o livello dati, ad esempio eventi di timeout della snackbar, casi d'uso o repository.

La pipeline di produzione dello stato della UI

La produzione statale nelle app Android può essere considerata come una pipeline di elaborazione che comprende:

  • Input: le origini della modifica dello stato. ad esempio:
    • Locali al livello UI: possono essere eventi utente come un utente che inserisce un titolo per una "da fare" in un'app di gestione delle attività o API che forniscono accesso alla logica UI che guidano le modifiche nello stato dell'interfaccia utente. Ad esempio, chiamando il metodo open su DrawerState in Jetpack Compose.
    • Esterni al livello UI: sono origini dal dominio o dai livelli dati che causano modifiche allo stato dell'interfaccia utente. Ad esempio, notizie che hanno terminato il caricamento da un evento NewsRepository o da altri eventi.
    • Una combinazione di tutte le funzionalità precedenti.
  • Titolari di stato: tipi che applicano la logica di business e/o la logica UI alle origini dei cambiamenti di stato ed elaborano gli eventi utente per produrre lo stato dell'UI.
  • Output: lo stato della UI che può essere visualizzato dall'app per fornire agli utenti le informazioni di cui hanno bisogno.
La pipeline di produzione dello stato
Figura 2: la pipeline di produzione dello stato

API di produzione statale

Nella produzione dello stato vengono utilizzate due API principali, a seconda della fase della pipeline in cui ti trovi:

Fase pipeline API
Ingresso Devi utilizzare API asincrone per eseguire operazioni sul thread dell'interfaccia utente e mantenere libero il jank della UI. Ad esempio, Coroutines o Flows in Kotlin e RxJava o callback in Java Programming Language.
Uscita Devi usare API osservabili dei titolari di dati per invalidare e eseguire il rendering dell'interfaccia utente quando lo stato cambia. Ad esempio StateFlow, Compose State o LiveData. I titolari di dati osservabili garantiscono che l'UI abbia sempre uno stato dell'UI da visualizzare sullo schermo

Dei due, la scelta dell'API asincrona per l'input ha una maggiore influenza sulla natura della pipeline di produzione dello stato rispetto alla scelta dell'API osservabile per l'output. Questo perché gli input indicano il tipo di elaborazione che potrebbe essere applicato alla pipeline.

Assemblaggio della pipeline di produzione statale

Nelle sezioni successive vengono descritte le tecniche di produzione degli stati più adatte per vari input e le API di output corrispondenti. Ogni pipeline di produzione dello stato è una combinazione di input e output e dovrebbe essere:

  • Conforme al ciclo di vita: nel caso in cui l'interfaccia utente non sia visibile o attiva, la pipeline di produzione dello stato non deve consumare risorse, a meno che non sia esplicitamente richiesta.
  • Facile da utilizzare: l'interfaccia utente deve essere in grado di eseguire facilmente il rendering dello stato dell'interfaccia utente generato. Le considerazioni sull'output della pipeline di produzione dello stato varieranno tra le diverse API View, ad esempio il sistema View o Jetpack Compose.

Input nelle pipeline di produzione dello stato

Gli input in una pipeline di produzione dello stato possono fornire le proprie origini di cambiamento di stato tramite:

  • Operazioni one-shot che possono essere sincrone o asincrone, ad esempio chiamate alle funzioni suspend.
  • API Stream, ad esempio Flows.
  • Tutte le risposte precedenti.

Le sezioni seguenti spiegano come assemblare una pipeline di produzione dello stato per ciascuno degli input riportati sopra.

API one-shot come origini del cambiamento di stato

Utilizza l'API MutableStateFlow come container di stato osservabile e modificabile. Nelle app Jetpack Compose, puoi anche prendere in considerazione mutableStateOf, soprattutto quando lavori con le API Compose Text. Entrambe le API offrono metodi che consentono aggiornamenti atomici sicuri dei valori ospitati, indipendentemente dal fatto che gli aggiornamenti siano sincroni o asincroni.

Ad esempio, prendi in considerazione gli aggiornamenti di stato in una semplice app che lancia il dado. Ogni lancio del dado da parte dell'utente richiama il metodo sincrono Random.nextInt() e il risultato viene scritto nello stato dell'interfaccia utente.

Flusso statale

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Stato di scrittura

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

Modifica dello stato dell'interfaccia utente dalle chiamate asincrone

Per cambiamenti di stato che richiedono un risultato asincrono, avvia una Coroutine nell'elemento CoroutineScope appropriato. In questo modo l'app può eliminare il lavoro quando CoroutineScope viene annullato. Il titolare dello stato scrive quindi il risultato della chiamata al metodo di sospensione nell'API osservabile utilizzata per esporre lo stato dell'interfaccia utente.

Ad esempio, considera AddEditTaskViewModel nel campo Esempio di architettura. Quando il metodo saveTask() di sospensione salva un'attività in modo asincrono, il metodo update su MutableStateFlow propaga la modifica dello stato allo stato della UI.

Flusso statale

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Stato di scrittura

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

Modifica dello stato dell'interfaccia utente dai thread in background

È preferibile avviare Coroutines sul supervisore principale per la produzione dello stato dell'interfaccia utente. Vale a dire, al di fuori del blocco withContext negli snippet di codice riportati di seguito. Tuttavia, se devi aggiornare lo stato della UI in un contesto in background diverso, puoi farlo utilizzando le API seguenti:

  • Utilizza il metodo withContext per eseguire le coroutine in un contesto simultaneo diverso.
  • Quando utilizzi MutableStateFlow, usa il metodo update come di consueto.
  • Quando usi lo stato di composizione, usa Snapshot.withMutableSnapshot per garantire gli aggiornamenti atomici allo stato nel contesto simultaneo.

Ad esempio, supponiamo nello snippet DiceRollViewModel riportato di seguito che SlowRandom.nextInt() sia una funzione suspend con elevata intensità di calcolo che deve essere chiamata da una Coroutine associata alla CPU.

Flusso statale

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Stato di scrittura

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

Trasmetti le API come origini del cambiamento di stato

Per le origini di cambiamento di stato che producono più valori nel tempo nei flussi, l'aggregazione degli output di tutte le origini in un insieme coeso è un approccio semplice alla produzione dello stato.

Quando utilizzi Kotlin Flows, puoi ottenere questo risultato con la funzione combine. Un esempio può essere visto nell'esempio "Ora in Android" in InteressiViewModel:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

L'uso dell'operatore stateIn per creare StateFlows offre all'interfaccia utente un controllo più granulare sull'attività della pipeline di produzione dello stato, in quanto potrebbe essere necessario essere attivo solo quando la UI è visibile.

  • Utilizza SharingStarted.WhileSubscribed() se la pipeline deve essere attiva solo quando l'UI è visibile durante la raccolta del flusso in un modo sensibile al ciclo di vita.
  • Utilizza SharingStarted.Lazily se la pipeline deve essere attiva, purché l'utente possa tornare all'interfaccia utente, ovvero se la UI si trova nel backstack o in un'altra scheda fuori schermo.

Nei casi in cui l'aggregazione di origini di stato basate su flussi non sia applicabile, le API per i flussi come Kotlin Flows offrono un'ampia gamma di trasformazioni come unione, scomposizione e così via per facilitare l'elaborazione dei flussi nello stato dell'interfaccia utente.

API one-shot e stream come origini del cambiamento di stato

Nel caso in cui la pipeline di produzione dello stato dipenda sia dalle chiamate one-shot sia dai flussi di dati come origini di modifica dello stato, i flussi sono il vincolo che lo definisce. Di conseguenza, converti le chiamate one-shot in API per i flussi oppure pubblica l'output in flussi e riprendi l'elaborazione come descritto nella precedente sezione relativa ai flussi.

Con i flussi, in genere ciò significa creare una o più istanze MutableStateFlow di supporto privato per propagare le modifiche di stato. Puoi anche creare flussi di snapshot dallo stato Scrivi.

Considera TaskDetailViewModel del repository architecture-samples di seguito:

Flusso statale

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Stato di scrittura

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

Tipi di output nelle pipeline di produzione dello stato

La scelta dell'API di output per lo stato dell'interfaccia utente e la natura della sua presentazione dipendono in gran parte dall'API utilizzata dall'app per il rendering dell'interfaccia utente. Nelle app per Android, puoi scegliere di usare Views o Jetpack Compose. Ecco alcune considerazioni da fare:

La tabella seguente riassume le API da utilizzare per la pipeline di produzione statale per ogni input e consumer specifico:

Ingresso Consumismo Uscita
API one-shot Visualizzazioni StateFlow o LiveData
API one-shot Scrivi StateFlow o Scrivi State
API Stream Visualizzazioni StateFlow o LiveData
API Stream Scrivi StateFlow
API one-shot e stream Visualizzazioni StateFlow o LiveData
API one-shot e stream Scrivi StateFlow

Inizializzazione della pipeline di produzione statale

L'inizializzazione delle pipeline di produzione dello stato implica l'impostazione delle condizioni iniziali per l'esecuzione della pipeline. Potresti dover fornire valori di input iniziali fondamentali per l'avvio della pipeline, ad esempio un valore id per la visualizzazione dettagliata di un articolo o l'avvio di un caricamento asincrono.

Dovresti inizializzare la pipeline di produzione dello stato in modo lento, quando possibile, per conservare le risorse di sistema. In pratica, questo spesso significa attendere fino a quando non ci sarà un consumatore dell'output. Le API Flow consentono questa operazione con l'argomento started nel metodo stateIn. Nei casi in cui non è applicabile, definisci una funzione idempotente initialize() per avviare esplicitamente la pipeline di produzione dello stato, come mostrato nello snippet seguente:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

Samples

I seguenti esempi di Google mostrano la produzione dello stato nel livello UI. Esplorale per vedere concretamente queste indicazioni: