Bitmaps et mémoire

Les objets bitmap sont souvent les principaux contributeurs à l'espace mémoire utilisé d'une application. Qu'il s'agisse d'icônes d'application, d'images de notification ou de contenu multimédia, une gestion inefficace des bitmaps peut rapidement entraîner des erreurs de mémoire insuffisante (OOM) et une pression sur la mémoire à l'échelle du système.

Configurations bitmap et données de pixels

La quantité de mémoire consommée par un bitmap est principalement déterminée par ses dimensions (largeur × hauteur) et sa configuration (Bitmap.Config).

La configuration définit le nombre d'octets utilisés pour représenter chaque pixel :

Configuration Octets par pixel Description
ALPHA_8 1 Canal alpha (transparence) uniquement. Utile pour les masques.
RGB_565 2 Rouge (5 bits), vert (6 bits), bleu (5 bits). Aucune valeur alpha. Convient aux images opaques pour lesquelles la fidélité des couleurs n'est pas essentielle.
ARGB_8888 4 Alpha, rouge, vert, bleu (8 bits chacun). Il s'agit de la valeur par défaut et de l'option la plus utilisée.
RGBA_F16 8 Valeur à virgule flottante à demi-précision. Utilisé pour les contenus HDR et à large gamme de couleurs.
HARDWARE N/A Stockées dans la mémoire graphique (gralloc/DMABuf). Consultez Bitmaps matériels.

Formule de mémoire : Memory (Bytes) = Width × Height × Bytes Per Pixel

Par exemple, une image en plein écran sur un appareil 1080p (1920 x 1080) dans ARGB_8888 prend : 1920 x 1080 x 4 octets ≈ 8,3 Mo.

Bitmaps de tas et bitmaps partagés

Bitmaps de tas (tas natif)

Dans les versions modernes d'Android (8.0 et versions ultérieures), les données de pixels bitmap sont stockées dans le tas de mémoire natif, tandis que seul un petit objet wrapper réside dans le tas de mémoire Java.

Lorsqu'une application doit présenter une image, elle est généralement décodée à partir d'un fichier image compressé dans un bitmap et stockée dans le tas de mémoire.

Bitmaps partagés (ashmem/memfd)

Lorsqu'un bitmap est transféré entre des processus (par exemple, via Binder vers SystemUI pour une notification), Android évite de copier les données de pixels en utilisant la mémoire partagée (ashmem ou memfd).

Une instance Bitmap peut être copiée dans la mémoire partagée de manière explicite en appelant Bitmap.asShared() ou de manière implicite si un Bitmap est placé dans un Parcel (généralement en ajoutant le Bitmap à un Parcelable tel qu'un Bundle) et envoyé via Binder IPC.

Lorsqu'un Bitmap partagé est envoyé via Binder IPC, les données de pixels elles-mêmes ne sont pas copiées. Au lieu de cela, un descripteur de fichier référençant une région de mémoire partagée est dupliqué dans le processus destinataire. La région de mémoire sous-jacente peut être partagée entre plusieurs processus et n'est libérée que lorsque tous les descripteurs de fichiers qui y font référence ont été fermés.

Bitmaps modifiables et immuables

  • Bitmaps modifiables : ils peuvent être modifiés après leur création (par exemple, via un Canvas). Ils nécessitent toujours leur propre allocation de mémoire privée. Si un Bitmap mutable est copié, une copie complète (deuxième copie de toutes les données de pixels) doit être effectuée.
  • Bitmap immuable : ne peut pas être modifié. Cela permet des optimisations telles que le partage du même tampon de mémoire sous-jacent entre différentes instances Bitmap. Les bitmaps chargés à partir des ressources APK (BitmapFactory) sont généralement immuables.

Gestion efficace des bitmaps

Mise en commun et réutilisation des bitmaps

L'allocation et la désallocation fréquentes de bitmaps entraînent un churn d'allocation, ce qui force le GC à s'exécuter en permanence. Les bibliothèques de chargement d'images courantes utilisent un pool de bitmaps.

Google recommande Glide pour les applications basées sur Java et Coil pour les applications basées sur Kotlin (en particulier lors de l'utilisation de Jetpack Compose).

Lorsqu'un bitmap n'est plus nécessaire, au lieu de le laisser être collecté par le garbage collector, l'application appelle bitmap.recycle() ou le renvoie à un pool. La prochaine fois qu'une bitmap de mêmes dimensions et configuration sera nécessaire, le pool fournira le tampon existant, ce qui évitera une nouvelle allocation.

Bitmaps matériels

Bitmap.Config.HARDWARE vous permet de stocker les données de pixels directement dans la mémoire graphique (DMABuf).

  • Avantages :
    • Économies de mémoire : n'utilise pas le tas de mémoire natif ni celui de l'application, mais la mémoire GPU. Souvent, les bitmaps affichés dans l'UI d'une application doivent de toute façon être copiés dans la mémoire du GPU. Cela permet donc d'économiser cette opération de copie et le coût de mémoire supplémentaire.
    • Performances : le dessin est extrêmement rapide, car les données se trouvent déjà sur le GPU.
  • Inconvénients :
    • Immuable : les bitmaps matériels ne peuvent pas être modifiés.
    • La lecture est lente : l'accès aux pixels depuis le processeur (par exemple, getPixel()) est très coûteux.
    • Attribution : plus difficile à suivre dans les outils standards tels qu'AHAT (voir ci-dessous).

Écueils courants liés à la mémoire bitmap

Même lorsque vous utilisez des configurations bitmap modernes, plusieurs modèles récurrents dans la façon dont vous décodez et planifiez les bitmaps peuvent entraîner des pics de mémoire importants.

Décodage des bitmaps surdimensionnés

Une photo haute résolution de 4 000 x 3 000 pixels occupe 48 Mo dans ARGB_8888. Le décodage de l'image complète uniquement pour l'afficher dans une vignette de 200 x 150 pixels gaspille plus de 99% du tampon de pixels alloué.

Lorsque vous décodez des images directement avec ImageDecoder ou BitmapFactory, sous-échantillonnez-les pendant la passe de décodage pour qu'elles correspondent aux dimensions de la vue cible à l'aide de ImageDecoder.setTargetSize() ou BitmapFactory.Options.inSampleSize. Les bibliothèques de chargement d'images comme Glide et Coil effectuent automatiquement ce sous-échantillonnage lorsque vous fournissez une taille de vue cible limitée.

Par exemple, lorsque vous décodez un bitmap avec ImageDecoder, transmettez un OnHeaderDecodedListener qui réduit les dimensions de sortie à la taille de votre vue cible :

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)
    }
}

Rétention élevée des spectateurs simultanés grâce au décodage parallèle

Même lorsque les bitmaps individuels sont correctement dimensionnés et de courte durée, le décodage de nombreuses images en parallèle peut entraîner de graves pics de mémoire. Par exemple, si un écran d'organisateur ou de galerie distribue 30 tâches sur un pool de threads illimité pour décoder des icônes ou des miniatures simultanément, les 30 tampons de pixels non compressés et les tampons de travail du décodeur occupent la RAM en même temps.

Cette rétention simultanée élevée augmente l'empreinte du tas natif maximal et peut déclencher des arrêts lmkd avant la fin du lot. Limitez la simultanéité du décodage avec un pool de threads, un sémaphore ou un répartiteur de coroutines tels que Dispatchers.IO.limitedParallelism(2) afin que seuls quelques bitmaps soient décodés à la fois.

Bitmaps transitoires non recyclés dans les boucles de traitement des frames

Dans Android 8.0 et versions ultérieures, l'objet wrapper Bitmap Java ne prend qu'environ 56 octets sur le tas de mémoire Java, tandis que son tampon de pixels réside dans le tas de mémoire natif et peut prendre plusieurs mégaoctets. Vous pouvez vérifier cette répartition dans le Profileur de mémoire Android Studio ou AHAT, où chaque instance Bitmap affiche une taille Java superficielle d'environ 56 octets à côté de sa taille native de plusieurs mégaoctets, et dans dumpsys meminfo sous Native Allocations (Bitmap (malloced)).

Les pipelines à haute fréquence tels que l'analyse des images de caméras, l'OCR ou les boucles d'inférence ML allouent souvent un nouveau bitmap à chaque frame en appelant ImageProxy.toBitmap() et Bitmap.createBitmap() pour la rotation ou le recadrage. La suppression des références aux frames remplacés sans les recycler peut entraîner une augmentation de la mémoire native. Les petits wrappers Java n'augmentent que très peu l'occupation du tas Java. Ils ne déclenchent donc pas la récupération de mémoire assez rapidement pour empêcher l'accumulation de centaines de mégaoctets de tampons de pixels natifs avant que NativeAllocationRegistry ne les récupère.

Lorsque vous traitez des frames dans une boucle fermée, réutilisez les tampons préalloués si possible, ou appelez bitmap.recycle() de manière explicite sur les bitmaps intermédiaires transitoires dès que chaque frame a fini d'être traité.

Exercice pratique : exploration des bitmaps

Nous allons utiliser l'application exemple BitmapLab pour explorer ces concepts.

1. Mesurer avec dumpsys meminfo

Lancez BitmapLab, puis appuyez sur ALLOCATE 10MB ARGB_8888. Exécutez ensuite la commande ci-dessous :

adb shell dumpsys meminfo -s com.android.bitmaplab

Sur les versions modernes d'Android, recherchez la section Allocations natives. Elles permettent une bien meilleure attribution pour les bitmaps que le Résumé de l'application générique :

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmap (malloced) : bitmaps alloués dans le tas natif du processus. C'est là que résident la plupart des bitmaps standards dans Android 8.0 et versions ultérieures.
  • Bitmap (nonmalloced) : bitmaps qui utilisent une mémoire spécialisée comme Hardware Bitmaps ou Shared Bitmaps (via ashmem ou memfd).

Si vous allouez un Bitmap partagé dans BitmapLab, il sera reflété dans Bitmap (nonmalloced) :

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

Suivi des bitmaps partagés

Sur certaines versions d'Android et configurations du noyau, dumpsys meminfo fournit également un suivi haute résolution pour les bitmaps mappés dans l'espace d'adressage du processus via des descripteurs de fichier.

Par défaut, les bitmaps partagés utilisent un nom générique ("bitmap"). Pour activer l'attribution détaillée et le suivi unique des bitmaps (identification des bitmaps partagés entre différents processus), vous devez activer la propriété système suivante :

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Lorsqu'il est activé, les régions ashmem de /proc/<pid>/smaps auront des noms plus descriptifs. meminfo en tirera parti, et les résultats se présenteront comme suit :

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Mappé : taille totale de tous les mappages de mémoire liés aux bitmaps.
  • Unique : taille des bitmaps ne tenant compte que des valeurs uniques (c'est-à-dire que deux mappages ou plus des mêmes données de pixels Bitmap partagées sous-jacentes ne sont comptabilisés qu'une seule fois).

2. Bitmaps dans AHAT

AHAT offre une excellente visualisation des bitmaps.

  1. Dans BitmapLab, allouez quelques bitmaps.
  2. Capturez une empreinte de la mémoire avec l'indicateur -b (pour inclure les données bitmap natives) :

    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. Ouvrez localhost:7100 et recherchez le lien Bitmaps dans la barre latérale ou recherchez la classe Bitmap.

  4. AHAT affichera les bitmaps dans le navigateur, ce qui permettra d'identifier facilement les images qui consomment beaucoup de mémoire.

AHAT affichant les bitmaps rendus

3. Traces bitmap dans Perfetto

Perfetto peut suivre les allocations et les nombres de bitmaps au fil du temps. Ces compteurs sont émis par le framework Android lorsque la catégorie atrace gfx est activée pour une application spécifique.

  1. Démarrez un traçage. Vous devez inclure la catégorie gfx et cibler le package d'application spécifique à l'aide de l'option -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. Dans BitmapLab, appuyez plusieurs fois sur les boutons Allocate (Allouer) et Clear (Effacer).

  3. Appuyez également sur Bitmap de parcelle/déparcelle.

  4. Analysez la trace dans ui.perfetto.dev.

Dans la section de processus pour com.android.bitmaplab, vous verrez les éléments suivants : * Nombre de bitmaps : compteur indiquant le nombre de bitmaps actifs. * Mémoire bitmap : compteur indiquant le nombre total d'octets utilisés par les bitmaps.

Tranches de haut niveau (SDK Perfetto)

BitmapLab utilise également le SDK Perfetto pour émettre des tranches de haut niveau pour les opérations bitmap. Recherchez BitmapLab_ dans la trace pour trouver : * BitmapLab_parcelUnparcel : tranches couvrant la logique de répartition et de dérépartition. * BitmapLab_postNotification : tranches couvrant le flux de publication des notifications.

Suivre les flux de notification

Lorsque vous appuyez sur Post Notification (Publier la notification), l'application crée une notification contenant le bitmap actuel et l'envoie au système. Le code du framework responsable de cette opération émet des tranches Perfetto avec des événements de flux qui connectent la mise en paquet (écriture du bitmap dans un Parcel à envoyer sur Binder IPC) et le dépaquetage (lecture du bitmap à partir d'un Parcel à la réception).

Dans la capture d'écran ci-dessous, vous pouvez voir l'application qui regroupe le grand bitmap à utiliser dans une transaction Binder pour publier la notification, ainsi que le dégroupement correspondant dans le processus system_server.

Perfetto montrant un flux de BitmapLab à system_server via une notification

Avec Perfetto, vous pouvez même suivre le même bitmap de notification lorsqu'il se propage davantage dans les threads et les processus, par exemple à partir d'un thread de binder dans system_server (qui implémente le serveur Binder INotificationManager) vers les threads de nœud de calcul system_server qui peuvent ensuite transférer le même bitmap vers com.android.systemui pour l'afficher dans le volet des notifications.

Difficultés liées aux applications système

Les applications système telles que SystemUI (Notifications) et Launcher sont confrontées à des défis uniques :

  1. Contenu illimité : les notifications et les widgets peuvent être nombreux. Si chacun d'eux contient un grand bitmap, le système peut rapidement manquer de mémoire.
  2. Duplication : la même icône d'application peut être conservée dans le cache du lanceur, dans la zone de notification de SystemUI et dans l'application Paramètres.
  3. Partage via des tampons matériels : pour atténuer ce problème, les composants système évoluent vers un service centralisé de "déchargement d'images" qui partage les instances HardwareBuffer entre les processus.
  4. Attribution DMABuf : les bitmaps matériels économisent de l'espace de tas, mais utilisent la mémoire DMABuf, qui est plus difficile à attribuer à un processus spécifique dans les outils de mémoire standards.

    Utilisez adb shell dmabuf_dump pour afficher les allocations DMABuf à l'échelle du système. Cet outil fournit une répartition des tampons par processus :

     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 : taille totale du tampon s'il est mappé dans le processus.
    • Pss : taille proportionnelle (RSS divisée par le nombre de processus partageant le tampon). Il s'agit de la meilleure métrique pour la comptabilité.
    • nr_procs : nombre de processus détenant actuellement une référence à ce tampon.
    • Exportateur : pilote ayant alloué le tampon (par exemple, virtio_gpu sur Cuttlefish ou un tas Ion/DMA-BUF spécifique au fournisseur sur le matériel).

    Vous pouvez également utiliser adb shell dmabuf_dump -b pour obtenir un récapitulatif de toutes les mémoires tampons et de l'utilisation totale de DMA-BUF à l'échelle du système.


← Java | ↑ Haut de page | Native →