Mapas de bits y memoria

Los objetos de mapa de bits suelen ser los mayores contribuyentes individuales al espacio en memoria de una aplicación. Ya sean íconos de apps, imágenes de notificaciones o contenido multimedia, el manejo ineficiente de mapas de bits puede generar rápidamente errores de memoria insuficiente (OOM) y presión de memoria en todo el sistema.

Configuraciones de mapa de bits y datos de píxeles

La cantidad de memoria que consume un mapa de bits se determina principalmente por sus dimensiones (ancho × alto) y su configuración (Bitmap.Config).

La configuración define cuántos bytes se usan para representar cada píxel:

Configuración Bytes por píxel Descripción
ALPHA_8 1 Solo el canal alfa (transparencia). Es útil para las máscaras.
RGB_565 2 Rojo (5 bits), verde (6 bits) y azul (5 bits). Sin alfa. Es adecuado para imágenes opacas en las que la alta fidelidad del color no es fundamental.
ARGB_8888 4 Alfa, rojo, verde y azul (8 bits cada uno). Es el valor predeterminado y el más común.
RGBA_F16 8 Número de punto flotante con precisión media. Se usa para contenido HDR y de gama amplia.
HARDWARE N/A Se almacena en la memoria de gráficos (gralloc/DMABuf). Consulta Mapas de bits de hardware.

Fórmula de memoria: Memory (Bytes) = Width × Height × Bytes Per Pixel

Por ejemplo, una imagen de pantalla completa en un dispositivo de 1080p (1920 x 1080) en ARGB_8888 ocupa: 1920 × 1080 × 4 bytes ≈ 8.3 MB.

Mapas de bits de montón vs. mapas de bits compartidos

Mapas de bits del montón (montón nativo)

En Android moderno (8.0 y versiones posteriores), los datos de píxeles del mapa de bits se almacenan en el montón nativo, mientras que solo un pequeño objeto wrapper reside en el montón de Java.

Cuando una app necesita presentar una imagen, por lo general, se decodifica de un archivo de imagen comprimido en un mapa de bits y se almacena en el montón.

Bitmaps compartidos (ashmem/memfd)

Cuando se transfiere un mapa de bits entre procesos (p.ej., a través de Binder a SystemUI para una notificación), Android evita copiar los datos de píxeles usando la memoria compartida (ashmem o memfd).

Se puede copiar una instancia de Bitmap a la memoria compartida de forma explícita llamando a Bitmap.asShared() o de forma implícita si se coloca un Bitmap dentro de un Parcel (por lo general, agregando el Bitmap a un Parcelable, como un Bundle) y se envía a través de la comunicación entre procesos de Binder.

Cuando se envía un mapa de bits compartido a través de la comunicación entre procesos de Binder, no se copian los datos de píxeles, sino que se duplica un descriptor de archivo que hace referencia a una región de memoria compartida en el proceso receptor. La región de memoria subyacente se puede compartir entre varios procesos y no se libera hasta que se cierran todos los descriptores de archivo que la referencian.

Mapas de bits mutables y mapas de bits inmutables

  • Bitmaps mutables: Se pueden modificar después de la creación (p.ej., a través de un Canvas). Siempre requieren su propia asignación de memoria privada. Si se copia un mapa de bits mutable, se debe realizar una copia profunda (segunda copia de todos los datos de píxeles).
  • Bitmaps inmutables: No se pueden cambiar. Esto permite optimizaciones, como compartir el mismo búfer de memoria subyacente entre diferentes instancias de Bitmap. Los mapas de bits cargados desde los recursos del APK (BitmapFactory) suelen ser inmutables.

Manejo eficiente de mapas de bits

Agrupación y reutilización de mapas de bits

La asignación y la desasignación de mapas de bits con frecuencia provocan una rotación de asignación, lo que obliga a la GC a ejecutarse constantemente. Las bibliotecas comunes de carga de imágenes usan un grupo de mapas de bits.

Google recomienda Glide como solución para las aplicaciones basadas en Java y Coil para las aplicaciones basadas en Kotlin (en especial, cuando se usa Jetpack Compose).

Cuando ya no se necesita un mapa de bits, en lugar de dejar que el recolector de basura lo elimine, la app llama a bitmap.recycle() o lo devuelve a un grupo. La próxima vez que se necesite un mapa de bits con las mismas dimensiones y configuración, el grupo proporcionará el búfer existente, lo que evitará una nueva asignación.

Mapas de bits de hardware

Bitmap.Config.HARDWARE te permite almacenar datos de píxeles directamente en la memoria de gráficos (DMABuf).

  • Ventajas:
    • Ahorro de memoria: No usa el montón nativo ni de la aplicación, sino la memoria de la GPU. A menudo, los mapas de bits que se muestran en la IU de una app deben copiarse en la memoria de la GPU de todos modos, por lo que esto ahorra esa operación de copia y el costo de memoria adicional.
    • Rendimiento: Es extremadamente rápido de dibujar porque los datos ya están en la GPU.
  • Desventajas:
    • Inmutable: Los mapas de bits de hardware no se pueden modificar.
    • La lectura es lenta: El acceso a los píxeles desde la CPU (p.ej., getPixel()) es muy costoso.
    • Atribución: Es más difícil hacer un seguimiento en herramientas estándar como AHAT (consulta a continuación).

Errores comunes de la memoria de mapa de bits

Incluso cuando usas configuraciones de mapa de bits modernas, varios patrones recurrentes en la forma en que decodificas y programas mapas de bits pueden causar grandes picos de memoria.

Decodificación de mapas de bits a gran escala

Una foto de 4,000 × 3,000 píxeles en resolución completa ocupa 48 MB en ARGB_8888. Decodificar esa imagen completa solo para renderizarla dentro de una miniatura de 200 × 150 píxeles desperdicia más del 99% del búfer de píxeles asignado.

Cuando decodifiques imágenes directamente con ImageDecoder o BitmapFactory, reduce el muestreo durante el paso de decodificación para que coincida con las dimensiones de la vista objetivo usando ImageDecoder.setTargetSize() o BitmapFactory.Options.inSampleSize. Las bibliotecas de carga de imágenes, como Glide y Coil, realizan esta reducción de muestreo automáticamente cuando proporcionas un tamaño de vista de destino delimitado.

Por ejemplo, cuando decodifiques un mapa de bits con ImageDecoder, pasa un OnHeaderDecodedListener que reduzca las dimensiones de salida al tamaño de la vista 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 retención simultánea a partir de la decodificación paralela

Incluso cuando los mapas de bits individuales tienen el tamaño adecuado y son de corta duración, decodificar muchas imágenes en paralelo puede causar picos graves de memoria. Por ejemplo, si una pantalla de galería o de organización despacha 30 tareas en un grupo de subprocesos no vinculados para decodificar íconos o miniaturas de forma simultánea, todos los búferes de píxeles sin comprimir y los búferes de trabajo del decodificador ocupan RAM al mismo tiempo.

Esta alta retención simultánea aumenta la huella de memoria nativa máxima y puede activar la finalización de lmkd antes de que finalice el lote. Limita la simultaneidad de decodificación con un grupo de subprocesos, un semáforo o un distribuidor de corrutinas limitados, como Dispatchers.IO.limitedParallelism(2), de modo que solo se decodifiquen algunos mapas de bits en vuelo a la vez.

Mapas de bits transitorios no reciclados en bucles de procesamiento de fotogramas

En Android 8.0 y versiones posteriores, el objeto wrapper Bitmap de Java solo ocupa alrededor de 56 bytes en el montón de Java, mientras que su búfer de píxeles reside en el montón nativo y puede ocupar varios megabytes. Puedes verificar esta división en el Generador de perfiles de memoria de Android Studio o en AHAT, donde cada instancia de Bitmap muestra un tamaño de Java superficial de alrededor de 56 bytes junto con su tamaño nativo de varios megabytes, y en dumpsys meminfo en Native Allocations (Bitmap (malloced)).

Las canalizaciones de alta frecuencia, como el análisis de fotogramas de la cámara, el OCR o los bucles de inferencia de AA, suelen asignar un nuevo mapa de bits en cada fotograma llamando a ImageProxy.toBitmap() y Bitmap.createBitmap() para la rotación o el recorte. Si se descartan las referencias a los fotogramas reemplazados sin reciclarlos, se puede inflar la memoria nativa. Los pequeños wrappers de Java apenas aumentan la ocupación del montón de Java, por lo que no activan la recolección de elementos no utilizados con la suficiente rapidez como para evitar que se acumulen cientos de megabytes de búferes de píxeles nativos antes de que NativeAllocationRegistry los reclame.

Cuando proceses fotogramas en un bucle ajustado, reutiliza los búferes asignados previamente si es posible o llama a bitmap.recycle() de forma explícita en los mapas de bits intermedios transitorios en cuanto finalice el procesamiento de cada fotograma.

Ejercicio práctico: Exploración de mapas de bits

Usaremos la app de ejemplo BitmapLab para explorar estos conceptos.

1. Medición con dumpsys meminfo

Inicia BitmapLab y presiona ALLOCATE 10MB ARGB_8888. Luego, ejecute lo siguiente:

adb shell dumpsys meminfo -s com.android.bitmaplab

En las versiones modernas de Android, busca la sección Native Allocations. Estos proporcionan una atribución mucho mejor para los mapas de bits que el Resumen de la app genérico:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmap (malloced): Mapas de bits asignados en el montón nativo del proceso. Aquí es donde residen la mayoría de los mapas de bits estándar en Android 8.0 y versiones posteriores.
  • Bitmap (nonmalloced): Son mapas de bits que usan memoria especializada, como Hardware Bitmaps o Shared Bitmaps (a través de ashmem o memfd).

Si asignas un mapa de bits compartido en BitmapLab, lo verás reflejado en Bitmap (nonmalloced):

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

Seguimiento de mapas de bits compartidos

En algunas versiones de Android y configuraciones del kernel, dumpsys meminfo también proporciona un seguimiento de alta resolución para los mapas de bits que se asignan al espacio de direcciones del proceso a través de descriptores de archivos.

De forma predeterminada, los mapas de bits compartidos usan un nombre genérico ("bitmap"). Para habilitar la atribución detallada y el seguimiento de mapas de bits únicos (que identifica los mapas de bits compartidos en diferentes procesos), debes habilitar la siguiente propiedad del sistema:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Cuando se habilita, las regiones de ashmem en /proc/<pid>/smaps tendrán nombres más descriptivos. meminfo aprovechará eso, y los resultados se verán así:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Asignado: Es el tamaño total de todas las asignaciones de memoria relacionadas con mapas de bits.
  • Único: Tamaño de los mapas de bits que solo tienen en cuenta los elementos únicos (es decir, dos o más asignaciones de los mismos datos de píxeles de mapa de bits compartidos subyacentes se cuentan solo una vez).

2. Mapas de bits en AHAT

AHAT proporciona una excelente visualización para los mapas de bits.

  1. En BitmapLab, asigna algunos mapas de bits.
  2. Captura un volcado de montón con la marca -b (para incluir datos de mapa de bits 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. Abre localhost:7100 y busca el vínculo Bitmaps en la barra lateral o busca la clase Bitmap.

  4. AHAT renderizará los mapas de bits en el navegador, lo que facilitará la identificación de las imágenes que consumen mucha memoria.

AHAT muestra mapas de bits renderizados

3. Pistas de mapa de bits en Perfetto

Perfetto puede hacer un seguimiento de las asignaciones y los recuentos de mapas de bits a lo largo del tiempo. El framework de Android emite estos contadores cuando se habilita la categoría de seguimiento gfx para una aplicación específica.

  1. Inicia un registro. Debes incluir la categoría gfx y segmentar el paquete de app específico con el parámetro -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. En BitmapLab, presiona los botones Allocate y Clear varias veces.

  3. También presiona Parcel/Unparcel Bitmap.

  4. Analiza el registro en ui.perfetto.dev.

En la sección de procesos de com.android.bitmaplab, verás lo siguiente: * Recuento de mapas de bits: Contador que muestra la cantidad de mapas de bits activos. * Memoria de mapa de bits: Es un contador que muestra el total de bytes que usan los mapas de bits.

Segmentos de alto nivel (SDK de Perfetto)

BitmapLab también usa el SDK de Perfetto para emitir segmentos de alto nivel para las operaciones de mapa de bits. Busca BitmapLab_ en el registro para encontrar lo siguiente: * BitmapLab_parcelUnparcel: Segmentos que abarcan la lógica de empaquetado y desempaquetado. * BitmapLab_postNotification: Son segmentos que abarcan el flujo de publicación de notificaciones.

Cómo hacer un seguimiento de los flujos de notificaciones

Cuando presionas Post Notification, la app crea una notificación que contiene el mapa de bits actual y la envía al sistema. El código del framework responsable de esto emite segmentos de Perfetto con eventos de flujo que conectan el empaquetado (escribir el mapa de bits en un Parcel para enviarlo a través de la comunicación entre procesos de Binder) y el desempaquetado (leer el mapa de bits de un Parcel en el extremo receptor).

En la siguiente captura de pantalla, puedes ver la app que serializa el mapa de bits grande para usarlo en una transacción de Binder para publicar la notificación y la deserialización correspondiente en el proceso system_server.

Perfetto muestra un flujo desde BitmapLab hasta system_server a través de Notification

Con Perfetto, incluso puedes seguir el mismo mapa de bits de notificación a medida que se propaga por subprocesos y procesos, por ejemplo, desde un subproceso de Binder en system_server (que implementa el servidor de Binder INotificationManager) hasta subprocesos de trabajo de system_server que luego podrían reenviar el mismo mapa de bits a com.android.systemui para que se muestre en la sombra de notificaciones.

Desafíos de las apps del sistema

Las apps del sistema, como SystemUI (Notificaciones) y Launcher, enfrentan desafíos únicos:

  1. Contenido sin límites: Las notificaciones y los widgets pueden ser numerosos. Si cada uno contiene un mapa de bits grande, el sistema puede quedarse sin memoria rápidamente.
  2. Duplicación: Es posible que el mismo ícono de la app se encuentre en la caché del Selector, el área de notificaciones de SystemUI y la app de Configuración.
  3. Compartir a través de Hardware Buffers: Para mitigar este problema, los componentes del sistema se están moviendo hacia un servicio centralizado de "descarga de imágenes" que comparte instancias de HardwareBuffer entre procesos.
  4. Atribución de DMABuf: Los mapas de bits de hardware ahorran espacio en el montón, pero usan memoria DMABuf, que es más difícil de atribuir a un proceso específico en las herramientas de memoria estándar.

    Usa adb shell dmabuf_dump para ver las asignaciones de DMABuf en todo el sistema. Esta herramienta proporciona un desglose de los búferes por proceso:

     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: Es el tamaño total del búfer si se asigna en el proceso.
    • Pss: Es el tamaño proporcional (RSS dividido por la cantidad de procesos que comparten el búfer). Esta es la mejor métrica para la contabilidad.
    • nr_procs: Es la cantidad de procesos que actualmente tienen una referencia a este búfer.
    • Exportador: Es el controlador que asignó el búfer (p.ej., virtio_gpu en Cuttlefish o un montón de Ion/DMA-BUF específico del proveedor en el hardware).

    También puedes usar adb shell dmabuf_dump -b para obtener un resumen de todos los búferes y el uso total de DMA-BUF en todo el sistema.


← Java | ↑ Arriba | Nativo →