Produção do estado da interface

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

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

  • saber quais APIs precisam ser usadas para produzir o estado da interface. 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 interface para considerar os recursos do sistema;
  • saber como expor o estado da interface para consumo pela interface.

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

Eventos State
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 interface ou de outras origens. Consumido pela interface.

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

Confira quais podem ser a origem dos eventos:

  • Usuários: à medida que interagem com a interface do app.
  • Outras origens de mudança de estado: APIs que apresentam dados de apps da interface, 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 interface

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 interface: 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 interface que gera mudanças no estado da interface. Por exemplo, chamar o método open no DrawerState no Jetpack Compose.
    • Externas à camada da interface: são origens do domínio ou das camadas de dados que causam mudanças no estado da interface. 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 interface a origens de mudança de estado e processam eventos de usuário para produzir o estado da interface.
  • Saída: o estado da interface 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 interface para manter a interface 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 interface quando o estado mudar. Por exemplo, StateFlow, Compose State ou LiveData. Os detentores de dados observáveis garantem que a interface 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 interface 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 interface precisa renderizar facilmente o estado produzido pela interface. 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 interface.

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 mudar o estado da interface 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 interface.

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 interface.

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 mudar o estado da interface em linhas de execução em segundo plano

É preferível iniciar corrotinas no agente principal para a produção do estado da interface. Ou seja, fora do bloco withContext nos snippets de código abaixo. No entanto, se você precisar atualizar o estado da interface 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 à interface 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 interface estiver visível.

  • Use SharingStarted.WhileSubscribed() se o pipeline só precisar ficar ativo quando a interface 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 à interface, ou seja, a interface 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 interface.

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 definidora. 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 interface e a natureza da apresentação dependem muito da API usada pelo app para renderizar a interface. 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

Inicialização do pipeline de produção do estado

A inicialização de pipelines de produção do estado envolve definir as condições iniciais para que o pipeline seja executado. Isso pode incluir o fornecimento de valores de entrada essenciais para a inicialização do pipeline. Por exemplo, um id para a visualização detalhada de uma matéria ou o início de um carregamento assíncrono.

Quando possível, inicialize o pipeline de produção de estado lentamente para economizar recursos do sistema. Na prática, isso significa aguardar até que haja um consumidor da saída. As APIs Flow permitem isso com o argumento started no método stateIn. Nos casos em que isso não é aplicável, defina uma função initialize() idempotente (link em inglês) para iniciar explicitamente o pipeline de produção de estado, conforme mostrado no snippet a seguir:

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

Exemplos

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