Como arquitetar a IU do Compose

No Compose, a IU é imutável. Não há como atualizá-la depois que ela for desenhada. O que pode ser controlado é o estado da IU. Cada vez que o estado da IU muda, o Compose recria as partes da árvore da IU que mudaram. As funções que podem ser compostas conseguem aceitar estados e expor eventos. Por exemplo, um TextField aceita um valor e expõe um onValueChange de callback que solicita que o gerenciador de callback mude o valor.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Como as funções que podem ser compostas aceitam estados e expõem eventos, o padrão de fluxo de dados unidirecional é adequado para o Jetpack Compose. Este guia se concentra em como implementar o padrão de fluxo de dados unidirecional no Compose, como implementar detentores de estados e eventos e como trabalhar com ViewModels no Compose.

Fluxo de dados unidirecional

Um fluxo de dados unidirecional (UDF, na sigla em inglês) é um padrão de design em que os estados fluem para baixo e os eventos para cima. Ao seguir o fluxo de dados unidirecional, você pode desagrupar as funções que podem ser compostas que exibem o estado na IU das partes do app que armazenam e mudam esse estado.

O loop de atualização da IU para um app usando o fluxo de dados unidirecional é semelhante a este:

  • Evento: parte da IU gera um evento e o transmite para cima, como um clique de botão transmitido ao ViewModel para ser processado, ou um evento transmitido de outras camadas do app, como indicar que a sessão do usuário expirou.
  • Estado de atualização: um manipulador de eventos pode mudar o estado.
  • Estado de exibição: o detentor do estado transmite esse estado e a IU o exibe.

Fluxo de dados unidirecional

Seguir esse padrão ao usar o Jetpack Compose oferece várias vantagens:

  • Capacidade de teste: a separação do estado da IU que o exibe facilita a realização de testes em ambos de forma isolada.
  • Encapsulamento de estado: como o estado pode ser atualizado em um só lugar e há apenas uma fonte de verdade para o estado de uma função que pode ser composta, é menos provável que você crie bugs causados por estados inconsistentes.
  • Consistência da IU: todas as atualizações de estado são refletidas imediatamente na IU pelo uso de detentores de estado observáveis, como LiveData ou StateFlow.

Fluxo de dados unidirecional no Jetpack Compose

Funções que podem ser compostas operam com base em estados e eventos. Por exemplo, um TextField só é atualizado quando o parâmetro value é atualizado e expõe um callback onValueChange, evento que solicita que o valor mude para um novo. O Compose define o objeto State como um detentor de valor, e as mudanças de valor do estado acionam uma recomposição. É possível manter o estado em um remember { mutableStateOf(value) } ou um rememberSaveable { mutableStateOf(value), dependendo do tempo pelo qual o valor precisa ser lembrado.

O tipo de valor do TextField que pode ser composto é String. Portanto, ele pode ser originado de qualquer lugar: seja de um valor fixo no código, um ViewModel ou transmitido da função que pode ser composta mãe. Não é necessário mantê-lo em um objeto State, mas é necessário atualizar o valor quando onValueChange é chamado.

Definir parâmetros que podem ser compostos

Ao definir os parâmetros de estado de uma função que pode ser composta, é necessário considerar as seguintes questões:

  • Qual é a capacidade de reutilização ou flexibilidade da função?
  • Como os parâmetros de estado afetam o desempenho dessa função?

Para incentivar o desagrupamento e a reutilização, cada função que pode ser composta precisa conter a menor quantidade possível de informações. Por exemplo, ao criar uma função para conter o cabeçalho de uma matéria jornalística, prefira transmitir apenas as informações que precisam ser exibidas, e não a matéria toda:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

Algumas vezes, o uso de parâmetros individuais também melhora o desempenho. Por exemplo, caso News contenha mais informações do que apenas title e subtitle, sempre que uma nova instância de News for transmitida para Header(news), a composição será recompilada, ainda que title e subtitle não tenham mudado.

Considere cuidadosamente o número de parâmetros transmitidos. Ter uma função com muitos parâmetros diminui a ergonomia dela. Portanto, nesse caso, agrupar as funções em uma classe é a melhor opção.

Eventos no Compose

Cada entrada do app precisa ser representada como um evento: toques, mudanças de texto e até mesmo timers ou outras atualizações. À medida que esses eventos mudam o estado da IU, o ViewModel será responsável por processá-los e atualizar o estado da IU.

A camada de IU nunca muda o estado fora de um gerenciador de eventos, porque isso pode introduzir inconsistências e bugs no aplicativo.

Prefira transmitir valores imutáveis para lambdas de estado e manipulador de evento. Essa abordagem tem os seguintes benefícios:

  • Melhora a reutilização.
  • Garante que a IU não mudará o valor do estado diretamente.
  • Evita problemas de simultaneidade, porque garante que o estado não será modificado a partir de outra linha de execução.
  • Geralmente, reduz a complexidade do código.

Por exemplo, uma função que pode ser composta que aceita uma String e um lambda como parâmetros pode ser chamada a partir de muitos contextos e é altamente reutilizável. Suponha que a barra superior do app sempre exiba texto e tenha um botão "Voltar". Você pode definir uma função MyAppTopAppBar mais genérica que pode ser composta e que receba o texto e o botão "Voltar" como parâmetros:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                        Icons.Filled.ArrowBack,
                        contentDescription = localizedString
                    )
            }
        },
        // ...
    )
}

ViewModels, estados e eventos: um exemplo

Ao usar ViewModel e mutableStateOf, você também pode introduzir o fluxo de dados unidirecional no app, se uma das condições a seguir for verdadeira:

  • O estado da IU é exposto tendo LiveData como a implementação do detentor de estado observável.
  • O ViewModel gerencia eventos provenientes da IU ou de outras camadas do app e atualiza o detentor do estado com base nos eventos.

Por exemplo, ao implementar uma tela de login, tocar em um botão Login fará com que o app exiba um ícone de progresso de carregamento e uma chamada de rede. Se o login for realizado corretamente, o app navegará para uma tela diferente. No caso de um erro, o app exibirá uma Snackbar. Veja como modelar o estado da tela e o evento:

A tela tem quatro estados:

  • Desconectado: quando o usuário ainda não fez login.
  • Em andamento: quando o app está tentando fazer login do usuário, realizando uma chamada de rede.
  • Erro: quando ocorreu um erro durante o login.
  • Conectado: quando o usuário está conectado.

Você pode modelar esses estados como uma classe selada. O ViewModel expõe o estado como State, define o estado inicial e atualiza o estado conforme necessário. O ViewModel também processa o evento de login ao expor um método onSignIn().

sealed class UiState {
    object SignedOut : UiState()
    object InProgress : UiState()
    object Error : UiState()
    object SignIn : UiState()
}

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Além da API mutableStateOf, o Compose fornece extensões para LiveData, Flow e Observable para serem registradas como um listener e representar o valor como um estado.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}