Ferramentas e performance

Solução de problemas de memória com o Firebase Crashlytics

Alto-falantes

  • Polina Govorkova

    Desenvolvedor de software sênior

Sobre

Sobre a palestra

Mantenha o consumo de memória do seu app enxuto e sem falhas de segmentação com o Firebase Crashlytics. Nesta sessão, vamos conhecer nosso pacote abrangente de ferramentas de solução de problemas de memória disponíveis no console do Firebase e no Android Studio. Vamos começar explicando como usar os insights de ANR e os relatórios do GWP-ASan para diagnosticar bugs de memória difíceis de encontrar. Depois, vamos falar sobre as ferramentas mais recentes para eventos de falta de memória (OOM) e encerramento por limite de memória (MLK), projetadas especificamente para ajudar você a navegar e se adaptar aos novos limites de memória introduzidos no Android 17.

Resumo

  • Os erros de "O app não está respondendo" geralmente são causados por gargalos em segundo plano ou esgotamento de memória, e não por carga ativa da CPU. O Firebase Crashlytics consulta ApplicationExitInfo para mostrar traces de várias linhas de execução e identificar as linhas de execução de bloqueio raiz.
  • As falhas por memória insuficiente ocorrem quando os apps excedem os limites de heap do Java no ambiente de execução. O Crashlytics se integra ao ProfilingManager do Android 17 para coletar instantâneos de alocação heap desofuscados que podem ser inspecionados no Perfetto ou pela CLI do Android.
  • O limitador de memória do Android 17 encerra processos individuais que consomem muita RAM, incluindo alocações nativas. O Crashlytics agrupa essas finalizações no painel com heap dumps para ajudar a separar os vazamentos de memória do C++ das alocações de heap gerenciadas.
  • A corrupção de memória nativa geralmente aparece em linhas de execução do sistema, e não no código do app. O Crashlytics é compatível com o GWP-ASan e o Arm MTE, e os relatórios do GWP-ASan identificam os locais exatos de alocação e desalocação de bugs, como use-after-free.

Recursos

Gemini Notebook

Tópicos

  • firebase-crashlytics
  • android-memory
  • anr
  • falta de memória
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

Transcrição

Introdução

  • Polina Govorkova

    Olá!

  • Este é o curso Solução de problemas de memória com o Firebase Crashlytics.

  • Espera, que título corporativo.

  • Vamos mudar um pouco.

  • Como manter os apps eficientes e eficazes com o Firebase Crashlytics.

  • Ah, isso parece melhor.

  • Meu nome é Polina, sou desenvolvedora de software sênior na equipe do Firebase Crashlytics e serei sua apresentadora hoje.

  • Em algum momento do ciclo de desenvolvimento, você vai achar que resolveu todos os bugs.

  • Os erros de digitação na análise de JSON, as verificações de ponteiro nulo esquecidas, tudo isso sumiu!

  • Você pode se perguntar: "Terminei?"

  • Tenho más notícias para você, porque não é.

  • Porque o que vai sobrar são os problemas difíceis.

  • E afirmo que as duas categorias de problemas mais difíceis que você vai enfrentar são responsividade e memória.

  • A boa notícia é que estamos aqui para ajudar.

  • Hoje, vamos falar sobre as ferramentas atuais e novas no Android e no Crashlytics para ajudar você a identificar a causa raiz de problemas de responsividade que se manifestam por erros de "O app não está respondendo", além de exceções de memória insuficiente, vazamentos de memória e corrupção de memória nativa.

  • Vamos lá!

  • Por que os problemas de responsividade e memória são tão conhecidos?

  • Vamos ver um bug clássico: você declara uma string, define como nula e, em algum momento, tenta ler o comprimento dela.

  • O que você ganha?

  • Uma boa e velha NullPointerException.

  • Mas, na verdade, a vida é fácil aqui, porque o stack trace da exceção aponta para a linha exata do código que causou o problema no app.

  • Observe o stack trace à esquerda.

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • Podemos ver imediatamente que o problema está na linha 42 do MainActivity.kt.

  • Essa abordagem é válida para a maioria dos bugs simples.

  • Agora, quando ocorre um problema de responsividade ou de memória, como o erro "O app não está respondendo" à direita, o stack trace quase nunca aponta para o verdadeiro culpado.

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • Na melhor das hipóteses, ele mostra apenas o efeito, que é uma linha de execução ou alocação inocente que estava em execução no momento em que o app travou ou ficou sem memória.

  • Enquanto isso, a verdadeira causa raiz aconteceu muito antes ou, talvez, em outro lugar.

  • Felizmente, quando se trata desses problemas, você não precisa adivinhar no escuro, porque o Firebase Crashlytics tem a resposta para nós.

  • Mas antes de começarmos a resolver problemas, levante a mão se você conhece o Crashlytics.

  • Ah, que legal.

  • Adoro ver todas essas mãos.

  • Para quem é novo aqui, o Firebase Crashlytics é uma solução de relatórios de erros em tempo real que fornece dados de campo, insights contextuais e orientados por IA, dicas de solução de problemas e indicadores claros sobre as novas versões, tudo para ajudar você a corrigir problemas mais rapidamente.

  • O Crashlytics é muito apreciado pelos nossos desenvolvedores, sendo a solução de relatórios de erros mais adotada em dispositivos móveis, com mais de 370 mil apps nativos, incluindo 57% dos principais apps Android.

  • Para ver como o Crashlytics nos ajuda a rastrear bugs em ação, vamos analisar um exemplo concreto.

  • Hoje, quero contar a história de um ChromaClip.

  • É um extrator de paleta de cores de vídeo que estou criando, e quero contar como lidei com os bugs de responsividade e memória durante o desenvolvimento.

  • A ideia do ChromaClip é simples: os usuários escolhem um vídeo, como um pôr do sol incrível ou uma cena com uma ótima gradação de cores, e o app analisa os frames para gerar uma paleta de cores personalizada. Depois, eles podem usar essa paleta em designs, arte digital, mood boards, o que quiserem.

  • Nossa história começa logo antes do meu lançamento 1.0.

  • Eu queria garantir que ele estivesse perfeito antes do lançamento, então estava fazendo os testes finais localmente em alguns dispositivos, escolhendo o vídeo, tocando no botão para extrair a paleta de cores e, para minha sorte, apareceu a caixa de diálogo favorita de todos os desenvolvedores Android, não é?

  • O ChromaClip não está respondendo.

  • Esse é um erro do tipo "O app não está respondendo", também conhecido como ANR.

Como depurar e analisar ANRs

  • Polina Govorkova

    Então, o que fazemos nessa situação, além de entrar em pânico?

  • Seguindo nosso primeiro instinto, já que o smartphone de teste está conectado ao laptop, abrimos o Android Studio e verificamos o Logcat.

  • Vemos o stack trace da linha de execução principal.

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • O que ela realmente nos diz?

  • Primeiro, na parte de cima, temos a confirmação de que é um ANR, em execução na MainActivity.

  • O que mais?

  • O motivo é que o envio de entrada expirou.

  • Isso significa que o usuário tocou na tela.

  • Sim, conseguimos.

  • Mas a linha de execução principal estava tão ocupada que não conseguiu processar esse evento dentro do tempo limite de cinco segundos do watchdog do Android.

  • Em seguida, também vemos que o sistema despejou o arquivo em traces.txt e, logo abaixo, o logcat mostra o stack trace da nossa linha de execução principal.

  • Observe que o estado é igual a S ali.

  • Nossa linha de execução principal nem está usando a CPU ativamente.

  • "S" significa "sleeping" (dormindo).

  • Por que nossa linha de execução principal está em suspensão enquanto esperamos?

  • Vamos analisar os frames de pilha com mais detalhes.

  • Bem aqui no meio do rastro, vemos que nossa linha de execução principal está estacionada dentro de Unsafe.park, aguardando um objeto desconhecido.

  • Por que não sabemos o que ele está esperando?

  • Bem, é verdade que, para alguns ANRs, como quando uma linha de execução é bloqueada por um bloqueio sincronizado clássico do Java, o stack trace da linha de execução principal é gentil o suficiente para nos dizer o que está esperando e o que está segurando o bloqueio.

  • Mas quando você usa primitivos de simultaneidade de nível mais alto, o logcat só informa que estamos esperando um objeto desconhecido.

  • Portanto, não temos ideia de qual objeto estamos esperando, em qual linha de execução de worker em segundo plano estamos esperando especificamente ou por que isso está levando mais de 5 segundos.

  • Como estamos testando localmente, podemos extrair esse traces.txt do dispositivo e rolar manualmente por dezenas de linhas de execução em suspensão, possivelmente encontrando o culpado ou talvez gastando os tokens perguntando à IA.

  • Em alguns casos, isso funcionaria, e, na verdade, funcionaria para este caso.

  • Mas não é só que eu não quero ler despejos de linhas de execução brutos manualmente (isso é tão 2019). Eu sei que essa abordagem não é viável quando estamos em produção.

  • Não temos acesso direto ao arquivo traces.txt nos smartphones dos usuários, certo?

  • Por isso, é bom que tenhamos integrado o Firebase Crashlytics.

  • Porque ele elimina a necessidade de pesquisa e chega ao fundo do ANR, mesmo na produção.

  • Como?

  • É aí que entra a API ApplicationExitInfo do Android.

  • A partir do Android 11, sempre que o sistema sinaliza um ANR ou encerra o processo do app, o SO registra metadados de saída detalhados, incluindo o motivo exato da falha, o estado da memória e um rastreamento completo do sistema para cada linha de execução em execução no processo no momento.

  • E o melhor de tudo é que você não precisa instalar plug-ins extras nem escrever uma única linha de código.

  • Se você estiver usando o SDK do Crashlytics 18.3.5 ou mais recente, ele vai funcionar sem problemas.

  • Assim que o app for reiniciado, o SDK do Crashlytics vai consultar automaticamente o ApplicationExitInfo, capturar o despejo completo de várias linhas de execução com a telemetria do dispositivo e enviar tudo para o back-end do Crashlytics.

  • Vamos abrir o console do Crashlytics e ver o que ele nos diz.

  • Esta é a visualização de detalhes do problema no console do Firebase Crashlytics para o ANR. Na guia de stack trace, vemos imediatamente o worker DefaultDispatcher.

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • Essa é a linha de execução que apareceu no topo do Crashlytics e que é o gargalo da nossa linha de execução principal. O Crashlytics a marca com um selo chamado "Bloqueio raiz".

  • Como a dica no selo explica, essa linha de execução de worker é a que bloqueia a linha de execução que acionou o ANR.

  • Aliás, o Crashlytics também tem selos inteligentes para outros culpados.

  • Por exemplo, se a linha de execução estivesse travada lendo um arquivo gigante ou realizando uma consulta de banco de dados muito lenta, se ela acionasse um ANR, seria marcada como bloqueio de raiz de E/S.

  • Se duas linhas de execução estivessem travadas esperando uma pela outra, o Crashlytics as marcaria como em deadlock.

  • Agora, vamos analisar os frames de pilha na linha de execução de bloqueio raiz.

  • Então, temos o PaletteExtractor processVideo chamando o FrameDecoder getUncompressedFrame destacado em azul, e isso está completamente travado dentro de createBitmap, apenas tentando alocar memória para um único frame.

  • Como sabemos que ele não está esperando outro bloqueio e que não está em uma chamada de rede, nosso principal suspeito é a memória. Podemos confirmar isso imediatamente verificando a memória livre do dispositivo aqui no console do Crashlytics.

  • Veja os dois lugares em que o Crashlytics mostra isso.

  • Na guia de dados do evento específico, ao lado da guia de stack trace que acabamos de analisar, e também no canto superior direito, mostrando a média de todos os eventos.

  • Em ambos os casos, parece que não há quase nada, nem mesmo 100 KB.

  • Vamos pensar.

  • O que aconteceu aqui é que, como o processo estava sem memória, o Android Runtime passou todo o tempo executando a coleta de lixo freneticamente, tentando encontrar espaço suficiente para o próximo bitmap, mas sem sucesso.

  • Congelando nossa linha de execução de worker e deixando a IU sem recursos por mais de cinco segundos.

  • Por isso, aqui vai uma dica rápida.

  • Sempre que você receber um ANR associado a esse tipo de RAM livre baixa, é um sinal claro de que o ANR é, na verdade, um vazamento de memória ou um problema de sobrecarga.

  • Agora, a pergunta é: nosso app é a vítima ou ele também está acumulando memória?

  • Vamos voltar à lista de problemas do projeto e ver se há mais alguma coisa relevante.

Investigar exceções de memória insuficiente

  • Polina Govorkova

    E olha ali.

  • Na parte de baixo, vemos a lista de problemas e, logo acima do ANR com os oito eventos, temos uma exceção OutOfMemory.

  • Então, talvez sejamos os culpados, afinal.

  • E essa é uma pista enorme.

  • No Android, um OutOfMemoryError do Java, ou OOM, não significa apenas que o smartphone estava com pouca RAM.

  • Isso significa que o processo do nosso app ultrapassou o limite de heap Java do Android Runtime.

  • Então, no momento, minha teoria é que o ANR e o OOM são dois lados da mesma moeda.

  • Dependendo do momento durante uma execução de teste, o ChromaClip ficava preso na coleta de lixo por tempo suficiente para acionar um ANR ou esgotava completamente o heap, resultando em um OOM.

  • Vamos investigar a exceção OutOfMemory para ter certeza.

  • Começando pelo stack trace.

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • Espera aí.

  • Bitmap.createBitmap, isso é familiar.

  • Não vimos esse antes?

  • Mas veja de onde ela é chamada desta vez.

  • Não foi possível alocar 256 bytes ao abrir a atividade de configurações.

  • Estamos caindo na mesma armadilha de novo.

  • Se o app falhar porque não consegue inflar um ícone de configurações minúsculo, a atividade de configurações não será a vilã.

  • É apenas o autor da chamada azarado que pediu um pouco de memória quando não havia nenhuma.

  • Para encontrar o verdadeiro culpado, precisamos diminuir o zoom e ver tudo o que estava na memória quando a falha ocorreu.

  • Em outras palavras, precisamos de um heap dump do Java.

  • Na sua mesa, se você souber como acionar um OOM, poderá capturar manualmente um heap dump do Java com o Memory Profiler do Android Studio.

  • Mas quando um OOM acontece de forma imprevisível, nas execuções de teste ou em produção, esse método não funciona.

Como capturar e analisar heap dumps

  • Polina Govorkova

    Esse é outro momento em que o Firebase Crashlytics se destaca.

  • Além das informações de saída do aplicativo, o Crashlytics agora se integra à API ProfilingManager do Android.

  • A API ProfilingManager no Android 17 é turbinada com a criação de perfil de memória acionada pelo sistema, e é exatamente disso que precisamos aqui.

  • Isso significa que, quando o app encontra um erro OutOfMemory ou viola os limites de memória do sistema, o SO captura um instantâneo de alocação heap em segundo plano. No Crashlytics, a coleta acionada é disponibilizada sem custo financeiro, desde que você use o SDK do Crashlytics 20.1.0 ou mais recente.

  • Em seguida, o Crashlytics agrupa automaticamente as finalizações do OOM e do limitador de memória, que vamos abordar mais adiante, no painel.

  • E quando você ativa a coleta de heap dumps pelo console do Firebase ou pela CLI, o Crashlytics começa a coletar automaticamente esses instantâneos de alocação heap capturados, desofuscando-os para você. Basicamente, você terá um raio-X completo de tudo o que está no heap Java do seu app no momento da falha.

  • Veja como o OOM do ChromaClip aparece no console do Crashlytics depois que o heap é capturado.

  • Na parte de cima, você vê uma explicação do motivo pelo qual o Java esgotou o heap e por que isso aconteceu.

  • Em seguida, você também vai conferir dicas úteis de solução de problemas e indicações de recursos externos.

  • Na guia de stack trace, logo acima daquela atividade de configurações enganosa, o Crashlytics sinaliza que os stack traces de OOM não são determinísticos, para que as pessoas não precisem perder tempo analisando o stack trace como acabamos de fazer.

  • Em seguida, o Crashlytics anexa o arquivo do Perfetto desofuscado, o heap dump, à sessão.

  • E, aqui, podemos fazer o download do arquivo.

  • Podemos navegar por todos os dumps no Google Cloud Storage ou abri-los na interface do Perfetto.

  • Ao clicar em "Analyze" na interface do Perfetto, o explorador de heap dumps do Perfetto é aberto.

  • Agora, levante a mão se você já viu este.

  • Certo, algumas pessoas levantaram a mão.

  • Para mim, essa visualização é muito intimidadora, mas, neste caso, o problema é bem simples.

  • Observe o conjunto de barras enormes que se estendem por quase todo o heap Java.

  • Portanto, na maior parte, é uma matriz de objetos de par, cada um envolvendo uma cor e alguma instância de string.

  • Oh!

  • Acho que sei o que está acontecendo.

  • Nosso código está alocando uma cor e alguns dados de suporte para cada pixel em cada frame, além de manter milhões deles em uma lista.

  • Por isso, não é de se admirar que a atividade de configurações não tenha conseguido alocar esses 256 bytes.

  • Bem, pelo menos um mistério foi resolvido.

  • Vamos corrigir isso.

  • Neste ponto, temos algumas opções.

  • Como o Perfetto já nos indicou o PaletteExtractor, podemos abrir nosso ambiente de desenvolvimento integrado e reescrever o loop manualmente ou usar o agente de programação de IA.

  • Ou podemos ignorar totalmente a inspeção manual de heap e usar a CLI do Android.

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • Portanto, ao instalar a CLI do Android e o Perfetto, você poderá listar e baixar heap dumps desofuscados do Crashlytics, consultar os principais objetos retidos desses heap dumps usando o trace_processor e buscar dados de problemas correspondentes do Crashlytics, como chaves personalizadas, registros, navegação estrutural, lançamentos associados e muito mais, tudo no seu terminal.

  • Melhor ainda, olhe para a parte de baixo dos slides.

  • Não execute esses comandos por conta própria.

  • Mais uma vez, peça ao seu agente de programação de IA favorito em linguagem natural para inspecionar o heap dump de OOM mais recente usando o comando do Android e enviar a correção.

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • E ele terá contexto suficiente para fazer todo o fluxo para você.

  • Ou seja, listar OOMs, baixar o trace, executar a análise e gerar o patch de código diretamente no seu projeto.

  • E observe este patch no arquivo PaletteExtractor.

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • Em vez de milhões de alocações de objetos, agora apenas reduzimos a amostragem do frame e contamos os números inteiros brutos diretamente.

  • Então, vamos aplicar a correção, fechar os problemas, atualizar a versão e continuar testando.

  • Se tudo der certo, não só o erro de falta de memória vai desaparecer, mas, como não vamos mais ter problemas com a coleta de lixo, o erro de ANR também vai sumir.

  • Então, implantamos o build de patch nos dispositivos de teste, escolhemos um novo clipe e começamos a extrair paletas.

  • E a interface congela de novo.

  • Mais um ANR.

  • Curiosamente, ao testar mais, descobrimos que, em alguns dispositivos, ainda vemos esse ANR, mas, em outros, o app agora falha aleatoriamente e com bastante frequência, até mais do que com esses OOMs.

  • Mas corrigimos o OOM, então o que está acontecendo?

Como investigar problemas de memória nativa

  • Polina Govorkova

    Vamos voltar ao Crashlytics.

  • Primeiro, as boas notícias.

  • Confira nossa lista de problemas.

  • A exceção de falta de memória desapareceu completamente, como esperávamos.

  • A má notícia?

  • Agora que o ChromaClip está sobrevivendo um pouco mais, ele tem tempo para descobrir o que mais estava escondido.

  • Por isso, o ANR não desapareceu.

  • E agora temos duas falhas nativas na parte de baixo, felizmente com apenas alguns eventos.

  • E bem no topo, com 11 eventos, temos o encerramento do limitador de memória, que, felizmente, já está marcado como tendo o heap dump.

  • Espera.

  • O que é um encerramento do limitador de memória?

  • Levantem a mão, quem já ouviu falar disso?

  • Certo.

  • Algumas mãos raras.

  • Vamos aprender juntos.

  • Se você nunca viu o Memory Limiter em ação, é porque ele é uma novidade do Android 17.

  • E um OOM do Java só é acionado quando você ultrapassa o limite do heap gerenciado do Java, mas quando o consumo total de memória do app também inclui RAM nativa, como buffers gráficos e alocações de C++, antes não havia um limite por app.

  • Então, tínhamos o Low Memory Killer, que era global.

  • Ele entrava em ação quando algum app consumia toda a RAM do sistema até o smartphone travar.

  • Agora, a partir do Android 17, o Limitador de Memória restringe o consumo de memória individual de cada app.

  • Portanto, quando o processo consome uma quantidade desproporcional de RAM, o SO encerra esse app específico.

  • O Crashlytics captura todos esses detalhes e agrupa as finalizações no painel.

  • Ao clicar em "Memory Limiter kill" no Crashlytics, assim como no OOM, você recebe uma explicação do motivo pelo qual o SO interveio, então não precisa memorizar o que acabei de dizer.

  • Você também recebe dicas sobre como lidar com o problema e links diretos para os documentos de aplicação de memória do Android.

  • Assim como no OOM, o stack trace culpa um frame inocente quando o SO encerra o processo, e o painel do Crashlytics destaca isso.

  • Agora, as interrupções do Limitador de Memória acontecem principalmente no código nativo, porque os ambientes nativos não têm essa camada que lida com a segurança e as alocações de memória.

  • Caso contrário, seria um OOM.

  • Então, nossa primeira pergunta é: nossos objetos Kotlin estão retendo grandes alocações nativas?

  • Ou é um código C++ bruto que está vazando por conta própria?

  • Vamos clicar em "Analyze" na interface do Perfetto para descobrir.

  • Confira a guia "Overview" no explorador de heap dump do Perfetto.

  • Em "General information", o RSS anônimo mais a troca mostram que a memória total do processo atingiu 4 GB.

  • Ok.

  • No entanto, abaixo disso, vemos que, em "Bytes Retained by Heap", todo o heap gerenciado, incluindo a memória nativa vinculada a objetos Kotlin, como bitmaps, tem apenas 42 MB.

  • Ok.

  • Em 10 segundos, descartamos todo o código Kotlin e Java.

  • Portanto, o culpado precisa estar vazando de uma camada nativa do C++.

  • Como o ChromaClip tem uma camada fina de C++ para processamento de frames, não precisamos procurar muito.

  • Ao apontar nosso agente de programação ou o criador de perfil nativo do Android Studio para NativePaletteEngine.cpp processFrame, o bug aparece.

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • Em cada frame de vídeo, nosso código C++ aloca um buffer de rascunho temporário, que não é tão temporário se você não o liberar. Que erro de iniciante!

  • Agora, vamos ligar os pontos.

  • O que aconteceu?

  • Depois de corrigir o vazamento de Java, o vazamento de C++ terá tempo para se acumular e se espalhar.

  • No Android 17, o Limitador de Memória detectou o problema e encerrou o app.

  • Mas nos dispositivos mais antigos, em que o limitador de memória ainda não estava presente, ele se manifestava como um ANR.

  • Então, finalmente, tudo faz sentido.

  • Vamos adicionar o delete[] scratch aqui, atualizar a versão e enviar a atualização.

  • Com as finalizações do Limitador de Memória e os ANRs finalmente resolvidos, podemos lançar a versão 1.0.

  • Empolgante.

  • E, graças à rapidez e estabilidade do ChromaClip, nosso app decola.

  • Milhares de pessoas estão enviando e remixando os vídeos todos os dias.

  • Mas, com todo o tráfego, lembra daquelas falhas nativas misteriosas que vimos antes na parte de baixo do painel?

  • Eles também estão correndo soltos.

  • De repente, há muito mais deles.

  • E nosso trabalho ainda não acabou.

Como investigar falhas de corrupção de memória nativa

  • Polina Govorkova

    Ao abrir essa falha nativa no Crashlytics, observe o stack trace.

  • Não há uma única linha de código do nosso próprio app.

  • RenderThread falhando no libc.so ao liberar a memória.

  • Outra dica: quando uma linha de execução do sistema falha dentro do alocador de memória sem nenhum código do app, isso é um sinal clássico de corrupção de memória nativa.

  • Portanto, nosso código C++ corrompeu ou liberou o buffer antes, e a linha de execução de renderização acabou tropeçando nele depois.

  • Então, como pegamos o verdadeiro culpado no momento exato em que a corrupção aconteceu?

  • O Android oferece duas ferramentas avançadas, e o Crashlytics é compatível com ambas.

  • Primeiro, o GWP-ASan, que é uma rede de segurança leve criada especificamente para produção.

  • Ele faz uma amostragem de uma pequena fração das alocações de heap nativas e as envolve com páginas de proteção para que a sobrecarga de CPU e memória seja quase zero.

  • Em milhares de sessões de usuários, quando um bug toca na memória liberada ou fora dos limites em uma alocação de amostra, o GWP-ASan o detecta imediatamente.

  • No entanto, como o GWP-ASan é, por natureza, probabilístico, ao testar as coisas localmente, você não quer executar o smartphone de teste por 16 horas ou mais para atingir a amostra aleatória.

  • Então, o que você recebe?

  • Para depuração local determinística, use a ARM MTE, ou Memory Tagging Extension.

  • A MTE usa um ponteiro de hardware e tags de memória para verificar cada alocação e está disponível em dispositivos ARM v9, como o Pixel 8 e versões mais recentes.

  • Portanto, combine-o com o GWP-ASan na produção para ter o melhor dos dois mundos.

  • Como esperado, quando uma das ferramentas detecta o bug, o Crashlytics automaticamente adiciona um selo ao problema, permitindo que você filtre por GWP-ASan ou MTE, e fixa um card de informações com a causa exata da corrupção no painel.

  • Veja o que acontece no console do Crashlytics quando o GWP-ASan detecta a falha.

  • Ao lado da guia de stack trace normal, temos o stack trace de memória.

  • Na parte de cima, vemos a causa exata da corrupção: Use After Free.

  • Em vez de mostrar a falha da linha de execução de renderização em libc.so, o GWP-ASan agora fornece a biografia completa desse buffer dentro do nosso próprio effect_pipeline.cc.

  • Em "Allocated By", vemos onde o buffer foi criado.

  • E logo abaixo de "Deallocated By", vemos o culpado exato que o liberou da linha de execução de renderização.

  • E isso está oculto na linha 208 do nosso pipeline de efeitos.

  • Então, abrimos nosso código nativo no EffectPipeline, e o bug está lá, rindo da nossa cara.

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • Sempre que um usuário troca o filtro de cor na linha de execução em segundo plano, liberamos o buffer antigo enquanto o RenderThread ainda o está lendo.

  • Então, adicionamos o bloqueio de mutex ao redor da troca de buffer, enviamos a atualização e, finalmente, vemos os relatórios do GWP-ASan e todas aquelas falhas misteriosas desaparecerem quase da noite para o dia.

  • E nossa métrica de usuários sem falhas disparou para 100%.

  • Uhu!

  • Lembra que, no início da conversa, nos perguntamos: "Terminei?"

  • Logo antes de ser atingido por basicamente todos os chefes de responsividade e memória do livro.

  • Bem, os ANRs, os erros de memória insuficiente, as finalizações do limitador de memória e as falhas de corrupção de memória nativa foram resolvidos.

  • E mesmo que o stack trace minta e o culpado esteja escondido em algum lugar nas linhas de execução em C++, o Android e o Firebase Crashlytics oferecem a ferramenta para rastrear e enviar com confiança.

Resumo e inscrição para acesso antecipado

  • Polina Govorkova

    Vamos recapitular o que você pode usar assim que voltar para o laptop: insights de linha de execução de ANR e outros metadados relevantes pelo ApplicationExitInfo, coleta automática de acionadores de encerramento de OOM e limitador de memória pelo ProfilingManager e suporte completo ao GWP-ASan e ao Arm MTE para corrupção de memória nativa.

  • E, em pré-lançamento particular, heap dumps de campo no Android 17 ou versões mais recentes.

  • Se você quiser ser uma das primeiras pessoas a testar a coleta automática de heap dumps no Firebase Crashlytics e analisá-los com o Perfetto e a CLI do Android, tire uma foto rápida do slide e se inscreva no link.

  • Agora que já passamos pela maior parte da palestra, quero agradecer pela sua participação e pelo seu tempo.

  • Que sua linha de execução principal permaneça desbloqueada, seu heap permaneça limpo e sua próxima versão seja a mais estável até agora.

  • Então, boa depuração.

  • Agradeço mais uma vez.

Código

Snippets de código

Trecho de texto em 01:45 em "Introdução"
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
Trecho de texto em 01:58 em "Introdução"
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
Trecho de texto em 04:45 em "Como depurar e analisar ANRs"
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
Trecho de texto em 08:08 em "Depuração e análise de ANRs"
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
Trecho de texto aos 11:54 em "Investigating out of memory exceptions"
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
Snippet de shell em 15:54 em "Capturar e analisar heap dumps"
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
Trecho de texto em 16:27 em "Capturar e analisar heap dumps"
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
Snippet do Kotlin aos 16:47 em "Capturing and analyzing heap dumps"
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
Snippet em C++ aos 21:06 em "Investigating native memory issues"
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
Snippet em C++ aos 25:14 em "Investigating native memory corruption crashes"
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}