State Management no Jetpack Compose

Tempo de leitura: 10 minutes

O gerenciamento de estado é o processo de rastreamento e atualização do estado de um aplicativo. No Jetpack Compose, o estado é representado por um valor que pode mudar com o tempo.

📝Alguns exemplos de estado em aplicativos Android:

  • Entrada do usuário: A entrada do usuário pode ser considerada um estado, pois pode mudar com o tempo. Por exemplo, a localização atual do usuário, o texto que ele digitou em um campo de texto ou os itens que selecionou em uma lista são exemplos de estado que podem ser alterados pelo usuário.
  • Dados do servidor: Os dados que são obtidos do servidor também podem ser considerados estado. Por exemplo, a previsão do tempo, as últimas manchetes de notícias ou os preços atuais das ações são exemplos de dados que podem ser obtidos do servidor e usados para atualizar o estado de um aplicativo.
  • A configuração do aplicativo: A configuração do aplicativo também pode ser considerada um estado. Por exemplo, a orientação atual do aplicativo, o idioma preferido do usuário ou o nível de bateria do dispositivo podem afetar a maneira como um aplicativo se comporta.
  • Estado de autenticação do usuário: Muitos aplicativos exigem que os usuários façam login ou se inscrevam. O estado da autenticação do usuário, como, por exemplo, se um usuário está conectado ou não, é uma parte importante do estado desses aplicativos. Ele determina quais telas ou recursos são acessíveis ao usuário e afeta o comportamento de vários componentes da interface do usuário.
  • Estado de solicitação de rede: Quando um aplicativo se comunica com um servidor ou uma API, normalmente envolve solicitações de rede. O estado das solicitações de rede inclui o fato de uma solicitação estar pendente, concluída ou ter encontrado um erro. Esse estado é crucial para a exibição de spinners de carregamento, tratamento de erros ou atualização de componentes da IU com base no resultado da solicitação de rede.

🚀O estado é um conceito importante no desenvolvimento de aplicativos Android. Ao entender como o estado funciona, você pode criar aplicativos mais responsivos e fáceis de usar.

📝Há duas maneiras principais de gerenciar o estado no Jetpack Compose:

  • Compostos com estado: Os compostáveis com estado são compostáveis que têm seu próprio estado. Normalmente, esse estado é armazenado em uma variável que é passada para o composable como um argumento. Quando o estado muda, o composable é recomposto e o novo estado é usado para renderizar a interface do usuário.
  • Compostáveis sem estado: Os compostáveis sem estado não têm seu próprio estado. Em vez disso, eles dependem do estado de seus pais ou irmãos. Quando o estado de um pai ou irmão muda, o composto sem estado é recomposto e o novo estado é usado para renderizar a interface do usuário.

Vamos criar uma função componível chamada CoffeeCounter que contém um Text componível que exibe o número de xícaras de café. O número de xícaras deve ser armazenado em um valor chamado coffeeCount, que você pode codificar por enquanto:

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    val coffeeCount = 0 // Assuming you start with 0 coffee
    Text(
        text = "You've had $coffeeCount cups of coffee.",
        modifier = modifier.padding(16.dp)
    )
}

O estado da função de composição CoffeeCounter é a variável coffeeCount. Mas ter um state static não é muito útil, pois ele não pode ser modificado. Para remediar isso, adicionaremos um botão para aumentar a contagem e controlar o número de xícaras de café que tomamos ao longo do dia.

Agora, vamos adicionar o botão para que os usuários possam modificar o estado adicionando mais xícaras de café.

A função composta Button recebe uma função lambda onClick – esse é o evento que acontece quando o botão é clicado.

Altere coffeeCount para var em vez de val para que ele se torne mutable.

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var coffeeCount = 0
        Text("You've had $coffeeCount cups of coffee.")
        Button(onClick = { coffeeCount++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}

Quando executamos o aplicativo e clicamos no botão, notamos que nada acontece. Isso ocorre porque não dissemos ao Compose que ele deveria redesenhar a tela (ou seja, “recompor” a função componível) quando o estado mudar.

👉A composição: uma descrição da interface do usuário criada pelo Jetpack Compose quando ele executa composições.

👉Composição inicial: criação de uma Composição executando os composables pela primeira vez.

👉Recomposição: reexecução de composables para atualizar a Composição quando os dados são alterados.

 

O Compose precisa saber qual estado deve ser rastreado para que, quando receber uma atualização, possa programar a recomposição.

O Compose tem um sistema especial de rastreamento de estado que agenda recomposições para quaisquer composições que leiam um determinado estado. Isso permite que o Compose seja granular e recomponha apenas as funções compostas que precisam ser alteradas, e não toda a interface do usuário. Isso é feito por meio do rastreamento não apenas de “gravações” (ou seja, alterações de estado), mas também de “leituras” do estado.

⭐️Precisamos usar os tipos State e MutableState do Compose para tornar o estado observável pelo Compose.

O Compose mantém o controle de cada composable que lê as propriedades de valor do State e aciona uma recomposição quando seu valor é alterado. Você pode usar a função mutableStateOf para criar um MutableState observável. Ela recebe um valor inicial como parâmetro, que é agrupado em um objeto State, o que torna seu valor observável.

📖O Compose também tem outras variantes de mutableStateOf, como mutableIntStateOf, mutableLongStateOf, mutableFloatStateOf ou mutableDoubleStateOf, que são otimizadas para os tipos primitivos.

Atualize o CoffeeCounter composable para que coffeeCount use a API mutableStateOf com 0 como valor inicial. Como mutableStateOf retorna um tipo MutableState, podemos atualizar seu valor para atualizar o estado, e o Compose acionará uma recomposição para as funções em que seu valor for lido.

 

@SuppressLint("UnrememberedMutableState")
@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        // Changes to count are now tracked by Compose
        val coffeeCount: MutableState<Int> = mutableStateOf(0)
        Text("You've had ${coffeeCount.value} cups of coffee.")
        Button(onClick = { coffeeCount.value++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}

Como mencionado anteriormente, qualquer alteração no coffeeCount programa uma recomposição de quaisquer funções compostas que leiam o valor do coffeeCount automaticamente. Nesse caso, CoffeeCounter é recomposto sempre que o botão é clicado.

Se executarmos o aplicativo agora, perceberemos novamente que nada acontece ainda!

O agendamento de recomposições está funcionando bem. No entanto, quando ocorre uma recomposição, a variável coffeeCount é reinicializada de volta a 0, portanto, precisamos de uma maneira de preservar esse valor em todas as recomposições.

Para isso, podemos usar a função em linha composta remember. Um valor calculado por remember é armazenado na composição durante a composição inicial, e o valor armazenado é mantido em todas as recomposições.

⭐️ Podemos pensar em usar o remember como um mecanismo para armazenar um único objeto na Composição, da mesma forma que uma propriedade val privada faz em um objeto.

Normalmente, remember e mutableStateOf são usados juntos em funções compostas.

Modifique CoffeeCounter, cercando a chamada para mutableStateOf com a função composta inline remember:

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        val coffeeCount: MutableState<Int> = remember { mutableStateOf(0) }
        Text("You've had ${coffeeCount.value} cups of coffee.")
        Button(onClick = { coffeeCount.value++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}

Como alternativa, poderíamos simplificar o uso do coffeeCount usando as propriedades delegadas do Kotlin.

Podemos usar a palavra-chave by para definir coffeeCount como um var. A adição das importações getter e setter do delegado nos permite ler e alterar coffeeCount indiretamente, sem nos referirmos explicitamente à propriedade value do MutableState todas as vezes.

Agora, CoffeeCounter tem a seguinte aparência:

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var coffeeCount by remember { mutableStateOf(0) }
        Text("You've had $coffeeCount cups of coffee.")
        Button(onClick = { coffeeCount++ }, Modifier.padding(top = 8.dp)) {
            Text("Add one")
        }
    }
}

Esse arranjo forma um loop de feedback de fluxo de dados com o usuário:

  • A interface do usuário apresenta o estado ao usuário (a contagem atual é exibida como texto).
  • O usuário produz eventos que são combinados com o estado existente para produzir um novo estado (clicar no botão adiciona um à contagem atual)

O Compose é uma estrutura de IU declarativa. Em vez de remover componentes da interface do usuário ou alterar sua visibilidade quando o estado muda, descrevemos como a interface do usuário está sob condições específicas de estado. Como resultado da chamada de uma recomposição e da atualização da interface do usuário, os componentes podem acabar entrando ou saindo da composição

Essa abordagem evita a complexidade de atualizar manualmente as exibições, como seria feito com o sistema View. Também é menos propensa a erros, pois você não pode se esquecer de atualizar uma visualização com base em um novo estado, porque isso acontece automaticamente.

Se uma função componível for chamada durante a composição inicial ou em recomposições, dizemos que ela está presente na composição. Uma função componível que não é chamada – por exemplo, porque a função é chamada dentro de uma instrução if e a condição não é atendida – está ausente da composição.

Para demonstrar isso, modificaremos o Button para que ele seja ativado até que coffeeCount seja 3 e, em seguida, seja desativado (e alcancemos nosso limite de café para o dia). Use o parâmetro enabled do Button para fazer isso.

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var coffeeCount by remember { mutableStateOf(0) }
        Text("You've had $coffeeCount cups of coffee.")
        Button(onClick = { coffeeCount++ }, Modifier.padding(top = 8.dp),  enabled = coffeeCount < 3) {
            Text("Add one")
        }
    }
}

👉 O remember armazena objetos na composição e esquece o objeto se o local de origem onde o remember é chamado não for invocado novamente durante uma recomposição.

Vamos girar o dispositivo e ver o que acontece.

Como o Activity é recriado após uma alteração de configuração (nesse caso, orientação), o estado que foi salvo é esquecido: o contador desaparece quando volta a 0.

📖 O mesmo acontece se você alterar o idioma, alternar entre o modo claro e escuro ou qualquer outra alteração de configuração que faça o Android recriar a atividade em execução.

Embora o remember nos ajude a reter o estado nas recomposições, ele não é retido nas alterações de configuração. Para isso, devemos usar o rememberSaveable em vez do remember.

O rememberSaveable salva automaticamente qualquer valor que possa ser salvo em um pacote. Para outros valores, podemos passar um objeto de salvamento personalizado.

Em CoffeeCounter, substitua remember por rememberSaveable:

@Composable
fun CoffeeCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var coffeeCount by rememberSaveable{ mutableStateOf(0) }
        Text("You've had $coffeeCount cups of coffee.")
        Button(onClick = { coffeeCount++ }, Modifier.padding(top = 8.dp),  enabled = coffeeCount < 3) {
            Text("Add one")
        }
    }
}

⭐️ Use o rememberSaveable para restaurar o estado da interface do usuário depois que uma atividade for recriada. Além de manter o estado durante as recomposições, o rememberSaveable também mantém o estado durante a recriação da atividade e a morte do processo iniciado pelo sistema.

 

Um composable que usa remember para armazenar um objeto contém estado interno, o que torna o composable stateful. Isso é útil em situações em que o chamador não precisa controlar o estado e pode usá-lo sem precisar gerenciar o estado por conta própria. Entretanto, os compostáveis com estado interno tendem a ser menos reutilizáveis e mais difíceis de testar.

Os compostáveis que não mantêm nenhum estado são chamados de stateless composables. Uma maneira fácil de criar um statelesscomposable é usar o state hoisting.

O state hoisting no Compose é um padrão de transferência de estado para o chamador de um composable para torná-lo stateless. O padrão geral para a elevação de estado no Jetpack Compose é substituir a variável de estado por dois parâmetros:

  • value: T – o valor atual a ser exibido
  • onValueChange: (T) -> Unit – um evento que solicita que o valor seja alterado com um novo valor T

em que esse valor representa qualquer estado que possa ser modificado.

 

Stateful vs Stateless

👉Um composable stateless é um composable que não possui nenhum estado, o que significa que não mantém, define ou modifica um novo estado.

👉Um stateful composable é um composable que possui uma parte do estado que pode mudar com o tempo.

📖 Em aplicativos reais, pode ser difícil ter um composable 100% stateless, dependendo das responsabilidades do composable. Devemos projetar nossos composables de forma que eles possuam o mínimo de estado possível e permitam que o state seja hoisted, quando fizer sentido, expondo-o na API do composable.

Refatoraremos o CoffeeCounter componível dividindo-o em duas partes: stateful e stateless Counter.

@Composable
fun StatelessCounter(coffeeCount: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        Text("You've had $coffeeCount cups of coffee.")
        Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = coffeeCount < 3) {
            Text("Add one")
        }
    }
}

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
    var coffeeCount by rememberSaveable { mutableStateOf(0) }
    StatelessCounter(coffeeCount, { coffeeCount++ }, modifier)
}
@Composable
fun CoffeeCounter() {
    StatefulCounter()
}

🎉Aumentamos o coffeeCount de StatelessCounter para StatefulCounter.

 

⭐️ Key Point: Ao hoisting state, há três regras para ajudá-lo a descobrir para onde o estado deve ir:

O estado deve ser hoisted pelo menos para o parent comum mais baixo de todos os composables que usam o state (read).

O estado deve ser hoisted pelo menos no nível mais alto em que pode ser alterado (write).

Se dois states forem alterados em resposta aos mesmos eventos, eles deverão ser elevados ao mesmo nível.

Você pode elevar o estado a um nível mais alto do que essas regras exigem, mas se não elevar o state a um nível suficientemente alto, poderá ser difícil ou impossível seguir o fluxo de dados unidirecional.

Conforme mencionado, o state hoisting tem alguns benefícios.

  1. Nosso stateless composable agora pode ser reutilizado.
@Composable
fun StatelessCounter(count: Int, name : String, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        Text("You've had $count cups of $name.")
        Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 3) {
            Text("Add one")
        }
    }
}

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
    var coffeeCount by rememberSaveable { mutableStateOf(0) }
    var waterCount by rememberSaveable { mutableStateOf(0) }
    var juiceCount by rememberSaveable { mutableStateOf(0) }
    Column {
        StatelessCounter(coffeeCount, "Coffee" ,{ coffeeCount++ }, modifier)
        Spacer(modifier = Modifier.height(16.dp)) // Add space between counters
        StatelessCounter(waterCount, "Water" ,{ waterCount++ }, modifier)
        Spacer(modifier = Modifier.height(16.dp)) // Add space between counters
        StatelessCounter(juiceCount,"Juice" ,{ juiceCount++ }, modifier)
    }
}
@Composable
fun CoffeeCounter() {
    StatefulCounter()
}

Se juiceCount for modificado, o StatefulCounter será recomposto. Durante a recomposição, o Compose identifica quais funções leem o juiceCount e aciona a recomposição somente dessas funções.

Quando o usuário toca para incrementar juiceCount, o StatefulCounter se recompõe, assim como o StatelessCounter que lê juiceCount. Mas o StatelessCounter que lê coffeeCount e waterCount não é recomposto.

2. Nossa função stateful composable pode fornecer o mesmo estado a várias funções composable.

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
    var coffeeCount by rememberSaveable { mutableStateOf(0) }

    StatelessCounter(coffeeCount, "Coffee" ,{ coffeeCount++ }, modifier)
    AnotherStatelessMethod(coffeeCount, { coffeeCount *= 2 })
    
}

Nesse caso, se a contagem for atualizada por StatelessCounter ou AnotherStatelessCounter, tudo será recomposto, o que é esperado.

Como o state hoisted pode ser compartilhado, certifique-se de passar somente o estado que os composables precisam para evitar recomposições desnecessárias e aumentar a reutilização.