Performance do Compose

Mantenha tudo organizado com as coleções Salve e categorize o conteúdo com base nas suas preferências.

O objetivo do Jetpack Compose é oferecer uma ótima performance por padrão. Esta página mostra como criar e configurar seu app para melhorar a performance e destaca alguns padrões a serem evitados.

Antes de ler isto, familiarize-se com os principais conceitos do Compose em Como trabalhar com o Compose.

Configurar o app corretamente

Se o app tiver uma performance ruim, isso pode significar que há um problema de configuração. Um bom ponto de partida é verificar as opções de configuração abaixo.

Criar no modo de lançamento e usar o R8

Se você tiver problemas de performance, tente executar o app no modo de lançamento. O modo de depuração é útil para detectar muitos problemas, mas causa um custo de performance significativo e pode dificultar a detecção de outros problemas de código que podem prejudicar a performance. Você também precisa usar o compilador R8 para remover o código desnecessário do app. Por padrão, a criação no modo de lançamento usa o compilador R8 automaticamente.

Usar um perfil de referência

O Compose é distribuído como uma biblioteca, em vez de fazer parte da plataforma Android. Essa abordagem permite atualizar o Compose com frequência e oferecer suporte a versões mais antigas do Android. No entanto, a distribuição do Compose como uma biblioteca implica em um custo. O código da Plataforma Android já está compilado e instalado no dispositivo. As bibliotecas, por outro lado, precisam ser carregadas quando o app é iniciado e interpretadas just-in-time quando a funcionalidade é necessária. Isso pode deixar o app mais lento na inicialização e sempre que ele usar um recurso da biblioteca pela primeira vez.

É possível melhorar a performance definindo perfis de referência. Esses perfis definem classes e métodos necessários em jornadas ideais do usuário e são distribuídos com o APK do app. Durante a instalação do app, o ART compila esse código essencial com antecedência para que ele esteja pronto para uso quando o app for iniciado.

Nem sempre é fácil definir um bom perfil de referência. Por isso, o Compose oferece um por padrão. Talvez não seja necessário fazer mais nada para aproveitar esse benefício. No entanto, se você definir um perfil próprio, talvez gere um que não melhore a performance do app. Teste o perfil para verificar se ele está ajudando. Uma boa maneira de fazer isso é criando testes Macrobenchmark para o app e verificando os resultados deles à medida que você grava e revisa seu perfil de valor de referência. Confira como criar testes de Macrobenchmark para a IU do Compose em exemplo de Macrobenchmark do Compose (link em inglês).

Para ver uma análise detalhada dos efeitos do modo de lançamento e dos perfis de R8 e referência, consulte a postagem do blog Por que testar a performance do Compose no lançamento? (link em inglês).

Como as três fases do Compose afetam a performance

Conforme discutido em Fases do Jetpack Compose, quando o Compose atualiza um frame, ele passa por três fases:

  • Composição: o Compose determina o que mostrar. Ele executa funções de composição e cria a árvore de IU.
  • Layout: o Compose determina o tamanho e a posição de cada elemento na árvore da IU.
  • Exibição: o Compose renderiza os elementos da IU individuais.

O Compose pode ignorar essas fases de maneira inteligente se elas não forem necessárias. Por exemplo, suponha que um único elemento gráfico alterne entre dois ícones do mesmo tamanho. Como esse elemento não muda de tamanho e nenhum elemento da árvore da IU é adicionado ou removido, o Compose pode pular as fases de composição e layout e reexibir esse único elemento.

No entanto, alguns erros de programação fazer com que seja difícil para o Compose saber quais fases ele pode ignorar com segurança. Em caso de dúvida, o Compose acaba executando as três fases, o que pode deixar a IU mais lenta do que deveria. Portanto, muitas práticas recomendadas de performance giram em torno de ajudar o Compose a pular as fases desnecessárias.

Há alguns princípios gerais a serem seguidos que podem melhorar a performance em geral.

Em primeiro lugar, sempre que possível, remova os cálculos das funções de composição. As funções de composição podem precisar ser executadas novamente sempre que a IU mudar. Qualquer código que você inserir nelas vai ser executado novamente, possivelmente para cada frame de uma animação. Portanto, limite o código do elemento de composição apenas ao que ele precisa para criar a IU.

Em segundo lugar, adie as leituras de estado pelo maior tempo possível. Ao mover a leitura do estado para um elemento de composição filho ou para uma fase posterior, é possível minimizar a recomposição ou pular completamente a fase de composição. Você pode fazer isso transmitindo funções lambda em vez do valor do estado quando ele muda com frequência e priorizando modificadores baseados em lambdas ao transmitir esse estado com mudanças frequentes. Veja um exemplo dessa técnica na seção Adiar leituras pelo maior tempo possível.

A seção abaixo descreve alguns erros de código específicos que podem causar esses tipos de problemas. Esperamos que os exemplos específicos abordados aqui também ajudem a identificar outros erros semelhantes no seu código.

Usar ferramentas para encontrar problemas

Pode ser difícil saber onde está um problema de desempenho e qual código começar a otimizar. Comece usando ferramentas para facilitar a localização do problema.

Ver o número de recomposições

Você pode usar o Layout Inspector para verificar com que frequência um elemento de composição é recomposto ou ignorado.

Número de recomposições no Layout Inspector

Para mais informações, consulte a seção de ferramentas.

Seguir as práticas recomendadas

Existem algumas 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. Esta seção lista algumas práticas recomendadas para ajudar você a evitar os erros.

Usar "remember" para minimizar os cálculos caros

As funções de composição 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 os resultados podem ser buscados sempre que 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 ->
            // ...
        }
    }
}

O problema é que, 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, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    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.

Usar chaves de layout lentas

Os layouts lentos fazem o melhor para reutilizar itens de forma inteligente, gerando ou compilando novamente os itens apenas quando necessário. Você pode ajudar os layouts a tomar decisões melhores.

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, logo, ela vai para o topo da lista e todas as outras descem uma posição.

O problema é que, 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 e assim por diante 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 vê 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)
        }
    }
}

Usar derivedStateOf para limitar as recomposições

Um risco de usar o estado nas composições é que, se ele mudar 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 rolar 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 baixo. 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. 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 mudar, 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()
}

Adiar leituras pelo maior tempo possível

É necessário adiar a leitura das variáveis de estado o máximo possível. Adiar leituras de estado pode ajudar a garantir 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 de composição e você ler o estado em um elemento de composição 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. Saiba como aplicamos essa abordagem ao 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: Como depurar a recomposição (link em inglês).

Para conseguir esse efeito, a função de composição Title precisa saber sobre o 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 procura o escopo de recomposição pai mais próximo e o invalida. Nesse caso, o pai mais próximo é a função de composição Box. Assim, o Compose faz a recomposição da Box e também todos os elementos de composição dentro dela. 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 troca 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, podemos usar 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 de composição abaixo mostra um exemplo desse tipo de erro.

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(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
}

Esse código atualiza a contagem no final da função de composição, depois de a ler na linha acima. 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.