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 visualizzare nuovi dati.

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

  • Scopri quali API devi utilizzare per produrre lo stato della UI. Ciò dipende dalla natura delle origini del cambiamento di stato disponibili nei tuoi titolari dello stato, seguendo i principi del flusso di dati unidirezionale.
  • Scopri come definire l'ambito della produzione dello stato dell'interfaccia utente per tenere conto delle risorse di sistema.
  • Scopri come esporre lo stato dell'interfaccia utente per il consumo da parte dell'interfaccia utente.

Fondamentalmente, la produzione dello stato è l'applicazione incrementale di queste modifiche allo stato della UI. Lo stato esiste sempre e cambia in seguito agli eventi. Le differenze tra eventi e stato sono riassunte nella tabella seguente:

Eventi Stato
Transitori, imprevedibili ed esistenti per un periodo di tempo finito. Esiste sempre.
Gli input della produzione statale. L'output della produzione dello stato.
Il prodotto dell'interfaccia utente o di altre fonti. Viene utilizzato dalla UI.

Un ottimo mnemonico che riassume quanto sopra è lo stato è; gli eventi si verificano. Il diagramma seguente aiuta a visualizzare le modifiche dello stato man mano che si verificano gli eventi in una cronologia. Ogni evento viene elaborato dal contenitore di stato appropriato e comporta una modifica dello stato:

Eventi e stato
Figura 1: gli eventi causano il cambiamento di stato

Gli eventi possono provenire da:

  • Utenti: mentre interagiscono con la UI dell'app.
  • Altre origini di modifica dello stato: API che presentano dati delle app da UI, domini o livelli di dati come eventi di timeout della barra delle notifiche, casi d'uso o repository rispettivamente.

La pipeline di produzione dello stato dell'interfaccia utente

La produzione di stati nelle app per Android può essere considerata una pipeline di elaborazione composta da:

  • Ingressi: le origini del cambio di stato. Potrebbero essere:
    • Locali al livello UI: potrebbero essere eventi utente come l'inserimento di un titolo per un'attività in un'app di gestione delle attività o API che forniscono l'accesso alla logica dell'interfaccia utente che determina le modifiche allo stato dell'interfaccia utente. Ad esempio, chiamando il metodo open su DrawerState in Jetpack Compose.
    • Esterno al livello UI: si tratta di origini dei livelli di dominio o dati che causano modifiche allo stato della UI. Ad esempio, notizie che hanno terminato il caricamento da un NewsRepository o altri eventi.
    • Un mix di tutti i precedenti.
  • State holder: tipi che applicano la logica di business e/o la logica dell'interfaccia utente alle origini del cambiamento di stato ed elaborano gli eventi utente per produrre lo stato dell'interfaccia utente.
  • Output: lo stato dell'interfaccia utente che l'app può visualizzare 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 dello stato

Esistono due API principali utilizzate nella produzione di stati a seconda della fase della pipeline in cui ti trovi:

Fase della pipeline API
Input Devi utilizzare le API asincrone per eseguire il lavoro al di fuori del thread UI per evitare problemi di prestazioni dell'interfaccia utente. Ad esempio, coroutine o flussi in Kotlin e RxJava o callback nel linguaggio di programmazione Java.
Output Devi utilizzare le API observable data holder per invalidare e eseguire nuovamente il rendering della UI quando lo stato cambia. Ad esempio, StateFlow, Compose State o LiveData. I contenitori di dati osservabili garantiscono che la UI abbia sempre uno stato UI da visualizzare sullo schermo

Tra le 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 determinano il tipo di elaborazione che può essere applicato alla pipeline.

Assemblaggio della pipeline di produzione dello stato

Le sezioni successive trattano le tecniche di produzione dello stato più adatte a vari input e le API di output corrispondenti. Ogni pipeline di produzione dello stato è una combinazione di input e output e deve essere:

  • Rilevamento del ciclo di vita: nel caso in cui la UI non sia visibile o attiva, la pipeline di produzione dello stato non deve consumare risorse a meno che non sia esplicitamente richiesto.
  • Facilità di lettura: la UI deve essere in grado di eseguire facilmente il rendering dello stato della UI prodotto. Le considerazioni per l'output della pipeline di produzione dello stato variano a seconda delle diverse API View, come 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 loro origini di modifica dello stato tramite:

  • Operazioni una tantum che possono essere sincrone o asincrone, ad esempio chiamate alle funzioni suspend.
  • API di streaming, ad esempio Flows.
  • Tutte le risposte precedenti.

Le sezioni seguenti descrivono come assemblare una pipeline di produzione di stati per ciascuno degli input precedenti.

API one-shot come origini di modifica dello stato

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

Ad esempio, considera gli aggiornamenti dello stato in una semplice app per il lancio di dadi. Ogni lancio di dadi da parte dell'utente richiama il metodo sincrono Random.nextInt() e il risultato viene scritto nello stato dell'interfaccia utente.

StateFlow

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 composizione

@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'UI dalle chiamate asincrone

Per le modifiche dello stato che richiedono un risultato asincrono, avvia una coroutine nel CoroutineScope appropriato. In questo modo l'app può ignorare il lavoro quando il CoroutineScope viene annullato. Il contenitore di stato scrive quindi il risultato della chiamata al metodo suspend nell'API osservabile utilizzata per esporre lo stato dell'UI.

Ad esempio, considera AddEditTaskViewModel nell'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.

StateFlow

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 composizione

@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 le coroutine sul dispatcher principale per la produzione dello stato dell'UI. ovvero al di fuori del blocco withContext negli snippet di codice di seguito. Tuttavia, se devi aggiornare lo stato dell'interfaccia utente in un contesto di background diverso, puoi farlo utilizzando le seguenti API:

  • Utilizza il metodo withContext per eseguire le coroutine in un contesto simultaneo diverso.
  • Quando utilizzi MutableStateFlow, utilizza il metodo update come di consueto.
  • Quando utilizzi Compose State, utilizza Snapshot.withMutableSnapshot per garantire aggiornamenti atomici a State nel contesto simultaneo.

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

StateFlow

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 composizione

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
                }
            }
        }
    }
}

API di streaming come origini di modifica dello stato

Per le origini del cambio 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 di stati.

Quando utilizzi Kotlin Flows, puoi farlo con la funzione combine. Un esempio può essere visto nell'esempio "Now in Android" in InterestsViewModel:

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'utilizzo 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 che sia attiva solo quando l'interfaccia utente è visibile.

  • Utilizza SharingStarted.WhileSubscribed() se la pipeline deve essere attiva solo quando l'interfaccia utente è visibile durante la raccolta del flusso in modo consapevole del ciclo di vita.
  • Utilizza SharingStarted.Lazily se la pipeline deve essere attiva finché l'utente può tornare all'interfaccia utente, ovvero se l'interfaccia utente si trova nello stack precedente o in un'altra scheda fuori dallo schermo.

Nei casi in cui l'aggregazione delle origini di stato basate su stream non si applica, le API di stream come Kotlin Flows offrono un ricco insieme di trasformazioni come unione, appiattimento e così via per facilitare l'elaborazione degli stream nello stato della UI.

API one-shot e di streaming come origini di modifica dello stato

Nel caso in cui la pipeline di produzione dello stato dipenda sia da chiamate one-shot sia da stream come fonti di modifica dello stato, gli stream sono il vincolo determinante. Pertanto, converti le chiamate one-shot in API di stream o convoglia il loro output in stream e riprendi l'elaborazione come descritto nella sezione precedente.

Con i flussi, in genere significa creare una o più istanze di backend privato MutableStateFlow per propagare le modifiche dello stato. Puoi anche creare flussi di snapshot dallo stato di composizione.

Considera il TaskDetailViewModel dal repository architecture-samples riportato di seguito:

StateFlow

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 composizione

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'UI e la natura della sua presentazione dipende in gran parte dall'API utilizzata dall'app per il rendering dell'UI. Nelle app per Android, puoi scegliere di utilizzare Views o Jetpack Compose. Le considerazioni da fare includono:

La tabella seguente riepiloga le API da utilizzare per la pipeline di produzione dello stato per un determinato input e consumer:

Input Consumer Output
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 di streaming e one-shot Visualizzazioni StateFlow o LiveData
API di streaming e one-shot Scrivi StateFlow

Stato dell'inizializzazione della pipeline di produzione

L'inizializzazione delle pipeline di produzione dello stato comporta l'impostazione delle condizioni iniziali per l'esecuzione della pipeline. Ciò potrebbe comportare la fornitura di valori di input iniziali fondamentali per l'avvio della pipeline, ad esempio un id per la visualizzazione dettagliata di un articolo di notizie o l'avvio di un caricamento asincrono.

Se possibile, inizializza la pipeline di produzione dello stato in modo differito per risparmiare risorse di sistema. In pratica, spesso significa attendere finché non ci sia un consumatore dell'output. Le API Flow lo consentono con l'argomento started nel metodo stateIn. Nei casi in cui ciò non è applicabile, definisci una funzione idempotente initialize() per avviare esplicitamente la pipeline di produzione dello stato come mostrato nel seguente snippet:

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
        }
    }
}

Esempi

I seguenti esempi di Google mostrano la produzione dello stato nel livello UI. Esplorali per vedere queste indicazioni in pratica: