DEV Community

Cover image for Problemas comuns na recomposição do Jetpack Compose
Alex Felipe
Alex Felipe

Posted on

Problemas comuns na recomposição do Jetpack Compose

A recomposição é uma etapa fundamental do funcionamento do Jetpack Compose. Se você não tem ideia do que seja, basicamente, é a etapa que redesenha a tela aplicando as atualizações de estado.

Em outras palavras, cada vez que temos algo novo na tela, seja a edição de um campo de texto, animações, mudança de cores etc, é porque aconteceu a recomposição.

A recomposição acontece cada vez que ocorre uma atualização de State, ou sendo mais preciso, o MutableState que é derivado de State e permite atualizar os valores para acontecer a recomposição... Esse é um resumo de como o Jetpack Compose funciona.

Embora pareça simples, ao desenvolver uma aplicação com esse comportamento, precisamos ter alguns cuidados para evitar certas armadilhas, seja a realização de recomposições desnecessariamente, ou até mesmo loops infinitos!

Para realizar uma simples demonstração, podemos adicionar um contador e exibí-lo na tela:

var counter = 0

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            PlaygroundTheme {
                Surface(
                    Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background,
                ) {
                    var text by remember {
                        mutableStateOf("")
                    }
                    Column {
                        Text(text = "${counter++}")
                        TextField(value = text, onValueChange = {
                            text = it
                        })
                    }
                }
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

App em execução apresentado um texto com um contador iniciando em zero e um campo de texto com o texto vazio. Ao digitar ou apagar texto do campo de texto, o contador é incrementado

Veja que apenas em escrever "alex felipe" e apagar todas as letras, acontecem 22 recomposições!

Por mais que pareça uma quantidade grande, é bastante comum ocorrerem muito mais recomposições, afinal, uma tela pode realizar diversas atualizações, ter animações, estruturas lazy etc.

Entretanto, há situações que temos mais recomposições do que deveria! E vamos começar a explorar um pouco, como por exemplo, tentar buscar informações e atualizar o estado em código de composição.

Simulando recomposições desnecessárias

Para exemplificar, vou criar um código que simula a busca de todos os usuários:

class User(
    val id: Long,
    val name: String
)

fun findAllUsers() = flow {
    delay(Random.nextLong(100, 500))
    emit(listOf(User(1, "alex")))
}
Enter fullscreen mode Exit fullscreen mode

Mesmo que seja um código que devolva a "mesma lista de usuário" (cada vez é criada uma nova referência de lista), é uma situação bastante interessante para observar a recomposição desnecessária!

O delay() com um período de 100 a 500 milissegundos é para facilitar a visualização do resultado durante o teste. Então, no composable podemos apresentar o conteúdo da lista:

val users by findAllUsers().collectAsState(initial = emptyList())
Column {
    Text(text = "${counter++}")
    for (user in users) {
        Column {
            Text(text = user.id.toString())
            Text(text = user.name)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

App em execução apresentando o contador zerado inicialmente. Após instantes, aparece um usuário abaixo com id e nome, e então, o contador é incrementado automaticamente em períodos diferentes

Um resultado curioso... Concorda? Por mais que a tela "não esteja adicionando novos usuários", a recomposição ocorre várias vezes tendendo ao infinito...

Entendendo os perigos das armadilhas na recomposição

É importante observar que essa é uma implementação controlada com um certo delay, mas agora imagine em situações que a busca da informação é a partir de uma integração que consome recursos do dispositivo, internet etc.

Iremos criar uma solução ineficiente, que compromete a qualidade do App, experiência de uso e pode tornar o produto mais caro! Afinal, há determinados serviços, como REST APIs, que cobram por acesso...

Em outras palavras, devido ao excesso de looping da recomposição, todos os códigos que fazem modificações de estado, devem ser executados fora do código de composição! Agora vem a grande questão:

"Como eu executo um código que não é afetado pela recomposição?"

Utilizando códigos com API do Side Effect

Para executar códigos que não são afetados pela recomposição, precisamos utilizar APIs de Side-effect. Basicamente, essa API oferece diversas ferramentas que permitem executar códigos que modificam o estado e não são afetados pela recomposição.

Uma das possibilidades bastante comum é o LaunchedEffect(), um composable que não emite elemento visual e roda uma coroutine via expressão lambda:

var users by remember {
    mutableStateOf(emptyList<User>())
}
LaunchedEffect(null) {
    findAllUsers().collect {
        users = it
    }
}
Column {
    Text(text = "${counter++}")
    for (user in users) {
        Column {
            Text(text = user.id.toString())
            Text(text = user.name)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Com esse ajuste, o App não apresenta mais o comportamento de looping! E isso acontece, pois o findAllUsers() é executado apenas uma única vez... Agora, vamos entender o que aconteceu.

Entendendo a API de chaves do Jetpack Compose

Existem diversas APIs do Jetpack Compose que recebem chaves como argumentos, como por exemplo, o remember e agora o LaunchedEffect(). O mecanismo de chave, é uma forma de indicar a quantidade de execuções do composable a mudança de valor na chave enviada.

Por exemplo, ao usar o remember sem enviar nenhuma chave, significa que ele será executado somente uma vez, logo, ao usar o LaunchedEffect() enviando null, significa também que será executado apenas uma vez! Afinal, o null não altera o seu valor...

Em outras palavras, se você tem a intenção de rodar mais de uma vez esses composables, é só enviar um valor mutável. Meio abstrato? Então vamos seguir com o exemplo do LaunchedEffect():

var users by remember {
    mutableStateOf(emptyList<User>())
}
LaunchedEffect(users) {
    findAllUsers().collect {
        users = it
    }
}
Enter fullscreen mode Exit fullscreen mode

Com apenas esse ajuste, o looping infinito volta novamente, pois cada vez que ocorre a recomposição, temos um novo valor para users... Portanto, escolha sabiamente a chave em APIs de side-effect.

O mecanismos de chaves pode recebe mais de uma chave via varargs, podendo reexecutar cada vez que uma das chaves tiverem novos valores...

Evitando a recomposição em eventos dos composables

Uma outra maneira de evitar problemas na recomposição, é utilizar eventos dos composables, como por exemplo, o evento de clique:

var users by remember {
    mutableStateOf(emptyList<User>())
}
val scope = rememberCoroutineScope()
Column {
    Button(onClick = {
        scope.launch {
            findAllUsers().collect {
                users = it
            }
        }
    }) {
        Text(text = "Find all users")
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

App em execução apresentando o botão para buscar todos os usuários e o contador iniciado com zero em coluna. Ao clicar no botão, após alguns segundos, o contador é incrementado e aparece o usuário, ao clicar novamente, apenas o contador é incrementado.

Veja que a recomposição acontece apenas ao realizar os cliques! O segredo desse comportamento é que esses eventos utilizam as APIs de side-effect internamente!

Evitando looping com o Flow na recomposição

Um outro detalhe importante, é que o uso do Flow no Jetpack Compose geralmente é feito a partir da coleção convertida para o State, assim como vimos na amostra inicial...

Para usar essa abordagem e evitar o looping infinito, a implementação do Flow NÃO DEVE alterar o estado! Isso significa que o código exposto do Flow, deve ser apenas para ler ou modificar o fluxo de leitura do dado.

Logo, a alteração para novas emissões, devem ser feitas em uma outra chamada que não será executada em um código de composição. Vamos seguir com um exemplo de um ViewModel:

class UsersViewModel : ViewModel() {

    private val _users = MutableStateFlow(emptyList<User>())
    val users = _users.asStateFlow()

    fun findAllUsers() {
        _users.update {
            listOf(User(1, "alex"))
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Neste código, o findAllUsers() não é mais responsável em devolver o Flow para leitura, e sim, emitir novos valores. E considerando essa abordagem, esse código tende a ser chamado fora do escopo da composição, como é o caso das APIs de side-effect.

Para ler os usuários encontrados com essa implementação, fazemos a coleção com a conversão de State da property users (que é um StateFlow) que vai notificar a mudança cada vez que o findAllUsers() for chamado. Veja só como fica no composable:

val viewModel by viewModels<UsersViewModel>()
val users by viewModel.users.collectAsState(
    initial = emptyList()
)
Column {
    Button(onClick = {
        viewModel.findAllUsers()
    }) {
        Text(text = "Find all users")
    }
    Text(text = "${counter++}")
    for (user in users) {
        Column {
            Text(text = user.id.toString())
            Text(text = user.name)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note que mesmo o código de coleção do Flow esteja em composição, não temos o problema de looping, pois é um código que apenas reage a atualizações que são feitas somente na API e side-effect!

E ai, o que achou desses detalhes da recomposição no Jetpack Compose? Já passou por alguns desafios que foram complicados neste tipo de código? Aproveite para compartilhar nos comentários.

Top comments (0)