Resolução prática de problemas de desempenho no Jetpack Compose

1. Antes de começar

Neste codelab, você aprenderá a melhorar o desempenho de um app do Compose no tempo de execução. Vamos seguir uma abordagem científica para medir, depurar e otimizar o desempenho. Você vai investigar diversos problemas usando o rastreamento do sistema e fazer mudanças no código falho de um app de exemplo, que tem várias telas representando tarefas diferentes. Cada tela é criada de forma distinta e inclui o seguinte:

  • A primeira tela é uma lista de duas colunas com itens de imagens e algumas tags sobre cada item. Aqui, você vai otimizar elementos combináveis pesados.

8afabbbbbfc1d506.gif

  • A segunda e terceira telas contêm um estado de recomposição frequente. Você removerá recomposições desnecessárias para otimizar o desempenho.

f0ccf14d1c240032.gif 51dc23231ebd5f1a.gif

  • A última tela contém itens instáveis. Nela, você vai estabilizar os itens com várias técnicas.

127f2e4a2fc1a381.gif

Pré-requisitos

O que você vai aprender

O que você precisa

2. Começar a configuração

Para começar, siga estas etapas:

  1. Clone o repositório do GitHub:
$ git clone https://github.com/android/codelab-android-compose.git

Se preferir, baixe o repositório como um arquivo ZIP:

  1. Abra o projeto PerformanceCodelab, que contém estas ramificações:
  • main: contém o código inicial do projeto, em que você vai fazer mudanças para concluir o codelab.
  • end: contém o código da solução do codelab.

É recomendado começar pela ramificação main e seguir cada etapa do codelab no seu próprio ritmo.

  1. Para conferir o código da solução, execute este comando:
$ git clone -b end https://github.com/android/codelab-android-compose.git

Você também pode baixar o código da solução:

Opcional: rastreamentos do sistema usados neste codelab

Você vai executar diversos comparativos para capturar rastreamentos do sistema durante o codelab.

Se você não puder executar esses comparativos, temos uma lista de rastreamentos do sistema disponíveis para download:

3. Abordagem para resolver problemas de desempenho

É possível encontrar problemas e atrasos na interface acessando e explorando o app. No entanto, antes de começar a corrigir o código com base nas suas impressões, avalie o desempenho dele para entender se as mudanças vão fazer a diferença.

Durante o desenvolvimento, com um build debuggable do seu app, talvez você perceba que algo não está sendo executado corretamente e sinta a necessidade de resolver esse problema. Porém, o desempenho de um app debuggable não é representativo do que vai chegar aos usuários, então é importante verificar com um app non-debuggable para saber se o problema realmente existe. Em um app debuggable, todo o código precisa ser interpretado pelo ambiente de execução.

Não há uma regra rígida para implementar funcionalidades específicas de desempenho no Compose. Você não deve fazer o seguinte prematuramente:

  • Não procure e corrija todo parâmetro instável no seu código.
  • Não remova animações que causam recomposições em elementos combináveis.
  • Não faça otimizações difíceis de ler com base na sua intuição.

Todas essas modificações precisam ser feitas de maneira informada usando as ferramentas disponíveis para garantir que o problema de desempenho está sendo resolvido.

Siga esta abordagem científica para resolver problemas de desempenho:

  1. Faça uma medição para definir o desempenho inicial.
  2. Observe o que está causando o problema.
  3. Modifique o código com base nas observações.
  4. Meça e compare com o desempenho inicial.
  5. Repita o processo.

Se você não seguir um método estruturado, talvez algumas das mudanças até melhorem o desempenho, mas outras poderão piorá-lo, deixando você com o mesmo resultado.

Recomendamos assistir o vídeo a seguir sobre a melhoria do desempenho do app com o Compose. Ele aborda o processo de resolução de problemas de desempenho e traz até algumas dicas de como fazer isso.

Gerar perfis de referência

Antes de começar a investigação dos problemas de desempenho, gere um perfil de referência para o app. No Android 6 (nível 23 da API) e versões mais recentes, os apps executam códigos interpretados no tempo de execução e compilados just-in-time (JIT) e ahead-of-time (AOT) na instalação. Códigos interpretados e compilados JIT são executados mais lentamente que AOT, mas ocupam menos espaço no disco e na memória. É por isso que nem todo código precisa ser compilado AOT.

Ao implementar perfis de referência, você pode melhorar a inicialização do app em 30%, e também reduzir em até oito vezes o código executado no modo JIT durante a execução, conforme mostrado nesta imagem baseada no app de exemplo Now in Android (link em inglês):

b51455a2ca65ea8.png

Para saber mais sobre perfis de referência, consulte estes recursos:

Avaliar o desempenho

Para medir o desempenho, recomendamos configurar e programar comparativos com a biblioteca Jetpack Macrobenchmark. Macrobenchmarks são testes instrumentados que interagem com o app como usuários ao mesmo tempo que monitoram o desempenho. Isso significa que não poluem o código do app com código de teste, oferecendo informações de desempenho confiáveis.

Neste codelab, a base de código já foi definida, e os comparativos já foram programados para se concentrarem diretamente na resolução de problemas de desempenho. Caso você não saiba como configurar e usar Macrobenchmarks no seu projeto, confira estes recursos:

Com Macrobenchmarks, você escolhe um destes modos de compilação:

  • None: redefine o estado de compilação e executa tudo no modo JIT.
  • Partial: pré-compila o app com perfis de referência e/ou iterações de aquecimento e o executa no modo JIT.
  • Full: pré-compila todo o código do app para que não haja código em execução no modo JIT.

Neste codelab, você vai usar apenas o modo CompilationMode.Full() para os comparativos, já que o importante são as mudanças feitas no código, e não o estado de compilação do app. Essa abordagem permite reduzir a variância que seria causada pela execução do código no modo JIT, a qual será reduzida com a implementação de perfis de referência personalizados. O modo Full pode ter um efeito negativo na inicialização do app, então o uso dele não é recomendado para medir o desempenho nesse momento. Use esse modo apenas para medir melhorias de desempenho no tempo de execução.

Quando você concluir as melhorias e quiser conferir o desempenho para os usuários, utilize o modo CompilationMode.Partial(), que usa perfis de referência.

Na próxima seção, você aprenderá a ler os rastreamentos para encontrar os problemas de desempenho.

4. Analisar o desempenho com rastreamentos do sistema

Com um build debuggable do app, você pode usar o Layout Inspector com a contagem de composições para entender rapidamente quando algo está sendo recomposto com muita frequência.

b7edfea340674732.gif

No entanto, isso é apenas parte da investigação geral do desempenho, já que você recebe apenas medições proxy, e não o tempo exato que esses elementos combináveis levaram para ser renderizados. Pode não importar muito se alguma coisa é recomposta N vezes se a duração total é de menos de um milissegundo. Por outro lado, é um dado importante saber que algo é recomposto apenas uma ou duas vezes e leva 100 milissegundos. Frequentemente, um elemento combinável pode ser recomposto apenas uma vez, mas levar muito tempo para fazer isso e reduzir a velocidade do app na tela.

Para investigar problemas de desempenho com confiança e receber insights sobre o que o app está fazendo e se ele leva mais tempo que o necessário, use rastreamentos do sistema com rastreamento de composições.

Os rastreamentos do sistema oferecem informações de tempo de tudo o que acontece no app. E eles não adicionam overhead ao app, então podem ser mantidos no app de produção sem efeitos negativos no desempenho.

Configurar rastreamentos de composição

O Compose preenche automaticamente algumas informações nas fases do tempo de execução, como quando algum elemento é recomposto ou quando um layout lento pré-busca itens. No entanto, essas informações não são suficientes para descobrir o que pode ser uma seção problemática. Você pode melhorar a quantidade de informação definindo rastreamentos de composição, que fornecem o nome de cada elemento combinável que foi composto durante o rastreamento. Isso permite investigar problemas de desempenho sem precisar adicionar muitas seções trace("label") personalizadas.

Para ativar os rastreamentos de composição, siga estas etapas:

  1. Adicione a dependência runtime-tracing ao módulo :app:
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-beta01")

Nesse ponto, você poderia gravar um rastreamento do sistema com o criador de perfis do Android Studio, que incluiria todas as informações, mas vamos usar a Macrobenchmark para medir o desempenho e gravar rastreamentos do sistema.

  1. Adicione outras dependências ao módulo :measure para permitir o rastreamento de composição com a Macrobenchmark:
implementation("androidx.tracing:tracing-perfetto:1.0.0")
implementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
  1. Adicione o argumento de instrumentação androidx.benchmark.fullTracing.enable=true ao arquivo build.gradle do módulo :measure:
defaultConfig {
    // ...
    testInstrumentationRunnerArguments["androidx.benchmark.fullTracing.enable"] = "true"
}

Para saber mais sobre a configuração dos rastreamentos de composição, como a forma de uso deles no terminal, confira a documentação.

Capturar o desempenho inicial com a Macrobenchmark

Há várias maneiras de recuperar um arquivo de rastreamento do sistema. Por exemplo, você pode gravar com o criador de perfis do Android Studio, capturar no dispositivo ou recuperar um rastreamento do sistema gravado com a Macrobenchmark. Neste codelab, você vai usar os rastreamentos feitos pela biblioteca Macrobenchmark.

Este projeto contém comparativos no módulo :measure, que podem ser executados para medir o desempenho. Eles foram definidos para executarem apenas uma iteração e economizar tempo durante o codelab. No app real, é recomendado ter pelo menos 10 iterações quando a variância de saída está alta.

Para capturar o desempenho inicial, use o teste AccelerateHeavyScreenBenchmark, que rola a tela da primeira tarefa. Siga estas etapas:

  1. Abra o arquivo AccelerateHeavyScreenBenchmark.kt.
  2. Execute o comparativo com a ação de gutter ao lado da classe de comparativo:

e93fb1dc8a9edf4b.png

Esse comparativo vai rolar a tela Tarefa 1 e capturar o tempo para a renderização do frame e as seções de rastreamento

personalizadas.

8afabbbbbfc1d506.gif

Após a conclusão do comparativo, você poderá conferir os resultados no painel de saída do Android Studio:

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0

Estas são as métricas importantes na saída:

  • frameDurationCpuMs: informa o tempo levado para renderizar os frames. Quanto menor, melhor.
  • frameOverrunMs: informa o tempo excedido do limite de frames, incluindo o trabalho na GPU. Um número negativo é bom, porque indica que sobrou tempo.

As outras métricas, como ImagePlaceholderMs, estão usando seções de rastreamento personalizadas e a duração somada das saídas de todas essas seções no arquivo de rastreamento, assim como o número de ocorrências com a métrica ImagePlaceholderCount.

Todas essas métricas informam se as mudanças feitas na base de código estão melhorando o desempenho.

Ler o arquivo de rastreamento

Você pode ler o rastreamento do sistema usando o Android Studio ou a ferramenta Perfetto baseada na Web.

Ainda que o criador de perfis do Android Studio seja uma maneira boa e rápida de abrir um rastreamento e conferir o processo do app, o Perfetto oferece capabilities de investigação mais aprofundadas sobre todos os processos em execução em um sistema com consultas SQL avançadas e muito mais. Neste codelab, você vai usar o Perfetto para analisar os rastreamentos do sistema.

  1. Abra o site do Perfetto, que carrega o painel da ferramenta.
  2. Localize no sistema de hospedagem de arquivos os rastreamentos do sistema capturados pela Macrobenchmark. Eles ficam armazenados na pasta [module]/outputs/connected_android_test_additional_output/benchmarkRelease/connected/[device]/. Toda iteração do comparativo grava um arquivo de rastreamento separado, cada um contendo as mesmas interações com o app.

51589f24d9da28be.png

  1. Arraste o arquivo AccelerateHeavyScreenBenchmark_...iter000...perfetto-trace para a interface do Perfetto e aguarde até que o arquivo de rastreamento seja carregado.
  2. Opcional: caso não seja possível executar o comparativo e gerar o arquivo de rastreamento, baixe e arraste nosso arquivo de rastreamento para o Perfetto.

547507cdf63ae73.gif

  1. Encontre o processo do seu app, chamado com.compose.performance. Normalmente, o app em primeiro plano fica abaixo das faixas de informações de hardware e algumas faixas do sistema.
  2. Abra o menu suspenso com o nome do processo do app. Você vai encontrar a lista de linhas de execução ativas no seu app. Mantenha o arquivo de rastreamento aberto, porque você precisará dele na próxima etapa.

582b71388fa7e8b.gif

Quando quiser encontrar um problema de desempenho no app, use as linhas do tempo Expected e Actual acima da lista de linhas de execução:

1bd6170d6642427e.png

A linha do tempo Expected informa quando o sistema espera que os frames produzidos pelo app mostrem uma interface fluida e funcional. Nesse caso, 16 ms e 600 µs (1000 ms / 60). A linha do tempo Actual mostra a duração real dos frames produzidos pelo app, incluindo o trabalho da GPU.

Talvez você encontre cores diferentes, que indicam o seguinte:

  • Frame verde: produzido no tempo certo.
  • Frame vermelho: instável e levou mais tempo que o esperado. Investigue o trabalho conduzido nesses frames para evitar problemas de desempenho.
  • Frame verde-claro: produzido dentro do limite de tempo, mas apresentado com atraso, resultando em um aumento na latência de entrada.
  • Frame amarelo: estava instável, mas o motivo não foi o app.

Quando a interface é renderizada em uma tela, as mudanças precisam ser mais rápidas que a duração esperada para a criação de um frame. Historicamente, esse valor era de aproximadamente 16,6 ms, já que a taxa de atualização da tela era de 60 Hz. Em dispositivos Android modernos, entretanto, ele pode ser de aproximadamente 11 ms ou menos, porque a taxa de atualização da tela é de 90 Hz ou mais. Ele também pode ser diferente para cada frame devido a taxas de atualização variáveis.

Por exemplo, se a interface for composta de 16 itens, cada um deles terá cerca de 1 ms para ser criado sem que frames sejam ignorados. Por outro lado, se você tiver apenas um item, como um player de vídeo, poderá levar até 16 ms para a composição ser feita sem instabilidade.

Entender o gráfico da chamada de rastreamentos do sistema

Confira na imagem a seguir o exemplo de uma versão simplificada de um rastreamento do sistema mostrando recomposição.

8f16db803ca19a7d.png

Cada barra de cima para baixo é o tempo total das barras abaixo, sendo que as barras também correspondem às seções de código das funções chamadas. As chamadas do Compose são recompostas na sua hierarquia de composição. O primeiro elemento combinável é o MaterialTheme. Dentro do MaterialTheme há um local de composição que fornece as informações de temas. De lá, o elemento combinável HomeScreen é chamado. O elemento combinável da tela inicial chama os elementos MyImage e MyButton como parte da composição.

As lacunas nos rastreamentos do sistema são seções de código não rastreadas em execução, já que os rastreamentos mostram apenas o código que foi marcado para esse fim. O código em execução acontece após a chamada de MyImage, mas antes da chamada de MyButton, e está ocupando a quantidade de tempo equivalente ao tamanho da lacuna.

Na próxima etapa você vai analisar o rastreamento gerado na etapa anterior.

5. Acelerar elementos combináveis pesados

Como primeira tarefa na tentativa de otimizar o desempenho do seu app, procure elementos combináveis pesados ou tarefas longas na linha de execução principal. Trabalhos de longa duração podem significar coisas diferentes, dependendo da complexidade da interface e de quanto tempo o app tem para fazer a composição.

Portanto, se um frame é ignorado, você precisa encontrar quais elementos combináveis estão levando muito tempo e descarregar a linha de execução principal ou pular parte do trabalho de cada um nessa linha para acelerar o processo.

Para analisar o rastreamento do teste AccelerateHeavyScreenBenchmark, siga estas etapas:

  1. Abra o rastreamento do sistema que você gravou na etapa anterior.
  2. Aumente o zoom do primeiro frame longo, que contém a inicialização da interface após o carregamento dos dados. O conteúdo do frame é parecido com o desta imagem:

838787b87b14bbaf.png

Na seção Choreographer#doFrame, o rastreamento mostra que há várias coisas acontecendo dentro de um frame. Confira na imagem que a maior parte do trabalho vem do elemento combinável com a seção ImagePlaceholder, que carrega uma imagem grande.

Não carregar imagens grandes na linha de execução principal

Pode ser óbvio carregar imagens de maneira assíncrona a partir de uma rede usando uma das bibliotecas de conveniência, como Coil ou Glide, mas e se você tiver uma imagem grande local que precisa ser mostrada no app?

A função combinável painterResource comum que carrega uma imagem de recursos faz isso na linha de execução principal durante a composição. Isso significa que uma imagem grande pode bloquear a linha de execução principal.

No seu caso, o problema faz parte do marcador de posição de imagem assíncrona. O elemento combinável painterResource carrega um marcador de posição de imagem que leva aproximadamente 23 ms para carregar.

c83d22c3870655a7.jpeg

Há várias maneiras de resolver esse problema, incluindo o seguinte:

  • Carregar a imagem de maneira assíncrona.
  • Reduzir o tamanho da imagem para que ela carregue mais depressa.
  • Usar um drawable vetorial que se ajusta de acordo com o tamanho necessário.

Para corrigir esse problema de desempenho, siga estas etapas:

  1. Navegue até o arquivo AccelerateHeavyScreen.kt.
  2. Localize o elemento combinável imagePlaceholder() que carrega a imagem. O marcador de posição da imagem tem dimensões de 1.600 x 1.600 px, claramente grande demais para o que é mostrado.

53b34f358f2ff74.jpeg

  1. Mude o drawable para R.drawable.placeholder_vector:
@Composable
fun imagePlaceholder() =
    trace("ImagePlaceholder") { painterResource(R.drawable.placeholder_vector) }
  1. Execute o teste AccelerateHeavyScreenBenchmark novamente para recriar o app e refazer o rastreamento do sistema.
  2. Arraste o rastreamento do sistema para o painel do Perfetto.

Você também pode baixar o rastreamento:

  1. Procure a seção ImagePlaceholder do rastro, que mostra diretamente a parte melhorada.

abac4ae93d599864.png

  1. A função ImagePlaceholder não bloqueia mais tanto a linha de execução principal.

8e76941fca0ae63c.jpeg

Como solução alternativa no app real, pode não ser um marcador de posição de imagem que está causando o problema, mas algum pôster. Nesse caso, use o elemento combinável rememberAsyncImage da Coil, que o carrega de maneira assíncrona. Essa solução mostra um espaço vazio até o carregamento do marcador de posição. Por isso, pode ser necessário ter um marcador para esse tipo de imagem.

Algumas outras coisas ainda não têm bom desempenho. Elas serão resolvidas na próxima etapa.

6. Descarregar uma operação pesada em uma linha de execução em segundo plano

Se você continuar a investigação do mesmo item em busca de outros problemas, vai encontrar seções com o nome binder transaction, que levam aproximadamente 1 ms cada.

5c08376b3824f33a.png

Seções chamadas binder transaction mostram que havia uma comunicação acontecendo entre seus processos e alguns do sistema. Essa é uma maneira normal de conseguir informações do sistema, como ao recuperar um serviço dele.

Essas transações são incluídas em várias das APIs em comunicação com o sistema. Por exemplo, ao recuperar um serviço do sistema com getSystemService, registrar um broadcast receiver ou pedir um ConnectivityManager.

Infelizmente, essas transações não oferecem muitas informações sobre o que precisam, então você deve analisar o código nos usos mencionados da API e adicionar uma seção trace personalizada para garantir que essa é a parte problemática.

Para melhorar as transações de binder, siga estas etapas:

  1. Abra o arquivo AccelerateHeavyScreen.kt.
  2. Localize o elemento combinável PublishedText. Ele formata um marcador de data/hora com o fuso horário atual e registra um objeto BroadcastReceiver que acompanha mudanças no fuso. Ele contém um estado currentTimeZone variável com o fuso horário padrão do sistema como valor inicial, assim como um DisposableEffect que registra um broadcast receiver para as mudanças de fuso. Por fim, esse elemento combinável mostra um marcador de data/hora formatado com Text. O DisposableEffect é uma boa escolha nesse cenário, já que você precisa de uma maneira de cancelar o registro do broadcast receiver, o que é feito pela lambda onDispose. No entanto, a parte problemática é que o código no DisposableEffect bloqueia a linha de execução principal:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        // TODO Codelab task: Wrap with a custom trace section
        context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))

        onDispose { context.unregisterReceiver(receiver) }
    }

    Text(
        text = published.format(currentTimeZone),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. Envolva o context.registerReceiver com uma chamada de trace para garantir que é isso que está causando todas as binder transactions:
trace("PublishDate.registerReceiver") {
    context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}

No geral, um código rodando por tanto tempo na linha de execução principal pode não causar muitos problemas, mas o fato de essa transação ser executada em cada item visível na tela é problemático. Supondo que haja seis itens visíveis na tela, eles precisam ser compostos com o primeiro frame. Essas chamadas podem levar 12 ms, o que é quase todo o limite para um frame.

Para corrigir esse problema, é necessário descarregar o registro de transmissão para uma linha de execução diferente. Você pode fazer isso usando corrotinas.

  1. Use um escopo vinculado ao ciclo de vida val scope = rememberCoroutineScope() do elemento combinável.
  2. Dentro do efeito, abra uma corrotina em um dispatcher diferente de Dispatchers.Main. Por exemplo, neste caso, Dispatchers.IO. Dessa forma, o registro da transmissão não bloqueia a linha de execução principal, mas o estado currentTimeZone em si é mantido nela.
val scope = rememberCoroutineScope()

DisposableEffect(Unit) {
    val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            currentTimeZone = TimeZone.currentSystemDefault()
        }
    }

    // launch the coroutine on Dispatchers.IO
    scope.launch(Dispatchers.IO) {
        trace("PublishDate.registerReceiver") {
            context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
        }
    }

    onDispose { context.unregisterReceiver(receiver) }
}

Há mais uma etapa para a otimização. Você não precisa de um broadcast receiver para cada item na lista, mas apenas um. Ele deve ser elevado.

Você pode fazer a elevação e transmitir o parâmetro de fuso horário pela árvore de elementos combináveis ou, já que ele não é usado em muitas partes da interface, você pode usar um local de composição.

Para os fins deste codelab, vamos manter o broadcast receiver como parte da árvore de elementos combináveis. No entanto, no app real pode ser benéfico separá-lo em uma camada de dados para evitar a poluição do código da interface.

  1. Defina o local de composição com o fuso horário padrão do sistema:
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }
  1. Atualize o elemento combinável ProvideCurrentTimeZone que usa uma lambda content para fornecer o fuso horário atual:
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    var currentTimeZone = TODO()

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. Mova o DisposableEffect do elemento combinável PublishedText para o novo, fazendo a elevação dele, e substitua currentTimeZone pelo estado e efeito colateral:
@Composable
fun ProvideCurrentTimeZone(content: @Composable () -> Unit) {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    var currentTimeZone: TimeZone by remember { mutableStateOf(TimeZone.currentSystemDefault()) }

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                currentTimeZone = TimeZone.currentSystemDefault()
            }
        }

        scope.launch(Dispatchers.IO) {
            trace("PublishDate.registerReceiver") {
                context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
            }
        }

        onDispose { context.unregisterReceiver(receiver) }
    }

    CompositionLocalProvider(
        value = LocalTimeZone provides currentTimeZone,
        content = content,
    )
}
  1. Envolva o elemento combinável em que você quer que o local de composição seja válido usando ProvideCurrentTimeZone. É possível envolver todo o AccelerateHeavyScreen, conforme mostrado neste sinppet:
@Composable
fun AccelerateHeavyScreen(items: List<HeavyItem>, modifier: Modifier = Modifier) {
    // TODO: Codelab task: Wrap this with timezone provider
    ProvideCurrentTimeZone {
        Box(
            modifier = modifier
                .fillMaxSize()
                .padding(24.dp)
        ) {
            ScreenContent(items = items)

            if (items.isEmpty()) {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
        }
    }
}
  1. Mude o elemento combinável PublishedText para conter apenas funcionalidades básicas de formatação e leia o valor atual do local de composição usando LocalTimeZone.current:
@Composable
fun PublishedText(published: Instant, modifier: Modifier = Modifier) {
    Text(
        text = published.format(LocalTimeZone.current),
        style = MaterialTheme.typography.labelMedium,
        modifier = modifier
    )
}
  1. Faça o comparativo novamente para criar o app.

Se preferir, faça o download do rastreamento do sistema com o código corrigido:

  1. Arraste o arquivo de rastreamento para o painel do Perfetto. Todas as seções de binder transactions desapareceram da linha de execução principal.
  2. Procure o nome de seção semelhante à etapa anterior. Ele está em uma das outras linhas de execução criadas por corrotinas (DefaultDispatch):

87feee260f900a76.png

7. Remover subcomposições desnecessárias

Você retirou o código pesado da linha de execução principal para não bloquear mais a composição. Ainda podemos melhorar. É possível remover parte do overhead desnecessário na forma de um elemento combinável LazyRow em cada item.

Neste exemplo, cada um dos itens contém uma linha de tags, conforme destacado na seguinte imagem:

e821c86604d3e670.png

Essa linha é implementada com um elemento combinável LazyRow porque é fácil programar dessa forma. Transmita os itens para o elemento LazyRow, que vai cuidar do resto:

@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    // TODO: remove unnecessary lazy layout
    LazyRow(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        items(tags) { ItemTag(it) }
    }
}

O problema é que, ainda que layouts Lazy sejam ótimos quando você tem muito mais itens que o tamanho restrito, eles podem ter um custo extra, que é desnecessário quando o app não precisa realizar composição lenta.

Considerando a natureza dos elementos combináveis Lazy, que usam um elemento SubcomposeLayout, eles são sempre mostrados como várias partes do trabalho: primeiro o contêiner, e depois os itens que estão visíveis na tela, que são a segunda parte do trabalho. Você também pode encontrar um compose:lazylist:prefetch no rastreamento do sistema, indicando que outros itens estão entrando na janela de visualização e, portanto, serão pré-buscados para ficarem prontos a tempo.

b3dc3662b5885a2e.jpeg

Para determinar aproximadamente quanto tempo esse processo leva no seu caso, abra o mesmo arquivo de rastreamento. Perceba que há seções separadas do item pai. Cada item consiste no item real que está sendo composto e nos itens de tags. Assim, cada item resulta em aproximadamente 2,5 milissegundos de tempo de composição, que se você multiplicar pelo número de itens visíveis, será outra grande parte do trabalho.

a204721c80497e0f.jpeg

Para corrigir isso, siga estas etapas:

  1. Navegue até o arquivo AccelerateHeavyScreen.kt e localize o elemento combinável ItemTags.
  2. Mude a implementação de LazyRow para um combinável Row que itera na lista tags, conforme mostrado neste snippet:
@Composable
fun ItemTags(tags: List<String>, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .padding(4.dp)
            .fillMaxWidth()
        horizontalArrangement = Arrangement.spacedBy(2.dp)
    ) {
        tags.forEach { ItemTag(it) }
    }
}
  1. Faça o comparativo novamente para criar o app.
  2. Opcional: baixe o rastreamento do sistema com o código corrigido:

  1. Encontre as seções ItemTag, observe que o processo leva menos tempo e usa a mesma seção raiz Compose:recompose.

219cd2e961defd1.jpeg

Uma situação semelhante pode ocorrer com outros contêineres usando um combinável SubcomposeLayout, como um BoxWithConstraints. Isso pode resultar na criação dos itens nas seções Compose:recompose, o que talvez não seja mostrado diretamente como um frame instável, mas ficará visível para o usuário. Se possível, evite um elemento combinável BoxWithConstraints em cada item, já que eles podem ser necessários apenas para compor uma interface diferente com base no espaço disponível.

Nesta seção você aprendeu a resolver composições demoradas.

8. Analisar resultados em contraste com o comparativo inicial

Agora que você terminou de otimizar o desempenho na tela, analise os resultados do comparativo em relação aos resultados iniciais.

  1. Abra o Test History no painel de execução do Android Studio 667294bf641c8fc2.png.
  2. Selecione a execução mais antiga, referente ao comparativo inicial sem mudanças, e compare as métricas frameDurationCpuMs e frameOverrunMs. Você terá resultados semelhantes à tabela a seguir:

Antes

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min  22.9,   median  22.9,   max  22.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.2,   median   3.2,   max   3.2
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.9,   median   1.9,   max   1.9
frameDurationCpuMs                  P50    5.4,   P90    9.0,   P95   10.5,   P99   57.5
frameOverrunMs                      P50   -4.2,   P90   -3.5,   P95   -3.2,   P99   74.9
Traces: Iteration 0
  1. Selecione a execução mais recente, referente ao comparativo com todas as otimizações. Você terá resultados semelhantes à tabela a seguir:

Depois

AccelerateHeavyScreenBenchmark_accelerateHeavyScreenCompilationFull
ImagePlaceholderCount               min  20.0,   median  20.0,   max  20.0
ImagePlaceholderMs                  min   2.9,   median   2.9,   max   2.9
ItemTagCount                        min  80.0,   median  80.0,   max  80.0
ItemTagMs                           min   3.4,   median   3.4,   max   3.4
PublishDate.registerReceiverCount   min   1.0,   median   1.0,   max   1.0
PublishDate.registerReceiverMs      min   1.1,   median   1.1,   max   1.1
frameDurationCpuMs                  P50    4.3,   P90    7.7,   P95    8.8,   P99   33.1
frameOverrunMs                      P50  -11.4,   P90   -8.3,   P95   -7.3,   P99   41.8
Traces: Iteration 0

Se você conferir a linha frameOverrunMs especificamente, verá que todos os percentuais melhoraram:

P50

P90

P95

P99

antes

-4,2

-3,5

-3,2

74,9

depois

-11,4

-8,3

-7,3

41,8

melhoria de

171%

137%

128%

44%

Na próxima seção, você aprenderá a corrigir uma composição que acontece com muita frequência.

9. Evitar recomposições desnecessárias

O Compose tem três fases:

  • A composição constrói uma árvore de elementos combináveis para determinar o que será mostrado.
  • O layout usa essa árvore para determinar onde os elementos vão aparecer na tela.
  • A exibição mostra os elementos na tela.

Geralmente, a ordem dessas fases é a mesma, permitindo que os dados fluam em uma única direção (da composição ao layout e à exibição) para produzir um frame da interface.

2147ae29192a1556.png

BoxWithConstraints, layouts lentos (por exemplo, LazyColumn ou LazyVerticalGrid) e todos os layouts baseados em elementos combináveis SubcomposeLayout são exceções notáveis em que a composição dos filhos depende das fases de layouts pais.

Normalmente, a composição é a fase mais custosa para a execução, já que é onde a maior parte do trabalho é feita, podendo também causar a recomposição de combináveis não relacionados.

A maioria dos frames contém todas as três fases, mas o Compose poderá ignorar completamente uma delas se não houver trabalho a fazer. Aproveite essa função para aumentar o desempenho do app.

Adiar fases de composição com modificadores lambda

As funções combináveis são executadas na fase de composição. Para permitir que o código seja executado em um momento diferente, você pode fornecê-lo como uma função lambda.

Siga estas instruções para fazer isso:

  1. Abra o arquivo PhasesComposeLogo.kt.
  2. Navegue até a tela Tarefa 2 no app. Você vai encontrar um logotipo que salta da borda da tela.
  3. Abra o Layout Inspector e inspecione Recomposition counts. Você vai encontrar um número crescente de recomposições.

a9e52e8ccf0d31c1.png

  1. Opcional: localize e execute o arquivo PhasesComposeLogoBenchmark.kt para recuperar o rastreamento do sistema e conferir a composição da seção de rastreamento PhasesComposeLogo que ocorre em cada quadro. As recomposições são mostradas em um rastreamento como seções repetidas com o mesmo nome.

4b6e72578c89b2c1.jpeg 7036a895a31138d3.png

  1. Caso necessário, feche o criador de perfis e o Layout Inspector e retorne ao código. Você vai encontrar o elemento combinável PhaseComposeLogo, que tem a seguinte aparência:
@Composable
fun PhasesComposeLogo() = trace("PhasesComposeLogo") {
    val logo = painterResource(id = R.drawable.compose_logo)
    var size by remember { mutableStateOf(IntSize.Zero) }
    val logoPosition by logoPosition(size = size, logoSize = logo.intrinsicSize)

    Box(
        modifier = Modifier
            .fillMaxSize()
            .onPlaced {
                size = it.size
            }
    ) {
        with(LocalDensity.current) {
            Image(
                painter = logo,
                contentDescription = "logo",
                modifier = Modifier.offset(logoPosition.x.toDp(), logoPosition.y.toDp())
            )
        }
    }
}

O elemento logoPosition contém lógica que muda o próprio estado a cada frame e tem a seguinte aparência:

@Composable
fun logoPosition(size: IntSize, logoSize: Size): State<IntOffset> =
    produceState(initialValue = IntOffset.Zero, size, logoSize) {
        if (size == IntSize.Zero) {
            this.value = IntOffset.Zero
            return@produceState
        }

        var xDirection = 1
        var yDirection = 1

        while (true) {
            withFrameMillis {
                value += IntOffset(x = MOVE_SPEED * xDirection, y = MOVE_SPEED * yDirection)

                if (value.x <= 0 || value.x >= size.width - logoSize.width) {
                    xDirection *= -1
                }

                if (value.y <= 0 || value.y >= size.height - logoSize.height) {
                    yDirection *= -1
                }
            }
        }
    }

O estado é lido no elemento PhasesComposeLogo com o modificador Modifier.offset(x.dp, y.dp), o que significa que isso acontece na composição.

Esse modificador é o motivo para o app recompor em todos os frames da animação. Nesse caso, há uma alternativa simples: o modificador Offset baseado em lambda.

  1. Atualize o elemento combinável Image para usar o modificador Modifier.offset, que aceita um lambda que retorna o objeto IntOffset, como mostrado neste snippet:
Image(
  painter = logo,
  contentDescription = "logo",
  modifier = Modifier.offset { IntOffset(logoPosition.x,  logoPosition.y) }
)
  1. Execute o app novamente e confira o Layout Inspector. A animação não estará mais gerando recomposições.

Não deveria ser necessário recompor apenas para ajustar o layout de uma tela, principalmente durante a rolagem, porque isso leva a frames instáveis. A recomposição que ocorre durante a rolagem é quase sempre desnecessária e precisa ser evitada.

Outros modificadores lambda

O modificador Modifier.offset não é o único modificador com a versão lambda. Na tabela a seguir, você pode conferir os modificadores comuns que seriam recompostos toda vez, podendo ser substituídos por alternativas adiadas ao transmitirem um valor de estado que muda com frequência:

Modificador comum

Alternativa adiada

.background(color)

.drawBehind { drawRect(color) }

.offset(0.dp, y)

.offset { IntOffset(0, y.roundToPx()) }

.alpha(a).rotate(r).scale(s)

.graphicsLayer { alpha = a; rotationZ = r; scaleX = s; scaleY = s}

10. Adiar fases do Compose com layout personalizado

Usar um modificador baseado em lambda costuma ser a maneira mais fácil de evitar a invalidação da composição, mas às vezes não há um modificador baseado em lambda que faça o que você precisa. Nesses casos, você pode implementar diretamente um layout personalizado ou até mesmo um Canvas combinável para ir direto para a fase de exibição. As leituras de estado do Compose feitas dentro de um layout personalizado apenas invalidam o layout e ignoram a recomposição. Se você só quer ajustar o layout ou o tamanho, mas não adicionar nem remover elementos combináveis, muitas vezes você pode conseguir esse efeito sem invalidar a composição.

Siga estas instruções para fazer isso:

  1. Abra o arquivo PhasesAnimatedShape.kt e execute o app.
  2. Navegue até a tela Tarefa 3. Ela contém uma forma que muda de tamanho quando você clica em um botão. O valor do tamanho é animado com a API de animação animateDpAsState do Compose.

51dc23231ebd5f1a.gif

  1. Abra o Layout Inspector.
  2. Clique em Toggle size.
  3. Observe que a forma se recompõe a cada frame da animação.

63d597a98fca1133.png

O elemento MyShape usa o objeto size como parâmetro, o que é uma leitura de estado. Isso significa que quando o objeto size muda, o elemento combinável PhasesAnimatedShape (o escopo de recomposição mais próximo) é recomposto e, subsequentemente, o elemento MyShape é recomposto porque teve as entradas mudadas.

Para pular a recomposição, siga estas etapas:

  1. Mude o parâmetro size para uma função lambda para que as mudanças de tamanho não recomponham MyShape diretamente:
@Composable
fun MyShape(
    size: () -> Dp,
    modifier: Modifier = Modifier
) {
  // ...
  1. Atualize o local de chamada no combinável PhasesAnimatedShape para usar a função lambda:
MyShape(size = { size }, modifier = Modifier.align(Alignment.Center))

Mudar o parâmetro size para uma lambda atrasa a leitura de estado. Agora essa mudança ocorre quando a lambda é invocada.

  1. Mude o corpo do elemento combinável MyShape para o seguinte:
Box(
    modifier = modifier
        .background(color = Purple80, shape = CircleShape)
        .layout { measurable, _ ->
            val sizePx = size()
                .roundToPx()
                .coerceAtLeast(0)

            val constraints = Constraints.fixed(
                width = sizePx,
                height = sizePx,
            )

            val placeable = measurable.measure(constraints)
            layout(sizePx, sizePx) {
                placeable.place(0, 0)
            }
        }
)

Na primeira linha da lambda de medida modificador layout, a lambda size é invocada. Isso acontece dentro do modificador layout, então invalida apenas o layout, não a composição.

  1. Execute o app de novo, navegue até a tela Tarefa 3 e abra o Layout Inspector.
  2. Clique em Toggle Size e observe que o tamanho da forma anima o mesmo que antes, mas o combinável MyShape não é recomposto.

11. Evitar recomposições com classes estáveis

O Compose gera código que pode ignorar a execução do elemento combinável quando todos os parâmetros de entrada estão estáveis e não foram mudados em relação à composição anterior. Um tipo é estável se é imutável ou quando é possível para o mecanismo do Compose saber se o valor dele mudou entre as recomposições.

Se o mecanismo do Compose não tiver certeza se um elemento combinável é estável ou não, ele o tratará como instável e não vai gerar a lógica de código para ignorar a recomposição, o que significa que o elemento será sempre recomposto. Isso pode acontecer quando uma classe não é um tipo primitivo e uma das seguintes situações ocorre:

  • Ela é uma classe mutável. Por exemplo, contém uma propriedade mutável.
  • Ela é uma classe definida em um módulo do Gradle que não usa o Compose. Eles não têm uma dependência no compilador do Compose.
  • Ela é uma classe que contém uma propriedade instável.

Esse comportamento pode ser indesejável em alguns casos, em que causa problemas de desempenho, e pode ser mudado quando você faz o seguinte:

  • Ativa o modo de rejeição avançada.
  • Inclui uma anotação @Immutable ou @Stable no parâmetro.
  • Adiciona a classe ao arquivo de configuração de estabilidade.

Para saber mais sobre estabilidade, leia a documentação.

Nesta tarefa, você tem uma lista de itens que podem ser adicionados, removidos ou verificados e precisa garantir que eles não sejam recompostos desnecessariamente. Há dois tipos de itens alternando entre aqueles que são recriados todas as vezes e aqueles que não são.

Os itens que são sempre recriados estão aqui como uma simulação do caso de uso do mundo real, em que os dados vêm de um banco de dados local (por exemplo, Room ou sqlDelight) ou uma fonte de dados remota (como solicitações da API ou entidades do Firestore), e retornam uma nova instância do objeto sempre que há uma mudança.

Vários elementos combináveis têm um modificador Modifier.recomposeHighlighter() anexado, que pode você encontrar no nosso repositório do GitHub (link em inglês). Ele mostra uma borda sempre que um elemento combinável é recomposto, podendo servir como uma solução temporária alternativa ao Layout Inspector.

127f2e4a2fc1a381.gif

Ativar o modo de rejeição avançada

O compilador do Jetpack Compose 1.5.4 e versões mais recentes tem a opção de ativar o modo de rejeição avançada, o que significa que até mesmo elementos combináveis com parâmetros instáveis podem gerar código de rejeição. Esse modo deve reduzir radicalmente a quantidade de combináveis que não podem ser ignorados no projeto, melhorando o desempenho sem mudanças no código.

Para os parâmetros instáveis, a lógica de rejeição é comparada quanto à igualdade de instância, o que significa que o parâmetro seria ignorado se a mesma instância fosse transmitida para o elemento combinável como no caso anterior. Em contraste, parâmetros estáveis usam a igualdade estrutural (chamando o método Object.equals()) para determinar a lógica de rejeição.

Além da lógica de rejeição, o modo de rejeição avançada também se lembra automaticamente das lambdas dentro de uma função combinável. Esse fato significa que você não precisa que uma chamada remember envolva uma função lambda, por exemplo, uma que chama o método ViewModel.

O modo de rejeição avançada pode ser ativado em um módulo do Gradle.

Para ativar o modo, siga estas etapas:

  1. Abra o arquivo build.gradle.kts do app.
  2. Atualize o bloco composeCompiler com o seguinte snippet.
composeCompiler {
    // Not required in Kotlin 2.0 final release
    suppressKotlinVersionCompatibilityCheck = "2.0.0-RC1"

    // This settings enables strong-skipping mode for all module in this project.
    // As an effect, Compose can skip a composable even if it's unstable by comparing it's instance equality (===).
    enableExperimentalStrongSkippingMode = true
}

Isso adiciona o argumento experimentalStrongSkipping do compilador ao módulo do Gradle.

  1. Clique em Sync project with Gradle files b8a9619d159a7d8e.png.
  2. Recrie o projeto.
  3. Abra a tela Tarefa 5 e observe que os itens que usam igualdade estrutural são marcados com um ícone EQU e não se recompõem quando você interage com a lista de itens.

1de2fd2c42a1f04f.gif

No entanto, outros tipos de itens ainda são recompostos. Eles serão corrigidos na próxima etapa.

Corrigir a estabilidade com anotações

Conforme mencionado anteriormente, com o modo de rejeição avançada ativado, um elemento combinável será ignorado quando o parâmetro tiver a mesma instância da composição anterior. No entanto, isso não acontece em situações em que uma nova instância da classe instável é fornecida a cada mudança.

Na sua situação, a classe StabilityItem é instável porque contém uma propriedade LocalDateTime instável.

Para corrigir a estabilidade dessa classe, siga estas etapas:

  1. Navegue até o arquivo StabilityViewModel.kt.
  2. Localize a classe StabilityItem e inclua a anotação @Immutable:
// TODO Codelab task: make this class Stable
@Immutable
data class StabilityItem(
    val id: Int,
    val type: StabilityItemType,
    val name: String,
    val checked: Boolean,
    val created: LocalDateTime
)
  1. Crie o app novamente.
  2. Navegue até a tela Tarefa 5 e observe que nenhum dos itens da lista foram recompostos.

938aad77b78f7590.gif

Essa classe agora usa a igualdade estrutural para conferir se algo mudou em relação à composição anterior e, dessa forma, não fazer uma recomposição.

Você ainda tem o combinável referente à data da mudança mais recente, que continua sendo recomposto independente do que foi feito até agora.

Corrigir a estabilidade com o arquivo de configuração

A abordagem anterior funciona bem em classes que fazem parte da sua base de código. No entanto, classes fora do seu alcance, como as de bibliotecas de terceiros ou de bibliotecas padrão, não podem ser editadas.

Você pode ativar um arquivo de configuração de estabilidade que seleciona classes (com possíveis caracteres curinga) que serão tratadas como estáveis.

Para fazer isso, siga estas etapas:

  1. Navegue até o arquivo build.gradle.kts do app.
  2. Adicione a opção stabilityConfigurationFile ao bloco composeCompiler.
composeCompiler {
    ...

    stabilityConfigurationFile = project.rootDir.resolve("stability_config.conf")
}
  1. Sincronize o projeto com arquivos do Gradle.
  2. Abra o arquivo stability_config.conf na pasta raiz do projeto ao lado do arquivo README.md.
  3. Adicione o seguinte:
// TODO Codelab task: Make a java.time.LocalDate class stable.
java.time.LocalDate
  1. Crie o app novamente. Se a data continuar a mesma, a classe LocalDateTime não fará com que o elemento combinável Latest change was YYYY-MM-DD seja recomposto.

332ab0b2c91617f2.gif

No seu app, você pode estender o arquivo para incluir padrões para que não seja necessário programar todas as classes que precisam ser tratadas como estáveis. Então, use o caractere curinga java.time.*, que vai tratar todas as classes no pacote como estáveis, como Instant, LocalDateTime, ZoneId e outras classes do java.time.

Ao seguir essas etapas, nada na tela será recomposto, a não ser o item adicionado ou que recebeu interação, o que é um comportamento esperado.

12. Parabéns

Você otimizou o desempenho de um app do Compose. Embora tenhamos mostrado uma pequena parte dos problemas de desempenho que podem ser encontrados no seu app, você aprendeu a encontrar e resolver outros possíveis problemas.

Qual é a próxima etapa?

Se você não gerou um perfil de referência para seu app, é altamente recomendado fazer isso.

Consulte o codelab Melhorar o desempenho do app com os perfis de referência. Se precisar de mais informações sobre a configuração de comparativos, confira o codelab Inspecionar o desempenho do app com a Macrobenchmark.

Saiba mais