Ferramentas e performance

Supere-se no Compose

Alto-falantes

Sobre

Sobre a palestra

O Jetpack Compose facilita a criação de interfaces do usuário de alta qualidade, mas pode ser difícil identificar problemas que afetam o desempenho da UI. Esses tipos de problemas podem levar a problemas que os usuários percebem, como latência de inicialização e frames perdidos que aparecem como instabilidade de rolagem ou animações instáveis.

Nesta palestra, vamos apresentar ferramentas para descobrir o que está afetando o desempenho da sua interface, com foco no Perfetto e no rastreamento de (re)composição. Em seguida, vamos analisar alguns antipadrões específicos que você pode estar usando e que estão custando caro, como encontrá-los no seu código e quais padrões positivos podem ser usados para substituí-los. Ao final desta palestra, você vai entender como pensar no Compose com foco no desempenho e como criar perfis, analisar e verificar otimizações de desempenho no seu app.

Resumo

  • Os rastros do Perfetto registram a atividade no dispositivo e mostram frações do Compose, como composição e layout, ao longo da linha de execução principal. Use-os para inspecionar as linhas do tempo dos frames e acompanhar as durações das fases.
  • Cada operação dentro de elementos que podem ser compostos tem um custo, incluindo iniciar e cancelar corrotinas. Audite as APIs de efeito colateral e considere mesclar efeitos com chaves idênticas para reduzir a sobrecarga de composição.
  • O rastreamento de composição anota funções combináveis, e o rastreamento de recomposição (1.13+) monitora leituras e gravações de estado que causam recomposição. Eles ajudam a diagnosticar trabalhos inesperados diretamente nos traces do Perfetto.
  • O ComputedState (Compose 1.13+) evita invalidações indesejadas e geralmente é mais rápido que o DerivedState durante invalidações de leitura após gravação. O DerivedState continua sendo útil quando cálculos ou alocações caros se beneficiam do armazenamento em cache.
  • Pular elementos que podem ser compostos gera custos de manutenção e sobrecarga de comparação de parâmetros. Confie na omissão forte e na igualdade de identidade de objetos ou evite totalmente as invalidações, em vez de forçar a estabilidade.

Habilidades

Recursos

Gemini Notebook

Tópicos

  • jetpack-compose
  • android-performance
  • perfetto
  • benchmarking
  • corrotinas
  • composition-tracing
  • android-cli

Transcrição

Introdução

  • Olá.

  • Olá!

  • Espero que você tenha descansado.

  • Espero que você esteja com o café em dia, a pele hidratada e tudo mais.

  • Hoje vamos nos aprofundar um pouco mais.

  • Sim, vamos lá.

  • Hoje vamos nos aprofundar um pouco mais.

  • Meu nome é Andrei Shikov, e hoje estou dividindo o palco com Andrew Bailey.

  • Nós dois trabalhamos na equipe de ambiente de execução do compilador do Compose, mas também em muitas outras coisas, incluindo desempenho.

  • E o tema da conversa de hoje é responder às suas perguntas sobre performance, já que o Jetpack Compose se estabeleceu como um kit de ferramentas de IU padrão para o desenvolvimento Android.

  • E agora muitas pessoas estão perguntando como garantir que a IU do Compose tenha o melhor desempenho possível.

  • Hoje vamos fazer uma investigação aprofundada, como já disse.

  • Além disso, vamos apresentar algumas ferramentas para você se superar no Compose.

  • Primeiro, quero falar sobre por que o desempenho é importante para nós.

  • Quando pensamos em qualidade e em criar apps de qualidade, geralmente pensamos em animações, em um conjunto de atributos avançados, e o desempenho às vezes é deixado de lado, mas a percepção do usuário realmente depende do desempenho do app.

  • Portanto, se as animações travam, a percepção cai imediatamente.

  • Portanto, todas as métricas e números que vamos mencionar hoje são, na verdade, métricas de proxy que selecionamos para representar como os usuários se sentem em relação ao app, às animações e a tudo o que acontece na tela.

  • Portanto, otimizar o app é otimizar para uma experiência melhor.

  • Quero que você tenha essa mentalidade ao se aprofundar em vários tópicos hoje.

  • Agora, ao analisar o desempenho do seu app, quero que você faça três coisas rápidas.

  • Primeiro, se você mede a performance, faça isso no modo de lançamento, para que não haja muitas informações depuráveis e tudo isso desacelere o app.

  • Em segundo lugar, ative o R8 ou outros minificadores.

  • Só neste ano, introduzimos uma otimização que dobra o desempenho das corrotinas, além de muitas outras melhorias.

  • Dependemos muito do R8 ao otimizar o Compose.

  • Por fim, principalmente para as métricas de inicialização, use um perfil de referência.

  • Assim, as classes que você carrega na inicialização são pré-compiladas e pré-carregadas quando o app é iniciado.

  • Como você pode ver, essa é uma diferença de 6 vezes em relação ao build depurável normal do app.

  • E, ao medir tudo isso, você vai somar esses resultados aos outros ganhos que está tendo no seu código.

  • Agora, você fez tudo isso, mas o app ainda está um pouco lento ou ainda há outros problemas de desempenho. É aí que você entra nesse loop de desempenho.

  • Você quer começar a analisar algumas métricas em cenários específicos.

  • Por exemplo, digamos que o app demore muito para carregar. Talvez você queira redefinir o app, iniciá-lo como um inicializador comum e verificar quanto tempo ele leva para chegar à primeira tela de dados.

  • Ou talvez o app trave um pouco, então você rola a tela e garante que essa rolagem seja repetível e estável.

  • Portanto, o ambiente estável neste slide é muito importante, porque, ao fazer otimizações mais tarde, você quer garantir que as métricas melhorem e confirmar que essas otimizações realmente funcionam como esperado.

  • Os comparativos de mercado são ótimos para isso, porque já capturam muitos dados, fazem muitas iterações para garantir que não haja muito ruído no dispositivo e também fazem muitas configurações que ajudam você.

  • Além disso, os comparativos de mercado também são cenários repetíveis por padrão, e é isso que estamos buscando aqui.

  • Mas você também pode fazer isso com vários scripts, se quiser.

  • Por fim, você cria um perfil, melhora algo no app, analisa os dados e repete o processo, talvez com métricas de cenários diferentes em que o app tem dificuldades.

Criação de perfil com rastros do Perfetto

  • Ao analisar os dados, a primeira ferramenta que estamos apresentando hoje e que você precisa conhecer bem são os traces do Perfetto.

  • Os rastreamentos do Perfetto registram tudo o que acontece no dispositivo durante a gravação, por exemplo, durante a execução de uma macrobenchmark ou de algum script que você esteja gravando.

  • E é uma grande ajuda para todos os engenheiros que trabalham com o desempenho do Android.

  • Eu passo a maior parte do meu dia de trabalho analisando os traces do Perfetto.

  • Eles são mais ou menos assim.

  • É uma quantidade enorme de dados.

  • Como eu disse, eles capturam tudo o que acontece em um dispositivo.

  • No início, pode ser difícil encontrar o app. Por isso, recomendo usar essa barra de pesquisa.

  • Pesquise o Compose, o pacote do app e coisas do tipo.

  • Basta se concentrar em coisas familiares.

  • Ao se familiarizar e encontrar o processo do app, você vai ver as linhas de tempo para a renderização do frame e quanto tempo leva para que muitas coisas aconteçam no app.

  • Se isolarmos um deles, você verá algo assim.

  • O primeiro é o trabalho da linha de execução principal.

  • É aqui que o Compose faz a maior parte do trabalho.

  • Depois, há a RenderThread.

  • É aqui que processamos o comando de tela e o codificamos para a GPU.

  • Em seguida, a GPU faz o trabalho dela, e o sistema faz a composição.

  • Hoje, vamos analisar apenas a linha de execução principal.

  • Porque é aqui que o Compose faz o trabalho dele.

  • Estamos falando sobre o Compose, então é razoável focar apenas nisso.

  • Ao isolar esse frame na linha de execução principal, começamos a ver as frações do Compose.

  • Selecionei algumas para você.

  • É como composição, medição, layout e desenho.

  • Se você já ouviu falar das fases do Compose, provavelmente vai encontrá-las ao analisar o desempenho.

  • Eles têm fatias individuais em um rastro do Perfetto, e é muito fácil descobrir quanto tempo cada fase leva e quais fases são executadas em cada frame específico.

  • Falamos bastante sobre como otimizar parte desse trabalho, e você precisa garantir que está fazendo o trabalho certo na fase certa.

  • Por exemplo, se você tiver uma animação que atualiza a cor da caixa ou algo assim, só vai querer invalidar a fase de desenho, e não uma composição, um layout ou algo do tipo.

  • Este é o básico sobre desempenho, e hoje vamos nos aprofundar um pouco mais em alguns aspectos, com foco especial na composição.

  • Agora, o Andrew vai falar sobre isso.

  • Obrigado, Andrei.

  • Como o Andrei mencionou, passamos muito tempo falando sobre otimizações no contexto de desempenho, como evitar leituras reversas, garantir que as leituras de estado sejam adiadas para a fase certa no ciclo de vida da composição e otimizar e garantir que os elementos combináveis sejam invalidados conforme o esperado, para que você não faça muito trabalho em cada frame.

  • Esse é um ótimo ponto de partida, mas já temos muito material sobre isso e queremos avançar um pouco mais nessa conversa.

  • Hoje, vamos abordar o que resta na composição depois de fazer essas otimizações e o que os próprios elementos combináveis estão fazendo.

  • A realidade infeliz de tudo o que está no código do Compose e de tudo o que você envia com o app em termos de desempenho é que tudo tem um custo associado.

  • Nenhum código que você envia no app é sem custo financeiro, e até mesmo coisas triviais, como ler o estado e compor coisas como um efeito colateral dentro da composição, têm custos tangíveis associados a elas. É importante estar ciente disso, especialmente quando você começa a analisar o que os elementos combináveis estão realmente fazendo e a extrair mais desempenho do app.

  • Para mostrar como isso funciona na menor escala possível, aqui estão algumas das menores operações que você pode fazer em qualquer lugar do código, especialmente dentro dos elementos combináveis. Nessa resolução, estamos falando da escala de nanossegundos em termos de quanto essas coisas custam, aproximadamente, como uma mentalidade.

  • Se você quiser melhorar sua performance, tem duas opções.

  • É possível remover um número muito grande de operações baratas ou um número menor de operações mais caras.

  • Neste gráfico, as corrotinas são as mais caras em termos de operações, e são um bom exemplo de onde começar a procurar, porque, se removermos algumas dessas instâncias, poderemos ver benefícios mais tangíveis no aplicativo com mais rapidez.

  • Outra coisa que você precisa lembrar sobre as corrotinas é que, como mostrei no slide, não é só o lançamento de uma corrotina que tem um custo associado, mas também o cancelamento dela.

  • O custo de cancelamento é especialmente importante porque ocorre principalmente quando você está descartando conteúdo e possivelmente se movendo para outras áreas do aplicativo.

  • A navegação para fora é um caso extremamente caro em muitos aplicativos em que você precisa aproveitar ao máximo o tempo para a renderização do frame e garantir que está usando o máximo do que tem.

  • Por isso, é importante considerar esses dois custos.

  • Todos eles, não importa o que estamos analisando, vão se somar nessa escala de cerca de 16 milhões de nanossegundos para trabalhar.

  • Temos um orçamento que precisamos reduzir, e as corrotinas são um bom exemplo para começar.

Gerenciamento de efeitos colaterais

  • No Compose, as corrotinas aparecem com mais frequência com as APIs de efeitos.

  • Há uma variedade de APIs que você pode usar com SideEffects, desde alta funcionalidade até baixa latência.

  • No lado mais funcional desse espectro, temos LaunchedEffect, que inclui tudo para trabalho assíncrono, especialmente animações, tudo o que precisa de um escopo de corrotina.

  • Se você não precisar disso e puder realizar sua tarefa com uma API mais barata, talvez valha a pena considerar o uso dela, se ela atender às necessidades do seu caso de uso.

  • Para mostrar como isso funciona na prática, temos aqui um elemento de mídia que pode ser composto para demonstrar todos os diferentes SideEffects e quando eles podem aparecer.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Asynchronous work and animations
        LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
    }
  • Podemos começar com um LaunchedEffect.

  • Neste exemplo, ele é usado para carregar dados de forma assíncrona no player de mídia.

  • Se você precisar de um escopo de corrotina, de corrotinas em geral ou de uma chamada de suspensão, use LaunchedEffect.

  • Mesmo que ainda seja mais caro e haja um custo associado às corrotinas, use a API certa para o trabalho, desde que você aproveite e justifique o uso.

  • Mas se você estiver usando um LaunchedEffect, mas não estiver aproveitando a corrotina, talvez seja uma boa ideia considerar outras APIs que podem fazer a mesma coisa, mas por um custo menor.

  • Para isso, descendo no espectro, podemos ver outros exemplos de efeitos colaterais, como DisposableEffect.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val activity = LocalActivity.current
    
        // Setup and teardown
        DisposableEffect(Unit) {
            activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
            onDispose {
                activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
            }
        }
    }
  • O DisposableEffect é ótimo quando você tem um processo de configuração e encerramento associado ao efeito.

  • Aqui, no mesmo exemplo de player de mídia, temos um exemplo que bloqueia a orientação da atividade para paisagem quando o elemento combinável está presente e, em seguida, libera esse bloqueio de orientação quando ele desaparece.

  • Se você quiser o efeito único de LaunchedEffect e DisposableEffect, mas não precisar de corrotinas e não tiver um efeito de descarte, também poderá usar a nova API SideEffect com chaves que introduzimos no Compose 1.12.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // One-shot events
        SideEffect(Unit) { mediaPlayer.play() }
    }
  • Essa é uma ótima alternativa para as outras duas APIs, caso você tenha usado LaunchedEffect por conveniência ou DisposableEffect por necessidade de um efeito único. Navegar ou migrar para as APIs SideEffect com chaves pode fazer o mesmo com menor sobrecarga e melhor desempenho, especialmente se você migrar várias vezes ao longo de um app inteiro.

  • Por fim, a API mais barata é a API SideEffect padrão.

    @Composable
    fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
        val mediaPlayer = rememberMediaPlayer()
    
        // Synchronize the outside world to the state of composition
        SideEffect { mediaPlayer.playbackRate = playbackRate }
    }
  • Isso é mais apropriado quando você quer sincronizar o estado externo do mundo com o que está acontecendo na composição.

  • No exemplo do player de mídia, temos outro exemplo que mostra a sincronização de um estado de reprodução com um parâmetro que está sendo recebido pela composição.

  • Se você decidir auditar os usos de efeitos colaterais e migrar para um menos caro, lembre-se de que, além dos outros comportamentos e casos de uso que mencionamos, também há uma diferença nas garantias de ordenação com efeitos colaterais.

  • Todos os efeitos descartáveis são executados primeiro, seguidos pelos efeitos colaterais normais, com ou sem chave. Depois que o frame é concluído, todos os efeitos iniciados são executados, porque há uma expedição envolvida.

  • Se você depende desse tempo para os efeitos iniciados, considere isso ao migrar.

  • E, para qualquer migração, teste seu código e verifique se você não depende de nenhuma dessas garantias de tempo e se o código ainda funciona depois.

  • Se não for possível reduzir a complexidade do efeito colateral em si, você também pode reduzir o número de efeitos colaterais que tem ao mesclá-los.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) { viewModel.loadCart() }
        LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
        LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }
    
        CartContent(...)
    }
  • Isso é especialmente importante se você tiver vários efeitos que usam a mesma chave.

  • É um bom candidato para mesclagem, porque eles podem ser executados ao mesmo tempo.

  • Neste exemplo, temos vários efeitos iniciados, cada um fazendo uma chamada assíncrona para algum modelo de visualização.

  • Se eu as substituir por uma única LaunchedEffect e outras chamadas de inicialização dentro dela, mesmo que eu ainda tenha o mesmo número de corrotinas em execução, isso ainda terá menos sobrecarga porque o trabalho será retirado da composição e colocado em corrotinas.

    @Composable
    fun ShoppingCartScreen(
        viewModel: CartViewModel
    ) {
        LaunchedEffect(Unit) {
            launch { viewModel.loadCart() }
            launch { viewModel.loadPaymentMethods() }
            launch { viewModel.loadDeliveryAddresses() }
        }
    
        CartContent(...)
    }
  • Portanto, é uma boa ideia considerar a migração em situações como essa para reduzir o número de efeitos quando possível.

  • Os efeitos e as corrotinas são apenas um exemplo de um lugar que você pode auditar no seu aplicativo, mas cada elemento que pode ser composto e cada aplicativo será diferente e terá oportunidades diferentes.

  • Para entender o que é relevante para seu aplicativo, precisamos falar sobre a próxima ferramenta que o Andrei vai apresentar, que é o rastreamento de composição.

Rastreamento de composição e recomposição

  • Certo.

  • Se você observar essa recriação fiel do frame do Perfetto que vimos antes em outro slide, vai notar que o rastreamento de composição fornece informações de tempo com fases diferentes, mas não muitos detalhes sobre o que está acontecendo.

  • O quê?

  • Sim, o que está acontecendo dentro da composição em que as funções combináveis são executadas.

  • O rastreamento de composição nos ajuda com isso.

  • Por isso, introduzimos alguns marcadores no compilador do Compose que basicamente anotam cada função e a tornam visível no rastro do Perfetto, para que você possa verificar quanto tempo cada função leva.

  • Você pode usar isso para descobrir quais funções exatas estão sendo recompostas.

  • No entanto, isso gera uma sobrecarga. Portanto, se você quiser capturar os dados de performance mais precisos, use o Perfetto normal ou execute sem as capturas do Perfetto e use outras métricas.

  • Mas o rastreamento de composição é muito útil se você quiser descobrir quanto tempo cada elemento combinável leva.

  • Às vezes, você também quer descobrir por que as coisas estão sendo recompostas, e é isso que estamos tentando resolver na próxima versão do rastreamento de ambiente de execução do Compose Tracing, ou melhor, na 1.13.

  • Estamos introduzindo o rastreamento de recomposição, que anota cada leitura e gravação de estado que resultou em recomposição.

  • Assim, você vai receber algo como isto no seu rastro do Perfetto, em que é possível ver cada uma das gravações de estado e as pilhas de chamadas em que a gravação e a leitura acontecem. Assim, você pode associá-las às linhas reais no seu código em que esses estados foram acessados ou modificados.

  • Para saber mais, acesse este link.

  • Ela tem toda a documentação sobre o rastreamento de composição e de recomposição.

  • Agora, o Andrew vai falar sobre como usar isso de forma mais eficiente para evitar gravações de estado.

Otimização e invalidações de estado

  • Obrigado, Andrei.

  • Com base nos resultados dessa investigação, você pode descobrir que há invalidações na sua composição que não são úteis para o aplicativo e que poderiam ter sido evitadas.

  • Por exemplo, o caso mais comum que tenho é um botão de rolagem para o topo que depende da visibilidade ou da progressão da rolagem para aparecer.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton = scrollState.value > 0
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • Como estou lendo o valor de um estado de rolagem dentro da composição, esse é um estado que muda com muita frequência, até uma vez por frame. Isso vai causar muitas recomposições que poderiam ser evitadas, porque, mesmo que o estado de rolagem esteja mudando, a condição real que nos interessa aqui não muda com tanta frequência.

  • Este é um exemplo em que recomendamos o uso de algo como um estado computado, que pode evitar invalidações para mudanças de estado que não importam para o aplicativo.

  • Neste exemplo, o que fiz foi encapsular o cálculo em um estado computado. Quando lemos esse estado, em vez de invalidar com base na posição de rolagem, ele invalida condicionalmente esse elemento combinável com base em toda a condição e o deriva do outro estado.

    @Composable
    fun ScrollToTopButton(
        scrollState: ScrollState
    ) {
        val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
        val coroutineScope = rememberCoroutineScope()
    
        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = {
                    coroutineScope.launch { scrollState.animateScrollTo(0) }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, "Scroll to Top")
            }
        }
    }
  • Se você já viu esse padrão antes, é provável que tenha visto ele ser usado com o estado derivado.

  • O ComputedState é novo no Compose 1.13. Embora o ComputedState e o DerivedState tenham um objetivo semelhante de invalidar condicionalmente quando o cálculo muda, eles têm uma diferença muito sutil na forma como fazem isso.

  • O DerivedState, que lançamos desde o início do Compose, armazena em cache o valor que calcula e o reutiliza até que as dependências mudem.

  • Com o ComputedState, ele calcula o valor sempre que você o lê.

  • O armazenamento em cache parece útil, mas, em muitas situações, ele é mais caro do que apenas ler o resultado.

  • Para entender como isso acontece, vamos analisar um exemplo de como esses estados podem ser usados no seu aplicativo e o que cada um deles faz.

  • Aqui, tenho o mesmo estado que reflete aproximadamente o que estávamos fazendo no botão de rolagem para o topo, em que há uma condição baseada em outro estado, e tenho isso como um DerivedState e um ComputedState.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    // First read
    derivedState.value
    computedState.value
    
    // Second read
    derivedState.value
    computedState.value
  • Também estou lendo o estado duas vezes e vamos receber o mesmo valor nas duas vezes, mas o que acontece nos bastidores é diferente entre as duas.

  • Para simplificar um rastro do que realmente apareceria aqui em algo como o Perfetto, podemos examinar o que realmente acontece quando você lê esses dois estados.

  • Aqui, começamos chamando o computedState.value.

  • Ele vai executar a lambda de cálculo, que lê o valor do estado, e também há um pouco de manutenção para notificar que a leitura aconteceu.

  • Isso é válido para todos os estados de snapshot no Compose.

  • A segunda leitura se comporta exatamente como a primeira e tem as mesmas características de desempenho.

  • Em comparação, a primeira leitura do DerivedState faz algo semelhante, em que ainda precisa chamar a lambda de cálculo e ainda faz a outra notificação, mas tem um pouco de sobrecarga de gerenciamento de cache, que é um pouco reduzida na primeira vez porque não há cache para verificar.

  • Portanto, ele faz um pouco de manutenção para o armazenamento em cache e, em seguida, realiza a mesma coisa em um pouco mais de tempo.

  • No entanto, a segunda leitura de DerivedState vence a segunda leitura de ComputedState, mesmo que ambos sejam estados bastante simples.

  • Como eu disse nos slides anteriores, o custo de uma leitura de snapshot não é trivial em alguns casos, e ler do cache é mais rápido do que fazer a leitura em si. É por isso que vemos essa pequena vantagem na segunda leitura do DerivedState.

  • Mas o cenário realmente interessante é o que acontece quando o estado precisa ser invalidado.

  • Isso é o que chamamos de cenário de leitura após gravação, em que você atualiza um dos estados que estão sendo usados no cálculo, e o cálculo precisa ser executado novamente.

  • No código, isso significa que modificamos "a", talvez incrementando-o, e depois lemos o valor novamente ou talvez fazemos uma recomposição.

    var a by mutableStateOf(0)
    
    val derivedState = derivedStateOf { a > 0 }
    val computedState = computedStateOf { a > 0 }
    
    ...
    
    // Read-after-write
    a++
    derivedState.value
    computedState.value
  • No diagrama real, isso vai ser exatamente igual para ComputedState.

  • Ele não tem um cache para gerenciar, então a primeira, segunda, terceira ou qualquer outra leitura é exatamente igual.

  • No entanto, o DerivedState tem muito trabalho pela frente.

  • Ele passa por um caminho muito caro para ver que o cache é inválido antes mesmo de atualizar e chamar o cálculo em si. Depois, ele precisa fazer o rastreamento de dependências e gerenciar muitas dependências e observações para gravar o valor do cache e entender que todos esses valores realmente mudaram, além de gerenciar a manutenção em si.

  • Tudo isso se soma e torna o custo de invalidar um DerivedState muito alto em comparação com um ComputedState.

  • Para colocar números em todas essas declarações, o ComputedState normalmente é mais rápido na primeira leitura em cerca de 200 ns.

  • O ComputedState perde por cerca de 350 ns em um Pixel 6, especificamente, em condições de comparativo de mercado específicas.

  • Portanto, na segunda leitura, o ComputedState vai perder um pouco em comparação com o DerivedState.

  • Mas no caso que realmente importa, em que estamos invalidando e provavelmente em que você realmente tem trabalho a fazer no frame, o ComputedState é quase duas vezes mais rápido que o DerivedState.

  • Portanto, dependendo do uso dos estados, o ComputedState pode ser uma ótima primeira opção.

  • Em geral, ainda é preciso tomar uma decisão ao usar DerivedState e ComputedState.

  • Não estamos descontinuando o ComputedState, ainda há usos para ambos.

  • Em geral, se você não tiver certeza de qual escolher, o estado computado é um bom padrão.

  • O estado derivado é mais útil quando você realmente aproveita o armazenamento em cache, ou seja, quando faz algo não trivial, como um algoritmo complicado, uma grande alocação, muitas leituras de estado que realmente se beneficiam do armazenamento em cache ou qualquer coisa não trivial ou que dependa de coisas como igualdade de objetos e igualdade de identidade de objetos, em que você compensa a diferença e ganha os benefícios do armazenamento em cache que o estado derivado oferece.

Pular e estabilidade no Compose

  • O outro elefante na sala de que quero falar é que o estado derivado e o estado computado estão tentando evitar a invalidação do próprio elemento combinável.

  • Isso é diferente de pular um elemento combinável, que é o que normalmente acontece quando pensamos em invalidar condicionalmente ou não realizar trabalho dentro de um elemento combinável.

  • A verdade que quero expor aqui é que, embora se fale muito sobre estabilidade e pulos no Compose, isso não é tudo, e nem sempre é o que deve ser otimizado.

  • Como dissemos anteriormente, tudo na composição tem custos associados, incluindo a ação de pular.

    // What you write
    @Composable fun ContactListItem(
        contact: Contact
    ) { ... }
    
    // What you get
    @Composable fun ContactListItem(
        contact: Contact,
        $composer: Composer?
        $changed: Int
    ) {
        val $dirty = $changed or
            $composer.changed(contact)
    
        if ($dirty) { /* Your function body */ }
        else $composer.skipToGroupEnd()
    }
  • Pular não é necessariamente trivial.

  • Há muita manutenção que precisamos fazer para que ele funcione.

  • Precisamos fazer coisas como armazenar todos os parâmetros e encontrar espaço para eles na composição inicial e, em seguida, atualizar esse registro quando você fizer a recomposição.

  • Também precisamos comparar os valores atuais com os anteriores para entender se o elemento combinável precisa ser ignorado ou não.

  • Depois, tudo isso é apenas o poder de uma verificação "if" que pula o código ou não.

  • Isso pode ser caro, dependendo do que está acontecendo aqui.

  • Há uma otimização interna para o compilador, em que, se você transmitir o mesmo parâmetro, podemos reutilizar as verificações de igualdade, mas isso não é o fim de tudo, e é bem fácil de derrotar.

  • Para ver um exemplo de como a omissão pode nos trair um pouco, podemos analisar um exemplo clássico de uma lista de rolagem que tem, principalmente, um objeto enorme que contém todos os dados a serem compostos na tela.

    data class ContactsModel(
        val header: String,
        val contacts: List<Contact>
    )
    
    data class Contact(...)
    
    @Composable
    fun ContactsScreen(model: ContactsModel) {
        Column {
            TopAppBar(title = { Text(model.header) })
            ContactsList(model.contacts)
        }
    }
    
    @Composable
    fun ContactsList(contacts: List<Contact>) {
        LazyColumn {
            items(contacts) { ContactListItem(it) }
        }
    }
    
    @Composable
    fun ContactListItem(contact: Contact) { ... }
  • Aqui, o modelo de tela contém algo como uma tela de cabeçalho e, em seguida, toda a lista de contatos que ele transmite por alguns elementos combináveis que, por fim, chamam uma lista lenta e os mostram na tela com algumas células de item.

  • A hierarquia de composição desse código é mais ou menos assim: começamos com a tela de contato, expandimos um pouco e temos outras funções que chamam e apresentam nossos itens.

  • Se analisarmos como os dados são transmitidos, começamos com o modelo na parte de cima.

  • Nós o desestruturamos em uma string e, em seguida, em uma lista, que é convertida em uma lambda de itens para a lista lenta, que vai compor todos os itens que aparecem na tela.

  • Vamos ver o que acontece quando modificamos o último contato da lista.

  • O que queremos é invalidar apenas o último item da lista, mas não é isso que vai acontecer aqui.

  • Se analisarmos um rastro de composição para essa recomposição, ele será parecido com este, em que começamos na tela de contato e, por fim, chegamos a esse item da lista.

  • E essa é uma maneira bastante cara de chegar aqui.

  • Também podemos ver que há todas essas seções em que parece que nada está acontecendo dentro do rastro.

  • E, embora não esteja claro aqui, é aqui que os elementos que podem ser compostos decidem se vão pular ou não.

  • Neste exemplo, apenas dois elementos combináveis são ignorados devido à forma como a lista lenta é recomposta.

  • Quando você fornece uma nova lambda de conteúdo, ela invalida todos os itens que tem.

  • Portanto, ele vai invalidar os dois contatos que não mudaram, além do que mudou, e esses dois são os únicos itens aqui que podem ser ignorados.

  • Todo o resto faz essa comparação de igualdade de pulo apenas para perceber que realmente precisa ser executado.

  • Se os objetos forem estáveis, essa comparação pode ser bastante cara.

  • Se você nos forçar a analisar todas as suas estruturas de dados, isso pode se acumular e se tornar um custo não trivial apenas para saber se dois objetos são iguais.

  • E pior, como desestruturamos, precisamos fazer a mesma comparação na lista duas vezes.

  • Se você deixar o compilador se comportar com o comportamento padrão e usar a pulagem forte, ainda será possível pular e fazer verificações para pular condicionalmente os elementos que podem ser compostos, mas eles vão seguir o caminho muito mais rápido de usar a igualdade de identidade de objeto em vez da igualdade estrutural.

  • Isso é muito mais rápido para computar e, mesmo que recomponhamos os mesmos itens nesse frame, em que é realmente importante entrar rapidamente, não perdemos muito tempo descobrindo que não poderíamos pular esses elementos que podem ser compostos.

  • É por isso que enfatizamos o uso de ferramentas como comparativos de mercado e traces para entender o que está acontecendo no aplicativo, em vez de usar diretamente as contagens de recomposição.

  • Eles podem ser um proxy útil, mas não são algo em que você necessariamente precisa se fixar ou indexar em excesso durante a otimização dos seus aplicativos.

  • Lembre-se, em vez de tentar pular a função combinável, evite invalidá-la.

Analisar a execução com rastreamentos de método

  • Neste exemplo, vimos que os traces de composição podem fornecer muitas informações sobre o que está acontecendo, mas não necessariamente mostram o panorama completo.

  • Para isso, Andrei vai falar sobre rastreamentos de método.

  • Certo.

  • Como o Andrew já disse, o rastreamento de composição nem sempre mostra tudo o que está acontecendo, porque ele só anota funções combináveis. Em alguns casos, você vê apenas um espaço em branco no rastreamento de composição que já aumenta o rastreamento do Perfetto, mas ainda não mostra tudo o que está acontecendo.

  • Às vezes, pode ser algo que o Compose faz, às vezes pode ser algo no seu código, é muito difícil descobrir apenas olhando para o rastro de composição aqui.

  • Agora, você precisa capturar o rastreamento de método.

  • Há um item no criador de perfil do Android Studio, ou você também pode usar comandos do ADB para capturar o rastro, que vai fornecer todas as chamadas de função que acontecem no mundo do Java.

  • Ele mostra tudo o que o compilador do Compose codifica, tudo o que o ambiente de execução do Compose faz e tudo o que seu código faz. É muito útil para rastrear e investigar o que exatamente está sendo executado e quais funções foram chamadas nesse espaço.

  • Uma coisa, quero... ver este item no meio.

  • Quero observar que o rastreamento de método tem uma sobrecarga muito alta.

  • Você tem de 10 a 20 vezes mais sobrecarga, dependendo da complexidade de uma árvore de funções, porque cada chamada de função adiciona um pouco de sobrecarga.

  • Para ilustrar isso, podemos analisar a captura de tela.

  • É assim que o rastreamento de método para o início do app geralmente se parece, e você pode se perguntar: "Como posso entender isso?"

  • Bem, você começa o rastro de composição geralmente, você olha para as funções que você tem aqui.

  • Este é um rastro de composição simplificado, com ScreenContent, um botão e algumas outras funções.

  • Digamos que queremos descobrir o que está acontecendo no ScreenContent.

  • O Perfetto tem uma barra de pesquisa.

  • Você carrega o rastreamento de método no visualizador do Perfetto, pesquisa a mesma função que tem aqui, ScreenContent, e chega a algo como isto, que é o rastreamento de método da tela.

  • Como você pode ver, há algumas chamadas em grupo e do Composer.startGroup.

  • É isso que os ambientes de execução e o compilador fazem em segundo plano para garantir que a composição funcione, mas a chamada interessante aqui é Composer.changed.

  • É assim que verificamos se os parâmetros mudaram entre as invocações anteriores e a atual do elemento combinável.

  • E a maioria dessas Composer.changed é dominada por essa ScreenModel.equals.

  • Talvez o ScreenModel seja uma classe de Big Data e tenha sido definido como estável para ajudar na contagem de recomposição, então talvez seja algo que apareceu no relatório do compilador.

  • E talvez remover essa anotação estável ajude com a performance nesse caso específico.

  • Portanto, é isso que você precisa procurar, e os rastreamentos de método de composição são a melhor ferramenta para encontrar isso.

  • Certo.

  • Analisamos muitas coisas hoje. Agora, passo a palavra para o Andrew, que vai encerrar o evento.

Ferramentas de design e desempenho de composição rápida

  • Para encerrar e dar um resumo geral de tudo o que falamos hoje, há algumas coisas que você precisa lembrar ao projetar elementos combináveis e otimizar os apps que já tem.

  • Primeiro, lembre-se de que elementos combináveis mais baratos e que não são invalidados são melhores do que os que você pula.

  • Ao projetar elementos combináveis, é melhor considerar argumentos menores e mais fáceis de comparar, além de reutilizar comparações com outras chamadas de função para aproveitar a otimização.

  • Ao usar APIs dentro dos elementos que podem ser compostos, lembre-se de que o desempenho ou o tempo para a renderização do frame será um produto do custo das próprias APIs e da frequência com que você as usa.

  • Fique atento à soma desses custos e lembre-se de que pode haver custos de desativação associados a eles que podem surgir no momento menos oportuno para seu aplicativo.

  • Além disso, lembre-se de que é importante ser um bom cidadão ao fazer otimizações no próprio app.

  • Comece fazendo comparativos de mercado para tomar decisões fundamentadas, crie um perfil do app para garantir que você está segmentando as áreas certas do código e valide se está fazendo as mudanças e vendo os resultados esperados das modificações feitas como resultado dos comparativos de mercado e da criação de perfil.

  • Além disso, lembre-se de que pode haver uma compensação entre a capacidade de manutenção e o desempenho no aplicativo.

  • Portanto, faça o que for melhor para você e sua equipe para garantir que não esteja otimizando demais e se encurralando com a manutenção.

  • E, para receber mais ajuda, há mais uma ferramenta à sua disposição: o agente de IA que você já tem.

  • Lançamos, como parte da CLI do Android e das habilidades que enviamos, uma habilidade de desempenho do Jetpack Compose que tem muitas das informações que discutimos hoje e pode auditar seu aplicativo e fazer sugestões para esses tipos de otimizações.

  • É possível instalá-lo no agente atual usando a CLI do Android.

    android skills add jetpack-compose-performance
  • Para mais informações sobre a CLI do Android, acesse developer.android.com.

  • Você também pode saber mais sobre as habilidades no nosso repositório do GitHub.

  • E com isso, faça comparativos de mercado seguros e boa composição.

Código

Snippets de código

Snippet do Kotlin às 09:34 em "Como gerenciar efeitos colaterais"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Asynchronous work and animations
    LaunchedEffect(contentSource) { mediaPlayer.load(contentSource) }
}
Snippet do Kotlin aos 10:15 em "Como gerenciar efeitos colaterais"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val activity = LocalActivity.current

    // Setup and teardown
    DisposableEffect(Unit) {
        activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
        onDispose {
            activity.requestedOrientation = SCREEN_ORIENTATION_UNSPECIFIED
        }
    }
}
Snippet do Kotlin aos 10:36 em "Como gerenciar efeitos colaterais"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // One-shot events
    SideEffect(Unit) { mediaPlayer.play() }
}
Snippet do Kotlin aos 11:12 em "Como gerenciar efeitos colaterais"
@Composable
fun MediaPlayer(contentSource: Uri, playbackRate: Float) {
    val mediaPlayer = rememberMediaPlayer()

    // Synchronize the outside world to the state of composition
    SideEffect { mediaPlayer.playbackRate = playbackRate }
}
Snippet do Kotlin aos 12:12 em "Como gerenciar efeitos colaterais"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) { viewModel.loadCart() }
    LaunchedEffect(Unit) { viewModel.loadPaymentMethods() }
    LaunchedEffect(Unit) { viewModel.loadDeliveryAddresses() }

    CartContent(...)
}
Snippet do Kotlin às 12:36 em "Como gerenciar efeitos colaterais"
@Composable
fun ShoppingCartScreen(
    viewModel: CartViewModel
) {
    LaunchedEffect(Unit) {
        launch { viewModel.loadCart() }
        launch { viewModel.loadPaymentMethods() }
        launch { viewModel.loadDeliveryAddresses() }
    }

    CartContent(...)
}
Snippet do Kotlin aos 15:12 em "Otimização e invalidações de estado"
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton = scrollState.value > 0
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
Snippet do Kotlin aos 15:51 em "Otimização e invalidações de estado"
@Composable
fun ScrollToTopButton(
    scrollState: ScrollState
) {
    val showScrollToTopButton by remember { computedStateOf { scrollState.value > 0 } }
    val coroutineScope = rememberCoroutineScope()

    if (showScrollToTopButton) {
        FloatingActionButton(
            onClick = {
                coroutineScope.launch { scrollState.animateScrollTo(0) }
            }
        ) {
            Icon(Icons.Default.ArrowUpward, "Scroll to Top")
        }
    }
}
Snippet do Kotlin às 16:49 em "Otimização e invalidações de estado"
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

// First read
derivedState.value
computedState.value

// Second read
derivedState.value
computedState.value
Snippet do Kotlin às 18:35 em "Otimização e invalidações de estado"
var a by mutableStateOf(0)

val derivedState = derivedStateOf { a > 0 }
val computedState = computedStateOf { a > 0 }

...

// Read-after-write
a++
derivedState.value
computedState.value
Snippet do Kotlin aos 21:07 em "Skipping and stability in Compose"
// What you write
@Composable fun ContactListItem(
    contact: Contact
) { ... }

// What you get
@Composable fun ContactListItem(
    contact: Contact,
    $composer: Composer?
    $changed: Int
) {
    val $dirty = $changed or
        $composer.changed(contact)

    if ($dirty) { /* Your function body */ }
    else $composer.skipToGroupEnd()
}
Snippet do Kotlin aos 21:53 em "Como pular e estabilidade no Compose"
data class ContactsModel(
    val header: String,
    val contacts: List<Contact>
)

data class Contact(...)

@Composable
fun ContactsScreen(model: ContactsModel) {
    Column {
        TopAppBar(title = { Text(model.header) })
        ContactsList(model.contacts)
    }
}

@Composable
fun ContactsList(contacts: List<Contact>) {
    LazyColumn {
        items(contacts) { ContactListItem(it) }
    }
}

@Composable
fun ContactListItem(contact: Contact) { ... }
Snippet de shell em 29:40 em "Ferramentas de desempenho e design combinável rápido"
android skills add jetpack-compose-performance