Estado e Jetpack Compose

Mantenha tudo organizado com as coleções Salve e categorize o conteúdo com base nas suas preferências.

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ções 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 estado e elementos de composição, 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 de composição. Esses argumentos são representações do estado da IU. Sempre que um estado é atualizado, ocorre uma recomposição. Por isso, itens como TextField não são atualizados automaticamente como seriam em visualizações imperativas baseadas em XML. Um elemento de composição precisa ser explicitamente informado sobre o novo estado para que seja atualizado corretamente.

@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 em elementos de composição

As funções de composição podem usar a API remember para armazenar um objeto na memória. Um valor calculado pela remember é armazenado durante a composição inicial e retornado durante a recomposição. remember pode ser usada para armazenar tanto objetos mutáveis quanto 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 programa a recomposição de qualquer função de composição 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 de composição:

  • 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 de composição 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 de composição ou mesmo como lógica em instruções para mudar quais desses elementos serão mostrados. 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 armazenamento personalizado.

Outros tipos de estado com suporte

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 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> com base em tipos observáveis comuns usados em apps Android:

Você pode criar uma função de extensão para o Jetpack Compose 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 de composição.

Com estado X sem estado

Um elemento de composição 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 têm estado interno tendem a ser menos reutilizáveis e mais difíceis de testar.

Um elemento de composição sem estado é aquele que não tem estado algum. Uma maneira fácil de ficar sem estado é usar a elevação de estado.

Ao desenvolver elementos de composição 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 que o autor da chamada possa transformar e remover o estado de um elemento de composição. 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 de composição, 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 de composição com estado poderão modificar esse estado. Ele é totalmente interno.
  • Compartilhável: o estado elevado pode ser compartilhado com vários elementos de composição. Por exemplo, a elevação permite usar name em um elemento de composição diferente.
  • Interceptável: os autores de chamada para elementos de composição 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, name e onValueChange são extraídos de HelloContent e movidos para cima na árvore até um elemento HelloScreen de composição 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, reutilizar em situações diferentes e testar o elemento de composição. O HelloContent está dissociado do modo como o estado é armazenado. Isso significa que, se você modifica ou substitui HelloScreen, não precisa mudar a forma como 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 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, @Parcelize não for adequado, use mapSaver para definir sua própria regra de conversão de um objeto em um conjunto de valores que o sistema pode 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 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 de composição. No entanto, caso a quantidade de estados a serem gerenciados aumente ou surja uma lógica para realizar em funções de composição, é 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 de composição, há diferentes alternativas a serem consideradas:

  • Elementos de composição para gerenciar de forma simple o estado do elemento da IU.
  • Detentores de estado para gerenciar de forma complexa o 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 IU na tela.

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 de composição pode depender de 0 ou mais detentores de estado, dependendo da complexidade. Eles podem ser objetos simples, ViewModels ou ambos.
  • Um detentor de estado simples pode depender de um ViewModel se precisa 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 serem considerados:

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

  • O estado do elemento da IU é o estado elevado desses elementos. Por exemplo, ScaffoldState processa o estado do elemento Scaffold de composição.

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

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

  • A lógica da IU está relacionada a como mostrar 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 da IU precisa estar sempre na composição.

Elementos de composição como fonte da verdade

Ter uma lógica da IU e um estado de elementos da IU entre os elementos de composição é uma boa abordagem se ambos são simples. Por exemplo, veja o processamento de ScaffoldState e CoroutineScope pelo elemento MyApp.

@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 de composição. Caso contrário, se ele é transmitido para outros elementos de composição, eles podem modificar o estado, o que vai contra o princípio de fonte única da verdade e dificulta o rastreamento de bugs.

Detentores de estado como fonte da verdade

Quando um elemento de composição contém uma lógica da IU complexa que envolve o estado de vários elementos da IU, ele precisa delegar essa responsabilidade a 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 de composição é 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 de composição, podem usar dependências do Compose.

Se o elemento MyApp da seção Elementos de composição como fonte da verdade passa a ter mais responsabilidades, é possível criar um detentor de estado MyAppState para gerenciar essa complexidade:

// 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 de composição 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 vai 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 IU na tela.

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 de composição no nível da tela usem instâncias do ViewModel para fornecer acesso à lógica de negócios e ser a fonte da verdade para o estado da IU. Não transmita instâncias do ViewModel para outros elementos de composição. Consulte a seção ViewModel e holders de estado para ver por que o ViewModel pode ser usado para isso.

Confira abaixo um exemplo de ViewModel usado em um elemento de composição da tela:

data class ExampleUiState(
    val dataToDisplayOnScreen: List<Example> = emptyList(),
    val userMessages: List<Message> = emptyList(),
    val 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
    /* ... */

    ExampleReusableComponent(
        someData = uiState.dataToDisplayOnScreen,
        onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
    )
}

@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
    /* ... */
    Button(onClick = onDoSomething) {
        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 de composição.
    • 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 de composição, 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 composição da tela tenha tanto um ViewModel que dá acesso à lógica de negócios quanto um detentor de estado que gerencia a lógica da IU e o estado dos 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:

class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) {
    fun isExpandedItem(item: Item): Boolean = TODO()
    /* ... */
}

@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }

@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