Siga as práticas recomendadas

Você pode encontrar armadilhas comuns do Compose. Esses erros podem criar um código que parece funcionar bem, mas que também pode prejudicar a performance da IU. Siga as práticas recomendadas para otimizar seu app no Compose.

Usar remember para minimizar os cálculos caros

As funções combináveis podem ser executadas com muita frequência, até mesmo para todos os frames de uma animação. Por isso, faça o menor cálculo possível no corpo da função.

Uma técnica importante é armazenar os resultados de cálculos com remember. Dessa forma, o cálculo é executado uma vez e você pode buscar os resultados sempre que eles forem necessários.

Por exemplo, confira este código que mostra uma lista ordenada de nomes, mas que faz a classificação de maneira muito cara:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

Toda vez que o elemento ContactsList é recomposto, toda a lista de contatos é classificada novamente, mesmo que ela não tenha mudado. Se o usuário rolar a lista, a função de composição vai ser recomposta sempre que uma nova linha aparecer.

Para resolver esse problema, classifique a lista fora de LazyColumn e armazene a lista ordenada com remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

Agora, a lista é classificada uma vez, quando a ContactList é composta pela primeira vez. Se os contatos ou o comparador mudarem, a lista classificada vai ser gerada novamente. Caso contrário, o elemento de composição pode continuar usando a lista classificada em cache.

Sempre transmita as entradas referenciadas dentro do bloco de cálculo remember como chaves para que o valor armazenado em cache seja atualizado quando essas entradas mudarem. Como remember armazena as chaves e o resultado armazenado em cache na tabela de slots e compara as chaves usando Object.equals() em cada passagem de recomposição, use remember apenas ao armazenar em cache cálculos caros ou alocações de objetos pesados (como formatadores de data, sombreamentos ou classificação de listas) ou quando a identidade do objeto precisa ser preservada.

Tenha cuidado ao transmitir uma coleção grande ou um objeto com uma implementação equals() cara como uma chave para remember para um cálculo barato, já que comparar a chave pode custar mais do que executar o cálculo diretamente:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

Usar chaves de layout lento e tipos de conteúdo

Os layouts lentos reutilizam itens de forma eficiente, gerando ou recompondo os itens apenas quando necessário. No entanto, é possível ajudar a otimizar layouts lentos para recomposição e reutilização de slots.

Suponha que uma operação do usuário faça com que um item seja movido na lista. Por exemplo, suponha que você mostre uma lista de notas classificadas por horário de modificação, com a nota modificada mais recentemente no topo.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

No entanto, há um problema com esse código. Suponha que a última nota mude. Ela passa a ser a nota modificada mais recentemente e, portanto, ela vai para o topo da lista, e todas as outras descem uma posição.

Sem sua ajuda, o Compose não percebe que os itens que não mudaram e estão sendo apenas movidos pela lista. Em vez disso, o Compose pensa que o antigo "item 2" foi excluído e um novo foi criado para o item 3, item 4, até o fim da lista. O resultado é que o Compose faz a recomposição de todos os itens na lista, mesmo que apenas um deles tenha mudado.

A solução aqui é fornecer chaves de item. Fornecer uma chave estável para cada item permite que o Compose evite recomposições desnecessárias. Nesse caso, o Compose pode determinar que o item na posição 3 agora é o mesmo que costumava ficar na posição 2. Como nenhum dos dados desses itens mudou, o Compose não precisa fazer a recomposição deles.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Ao trabalhar com layouts lentos (LazyColumn, LazyRow, LazyGrid), também tenha em mente as seguintes diretrizes:

  • Use chaves exclusivas que podem ser salvas em um pacote: as chaves precisam ser exclusivas, determinísticas e poder ser salvas em um Bundle do Android, porque os layouts lentos usam rememberSaveable internamente. Dê preferência a tipos primitivos, como Int, Long ou String, e nunca use índices de lista ou hashCode() como chaves.
  • Sempre especifique contentType para listas heterogêneas: transmita contentType = { it.type } para items ou itemsIndexed para que o Compose reutilize somente slots de composição entre itens que compartilham a mesma estrutura visual durante a rolagem.
  • Use itemsIndexed em vez de indexOf: evite chamar list.indexOf(item) dentro de um bloco items, o que transforma o layout em uma verificação O(N²). Use itemsIndexed.
  • Eleve o estado compartilhado e as leituras de CompositionLocal: colete instâncias compartilhadas de Flow e leia os valores de CompositionLocal (como LocalContext.current ou LocalDensity.current) uma vez no elemento combinável pai acima do layout lento e transmita os valores resolvidos para os elementos combináveis de item.
  • Use Row ou Column para listas pequenas e limitadas: para coleções estáticas ou limitadas pequenas (como de 1 a 10 itens), um Row ou Column padrão com forEach evita a virtualização lenta e a sobrecarga de pré-busca.

Usar computedStateOf ou derivedStateOf para limitar as recomposições

Um risco de usar o estado nas composições é que, se ele muda rapidamente, a IU pode ser recomposta mais do que o necessário. Por exemplo, suponha que você esteja mostrando uma lista rolável. Examine o estado da lista para verificar qual item é o primeiro visível:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

O problema é que, se o usuário rola a lista, o listState muda constantemente, conforme o usuário arrasta o dedo. Isso significa que a lista está sendo recomposta constantemente. No entanto, você não precisa fazer a recomposição com essa frequência, já que só é necessário fazer isso quando um novo item se torna visível na parte de cima. Isso exige muitos cálculos desnecessários, o que faz com que a IU tenha uma performance ruim.

A solução é usar um estado derivado (ou computedStateOf no ambiente de execução do Compose 1.13 e versões mais recentes). O estado derivado permite que o Compose informe quais mudanças de estado realmente acionam a recomposição. Nesse caso, especifique quando você quer mudar o primeiro item visível. Quando esse valor de estado muda, a IU precisa ser recomposta. Se o usuário ainda não tiver rolado o suficiente para trazer um novo item ao topo, não é necessário fazer a recomposição.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Escolha a abordagem de derivação de estado adequada com base na sua versão do Compose e no custo de cálculo:

  • remember { computedStateOf { ... } } (Compose Runtime 1.13 e versões mais recentes): reavalia o cálculo em cada leitura sem armazenar o resultado em cache e só invalida a composição quando a saída calculada muda. Como ele evita a contabilidade de cache e a sobrecarga de gravação de invalidação, prefira remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } para verificações de condições básicas e derivações leves impulsionadas por estados de mudança rápida, como deslocamentos de rolagem.
  • remember { derivedStateOf { ... } }: armazena em cache o resultado do cálculo em várias leituras. Use derivedStateOf em versões do Compose anteriores à 1.13 ou quando o cálculo em si for computacionalmente caro e lido várias vezes durante uma passagem de composição. Não transmita os valores de estado do snapshot lidos em derivedStateOf (como listState.firstVisibleItemIndex) como chaves remember, porque isso recria o DerivedState em cada mudança de estado e impede a omissão.
  • Getters simples do Kotlin (get()) e leituras diretas de propriedades: prefira um getter personalizado simples para expressões booleanas triviais em que a redução de invalidação não é necessária e leia a coleção .size diretamente em vez de envolvê-la em derivedStateOf. Evite usar produceState com snapshotFlow para derivações de estado síncronas, porque isso adiciona uma sobrecarga desnecessária de envio de corrotinas.

Adiar leituras pelo maior tempo possível

Quando um problema de desempenho é identificado, o adiamento de leituras de estado pode ajudar. Adiar leituras de estado garante que o Compose execute novamente o mínimo possível de código na recomposição. Por exemplo, se a IU tiver um estado elevado no lugar da árvore combinável e você ler o estado em um elemento combinável filho, vai ser possível unir o estado de leitura em uma função lambda. Isso faz com que a leitura ocorra somente quando necessário. Para referência, consulte a implementação no app de exemplo Jetsnack (link em inglês). O Jetsnack implementa um efeito do tipo "barra de ferramentas recolhível" na tela de detalhes. Para entender por que essa técnica funciona, consulte a postagem do blog Jetpack Compose: Debugging Recomposition (link em inglês).

Para conseguir esse efeito, a função de composição Title precisa do deslocamento de rolagem para se deslocar usando um Modifier. Confira uma versão simplificada do código do Jetsnack antes da otimização ser feita:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Quando o estado de rolagem muda, o Compose invalida o escopo de recomposição pai mais próximo. Nesse caso, o escopo mais próximo é o elemento combinável SnackDetail. Observe que Box é uma função inline e, portanto, não é um escopo de recomposição. Assim, o Compose faz a recomposição de SnackDetail e de qualquer elemento dentro de SnackDetail. Se você mudar o código para ler apenas o estado em que ele é usado, vai ser possível reduzir o número de elementos que precisam ser recompostos.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

O parâmetro de rolagem agora é uma lambda. Isso significa que o Title ainda pode fazer referência ao estado elevado, mas o valor só é lido dentro de Title, onde é realmente necessário. Como resultado, quando o valor de rolagem mudar, o escopo de recomposição mais próximo vai ser o elemento de composição Title. O Compose não precisa mais recompor toda a Box.

Essa é uma boa melhoria, mas você pode fazer ainda melhor. Verifique se você não está fazendo a recomposição apenas para mudar o layout ou redesenhar um elemento de composição. Neste caso, você só está mudando o deslocamento do elemento de composição Title, o que pode ser feito na fase de layout.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

Anteriormente, o código usava Modifier.offset(x: Dp, y: Dp), que usa o deslocamento como parâmetro. Ao mudar para a versão lambda do modificador, você garante que a função leia o estado de rolagem na fase de layout. Como resultado, quando o estado de rolagem muda, o Compose pode pular completamente a fase de composição e ir diretamente para a fase de layout. Ao transmitir com frequência mudanças de variáveis de estado em modificadores, use as versões lambda dos modificadores sempre que possível.

Confira outro exemplo dessa abordagem. Este código ainda não foi otimizado:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

Aqui, a cor de fundo da caixa alterna rapidamente entre duas cores. Esse estado muda com muita frequência. A função de composição lê esse estado no modificador em segundo plano. Como resultado, a caixa precisa ser recomposta em cada frame, já que a cor muda em cada um.

Para melhorar isso, use um modificador baseado em lambdas, neste caso, drawBehind. Isso significa que o estado da cor só é lido durante a fase de exibição. Como resultado, o Compose pode pular completamente as fases de composição e de layout. Quando a cor muda, o Compose vai direto para a fase de exibição.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

Evitar gravações inversas

O Compose tem como base a suposição que você nunca vai gravar em um estado que já foi lido. Isso é conhecido como gravação inversa e pode fazer com que a recomposição ocorra em cada frame, indefinidamente.

O elemento combinável abaixo mostra um exemplo desse tipo de erro.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

Esse código atualiza a contagem no final da função de composição, depois de fazer a leitura dela na linha anterior. Ao executar esse código, você vai notar que, depois de clicar no botão, o que causa uma recomposição, o contador aumenta rapidamente em um loop infinito. Conforme o Compose faz a recomposição desse elemento, ele encontra uma leitura de estado desatualizada e programa outra recomposição.

Nunca gravar no estado da composição evita gravações inversas. Se possível, grave sempre no estado em resposta a um evento e em uma lambda, como no exemplo onClick anterior.

Para um guia detalhado sobre gravações reversas, loops de recomposição e coordenação de fases, consulte Gravações reversas no Compose.

Outros recursos