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. Il suo scopo è quello di aiutarti a capire quanto segue:

  • Quali API utilizzare per produrre lo stato della UI. Dipende dalla natura delle origini della modifica dello stato disponibili nei tuoi gestori dello stato, seguendo i principi del flusso di dati unidirezionale.
  • Come definire l'ambito della produzione dello stato dell'UI in modo da tenere conto delle risorse di sistema.
  • Come esporre lo stato dell'interfaccia utente per l'utilizzo 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 di Snackbar, 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 che comprende quanto segue:

  • Ingressi: le origini del cambio di stato. Possono 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 nello stato dell'interfaccia utente, ad esempio la chiamata del 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 quanto sopra.
  • State holder: tipi che applicano la logica di business e 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 Utilizza API asincrone come coroutine e flussi per eseguire il lavoro al di fuori del thread dell'interfaccia utente per mantenere la UI priva di jank.
Output Utilizza API di contenitori di dati osservabili come Compose State o StateFlow per invalidare e eseguire nuovamente il rendering dell'UI quando lo stato cambia. I titolari dei dati osservabili assicurano che la UI abbia sempre uno stato da visualizzare sullo schermo.

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 la seguente:

  • Riconoscimento 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 utilizzo: la UI deve essere in grado di eseguire facilmente il rendering dello stato della UI prodotto. In Jetpack Compose, il consumo di stato è fondamentale per la UI, perché i composable possono essere aggiornati in base alle modifiche dello stato.

Input nelle pipeline di produzione dello stato

Gli input in una pipeline di produzione dello stato forniscono le loro origini di modifica dello stato nel seguente modo:

  • 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

Gestire lo stato con i contenitori di dati osservabili. Utilizza l'API mutableStateOf, soprattutto quando lavori con le API Compose text. Per una gestione dello stato più complessa o quando esegui l'integrazione con altri componenti architetturali, utilizza l'API MutableStateFlow. 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 dei dadi. Ogni lancio dei dadi da parte dell'utente richiama il metodo sincrono Random.nextInt e il risultato viene scritto nello stato dell'UI.

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

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

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 dell'UI.

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

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

Modifica dello stato dell'interfaccia utente dai thread in background

È preferibile avviare le coroutine sul dispatcher principale per la produzione dello stato dell'interfaccia utente, ovvero al di fuori del blocco withContext negli snippet di codice riportati di seguito. Tuttavia, se devi aggiornare lo stato della UI in un contesto di background diverso, puoi procedere nel seguente modo:

  • Utilizza il metodo withContext per eseguire le coroutine in un contesto concorrente diverso.
  • Quando utilizzi MutableStateFlow, utilizza il metodo update come di consueto.
  • Quando utilizzi Compose State, utilizza il metodo Snapshot.withMutableSnapshot per garantire aggiornamenti atomici di 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.

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

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

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 è visibile nel sample"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 alla UI un controllo più granulare sull'attività della pipeline di produzione dello stato, in quanto potrebbe dover essere attiva solo quando la UI è visibile.

  • Utilizza SharingStarted.WhileSubscribed se la pipeline deve essere attiva solo quando la UI è 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 potrebbe tornare all'interfaccia utente, ovvero l'interfaccia utente si trova nello stack precedente o in un'altra scheda fuori dallo schermo.

Nei casi in cui l'aggregazione di origini 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 flussi o convoglia il loro output in flussi e riprendi l'elaborazione come descritto nella sezione sui flussi 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.

Prendi in considerazione TaskDetailViewModel dal repository architecture-samples. Lo stato della UI dipende da uno stream per l'attività corrente (_task) e da un'origine one-shot (_isTaskDeleted) che si aggiorna quando l'attività viene eliminata. Questo flag è necessario per distinguere i casi in cui un'attività non viene trovata nel database a causa di un ID errato e i casi in cui non viene trovata perché l'utente l'ha appena eliminata:

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

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

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 dipendono in gran parte dall'API utilizzata dall'app per il rendering dell'UI, ad esempio Compose. Jetpack Compose è il toolkit moderno consigliato per la creazione di una UI nativa. Le considerazioni da fare includono quanto segue:

La seguente tabella riepiloga le API da utilizzare per la pipeline di produzione dello stato quando utilizzi Jetpack Compose:

Input Output
API one-shot StateFlow o Scrivi State
API Stream StateFlow
API di streaming e one-shot 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 le risorse di sistema. In pratica, spesso significa attendere che ci sia un consumer 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:

Risorse aggiuntive

Per saperne di più sullo stato dell'interfaccia utente, consulta le seguenti risorse aggiuntive:

Documentazione

Visualizza contenuti