Bitmapy i pamięć

Obiekty bitmapowe często w największym stopniu przyczyniają się do wykorzystania pamięci aplikacji. Niewydajne zarządzanie mapami bitowymi, niezależnie od tego, czy są to ikony aplikacji, obrazy powiadomień czy treści multimedialne, może szybko prowadzić do błędów braku pamięci (OOM) i obciążenia pamięci w całym systemie.

Konfiguracje map bitowych i dane pikseli

Ilość pamięci zużywanej przez bitmapę zależy przede wszystkim od jej wymiarów (szerokość × wysokość) i konfiguracji (Bitmap.Config).

Konfiguracja określa, ile bajtów jest używanych do reprezentowania każdego piksela:

Konfiguracja Bajty na piksel Opis
ALPHA_8 1 Tylko kanał alfa (przezroczystość). Przydatne w przypadku masek.
RGB_565 2 Czerwony (5 bitów), zielony (6 bitów), niebieski (5 bitów). Brak wersji alfa. Dobre w przypadku nieprzezroczystych obrazów, w których wysoka wierność kolorów nie jest kluczowa.
ARGB_8888 4 Alfa, czerwony, zielony, niebieski (po 8 bitów). Domyślna i najczęściej używana.
RGBA_F16 8 Liczba zmiennoprzecinkowa o połowie precyzji. Używany w przypadku treści o szerokiej gamie kolorów i HDR.
HARDWARE Nie dotyczy Przechowywane w pamięci karty graficznej (gralloc/DMABuf). Zobacz Mapy bitowe sprzętu.

Formuła pamięci: Memory (Bytes) = Width × Height × Bytes Per Pixel

Na przykład obraz na pełnym ekranie urządzenia o rozdzielczości 1080p (1920 x 1080) w ARGB_8888 zajmuje: 1920 × 1080 × 4 bajtów ≈ 8, 3 MB.

Mapy bitowe sterty a współdzielone mapy bitowe

Mapy bitowe sterty (sterta natywna)

W nowoczesnym Androidzie (8.0 i nowszym) dane pikseli mapy bitowej są przechowywane na stercie natywnej, a na stercie Javy znajduje się tylko mały obiekt opakowujący.

Gdy aplikacja musi wyświetlić obraz, jest on zwykle dekodowany ze skompresowanego pliku obrazu do mapy bitowej i przechowywany w stercie.

Udostępnione mapy bitowe (ashmem/memfd)

Gdy bitmapa jest przesyłana między procesami (np. za pomocą interfejsu Binder do SystemUI w przypadku powiadomienia), Android unika kopiowania danych pikseli, korzystając z pamięci współdzielonej (ashmem lub memfd).

Instancję Bitmap można skopiować do pamięci współdzielonej jawnie, wywołując Bitmap.asShared(), lub niejawnie, jeśli instancja Bitmap zostanie umieszczona w obiekcie Parcel (zwykle przez dodanie obiektu Bitmap do obiektu Parcelable, takiego jak Bundle) i wysłana za pomocą Binder IPC.

Gdy udostępniona mapa bitowa jest wysyłana za pomocą Binder IPC, dane pikseli nie są kopiowane, ale deskryptor pliku odwołujący się do regionu pamięci współdzielonej jest duplikowany w procesie odbiorcy. Podstawowy region pamięci może być współdzielony przez wiele procesów i nie jest zwalniany, dopóki nie zostaną zamknięte wszystkie deskryptory plików, które się do niego odwołują.

Bitmapy modyfikowalne i niemodyfikowalne

  • Zmienne mapy bitowe: można je modyfikować po utworzeniu (np. za pomocą Canvas). Zawsze wymagają własnej prywatnej alokacji pamięci. Jeśli kopiowana jest modyfikowalna mapa bitowa, należy utworzyć jej kopię głęboką (drugą kopię wszystkich danych pikseli).
  • Niezmienne mapy bitowe: nie można ich zmienić. Umożliwia to optymalizacje, takie jak współdzielenie tego samego bufora pamięci bazowej przez różne Bitmapinstancje. Bitmapy wczytywane z zasobów APK (BitmapFactory) są zwykle niezmienne.

Efektywne przetwarzanie bitmap

Agregacja i ponowne wykorzystanie bitmap

Częste przydzielanie i zwalnianie map bitowych powoduje częste przydzielanie pamięci, co zmusza moduł odśmiecania do ciągłego działania. Popularne biblioteki wczytywania obrazów korzystają z puli bitmap.

W przypadku aplikacji opartych na Javie Google zaleca korzystanie z biblioteki Glide, a w przypadku aplikacji opartych na Kotlinie – z biblioteki Coil (zwłaszcza w przypadku korzystania z Jetpack Compose).

Gdy bitmapa nie jest już potrzebna, zamiast pozwolić na jej usunięcie przez mechanizm odzyskiwania pamięci, aplikacja wywołuje funkcję bitmap.recycle() lub zwraca ją do puli. Gdy następnym razem będzie potrzebna mapa bitowa o tych samych wymiarach i konfiguracji, pula udostępni istniejący bufor, co pozwoli uniknąć nowej alokacji.

Mapy bitowe sprzętu

Bitmap.Config.HARDWARE umożliwia przechowywanie danych pikseli bezpośrednio w pamięci graficznej (DMABuf).

  • Zalety:
    • Oszczędność pamięci: nie używa sterty aplikacji ani sterty natywnej, tylko pamięci GPU. Bitmapy wyświetlane w interfejsie aplikacji i tak często muszą być kopiowane do pamięci GPU, więc pozwala to zaoszczędzić operację kopiowania i dodatkowy koszt pamięci.
    • Wydajność: bardzo szybkie rysowanie, ponieważ dane są już na GPU.
  • Wady:
    • Niezmienne: map bitowych sprzętu nie można modyfikować.
    • Powolne odczytywanie: dostęp do pikseli z procesora (np. getPixel()) jest bardzo kosztowny.
    • Atrybucja: trudniejsza do śledzenia w standardowych narzędziach, takich jak AHAT (patrz poniżej).

Typowe problemy z pamięcią bitmapy

Nawet w przypadku korzystania z nowoczesnych konfiguracji bitmap kilka powtarzających się wzorców dekodowania i planowania bitmap może powodować duże skoki zużycia pamięci.

Dekodowanie przeskalowanych bitmap

Zdjęcie w pełnej rozdzielczości 4000 × 3000 pikseli zajmuje 48 MB w ARGB_8888. Dekodowanie całego obrazu tylko po to, aby wyświetlić go w postaci miniatury o wymiarach 200 × 150 pikseli, powoduje zmarnowanie ponad 99% przydzielonego bufora pikseli.

Gdy dekodujesz obrazy bezpośrednio za pomocą ImageDecoder lub BitmapFactory, podczas dekodowania wykonaj próbkowanie w dół, aby dopasować wymiary widoku docelowego za pomocą ImageDecoder.setTargetSize() lub BitmapFactory.Options.inSampleSize. Biblioteki do wczytywania obrazów, takie jak Glide i Coil, automatycznie przeprowadzają próbkowanie w dół, gdy podasz ograniczony rozmiar widoku docelowego.

Na przykład podczas dekodowania bitmapy za pomocą ImageDecoder przekaż OnHeaderDecodedListener, który zmniejsza wymiary wyjściowe do rozmiaru docelowego widoku:

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

Wysoki poziom utrzymania klientów dzięki równoległemu dekodowaniu

Nawet jeśli poszczególne mapy bitowe mają odpowiedni rozmiar i krótki czas życia, dekodowanie wielu obrazów równolegle może powodować gwałtowne skoki zużycia pamięci. Jeśli na przykład ekran organizatora lub galerii wysyła 30 zadań do nieograniczonej puli wątków, aby jednocześnie dekodować ikony lub miniatury, wszystkie 30 nieskompresowanych buforów pikseli i buforów roboczych dekodera zajmuje pamięć RAM w tym samym czasie.

To wysokie współbieżne przechowywanie zwiększa maksymalny rozmiar natywnej sterty i może powodować lmkd zabijanie procesów przed zakończeniem przetwarzania pakietu. Ogranicz współbieżność dekodowania za pomocą puli wątków, semafora lub dyspozytora korutyn, np. Dispatchers.IO.limitedParallelism(2), aby jednocześnie dekodowanych było tylko kilka bitmap.

Nieużywane ponownie tymczasowe mapy bitowe w pętlach przetwarzania klatek

W Androidzie 8.0 i nowszych wersjach obiekt opakowujący Java Bitmap zajmuje na stercie Javy tylko około 56 bajtów, a jego bufor pikseli znajduje się na stercie natywnej i może zajmować kilka megabajtów. Możesz sprawdzić ten podział w narzędziu Memory Profiler w Android Studio lub w AHAT, gdzie każda instancja Bitmap ma rozmiar Java ~56 bajtów, a rozmiar natywny to kilka megabajtów. Możesz też sprawdzić to w dumpsys meminfo w sekcji Native Allocations (Bitmap (malloced)).

Potoki o wysokiej częstotliwości, takie jak analiza klatek z kamery, OCR czy pętle wnioskowania ML, często przydzielają nową mapę bitową w każdej klatce, wywołując funkcje ImageProxy.toBitmap() i Bitmap.createBitmap() w celu obrócenia lub przycięcia. Usuwanie odwołań do zastąpionych ramek bez ich ponownego wykorzystania może spowodować nadmierne zużycie pamięci natywnej. Małe otoczki Javy w niewielkim stopniu zwiększają zajętość sterty Javy, więc nie wywołują odśmiecania pamięci wystarczająco szybko, aby zapobiec gromadzeniu się setek megabajtów natywnych buforów pikseli, zanim NativeAllocationRegistry je odzyska.

Podczas przetwarzania klatek w ciasnej pętli w miarę możliwości używaj ponownie wstępnie przydzielonych buforów lub wywołuj bitmap.recycle() w przypadku tymczasowych pośrednich bitmap, gdy tylko zakończy się przetwarzanie każdej klatki.

Praktyczne ćwiczenie: eksploracja bitmap

Do zbadania tych koncepcji użyjemy przykładowej aplikacji BitmapLab.

1. Pomiar za pomocą urządzenia dumpsys meminfo

Uruchom BitmapLab i kliknij ALLOCATE 10MB ARGB_8888 (PRZYDZIEL 10 MB ARGB_8888). Następnie uruchom:

adb shell dumpsys meminfo -s com.android.bitmaplab

W nowoczesnych wersjach Androida poszukaj sekcji Native Allocations (Alokacje natywne). Zapewniają one znacznie lepszą atrybucję bitmap niż ogólne podsumowanie aplikacji:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • Bitmapa (malloced): bitmapy przydzielone w natywnym stercie procesu. W tym miejscu w Androidzie 8.0 i nowszym znajduje się większość standardowych bitmap.
  • Bitmapa (nieprzydzielona): bitmapy, które korzystają ze specjalistycznej pamięci, np. bitmap sprzętowych lub bitmap współdzielonych (za pomocą ashmem lub memfd).

Jeśli w BitmapLab przydzielisz współdzieloną mapę bitową, zobaczysz ją w Bitmap (nonmalloced):

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

Śledzenie udostępnionych bitmap

W niektórych wersjach Androida i konfiguracjach jądra dumpsys meminfo zapewnia też śledzenie w wysokiej rozdzielczości w przypadku bitmap, które są mapowane w przestrzeni adresowej procesu za pomocą deskryptorów plików.

Domyślnie udostępnione mapy bitowe mają ogólną nazwę („bitmapa”). Aby włączyć szczegółową atrybucję i śledzenie unikalnych bitmap (identyfikowanie udostępnionych bitmap w różnych procesach), musisz włączyć tę właściwość systemu:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

Gdy ta opcja jest włączona, regiony ashmem w /proc/<pid>/smaps będą miały bardziej opisowe nazwy. meminfo skorzysta z tej możliwości, a wyniki będą wyglądać tak:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Zmapowana: łączny rozmiar wszystkich mapowań pamięci związanych z bitmapami.
  • Unikalne: rozmiar map bitowych z uwzględnieniem tylko unikalnych elementów (tzn. dwa lub więcej mapowań tych samych danych pikseli bazowej udostępnionej mapy bitowej liczy się tylko raz).

2. Bitma w AHAT

AHAT zapewnia doskonałą wizualizację map bitowych.

  1. W BitmapLab przydziel kilka bitmap.
  2. Zrób zrzut sterty za pomocą flagi -b (aby uwzględnić natywne dane bitmapy):

    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. Otwórz localhost:7100 i na pasku bocznym znajdź link Mapy bitowe lub wyszukaj klasę Bitmap.

  4. AHAT renderuje mapy bitowe w przeglądarce, co ułatwia identyfikowanie obrazów, które zajmują najwięcej pamięci.

AHAT wyświetla renderowane mapy bitowe

3. Ślady bitmapowe w Perfetto

Perfetto może śledzić alokacje i liczbę bitmap w czasie. Te liczniki są emitowane przez platformę Android, gdy w przypadku konkretnej aplikacji włączona jest kategoria śledzenia gfx.

  1. Rozpocznij śledzenie. Musisz uwzględnić gfx kategorię i skierować reklamy na konkretny pakiet aplikacji za pomocą flagi -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. W BitmapLab wielokrotnie klikaj przyciski Allocate (Przydziel) i Clear (Wyczyść).

  3. Kliknij też Parcel/Unparcel Bitmap (Dzielenie/łączenie mapy bitowej).

  4. Przeanalizuj ślad na stronie ui.perfetto.dev.

W sekcji procesu com.android.bitmaplab zobaczysz:Liczba bitmap: licznik pokazujący liczbę aktywnych bitmap. * Pamięć bitmapy: licznik pokazujący łączną liczbę bajtów używanych przez bitmapy.

Szerokie przedziały (pakiet SDK Perfetto)

BitmapLab używa też pakietu Perfetto SDK do emitowania segmentów wysokiego poziomu dla operacji na bitmapach. Wyszukaj w śladzie BitmapLab_, aby znaleźć:BitmapLab_parcelUnparcel: wycinki obejmujące logikę dzielenia i scalania. * BitmapLab_postNotification: wycinki obejmujące proces publikowania powiadomień.

Śledzenie przepływów powiadomień

Gdy klikniesz Opublikuj powiadomienie, aplikacja utworzy powiadomienie zawierające bieżącą mapę bitową i wyśle je do systemu. Odpowiedzialny za to kod platformy emituje wycinki Perfetto ze zdarzeniami przepływu, które łączą pakowanie (zapisywanie bitmapy w pakiecie do wysłania przez binder IPC) i rozpakowywanie (odczytywanie bitmapy z pakietu po stronie odbiorcy).

Na zrzucie ekranu poniżej widać, jak aplikacja dzieli dużą bitmapę, aby użyć jej w transakcji Binder do opublikowania powiadomienia, oraz jak w procesie system_server następuje jej rozdzielenie.

Perfetto pokazujące przepływ od BitmapLab do system_server przez powiadomienie

Za pomocą Perfetto możesz nawet śledzić tę samą bitmapę powiadomienia, gdy jest ona dalej propagowana między wątkami i procesami, np. z wątku bindera w system_server (który implementuje serwer bindera INotificationManager) do wątków roboczych system_server, które mogą następnie przekazywać tę samą bitmapę do com.android.systemui, aby wyświetlić ją w panelu powiadomień.

Wyzwania związane z aplikacjami systemowymi

Aplikacje systemowe, takie jak SystemUI (powiadomienia) i Launcher, mają wyjątkowe wyzwania:

  1. Nieograniczone treści: powiadomienia i widżety mogą być liczne. Jeśli każdy z nich zawiera dużą mapę bitową, system może szybko wyczerpać pamięć.
  2. Duplikacja: ta sama ikona aplikacji może być przechowywana w pamięci podręcznej Launchera, obszarze powiadomień SystemUI i aplikacji Ustawienia.
  3. Udostępnianie za pomocą buforów sprzętowych: aby temu zapobiec, komponenty systemu przechodzą na scentralizowaną usługę „odciążania obrazu”, która udostępnia instancje HardwareBuffer w różnych procesach.
  4. Atrybucja DMABuf: mapy bitowe sprzętu oszczędzają miejsce na stercie, ale używają pamięci DMABuf, którą trudniej przypisać do konkretnego procesu w standardowych narzędziach do zarządzania pamięcią.

    Użyj adb shell dmabuf_dump, aby wyświetlić alokacje DMABuf w całym systemie. To narzędzie zawiera podział buforów według procesu:

     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: łączny rozmiar bufora, jeśli jest on mapowany w procesie.
    • Pss: rozmiar proporcjonalny (RSS podzielony przez liczbę procesów współdzielących bufor). To najlepszy wskaźnik do celów księgowych.
    • nr_procs: liczba procesów, które obecnie mają odwołanie do tego bufora.
    • Eksporter: sterownik, który przydzielił bufor (np. virtio_gpu w przypadku Cuttlefish lub stos Ion/DMA-BUF specyficzny dla dostawcy na sprzęcie).

    Możesz też użyć polecenia adb shell dmabuf_dump -b, aby uzyskać podsumowanie wszystkich buforów i całkowitego wykorzystania DMA-BUF w systemie.


← Java | ↑ W górę | Natywne →