Estado e Jetpack Compose

O estado em um app é qualquer valor que pode mudar ao longo do tempo. Essa é uma definição muito ampla e abrange tudo, de um banco de dados da Room até a variável em uma classe.

Todos os apps Android exibem o estado para o usuário. Alguns exemplos de estado em apps Android:

  • Um snackbar que mostra quando não é possível estabelecer uma conexão de rede.
  • Uma postagem de blog e comentários associados.
  • Animação de ripple em botões que são reproduzidas quando um usuário clica neles.
  • Adesivos que podem ser desenhados sobre uma imagem.

O Jetpack Compose ajuda a deixar claro onde e como você armazena e usa o estado em um app Android. Este guia se concentra na conexão entre o estado e os elementos que podem ser compostos, assim como nas APIs que o Jetpack Compose oferece para trabalhar mais facilmente com o estado.

Estado e composição

O Compose é declarativo e, portanto, a única maneira de atualizá-lo é chamando com novos argumentos o mesmo elemento que pode composto. Esses argumentos são representações do estado da IU. Sempre que um estado é atualizado, ocorre uma recomposição. Por isso, itens como o TextField não são atualizados automaticamente como seriam em visualizações imperativas baseadas em XML. Um elemento que pode ser composto precisa ser explicitamente informado sobre o novo estado para que seja adequadamente atualizado.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

Se você executar esse código, verá que nada acontece. Isso ocorre porque o TextField não atualiza a si mesmo. Ele é atualizado quando o parâmetro value muda. Isso se deve à maneira como a composição e a recombinação funcionam no Compose.

Para saber mais sobre a composição inicial e a recomposição, consulte Trabalhando com o Compose.

Estado dos elementos que podem ser compostos

As funções que podem ser compostas podem armazenar um único objeto na memória usando o elemento remember. Um valor calculado por remember é armazenado durante a composição inicial, e o valor armazenado é retornado durante a recomposição. O remember pode ser usado para armazenar objetos mutáveis e imutáveis.

A função mutableStateOf cria um MutableState<T> observável, que é integrado ao ambiente de execução do Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Qualquer mudança em value programará a recomposição de qualquer função que pode ser composta que leia value. No caso de ExpandingCard, sempre que expanded é mudado, isso faz com que ExpandingCard seja recomposto.

Há três maneiras de declarar um objeto MutableState em um elemento que pode ser composto:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Essas declarações são equivalentes e são fornecidas como açúcar de sintaxe para diferentes usos do estado. Escolha aquela que produz o código mais fácil de ler no elemento que pode ser composto que você está criando.

A sintaxe by delegada requer estas importações:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

É possível usar o valor salvo como parâmetro para outros elementos que podem ser compostos ou mesmo como lógica em instruções para mudar quais desses elementos serão exibidos. Por exemplo, se você não quiser exibir a saudação se o nome estiver vazio, use o estado em uma instrução if:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

Embora remember ajude a manter o estado em recomposições, o estado não é mantido em todas as mudanças de configuração. Para isso, use rememberSaveable. O rememberSaveable salva automaticamente qualquer valor que possa ser salvo em um Bundle. Para outros valores, é possível transmitir um objeto de economia personalizado.

Outros tipos de estado compatíveis

O Jetpack Compose não exige que você use MutableState<T> para manter o estado. Ele é compatível com outros tipos observáveis. Antes de ler outro tipo observável no Jetpack Compose, você precisa convertê-lo em um State<T> para que o Jetpack Compose possa fazer automaticamente a recomposição quando o estado for modificado.

O Compose é enviado com funções para criar State<T> a partir de tipos observáveis comuns usados em apps Android:

Você pode criar uma função de extensão para o Jetpack Compose para ler outros tipos observáveis se o app usar uma classe observável personalizada. Consulte a implementação dos builtins para ver exemplos de como fazer isso. Qualquer objeto que permita que o Jetpack Compose faça a inscrição em todas as mudanças pode ser convertido em State<T> e lido por um elemento que pode ser composto.

Com estado X sem estado

Um elemento que pode ser composto que usa o remember para armazenar um objeto cria um estado interno, transformando o elemento em com estado. O HelloContent é um exemplo de elemento com estado porque mantém e modifica internamente o estado de name. Isso pode ser útil em situações em que um autor de chamada não precisa controlar o estado e pode usá-lo sem ter que gerenciar o estado por conta própria. No entanto, os elementos que podem ser compostos que têm estado interno tendem a ser menos reutilizáveis e mais difíceis de testar.

Um elemento que pode ser composto sem estado é um elemento que não tem um estado. Uma maneira fácil de ficar sem estado é usando a elevação de estado.

Ao desenvolver elementos que podem ser compostos reutilizáveis, frequentemente você quer expor uma versão com estado e uma sem estado do mesmo elemento. A versão com estado é conveniente para autores de chamada que não se importam com ele, e a sem estado é necessária para autores de chamada que precisam controlar ou elevar o estado.

Elevação de estado

A elevação de estado no Compose é um padrão para mover um estado para o autor da chamada de modo a criar um elemento que pode ser composto sem estado. O padrão geral para elevação de estado no Jetpack Compose é substituir a variável por dois parâmetros:

  • value: T: o valor atual a ser exibido.
  • onValueChange: (T) -> Unit: um evento que solicita a mudança do valor, em que T é o novo valor proposto.

No entanto, você não se limita a onValueChange. Se eventos mais específicos forem apropriados para o elemento que pode ser composto, defina-os usando lambdas da mesma forma que ExpandingCard faz com onExpand e onCollapse.

O estado elevado dessa maneira tem algumas propriedades importantes:

  • Única fonte da verdade: ao mover o estado em vez de duplicá-lo, garantimos que exista apenas uma fonte de verdade. Isso ajuda a evitar bugs.
  • Encapsulado: somente elementos que podem ser compostos com estado poderão modificar esse estado. Ele é totalmente interno.
  • Compartilhável: o estado elevado pode ser compartilhado com vários elementos que podem ser compostos. Caso quiséssemos usar name em um tipo diferente de elemento que pode ser composto, por exemplo, a elevação permitiria isso.
  • Interceptável: os autores de chamada para elementos que podem ser compostos sem estado podem decidir ignorar ou modificar eventos antes de mudar o estado.
  • Dissociado: o estado do ExpandingCard sem estado pode ser armazenado em qualquer lugar. Por exemplo, agora é possível mover o name para um ViewModel.

No exemplo, o name e o onValueChange são extraídos de HelloContent e movidos para cima na árvore até um elemento HelloScreen que pode ser composto e que chama o HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

Ao elevar o estado do HelloContent, é mais fácil entender o elemento que pode ser composto, reutilizá-lo em situações diferentes e testá-lo. O HelloContent está dissociado do modo como o estado é armazenado. Isso significa que, se você modificar ou substituir HelloScreen, não precisará mudar a forma como o HelloContent é implementado.

O padrão em que o estado desce e os eventos sobem é chamado de fluxo de dados unidirecional. Nesse caso, o estado desce de HelloScreen para HelloContent e os eventos sobem de HelloContent para HelloScreen. Ao seguir o fluxo de dados unidirecional, você pode dissociar os elementos que exibem o estado na IU das partes do app que armazenam e mudam o estado.

Como restaurar o estado no Compose

Use o rememberSaveable para restaurar o estado da IU após a recriação de uma atividade ou de um processo. O rememberSaveable mantém o estado nas recomposições. Além disso, ele também mantém o estado nas recriações de atividades e de processos.

Formas de armazenar o estado

Todos os tipos de dados adicionados ao Bundle são salvos automaticamente. Caso você queira salvar algo que não possa ser adicionado ao Bundle, há várias opções.

Parcelize

A solução mais simples é adicionar a anotação @Parcelize (link em inglês) ao objeto. O objeto se tornará parcelable e poderá ser empacotado. Por exemplo, esse código cria um tipo de dado parcelable City e o salva no estado.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Se, por algum motivo, o @Parcelize não for adequado, use o mapSaver para definir sua própria regra de conversão de um objeto em um conjunto de valores que o sistema poderá salvar no Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Para evitar a necessidade de definir as chaves do mapa, você também pode usar listSaver e usar seus índices como chaves:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Como gerenciar o estado no Compose

A elevação de estado simples pode ser gerenciada nas próprias funções que podem ser compostas. No entanto, caso a quantidade de estados para gerenciar aumente ou surja uma lógica para realizar em funções que podem ser compostas, é recomendável delegar as responsabilidades de lógica e estado a outras classes: detentores de estado.

Esta seção aborda como gerenciar o estado de várias maneiras no Compose. Dependendo da complexidade do elemento que pode ser composto, há diferentes alternativas para considerar:

  • Elementos que podem ser compostos no gerenciamento simples de estado do elemento da IU.
  • Detentores de estado no gerenciamento complexo de estado do elemento da IU. Eles são detentores do estado e da lógica dos elementos da IU.
  • Os ViewModels dos componentes da arquitetura são um tipo especial de detentores de estado responsáveis por fornecer acesso à lógica de negócios e ao estado da tela ou da IU.

Os detentores de estado têm vários tamanhos, dependendo do escopo dos elementos da IU correspondentes que gerenciam, variando de um único widget, como uma barra inferior de apps, à tela inteira. Os detentores de estado são agrupáveis, ou seja, um detentor de estado pode ser integrado a outro, principalmente ao agregar estados.

O diagrama a seguir mostra um resumo das relações entre as entidades envolvidas no gerenciamento de estado do Compose. O restante da seção abrange cada entidade em detalhes:

  • Um elemento que pode ser composto pode depender de 0 ou mais detentores de estado (que podem ser objetos simples, ViewModels ou ambos), dependendo da complexidade.
  • Um detentor de estado simples poderá depender de um ViewModel se precisar de acesso à lógica de negócios ou ao estado da tela.
  • Um ViewModel depende das camadas de negócios ou de dados.

Diagrama mostrando as dependências no gerenciamento de estado, conforme descrito na lista anterior.

Resumo das dependências (opcionais) para cada entidade envolvida no gerenciamento de estado do Compose.

Tipos de estado e lógica

Em um app Android, há diferentes tipos de estado a considerar:

  • O estado do elemento da IU é o estado elevado desses elementos. Por exemplo, ScaffoldState processa o estado do elemento Scaffold que pode ser composto.

  • O estado da tela ou IU é o que precisa ser exibido na tela. Por exemplo, uma classe CartUiState que pode conter itens do carrinho, mensagens a serem exibidas ao usuário ou carregamento de sinalizações. Esse estado geralmente está conectado a outras camadas da hierarquia, já que contém dados do aplicativo.

Além disso, existem diferentes tipos de lógica:

  • A lógica do comportamento da IU ou lógica da IU está relacionada a como exibir mudanças de estado na tela. Por exemplo, a lógica de navegação decide qual tela exibir em seguida, ou então a lógica da IU decide como exibir mensagens do usuário na tela com snackbars ou avisos. A lógica do comportamento da IU precisa estar sempre na composição.

  • A lógica de negócios informa o que fazer com as mudanças de estado. Por exemplo, fazer um pagamento ou armazenar preferências do usuário. Essa lógica geralmente é colocada nas camadas de negócios ou de dados, nunca na camada da IU.

Elementos que podem ser compostos como fonte da verdade

Ter uma lógica da IU e um estado de elementos da IU entre os elementos que podem ser compostos será uma boa abordagem se ambos forem simples. Por exemplo, veja o processamento de ScaffoldState e CoroutineScope pelo elemento MyApp que pode ser composto.

@Composable
fun MyApp() {
    MyTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()

        Scaffold(scaffoldState = scaffoldState) {
            MyContent(
                showSnackbar = { message ->
                    coroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(message)
                    }
                }
            )
        }
    }
}

Como ScaffoldState contém propriedades mutáveis, todas as interações com ele precisam acontecer no elemento MyApp que pode ser composto. Caso contrário, se ele for transmitido para outros elementos que podem ser compostos, eles poderão modificar o estado dele, o que não está de acordo com a única fonte do princípio da verdade e dificulta o rastreamento de bugs.

Detentores de estado como fonte da verdade

Quando um elemento que pode ser composto contém uma lógica da IU complexa que envolve o estado de vários elementos da IU, ele precisa delegar essa responsabilidade aos detentores de estado. Isso faz com que essa lógica seja mais testável em isolamento e reduz a complexidade do elemento. Essa abordagem favorece o princípio de separação de conceitos: o elemento que pode ser composto é responsável por emitir elementos da IU, e o detentor do estado contém a lógica da IU e o estado de elementos da IU.

Os detentores de estado são classes simples criadas e lembradas na composição. Como eles seguem o ciclo de vida dos elementos que podem ser compostos, podem usar dependências do Compose.

Se o elemento MyApp que pode ser composto da seção Elementos que podem ser compostos como fonte da verdade passar a ter mais responsabilidades, será possível criar um detentor de estado MyAppState para gerenciar a complexidade dele:

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
        get() = /* ... */

    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

Como MyAppState usa dependências, é recomendável fornecer um método que se lembre de uma instância de MyAppState na composição. Nesse caso, a função rememberMyAppState.

Agora, o foco de MyApp é a emissão de elementos da IU, delegando toda a lógica da IU e o estado dos elementos da IU a MyAppState:

@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { /* ... */ }
        }
    }
}

Como você pode ver, incrementar as responsabilidades de um elemento que pode ser composto aumenta a necessidade de um detentor de estado. As responsabilidades podem estar na lógica da IU ou apenas na quantidade de estados para gerenciar.

ViewModels como fonte da verdade

Se as classes de detentores de estado simples forem responsáveis pela lógica da IU e pelo estado dos elementos da IU, um ViewModel será um tipo especial de detentor de estado responsável por:

  • fornecer acesso à lógica de negócios do aplicativo, que normalmente é colocada em outras camadas da hierarquia, como as camadas de negócios e de dados;
  • preparar os dados do aplicativo para a apresentação em uma tela específica, que se torna o estado da tela ou da IU.

Os ViewModels têm um ciclo de vida mais longo que o da composição porque sobrevivem a mudanças de configuração. Eles podem seguir o ciclo de vida do host do conteúdo do Compose, ou seja, atividades ou fragmentos, ou o ciclo de vida de um destino ou gráfico de navegação, caso você esteja usando a biblioteca Navigation. Devido ao ciclo de vida mais longo, os ViewModels não podem conter referências ao estado de duração longa vinculadas ao ciclo de vida da composição. Se isso acontecer, eles poderão causar vazamentos de memória.

Recomendamos que os elementos que podem ser compostos da tela usem ViewModels para fornecer acesso à lógica de negócios e ser a fonte da verdade para o estado da IU. Consulte a seção ViewModel e detentores de estado para ver por que os ViewModels são adequados para essa função.

Veja a seguir um exemplo de ViewModel usado em um elemento que pode ser composto na tela:

data class ExampleUiState(
    dataToDisplayOnScreen: List<Example> = emptyList(),
    userMessages: List<Message> = emptyList(),
    loading: Boolean = false
)

class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf<ExampleUiState>(...)
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { ... }
}

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    ...

    Button(onClick = { viewModel.somethingRelatedToBusinessLogic() }) {
        Text("Do something")
    }
}

ViewModel e detentores de estado

Os benefícios dos ViewModels no desenvolvimento Android os tornam adequados para fornecer acesso à lógica de negócios e preparar os dados do aplicativo para serem apresentados na tela. Estas são as vantagens:

  • Operações acionadas por ViewModels sobrevivem a mudanças de configuração.
  • Integração com o Navigation:
    • O Navigation armazena em cache os ViewModels enquanto a tela está na backstack. Isso é importante para disponibilizar os dados já carregados instantaneamente ao retornar ao destino. É mais difícil fazer isso com um detentor de estado que segue o ciclo de vida da tela que pode ser composta.
    • O ViewModel também é apagado quando o destino é retirado da backstack, garantindo que o estado seja limpo automaticamente. Isso é diferente da detecção do descarte de elementos que podem ser compostos, que pode ocorrer por vários motivos, como abertura de uma nova tela devido a uma mudança de configuração etc.
  • Integração com outras bibliotecas do Jetpack, como a Hilt.

Como os detentores de estado são agrupáveis e os ViewModels e detentores de estado simples têm responsabilidades diferentes, é possível que um elemento de tela que pode ser composto tenha tanto um ViewModel que fornece acesso à lógica de negócios QUANTO um detentor de estado que gerencia a lógica da IU e o estado de elementos da IU. Como os ViewModels têm um ciclo de vida mais longo que o dos detentores de estado, eles poderão usar os ViewModels como uma dependência, se necessário.

O código a seguir mostra um ViewModel e um detentor de estado simples trabalhando juntos em uma ExampleScreen:

private class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) { ... }

@Composable
private fun rememberExampleState(...) { ... }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item) {
                ...
            }
            ...
        }
    }
}

Saiba mais

Para saber mais sobre o estado e Jetpack Compose, consulte os recursos abaixo.

Codelabs

Vídeos