Bitmaps e memória

Objetos bitmap geralmente são os maiores contribuintes individuais para o consumo de memória de um aplicativo. Sejam ícones de apps, imagens de notificações ou conteúdo de mídia, o processamento ineficiente de bitmaps pode levar rapidamente a erros de falta de memória (OOM, na sigla em inglês) e pressão de memória em todo o sistema.

Configurações de bitmap e dados de pixel

A quantidade de memória que um bitmap consome é determinada principalmente pelas dimensões (largura × altura) e pela configuração (Bitmap.Config).

A configuração define quantos bytes são usados para representar cada pixel:

Configuração Bytes por pixel Descrição
ALPHA_8 1 Apenas o canal alfa (transparência). Útil para máscaras.
RGB_565 2 Vermelho (5 bits), verde (6 bits) e azul (5 bits). Sem alfa. Ideal para imagens opacas em que a alta fidelidade de cores não é essencial.
ARGB_8888 4 Alfa, vermelho, verde, azul (8 bits cada). Padrão e mais comum.
RGBA_F16 8 Ponto flutuante de meia precisão. Usado para conteúdo HDR e de ampla gama.
HARDWARE N/A Armazenado na memória gráfica (gralloc/DMABuf). Consulte Bitmaps de hardware.

Fórmula da memória:Memory (Bytes) = Width × Height × Bytes Per Pixel

Por exemplo, uma imagem em tela cheia em um dispositivo 1080p (1920x1080) em ARGB_8888 ocupa: 1920 × 1080 × 4 bytes ≈ 8,3 MB.

Bitmaps de heap x bitmaps compartilhados

Bitmaps de heap (heap nativo)

No Android moderno (8.0 ou mais recente), os dados de pixel do bitmap são armazenados no heap nativo, enquanto apenas um pequeno objeto wrapper reside no heap do Java.

Quando um app precisa apresentar uma imagem, ela geralmente é decodificada de um arquivo de imagem compactado em um bitmap e armazenada no heap.

Bitmaps compartilhados (ashmem/memfd)

Quando um bitmap é transferido entre processos (por exemplo, via Binder para SystemUI em uma notificação), o Android evita copiar os dados de pixel usando memória compartilhada (ashmem ou memfd).

Uma instância Bitmap pode ser copiada para a memória compartilhada de maneira explícita chamando Bitmap.asShared(), ou implícita se um Bitmap for colocado dentro de um Parcel (normalmente adicionando o Bitmap a um Parcelable, como um Bundle) e enviado pelo Binder IPC.

Quando um bitmap compartilhado é enviado pelo Binder IPC, os dados de pixel não são copiados. Em vez disso, um descritor do arquivo que faz referência a uma região de memória compartilhada é duplicado para o processo destinatário. A região de memória subjacente pode ser compartilhada entre vários processos e não é liberada até que todos os descritores de arquivo que se referem a ela sejam fechados.

Bitmaps mutáveis x imutáveis

  • Bitmaps mutáveis: podem ser modificados após a criação (por exemplo, usando um Canvas). Eles sempre exigem alocação de memória privada própria. Se um Bitmap mutável for copiado, uma cópia completa (segunda cópia de todos os dados de pixel) precisará ser feita.
  • Bitmaps imutáveis: não podem ser mudados. Isso permite otimizações como compartilhar o mesmo buffer de memória subjacente entre diferentes instâncias de Bitmap. Os bitmaps carregados de recursos do APK (BitmapFactory) geralmente são imutáveis.

Processamento eficiente de bitmaps

Agrupamento e reutilização de bitmaps

Alocar e desalocar bitmaps com frequência causa churn de alocação, o que força o GC a ser executado constantemente. As bibliotecas comuns de carregamento de imagens usam um pool de bitmaps.

O Google recomenda o Glide como solução para aplicativos baseados em Java e o Coil para aplicativos baseados em Kotlin (especialmente ao usar o Jetpack Compose).

Quando um bitmap não é mais necessário, em vez de deixar que ele seja coletado pelo GC, o app chama bitmap.recycle() ou o retorna a um pool. Na próxima vez que um bitmap das mesmas dimensões e configuração for necessário, o pool vai fornecer o buffer existente, evitando uma nova alocação.

Bitmaps de hardware

O Bitmap.Config.HARDWARE permite armazenar dados de pixel diretamente na memória gráfica (DMABuf).

  • Prós:
    • Economia de memória: não usa o heap nativo ou do aplicativo, mas sim a memória da GPU. Muitas vezes, os bitmaps mostrados na interface de um app precisam ser copiados para a memória da GPU. Assim, isso economiza a operação de cópia e o custo adicional de memória.
    • Performance: extremamente rápido para desenhar porque os dados já estão na GPU.
  • Contras:
    • Imutável: não é possível modificar bitmaps de hardware.
    • Leitura lenta: acessar pixels da CPU (por exemplo, getPixel()) é muito caro.
    • Atribuição: mais difícil de rastrear em ferramentas padrão como o AHAT (veja abaixo).

Problemas comuns de memória de bitmap

Mesmo ao usar configurações de bitmap modernas, vários padrões recorrentes na forma como você decodifica e programa bitmaps podem causar picos de memória grandes.

Decodificação de bitmaps superdimensionados

Uma foto de 4.000 × 3.000 pixels em resolução máxima ocupa 48 MB no ARGB_8888. Decodificar a imagem completa apenas para renderizar uma miniatura de 200 × 150 pixels desperdiça mais de 99% do buffer de pixels alocado.

Ao decodificar imagens diretamente com ImageDecoder ou BitmapFactory, reduza a resolução durante a transmissão de decodificação para corresponder às dimensões da visualização de destino usando ImageDecoder.setTargetSize() ou BitmapFactory.Options.inSampleSize. As bibliotecas de carregamento de imagens, como Glide e Coil, fazem essa redução de resolução automaticamente quando você fornece um tamanho de visualização de destino limitado.

Por exemplo, ao decodificar um bitmap com ImageDecoder, transmita um OnHeaderDecodedListener que reduza as dimensões de saída para o tamanho da visualização de destino:

val source = ImageDecoder.createSource(resources, R.drawable.high_res_photo)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
    if (info.size.width > targetWidth || info.size.height > targetHeight) {
        decoder.setTargetSize(targetWidth, targetHeight)
    }
}

Alta retenção simultânea da decodificação paralela

Mesmo quando os bitmaps individuais têm o tamanho adequado e são de curta duração, a decodificação de muitas imagens em paralelo pode causar picos graves de memória. Por exemplo, se um organizador ou uma tela de galeria despachar 30 tarefas em um pool de linhas de execução sem limites para decodificar ícones ou miniaturas simultaneamente, todos os 30 buffers de pixels não compactados e buffers de trabalho do decodificador ocuparão a RAM ao mesmo tempo.

Essa alta retenção simultânea aumenta a pegada de heap nativo de pico e pode acionar encerramentos lmkd antes da conclusão do lote. Limite a simultaneidade de decodificação com um pool de linhas de execução, um semáforo ou um dispatcher de corrotina limitado, como Dispatchers.IO.limitedParallelism(2), para que apenas alguns bitmaps sejam decodificados em voo de uma só vez.

Bitmaps temporários não reciclados em loops de processamento de frames

No Android 8.0 e em versões mais recentes, o objeto wrapper Bitmap do Java ocupa apenas cerca de 56 bytes no heap do Java, enquanto o buffer de pixels fica no heap nativo e pode ocupar vários megabytes. Você pode verificar essa divisão no criador de perfil de memória do Android Studio ou no AHAT, em que cada instância Bitmap mostra um tamanho Java superficial de ~56 bytes ao lado do tamanho nativo de vários megabytes e em dumpsys meminfo em Native Allocations (Bitmap (malloced)).

Pipelines de alta frequência, como análise de frames de câmera, OCR ou loops de inferência de ML, geralmente alocam um novo bitmap em cada frame chamando ImageProxy.toBitmap() e Bitmap.createBitmap() para rotação ou corte. Descartar referências a frames substituídos sem reciclagem pode aumentar a memória nativa. Os pequenos wrappers Java quase não aumentam a ocupação do heap Java. Por isso, eles não acionam a coleta de lixo com rapidez suficiente para evitar que centenas de megabytes de buffers de pixels nativos se acumulem antes que o NativeAllocationRegistry os recupere.

Ao processar frames em um loop apertado, reutilize buffers pré-alocados, se possível, ou chame bitmap.recycle() explicitamente em bitmaps intermediários transientes assim que cada frame terminar o processamento.

Exercício prático: análise de bitmap

Vamos usar o app de exemplo BitmapLab para conhecer esses conceitos.

1. Medição com dumpsys meminfo

Inicie o BitmapLab e toque em ALOCAR 10 MB ARGB_8888. Depois, execute:

adb shell dumpsys meminfo -s com.android.bitmaplab

Em versões modernas do Android, procure a seção Alocações nativas. Esses fornecem uma atribuição muito melhor para bitmaps do que o Resumo do app genérico:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmap (malloced): bitmaps alocados no heap nativo do processo. É aqui que a maioria dos bitmaps padrão fica no Android 8.0 e versões mais recentes.
  • Bitmap (não alocado): bitmaps que usam memória especializada, como Bitmaps de hardware ou Bitmaps compartilhados (via ashmem ou memfd).

Se você alocar um Bitmap compartilhado em BitmapLab, ele vai aparecer em Bitmap (nonmalloced):

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240
Bitmap (nonmalloced):        1                          10240  # <--- Shared Bitmap!

Rastreamento de bitmaps compartilhados

Em algumas versões do Android e configurações de kernel, dumpsys meminfo também fornece rastreamento de alta resolução para bitmaps mapeados no espaço de endereços do processo por descritores de arquivo.

Por padrão, os bitmaps compartilhados usam um nome genérico ("bitmap"). Para ativar a atribuição detalhada e o rastreamento de bitmap exclusivo (identificando bitmaps compartilhados em diferentes processos), ative a seguinte propriedade do sistema:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Quando ativadas, as regiões ashmem em /proc/<pid>/smaps terão nomes mais descritivos. O meminfo vai aproveitar isso, e os resultados vão ficar assim:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Mapeado: o tamanho total de todos os mapeamentos de memória relacionados a bitmap.
  • Único: o tamanho dos bitmaps considerando apenas os únicos (ou seja, dois ou mais mapeamentos dos mesmos dados de pixel de bitmap compartilhados contam apenas uma vez).

2. Bitmaps no AHAT

O AHAT oferece uma excelente visualização para bitmaps.

  1. Em BitmapLab, aloque alguns bitmaps.
  2. Capture um despejo de heap com a flag -b (para incluir dados de bitmap nativos):

    adb shell am dumpheap -b png com.android.bitmaplab /data/local/tmp/bitmaps.hprof
    adb pull /data/local/tmp/bitmaps.hprof .
    ahat bitmaps.hprof
    
  3. Abra localhost:7100 e procure o link Bitmaps na barra lateral ou pesquise a classe Bitmap.

  4. O AHAT renderiza os bitmaps no navegador, facilitando a identificação de quais imagens estão consumindo muita memória.

AHAT mostrando bitmaps renderizados

3. Rastreamentos de bitmap no Perfetto

O Perfetto pode rastrear alocações e contagens de bitmap ao longo do tempo. Esses contadores são emitidos pelo framework Android quando a categoria gfx do atrace é ativada para um aplicativo específico.

  1. Inicie um rastreamento. É necessário incluir a categoria gfx e segmentar o pacote do app específico usando a flag -a:

    external/perfetto/tools/record_android_trace -o bitmaps.perfetto-trace \
        -t 15s -b 64mb view gfx dalvik am res memory -a com.android.bitmaplab
    
  2. No BitmapLab, toque nos botões Allocate e Clear várias vezes.

  3. Toque também em Parcelar/Desparcelar bitmap.

  4. Analise o rastro em ui.perfetto.dev.

Na seção de processo para com.android.bitmaplab, você vai encontrar: * Contagem de bitmaps: um contador que mostra o número de bitmaps ativos. * Memória de bitmap: um contador que mostra o total de bytes usados por bitmaps.

Intervalos de alto nível (SDK do Perfetto)

O BitmapLab também usa o SDK do Perfetto para emitir intervalos de alto nível para operações de bitmap. Pesquise BitmapLab_ no rastreamento para encontrar: * BitmapLab_parcelUnparcel: intervalos que abrangem a lógica de parcelamento e desparcelamento. * BitmapLab_postNotification: frações que abrangem o fluxo de postagem de notificações.

Como acompanhar fluxos de notificação

Ao tocar em Post Notification, o app cria uma notificação com o bitmap atual e a envia para o sistema. O código do framework responsável por isso emite intervalos do Perfetto com eventos de fluxo que conectam o parcelamento (gravação do bitmap em um pacote para ser enviado pelo Binder IPC) e o desparcelamento (leitura do bitmap de um pacote na extremidade de recebimento).

Na captura de tela abaixo, você pode ver o app dividindo o bitmap grande para ser usado em uma transação do Binder para postar a notificação e o desempacotamento correspondente no processo system_server.

Perfetto mostrando um fluxo do BitmapLab para system_server via notificação

Usando o Perfetto, você pode até mesmo acompanhar o mesmo bitmap de notificação à medida que ele se propaga por threads e processos, por exemplo, de uma thread de vinculador em system_server (que implementa o servidor Binder INotificationManager) para threads de worker system_server que podem encaminhar o mesmo bitmap para com.android.systemui para ser mostrado na tela de notificações.

Desafios dos apps do sistema

Os apps do sistema, como SystemUI (notificações) e Launcher, enfrentam desafios exclusivos:

  1. Conteúdo sem limites: notificações e widgets podem ser numerosos. Se cada um deles tiver um bitmap grande, o sistema poderá ficar sem memória rapidamente.
  2. Duplicação: o mesmo ícone do app pode ser mantido no cache do Launcher, na área de notificação da SystemUI e no app Configurações.
  3. Compartilhamento via buffers de hardware: para reduzir esse problema, os componentes do sistema estão migrando para um serviço centralizado de "descarregamento de imagem" que compartilha instâncias HardwareBuffer entre processos.
  4. Atribuição de DMABuf: os bitmaps de hardware economizam espaço de heap, mas usam a memória DMABuf, que é mais difícil de atribuir a um processo específico em ferramentas de memória padrão.

    Use adb shell dmabuf_dump para ver as alocações de DMABuf em todo o sistema. Essa ferramenta fornece um detalhamento por processo dos buffers:

     droid.bitmaplab:19562
                      Name              Rss              Pss         nr_procs            Inode               Exporter
                 <unknown>          3840 kB          1280 kB                3             3397              virtio_gpu
                    system            12 kB             4 kB                3             3398                  system
                 <unknown>          3840 kB          1920 kB                2             3399              virtio_gpu
                    system            12 kB             6 kB                2             3400                  system
             PROCESS TOTAL         11556 kB          5136 kB
    
    • RSS: o tamanho total do buffer se ele estiver mapeado no processo.
    • Pss: o tamanho proporcional (RSS dividido pelo número de processos que compartilham o buffer). Essa é a melhor métrica para contabilidade.
    • nr_procs: o número de processos que atualmente têm uma referência a esse buffer.
    • Exportador: o driver que alocou o buffer (por exemplo, virtio_gpu no Cuttlefish ou um heap Ion/DMA-BUF específico do fornecedor no hardware).

    Você também pode usar adb shell dmabuf_dump -b para um resumo de todos os buffers e do uso total de DMA-BUF em todo o sistema.


← Java | ↑ Up | Native →