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 do Room até uma variável em uma classe.

Todos os apps Android mostram 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 combináveis, assim como nas APIs que o Jetpack Compose oferece para trabalhar 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 combinável. Esses argumentos são representações do estado da interface. 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 combinável precisa ser explicitamente informado sobre o novo estado para que seja atualizado corretamente.

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

Se você executar esse código e tentar inserir texto, vai notar 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.

Composição inicial:a criação de uma composição ao executar elementos combináveis pela primeira vez.

Recomposição:nova execução de elementos combináveis para atualizar a composição quando os dados mudam.

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

Estado em elementos combináveis

As funções combináveis podem usar a API remember para armazenar um objeto na memória. Um valor calculado por remember é armazenado na composição 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 agenda a recomposição de todas as funções combináveis que leem value.

Há três maneiras de declarar um objeto MutableState em um elemento combinável:

  • 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 combinável 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 combináveis 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 quando 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.bodyMedium
            )
        }
        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 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 Compose, você precisa convertê-lo em um State<T> para que os elementos combináveis possam fazer a recomposição automaticamente quando o estado for modificado.

O Compose tem funções integradas para criar State<T> com base em tipos observáveis comuns usados em apps Android. Antes de usar essas integrações, adicione os artefatos adequados da seguinte forma:

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.10.0"
}
  • Flow (link em inglês): collectAsState()

    O collectAsState é semelhante ao collectAsStateWithLifecycle porque também coleta valores de um Flow e o transforma em um State do Compose.

    Use o collectAsState para o código independente de plataforma em vez de collectAsStateWithLifecycle, que é exclusivo para o Android.

    Outras dependências não são necessárias para collectAsState porque ele está disponível em compose-runtime.

  • LiveData: observeAsState()

    O observeAsState() começa a observar esse LiveData e representa os valores usando State.

    A dependência abaixo é necessária no arquivo build.gradle:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.12.0")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.12.0"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.12.0")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.12.0"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.12.0")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.12.0"
}

Com estado X sem estado

Um elemento combinável 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 combinável sem estado é aquele que não tem estado algum. Uma maneira comum de alcançar a ausência de estado é usando a elevação de estado.

Ao desenvolver elementos combináveis 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 combinável. 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 combinável, defina-os usando lambdas.

O estado elevado dessa maneira tem algumas propriedades importantes:

  • Única fonte de 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 combináveis com estado poderão modificar esse estado. Ele é totalmente interno.
  • Compartilhável: o estado elevado pode ser compartilhado com vários elementos combináveis. Se você quiser ler name em um combinável diferente, a elevação permitirá fazer isso.
  • Interceptável: os autores de chamada para elementos combináveis sem estado podem decidir ignorar ou modificar eventos antes de mudar o estado.
  • Dissociado:o estado dos elementos combináveis 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 combinável 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.bodyMedium
        )
        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 combinável. 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.

Fluxo de dados unidirecional

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 interface das partes do app que armazenam e mudam o estado.

  1. O estado precisa ser elevado pelo menos até o pai menos comum de todos os elementos combináveis que usam esse estado (leitura).
  2. O estado precisa ser elevado pelo menos até o nível mais alto de mudança possível (gravação).
  3. Se dois estados mudarem em resposta aos mesmos eventos, eles precisarão ser elevados juntos.

Você pode elevar um estado além do que essas regras exigem, mas se elevar abaixo do mínimo, isso vai dificultar ou impossibilitar seguir o fluxo de dados unidirecional.

Consulte a página Onde elevar o estado para saber mais.

Como restaurar o estado no Compose

A API rememberSaveable se comporta de maneira semelhante a remember porque mantém o estado entre recomposições e também entre recriações de atividades ou processos usando o mecanismo de estado da instância salvo. Por exemplo, isso acontece quando a tela é girada.

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

Detentores de estado no Compose

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

Em outros materiais, os detentores de estado também são chamados de objetos de estado elevado.

Consulte a documentação sobre elevação de estado no Compose ou, de forma mais geral, a página Detentores de estado e estado da interface no guia de arquitetura para saber mais.

Reativar cálculos de recuperação quando as chaves mudarem

A API remember é frequentemente usada com MutableState:

var name by remember { mutableStateOf("") }

Aqui, o uso da função remember faz com que o valor MutableState sobreviva a recomposições.

Em geral, remember usa um parâmetro lambda calculation. Quando remember é executado pela primeira vez, ele invoca o lambda calculation e armazena o resultado. Durante a recomposição, remember retorna o valor armazenado pela última vez.

Além do estado de armazenamento em cache, também é possível usar remember para armazenar qualquer objeto ou resultado de uma operação na composição que é cara para inicializar ou calcular. Talvez você não queira repetir esse cálculo a cada recomposição. Um exemplo é a criação deste objeto ShaderBrush, que é uma operação cara:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

O remember armazena o valor até sair da composição. No entanto, há uma maneira de invalidar o valor armazenado em cache. A API remember também usa um parâmetro key ou keys. Se qualquer uma dessas chaves mudar, na próxima recomposição da função, remember vai invalidar o cache e executar o bloco lambda de cálculo novamente. Esse mecanismo oferece controle sobre a vida útil de um objeto na composição. O cálculo permanece válido até que as entradas mudem, e não até que o valor lembrado saia da composição.

Os exemplos a seguir mostram como esse mecanismo funciona.

Neste snippet, um ShaderBrush é criado e usado como a pintura em segundo plano de um elemento combinável Box. remember armazena a instância ShaderBrush porque a recriação dela é cara, conforme explicado anteriormente. remember usa avatarRes como o parâmetro key1, que é a imagem de plano de fundo selecionada. Se avatarRes muda, o pincel é recomposto com a nova imagem e se aplica de novo ao Box. Isso pode ocorrer quando o usuário seleciona outra imagem para ser o segundo plano de um seletor.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

No próximo snippet, o estado é elevado para uma classe detentora de estado simples MyAppState. Ele expõe uma função rememberMyAppState para inicializar uma instância da classe usando remember. A exposição dessas funções para criar uma instância que resiste a recomposições é um padrão comum no Compose. A função rememberMyAppState recebe windowSizeClass, que serve como o parâmetro key para remember. Se esse parâmetro mudar, o app vai precisar recriar a classe de detentor de estado simples com o valor mais recente. Isso pode ocorrer se, por exemplo, o usuário gira o dispositivo.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

O Compose usa a implementação de equalização da classe para decidir se uma chave mudou e invalida o valor armazenado.

Armazenar o estado com chaves além da recomposição

A API rememberSaveable é um wrapper em torno de remember que pode armazenar dados em um Bundle. Essa API permite que o estado sobreviva não apenas à recomposição, mas também à recriação de atividades e à interrupção do processo iniciada pelo sistema. rememberSaveable recebe parâmetros input para a mesma finalidade que remember recebe keys. O cache é invalidado quando qualquer uma das entradas muda. Na próxima vez que a função for recomposta, o rememberSaveable vai executar o bloco lambda de cálculo de novo.

No exemplo a seguir, rememberSaveable armazena userTypedQuery até que typedQuery mude:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

Saiba mais

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

Exemplos

Codelabs

Vídeos

Blogs