O Google tem o compromisso de promover a igualdade racial para as comunidades negras. Saiba como.

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 compreende tudo, desde um banco de dados da Room até uma variável de 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 você a especificar onde e como armazenar e usar o estado em um app Android.

Loop de atualização da IU e eventos

Em um app Android, o estado é atualizado em resposta aos eventos. Eventos são entradas geradas fora do nosso app, como o toque do usuário em um botão que chama um OnClickListener, um EditText que chama um afterTextChanged ou um acelerômetro que envia um novo valor.

Todos os apps Android têm um loop de atualização de IU principal que tem a seguinte aparência:

O loop de atualização de IU principal para apps Android.

  • Evento: um evento é gerado pelo usuário ou por outra parte do programa.
  • Estado de atualização: um manipulador de eventos muda o estado.
  • Estado de exibição: a IU é atualizada para exibir o novo estado.

No Jetpack Compose, o estado e os eventos são separados. Um estado representa um valor mutável, enquanto um evento representa uma notificação de que algo aconteceu.

Ao separar o estado dos eventos, é possível dissociar a exibição do estado da maneira como ele é armazenado e modificado.

Fluxo de dados unidirecional no Jetpack Compose

O Compose foi criado para fluxo de dados unidirecional. Esse é um design em que o estado desce e os eventos sobem.

Figura 1. Fluxo de dados unidirecional.

Ao seguir o fluxo de dados unidirecional, você pode dissociar os elementos que podem ser compostos que exibem o estado na IU das partes do app que o armazenam e mudam.

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

  • Evento: um evento é gerado por parte da IU e sobe.
  • Estado de atualização: um manipulador de eventos pode mudar o estado.
  • Estado de exibição: o estado desce e a IU observa o novo estado e o exibe.

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

  • Capacidade de teste: ao dissociar o estado da IU que o exibe, fica mais fácil testar os dois isoladamente.
  • Encapsulamento de estado: como o estado só pode ser atualizado em um lugar, é menos provável que você crie estados inconsistentes (ou bugs).
  • Consistência da IU: todas as atualizações de estado são refletidas imediatamente na IU pelo uso de titulares de estado observáveis.

Fluxo de dados ViewModel e unidirecional

Ao usar ViewModel e LiveData nos Componentes da arquitetura do Android, você introduz um fluxo de dados unidirecional no app.

Antes de analisar ViewModels com o Compose, considere uma Activity usando Android Views e fluxo de dados unidirecional que exibe "Hello, ${name}" e permite que o usuário insira o nome dele.

Exemplo de entrada do usuário com ViewModels.

O código para essa tela usando um ViewModel e uma Activity:

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

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

class HelloActivity : AppCompatActivity() {
   val helloViewModel by viewModels<HelloViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* … */
       // binding represents the activity layout, inflated with ViewBinding
       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

Ao usar os Componentes da arquitetura do Android, introduzimos um design de fluxo de dados unidirecional nessa Activity.

Figura 2. Fluxo de dados unidirecional em uma Activity usando ViewModel.

Para ver como o fluxo de dados unidirecional funciona no loop de atualização da IU, considere o loop desta Activity:

  1. Evento: onNameChanged é chamado pela IU quando a entrada de texto é modificada.
  2. Estado de atualização: onNameChanged faz o processamento e define o estado de _name.
  3. Estado de exibição: os observadores de name são chamados, e a IU exibe o novo estado.

ViewModel e Jetpack Compose

Você pode usar LiveData e ViewModel no Jetpack Compose para implementar o fluxo de dados unidirecional, assim como fez em uma Activity na seção anterior.

Este é o código para a mesma tela que a HelloActivity escrita no Jetpack Compose usando o mesmo HelloViewModel:

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

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(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("")

   Column {
       Text(text = name)
       TextField(
           value = name,
           onValueChange = { helloViewModel.onNameChanged(it) },
           label = { Text("Name") }
       )
   }
}

HelloViewModel e HelloScreen seguem o design do fluxo de dados unidirecional. O estado flui para baixo de HelloViewModel, e os eventos fluem para cima de HelloScreen.

O fluxo unidirecional entre o modelo de visualização e a tela de boas-vindas.

Considere o loop de evento de IU para este elemento que pode ser composto:

  1. Evento: onNameChanged é chamado em resposta à digitação de um caractere pelo usuário.
  2. Estado de atualização: onNameChanged faz o processamento e define o estado de _name.
  3. Estado de exibição: o valor de name muda e é observado no Compose em observeAsState. Depois, HelloScreen é executado novamente (ou faz a recomposição) para descrever a IU com base no novo valor de name.

Para saber mais sobre como usar ViewModel e LiveData para criar um fluxo de dados unidirecional no Android, leia o Guia para a arquitetura do app.

Elementos que podem ser compostos sem estado

Um elemento que pode ser composto sem estado é aquele que não pode mudar nenhum estado por conta própria. Componentes sem estado são mais fáceis de testar, tendem a ter menos bugs e abrem mais oportunidades para reutilização.

Se o elemento que pode ser composto tiver estado, será possível torná-lo sem estado usando. Isso é feito com a elevação de estado, que é um padrão de programação em que o estado é movido para o autor da chamada, substituindo o estado interno em um elemento que pode ser composto por um parâmetro e eventos.

Para ver um exemplo de elevação de estado, extraia um elemento que pode ser composto sem estado de HelloScreen.

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

   // name is the _current_ value of [helloViewModel.name]
   val name: String by helloViewModel.name.observeAsState("")

   HelloInput(name = name, onNameChange = { helloViewModel.onNameChanged(it) })
}

@Composable
fun HelloInput(
   /* state */ name: String,
   /* event */ onNameChange: (String) -> Unit
) {
   Column {
       Text(name)
       TextField(
           value = name,
           onValueChange = onNameChange,
           label = { Text("Name") }
       )
   }
}

HelloInput tem acesso ao estado como um parâmetro String imutável, bem como um evento onNameChange que pode ela pode chamar quando quiser solicitar a mudança de estado.

Lambdas são a maneira mais comum de descrever eventos em um elemento que pode ser composto. Aqui, definimos um evento onNameChange com um lambda que usa uma String, aplicando a sintaxe de tipo de função (String) -> Unit do Kotlin. Observe que onNameChange está no presente, porque o evento não significa que o estado já foi modificado, mas que o elemento que pode ser composto está solicitando que o gerenciador de eventos o modifique.

HelloScreen é um elemento que pode ser composto com estado porque tem uma dependência na classe final HelloViewModel, que pode mudar diretamente o estado name. Não há como o autor da chamada de HelloScreen controlar atualizações para o estado name. HelloInput é um elemento que pode ser composto sem estado porque não consegue mudar diretamente nenhum estado.

Ao elevar o estado de HelloInput, é mais fácil fundamentar o elemento, reutilizá-lo em situações diferentes e fazer testes. HelloInput está dissociada do modo como o estado é armazenado. Isso significa que, se você modificar ou substituir HelloViewModel, não precisará mudar a forma como HelloInput é implementada.

O processo de elevação de estado permite estender o fluxo de dados unidirecional para elementos que podem ser compostos sem estado. O diagrama de fluxo de dados unidirecional desses elementos mantém o estado para baixo e os eventos para cima à medida que mais elementos interagem com o estado.

O estado e o fluxo de eventos entre HelloInput, HelloScreen e HelloViewModel

É importante entender que um elemento que pode ser composto sem estado ainda pode interagir com um estado que muda ao longo do tempo usando o fluxo de dados unidirecional e a elevação de estado.

Para entender como isso funciona, considere o loop de atualização da IU para HelloInput:

  1. Evento: onNameChange é chamado em resposta à digitação de um caractere pelo usuário.
  2. Estado de atualização: HelloInput não pode modificar diretamente o estado. O autor da chamada pode optar por modificar os estados em resposta ao evento onNameChange. Aqui, o autor da chamada, HelloScreen, chamará onNameChanged em HelloViewModel, o que faz com que o estado name seja atualizado.
  3. Estado de exibição: quando o valor de name é modificado, HelloScreen é chamada novamente com o name atualizado devido a observeAsState. Ele chamará HelloInput novamente com o novo parâmetro name. O ato de chamar novamente elementos que podem ser compostos em resposta a mudanças de estado é denominado recomposição.

Composição e recomposição

Uma composição descreve a IU e é produzida pela execução de elementos que podem ser compostos. Uma composição é uma estrutura em árvore dos elementos que podem ser compostos que descrevem sua IU.

Durante a composição inicial, o Jetpack Compose acompanhará os elementos que você chama para descrever sua IU em uma composição. Depois, quando o estado do app mudar, o Jetpack Compose programará a recomposição. A recomposição executa os elementos que podem ser compostos que podem ter mudado em resposta a modificações de estado, e o Jetpack Compose atualiza a composição para refletir as mudanças.

Uma composição só pode ser produzida por uma composição inicial e atualizada por recomposição. A única maneira de modificar uma composição é pela recomposição.

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.

Usar remember para armazenar valores imutáveis

É possível armazenar valores imutáveis ao armazenar em cache operações da IU de alto custo, como o cálculo da formatação de texto. O valor memorizado é armazenado na composição com o elemento que pode ser composto que chamou o remember.

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    …
}
Figura 3. Composição de FancyText com formattedText como filho.

Usar remember para criar um estado interno em um elemento que pode ser composto

Ao armazenar um objeto mutável usando o remember, você adiciona o estado a um elemento que pode ser composto. É possível usar essa abordagem para criar um estado interno para um único elemento que pode ser composto com estado.

É altamente recomendável que todo estado mutável usado por elementos que podem ser compostos seja observável. Isso permite que o Compose recomponha automaticamente sempre que o estado mudar. O Compose vem com um tipo State<T> observável integrado, que é diretamente integrado ao ambiente de execução dele.

Um bom exemplo de estado interno em um elemento que pode ser composto é o movimento de expandir e recolher de um ExpandingCard quando o usuário clica em um botão.

Figura 4. Elemento que pode ser composto ExpandedCard entre os estados recolhido e expandido.

Esse elemento que pode ser composto tem um estado importante: expanded. Quando estiver no estado expanded, o elemento mostrará o corpo, mas o ocultará quando estiver recolhido.

Figura 5. Composição de ExpandingCard com o estado expanded como filho.

É possível adicionar um estado expanded a um elemento que pode ser composto. Para isso, lembre-se de mutableStateOf(initialValue).

@Composable
fun ExpandingCard(title: String, body: String) {
   // expanded is "internal state" for ExpandingCard
   var expanded by remember { mutableStateOf(false)  }

   // describe the card for the current state of expanded
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(text = title)

           // content of the card depends on the current value of expanded
           if (expanded) {
               // TODO: show body & collapse icon
           } else {
               // TODO: show expand icon
           }
       }
   }

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. É preciso escolher aquela que produz o código mais fácil de ler no elemento que pode ser composto que você está escrevendo.

É possível usar o valor do estado interno em um elemento que pode ser composto como um parâmetro para outro elemento ou até mesmo mudar quais elementos são chamados. Em ExpandingCard, uma declaração "if" mudará o conteúdo do cartão com base no valor atual de expanded.

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

Modificar o estado interno em um elemento que pode ser composto

O estado precisa ser modificado por eventos em um elemento que pode ser composto. Se você modificar o estado ao executar um elemento que pode ser composto em vez de em um evento, esse será um efeito colateral do elemento, o que precisará ser evitado. Para ver mais informações sobre efeitos colaterais no Jetpack Compose, consulte Trabalhando com o Compose.

Para concluir o elemento que pode ser composto ExpandingCard, exibiremos o body e um botão de recolher quando expanded for true e um botão de expansão quando expanded for false.

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by remember { mutableStateOf(false)  }

   // describe the card for the current state of expanded
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(text = title)

           // content of the card depends on the current value of expanded
           if (expanded) {
               Text(text = body, Modifier.padding(top = 8.dp))
               // change expanded in response to click events
               IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandLess)
               }
           } else {
               // change expanded in response to click events
               IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

Nesse elemento que pode ser composto, o estado é modificado em resposta a eventos onClick. Como expanded está usando var com a sintaxe de delegação de propriedade (link em inglês), os callbacks onClick podem atribuir expanded diretamente.

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

Agora, podemos descrever o loop de atualização da IU para ExpandingCard para ver como o estado interno é modificado e usado pelo Compose.

  1. Evento: onClick é chamado em resposta ao toque do usuário em um dos botões.
  2. Estado de atualização: expanded é mudado no listener onClick usando a atribuição.
  3. Estado de exibição: ExpandingCard faz a recomposição porque expanded é o State<Boolean> que foi modificado, e ExpandingCard o lê na linha if(expanded). Em seguida, ExpandingCard descreve a tela para o novo valor de expanded.

Usar outros tipos de estado no Jetpack Compose

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.

Também é possível criar camadas de integração para objetos de estado não observáveis usando invalidate para acionar a recomposição manualmente. Isso é reservado para situações em que você precisa interoperar com um tipo não observável. Usar invalidate de forma incorreta é fácil e tende a levar a um código complexo que é mais difícil de ler do que o mesmo código usando objetos de estado observáveis.

Separar o estado interno dos elementos de IU que podem ser compostos

O ExpandingCard na última seção tem o estado interno. Como resultado, o autor da chamada não pode controlar o estado. Isso significa, por exemplo, que se você quiser iniciar um ExpandingCard no estado expandido, não será possível fazer isso. Também não é possível fazer o cartão se expandir em resposta a outro evento, como o clique do usuário em um Fab. Isso também significa que se você quiser mover o estado expanded para um ViewModel, isso não será possível.

Por outro lado, usando o estado interno em ExpandingCard, um autor de chamada que não precisa controlar ou elevar o estado pode usá-lo sem precisar gerenciá-lo.

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 chamadas que não se importam com ele, e a sem estado é necessária para autores de chamada que precisam controlar ou elevar o estado.

Para fornecer as interfaces com e sem estado, extraia um elemento que pode ser composto sem estado que exiba a IU usando uma elevação de estado.

Observe que os dois elementos são chamados ExpandingCard, mesmo que tenham parâmetros diferentes. A convenção de nomenclatura para elementos que podem ser compostos que emitem a IU é um substantivo com IniciaisMaiúsculas que descreve o que o composto representa na tela. Nesse caso, ambos representam um ExpandingCard. Essa convenção de nomenclatura é aplicada em todas as bibliotecas do Compose, como em TextField e TextField.

Este ExpandingCard é dividido em elementos que podem ser compostos com e sem estado:

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by remember { mutableStateOf(false)  }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
   title: String,
   body: String,
   expanded: Boolean,
   onExpand: () -> Unit,
   onCollapse: () -> Unit
) {
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(title)
           if (expanded) {
               Spacer(Modifier.height(8.dp))
               Text(body)
               IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandLess)
               }
           } else {
               IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

A elevação de estado no Compose é um padrão para mover um estado para o autor da chamada e transformar 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:

  • Fonte única da verdade: ao mover o estado em vez de duplicá-lo, garantimos que existe apenas uma fonte de verdade para expanded. Isso ajuda a evitar bugs.
  • Encapsulado: somente o ExpandingCard com estado poderá modificar o estado. É totalmente interno.
  • Compartilhável: o estado elevado pode ser compartilhado com vários elementos que podem ser compostos. Digamos que queremos ocultar um botão Fab quando o Card for expandido. A elevação poderia nos permitir fazer isso.
  • Interceptável: os autores de chamadas para ExpandingCard sem estado podem decidir ignorar ou modificar eventos antes de mudar o estado.
  • Desacoplado: o estado de ExpandingCard sem estado pode ser armazenado em qualquer lugar. Por exemplo, agora é possível mover title, body e expanded para um ViewModel.

A hospedagem feita dessa forma também segue o fluxo de dados unidirecional. O estado é transmitido para baixo do elemento que pode ser composto com estado, e os eventos fluem para cima do elemento sem estado.

Figura 6. Diagrama de fluxo de dados unidirecional para ExpandingCard com e sem estado.

Mudanças internas de estado e configuração

Os valores que são lembrados por remember em uma composição são esquecidos e recriados durante mudanças de configuração, como a rotação.

Se você usar o remember { mutableStateOf(false) }, o ExpandingCard com estado será redefinido para ser recolhido sempre que o usuário rotacionar o smartphone. Podemos corrigir isso usando o estado de instância salvo, para salvar e restaurar automaticamente o estado quando houver mudanças na configuração.

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by savedInstanceState { false }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

A função que pode ser composta savedInstanceState<T> retorna um MutableState<T> que é automaticamente salvo e restaurado em mudanças de configuração. Use-a para qualquer estado interno que um usuário esperaria que resistissem às mudanças de configuração.

Saiba mais

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