Produção do estado da IU

As IUs modernas raramente são estáticas. O estado da IU muda quando o usuário interage com a IU ou quando o app precisa exibir novos dados.

Este documento descreve diretrizes para a produção e o gerenciamento do estado da IU. No final, você vai:

  • saber quais APIs precisam ser usadas para produzir o estado da IU. Isso depende da natureza das origens de mudança de estado disponíveis nos detentores de estado, seguindo os princípios do fluxo de dados unidirecional;
  • saber como definir o escopo da produção do estado da IU para considerar os recursos do sistema;
  • saber como expor o estado da IU para consumo pela IU.

Basicamente, a produção de estado é a aplicação incremental dessas mudanças ao estado da IU. O estado sempre existe e muda como resultado de eventos. As diferenças entre eventos e estados estão resumidas na tabela abaixo:

Eventos Estado
Transitório, imprevisível e existe por um período finito. Sempre existe.
As entradas da produção de estado. A saída da produção de estado.
O produto da IU ou de outras origens. Consumido pela IU.

Uma ótima mnemônica que resume o que foi dito acima é o estado é; os eventos acontecem. O diagrama abaixo ajuda a visualizar as mudanças de estado à medida que os eventos ocorrem em uma linha do tempo. Cada evento é processado pelo detentor de estado adequado e resulta em uma mudança de estado:

Eventos vs. estado
Figura 1: os eventos provocam mudanças de estado

Veja quais podem ser a origem dos eventos:

  • Usuários: à medida que interagem com a IU do app.
  • Outras origens de mudança de estado: APIs que apresentam dados de apps da IU, do domínio ou de camadas de dados, como eventos de tempo limite da snackbar, casos de uso ou repositórios, respectivamente.

O pipeline de produção do estado da IU

A produção de estado nos apps Android pode ser considerada um pipeline de processamento com estas características:

  • Entradas: as origens de mudança de estado. Alguns exemplos:
    • Locais para a camada da IU: podem ser eventos do usuário, como a inserção de um título para uma "tarefa" em um app gerenciador de tarefas ou APIs que fornecem acesso à lógica da IU que gera mudanças no estado da IU. Por exemplo, chamar o método open no DrawerState no Jetpack Compose.
    • Externas à camada da IU: são origens do domínio ou das camadas de dados que causam mudanças no estado da IU. Por exemplo, notícias que acabaram de carregar em um NewsRepository ou outros eventos.
    • Uma mistura de todas as opções acima.
  • Detentores de estado: tipos que aplicam a lógica de negócios e/ou a lógica da IU a origens de mudança de estado e processam eventos de usuário para produzir o estado da IU.
  • Saída: o estado da IU que o app pode renderizar para fornecer aos usuários as informações de que eles precisam.
O pipeline de produção do estado
Figura 2: o pipeline de produção do estado

APIs de produção de estado

Há duas APIs principais usadas na produção do estado, dependendo do estágio em que você está no pipeline:

Estágio do pipeline API
Entrada Use APIs assíncronas para realizar o trabalho fora da linha de execução de IU para manter a IU sem instabilidade. Por exemplo, corrotinas ou fluxos em Kotlin e RxJava ou callbacks na linguagem de programação Java.
Saída Use APIs do detentor de dados observáveis para invalidar e renderizar novamente a IU quando o estado mudar. Por exemplo, StateFlow, Compose State ou LiveData. Os detentores de dados observáveis garantem que a IU sempre tenha um estado para mostrar na tela

Das duas, a escolha da API assíncrona para entrada tem mais influência sobre a natureza do pipeline de produção de estado do que a escolha da API observável para saída. Isso ocorre porque as entradas ditam o tipo de processamento que pode ser aplicado ao pipeline.

Montagem do pipeline de produção de estado

As próximas seções abordam as técnicas de produção de estado mais adequadas para várias entradas e as APIs de saída correspondentes. Cada pipeline de produção de estado é uma combinação de entradas e saídas e precisa ter estas características:

  • Conhecimento do ciclo de vida: quando a IU não está visível ou ativa, o pipeline de produção do estado não consome nenhum recurso, a menos que explicitamente necessário.
  • Fácil de consumir: a IU precisa renderizar facilmente o estado produzido pela IU. As considerações da saída do pipeline de produção de estado variam em diferentes APIs de visualização, como o sistema de visualização ou o Jetpack Compose.

Entradas em pipelines de produção de estado

As entradas em um pipeline de produção de estado podem fornecer as origens de mudança de estado por meio de:

  • operações únicas que podem ser síncronas ou assíncronas. Por exemplo, chamadas para funções suspend;
  • APIs de streaming. Por exemplo, Flows;
  • Todas as alternativas acima.

Nas seções a seguir, abordamos como montar um pipeline de produção de estado para cada uma das entradas acima.

APIs únicas como origens de mudança de estado

Use a API MutableStateFlow como um contêiner de estado observável e mutável. Em apps do Jetpack Compose, você também pode considerar mutableStateOf, principalmente ao trabalhar com APIs de texto do Compose. As duas APIs oferecem métodos que permitem atualizações atômicas seguras nos valores que hospedam, independentemente das atualizações serem síncronas ou assíncronas.

Por exemplo, pense em atualizações de estado em um app simples para jogar dados. Cada jogada do dado do usuário invoca o método síncrono Random.nextInt(), e o resultado é gravado no estado da IU.

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

Estado do Compose

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

Como silenciar o estado da IU em chamadas assíncronas

Para mudanças de estado que exigem um resultado assíncrono, inicie uma corrotina no CoroutineScope apropriado. Isso permite que o app descarte o trabalho quando o CoroutineScope é cancelado. O detentor do estado grava o resultado da chamada do método de suspensão na API observável usada para expor o estado da IU.

Por exemplo, considere o AddEditTaskViewModel no Exemplo de arquitetura. Quando o método saveTask() de suspensão salva uma tarefa de forma assíncrona, o método update no MutableStateFlow propaga a mudança de estado para o estado da IU.

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

Estado do Compose

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

Como silenciar o estado da IU em linhas de execução em segundo plano

É preferível iniciar corrotinas no agente principal para a produção do estado da IU. Ou seja, fora do bloco withContext nos snippets de código abaixo. No entanto, se você precisar atualizar o estado da IU em um contexto de segundo plano diferente, use as APIs a seguir:

  • Use o método withContext para executar corrotinas em um contexto simultâneo diferente.
  • Ao usar MutableStateFlow, use o método update normalmente.
  • Ao usar o estado do Compose, use o Snapshot.withMutableSnapshot para garantir atualizações atômicas no estado no contexto simultâneo.

Por exemplo, no snippet DiceRollViewModel abaixo, suponha que SlowRandom.nextInt() seja uma função suspend de uso intensivo de computação que precisa ser chamada em uma corrotina vinculada à 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,
                    )
                }
            }
        }
    }
}

Estado do Compose

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

APIs de fluxo como origens de mudança de estado

Para origens de mudança de estado que produzem vários valores ao longo do tempo em fluxos, agregar as saídas de todas as origens em um todo coeso é uma abordagem simples para a produção do estado.

Ao usar fluxos do Kotlin, é possível fazer isso com a função combinar. Confira um exemplo disso no InterestsViewModel do código do "Now in Android" (link em inglês):

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

O uso do operador stateIn para criar StateFlows proporciona à IU um controle mais refinado sobre a atividade do pipeline de produção de estado, já que pode ser necessário que ele esteja ativo apenas quando a IU estiver visível.

  • Use SharingStarted.WhileSubscribed() se o pipeline só precisar ficar ativo quando a IU estiver visível ao coletar o fluxo de uma forma que reconhece o ciclo de vida.
  • Use SharingStarted.Lazily se o pipeline precisar ficar ativo desde que o usuário possa retornar à IU, ou seja, a IU esteja na backstack ou em outra guia fora da tela.

Quando a agregação de origens de estado baseadas em fluxo não se aplica, as APIs de fluxo, como fluxos do Kotlin, oferecem um conjunto avançado de transformações, por exemplo, fusão e nivelamento (links em inglês) para ajudar no processamento dos fluxos no estado da IU.

APIs únicas e de fluxo como origens de mudança de estado

Quando o pipeline de produção de estado depende de chamadas únicas e de fluxos como origens de mudança de estado, os fluxos são a restrição que define. Portanto, converta as chamadas únicas em APIs de fluxo ou canalize a saída em fluxos e retome o processamento conforme descrito na seção de fluxos acima.

Com fluxos, isso geralmente significa criar uma ou mais instâncias de MutableStateFlow de apoio particulares para propagar mudanças de estado. Também é possível criar fluxos de snapshot no estado do Compose.

Considere o TaskDetailViewModel no repositório architecture-samples (link em inglês) abaixo:

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

Estado do Compose

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

Tipos de saída em pipelines de produção de estado

A escolha da API de saída para o estado da IU e a natureza da apresentação dependem muito da API usada pelo app para renderizar a IU. Em apps Android, você pode usar visualizações ou o Jetpack Compose. Inclui as seguintes considerações:

A tabela a seguir resume as APIs a serem usadas para o pipeline de produção do estado para qualquer entrada e consumidor:

Entrada Consumidor Saída
APIs únicas Visualizações StateFlow ou LiveData
APIs únicas Compose StateFlow ou State do Compose
APIs de fluxo Visualizações StateFlow ou LiveData
APIs de fluxo Compose StateFlow
APIs únicas e de fluxo Visualizações StateFlow ou LiveData
APIs únicas e de fluxo Compose StateFlow

Exemplos

Os exemplos do Google a seguir demonstram como ocorre a produção de estado na camada de IU. Acesse-os para ver a orientação na prática: