Mapas de bits y memoria

Los objetos de mapa de bits suelen ser los que más contribuyen 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 provocar rápidamente errores de falta de memoria (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 está determinada 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 canal alfa (transparencia). Es útil para 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 de 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 Punto flotante de media precisión. 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 de 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 contenedor 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 a un mapa de bits y se almacena en el montón.

Mapas de bits 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 mediante el uso de 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 mapa de bits a un Parcelable, como un Bundle) y se envía a través de Binder IPC.

Cuando se envía un mapa de bits compartido a través de Binder IPC, los datos de píxeles en sí no se copian, sino que se duplica un descriptor de archivo que hace referencia a una región de memoria compartida en el proceso del destinatario. 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 hacen referencia a ella.

Mapas de bits mutables vs. inmutables

  • Mapas de bits 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).
  • Mapas de bits 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 recursos de 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 aplicaciones basadas en Java y Coil para 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 se realice la GC, la app llama a bitmap.recycle() o lo devuelve a un grupo. La próxima vez que se necesite un mapa de bits de 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 la aplicación ni el montón nativo; usa 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 guarda 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: No se pueden modificar los mapas de bits de hardware.
    • 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 realizar un seguimiento en herramientas estándar como AHAT (consulta más abajo).

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, ejecuta 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:

 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): Mapas de bits que usan memoria especializada, como mapas de bits de hardware o mapas de bits compartidos (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 de 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 archivo.

De forma predeterminada, los mapas de bits compartidos usan un nombre genérico ("bitmap"). Para habilitar la atribución detallada y el seguimiento único de mapas de bits (identificar 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 esté habilitado, las regiones de ashmem en /proc/<pid>/smaps tendrán nombres más descriptivos. meminfo aprovechará eso, y los resultados se verán de la siguiente manera:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Mapped: Es el tamaño total de todas las asignaciones de memoria relacionadas con el mapa de bits.
  • Único: Es el tamaño de los mapas de bits que solo consideran los únicos (es decir, dos o más asignaciones de los mismos datos de píxeles de mapa de bits compartidos subyacentes solo se cuentan 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 acaparan la memoria.

AHAT que muestra mapas de bits renderizados

3. Registros de mapas 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 gfx de atrace 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 la marca -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 para com.android.bitmaplab, verás lo siguiente: * Bitmap Count: Es un contador que muestra la cantidad de mapas de bits activos. * Bitmap Memory: Es un contador que muestra los bytes totales 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 logic. * BitmapLab_postNotification: Segmentos que abarcan el flujo de publicación de notificaciones.

Seguimiento de flujos de notificación

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 paquete para enviarlo a través de Binder IPC) y el desempaquetado (leer el mapa de bits de un paquete en el extremo receptor).

En la siguiente captura de pantalla, puedes ver la app que empaqueta el mapa de bits grande para usarlo en una transacción de Binder para publicar la notificación y el desempaquetado 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 aún más a través de subprocesos y procesos, por ejemplo, desde un subproceso de Binder en system_server (que implementa el servidor de Binder INotificationManager) hasta subprocesos de trabajo system_server que podrían reenviar el mismo mapa de bits a com.android.systemui para que se muestre en el panel 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: El mismo ícono de la app se puede guardar en la caché de Launcher, el área de notificaciones de SystemUI y la app de Configuración.
  3. Uso compartido a través de búferes de hardware: Para mitigar esto, los componentes del sistema se están moviendo hacia un servicio centralizado de "descarga de imágenes" que comparte HardwareBuffer instancias en todos los procesos.
  4. Atribución de DMABuf: Los mapas de bits de hardware guardan espacio de montón, pero usan memoria DMABuf, que es más difícil de atribuir a un proceso específico en 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.
    • Exporter: 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 →