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. 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 mudar, isso fará com que o 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. 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á desacoplado do modo como o estado é armazenado. Isso significa que, se você modificar ou substituir a 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 da HelloScreen para HelloContent e os eventos sobem do HelloContent para a 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.

ViewModel e estado

Os ViewModels são os detentores de estado recomendados para elementos que podem ser compostos que estão no topo da árvore de IU do Compose ou para elementos que são destinos na biblioteca Navigation. Os ViewModels sobrevivem a mudanças de configuração. Assim, eles permitem agrupar estados e eventos relacionados à IU sem que seja necessário lidar com a atividade ou o ciclo de vida do fragmento que hospeda o código do Compose.

Os ViewModels precisam expor o estado em um detentor observável, como LiveData ou StateFlow. Quando o objeto de estado é lido durante uma composição, o escopo de recomposição atual da composição é automaticamente inscrito para atualizações desse objeto de estado.

É possível ter um ou mais detentores de estado observáveis: cada um precisa deter o estado de partes da tela conceitualmente relacionadas e que mudam em conjunto. Dessa forma, você preserva uma única fonte da verdade, mesmo que o estado seja usado em vários elementos que podem ser compostos.

Você pode usar o LiveData e o ViewModel no Jetpack Compose para implementar o fluxo de dados unidirecional. O exemplo da HelloScreen seria implementado usando um ViewModel, desta forma:

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(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") }
        )
    }
}

O observeAsState observa um LiveData<T> e retorna um objeto State<T> que é atualizado sempre que o LiveData muda. O State<T> é um tipo observável que o Jetpack Compose pode usar diretamente. O observeAsState observará o LiveData somente quando ele estiver na composição.

A linha:

val name: String by helloViewModel.name.observeAsState("")

... é o intervalo sintático para decodificar automaticamente o objeto de estado retornado por observeAsState. Você também pode atribuir o objeto de estado usando um operador de atribuição (=), que o transforma em um State<String> em vez de uma String:

val nameState: State<String> = helloViewModel.name.observeAsState("")

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. rememberSaveable mantém o estado nas recomposições. Além disso, rememberSaveable também armazena 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 o listSaver e usar os índices dele 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"))
    }
}

Saiba mais

Para saber mais sobre o estado e o Jetpack Compose, consulte Como usar estados no codelab do Jetpack Compose.