ビットマップとメモリ

ビットマップ オブジェクトは、アプリケーションのメモリ使用量の最大の単一の要因となることがよくあります。アプリのアイコン、通知画像、メディア コンテンツなど、非効率的なビットマップ処理は、すぐにメモリ不足(OOM)エラーやシステム全体のメモリ不足につながる可能性があります。

ビットマップ構成とピクセルデータ

ビットマップが消費するメモリの量は、主にその寸法(幅 × 高さ)と構成(Bitmap.Config)によって決まります。

構成では、各ピクセルを表すために使用されるバイト数を定義します。

構成 ピクセルあたりのバイト数 説明
ALPHA_8 1 アルファ(透明度)チャンネルのみ。マスクに便利です。
RGB_565 2 赤(5 ビット)、緑(6 ビット)、青(5 ビット)。アルファ版はありません。高い色忠実度が重要でない不透明な画像に適しています。
ARGB_8888 4 アルファ、赤、緑、青(各 8 ビット)。デフォルトで最も一般的なオプションです。
RGBA_F16 8 半精度浮動小数点。広色域と HDR コンテンツに使用されます。
HARDWARE なし グラフィック メモリ(gralloc/DMABuf)に保存されます。ハードウェア ビットマップをご覧ください。

メモリの数式: Memory (Bytes) = Width × Height × Bytes Per Pixel

たとえば、1080p デバイス(1920x1080)の ARGB_8888 で全画面表示の画像を表示すると、1920 × 1080 × 4 バイト ≈ 8.3 MB を使用します。

ヒープ ビットマップと共有ビットマップ

ヒープ ビットマップ(ネイティブ ヒープ)

最新の Android(8.0 以降)では、ビットマップ ピクセルデータは Native Heap に格納され、Java ヒープには小さなラッパー オブジェクトのみが存在します。

アプリで画像を表示する必要がある場合、通常は圧縮された画像ファイルから Bitmap にデコードされ、ヒープに保存されます。

共有ビットマップ(ashmem/memfd)

ビットマップがプロセス間で転送される場合(通知のために Binder 経由で SystemUI に転送される場合など)、Android は共有メモリ(ashmem または memfd)を使用してピクセルデータのコピーを回避します。

Bitmap インスタンスは、Bitmap.asShared() を呼び出すことで明示的に共有メモリにコピーできます。また、Bitmap が Parcel 内に配置され(通常は Bitmap を Parcelable(Bundle など)に追加)、Binder IPC を介して送信される場合は、暗黙的にコピーできます。

共有 Bitmap が Binder IPC を介して送信される場合、ピクセルデータ自体はコピーされず、共有メモリ領域を参照するファイル記述子が受信側プロセスに複製されます。基盤となるメモリ領域は複数のプロセス間で共有されることがあり、それを参照するすべてのファイル記述子が閉じられるまで解放されません。

変更可能なビットマップと変更不可能なビットマップ

  • 変更可能なビットマップ: 作成後に変更できます(Canvas 経由など)。常に独自のプライベート メモリ割り当てが必要です。変更可能な Bitmap がコピーされる場合、ディープコピー(すべてのピクセルデータの 2 つ目のコピー)を作成する必要があります。
  • 変更不可のビットマップ: 変更できません。これにより、異なる Bitmap インスタンス間で同じ基盤となるメモリバッファを共有するなどの最適化が可能になります。APK リソース(BitmapFactory)から読み込まれたビットマップは通常、不変です。

効率的なビットマップ処理

ビットマップのプールと再利用

ビットマップの割り当てと割り当て解除を頻繁に行うと、割り当てのチャーンが発生し、GC が常に実行されることになります。一般的な画像読み込みライブラリは、ビットマップ プールを使用します。

Google は、Java ベースのアプリケーションには Glide を、Kotlin ベースのアプリケーション(特に Jetpack Compose を使用する場合)には Coil をソリューションとして推奨しています。

ビットマップが不要になったら、アプリは GC に任せるのではなく、bitmap.recycle() を呼び出すか、プールに返します。同じサイズと構成のビットマップが次回必要になったとき、プールは既存のバッファを提供し、新しい割り当てを回避します。

ハードウェア ビットマップ

Bitmap.Config.HARDWARE を使用すると、ピクセルデータをグラフィック メモリ(DMABuf)に直接保存できます。

  • メリット:
    • メモリの節約: アプリケーションまたはネイティブ ヒープを使用せず、GPU メモリを使用します。アプリの UI に表示されるビットマップは、多くの場合、GPU メモリにコピーする必要があるため、このコピー オペレーションとメモリの追加コストを節約できます。
    • パフォーマンス: データがすでに GPU 上にあるため、描画が非常に高速です。
  • デメリット:
    • 不変: ハードウェア ビットマップは変更できません。
    • 読み取りが遅い: CPU からピクセルにアクセスする(getPixel() など)のは非常にコストがかかります。
    • 帰属: AHAT などの標準ツールでは追跡が困難です(下記を参照)。

ビットマップ メモリに関する一般的な注意点

最新のビットマップ構成を使用している場合でも、ビットマップのデコードとスケジューリングの方法でいくつかのパターンが繰り返されると、メモリ使用量が急増する可能性があります。

拡大されたビットマップのデコード

4,000 × 3,000 ピクセルのフル解像度写真の場合、ARGB_8888 で 48 MB の容量を使用します。200 × 150 ピクセルのサムネイル内にレンダリングするだけのために、フルサイズの画像をデコードすると、割り当てられたピクセル バッファの 99% 以上が無駄になります。

ImageDecoder または BitmapFactory で画像を直接デコードする場合は、デコード パス中に ImageDecoder.setTargetSize() または BitmapFactory.Options.inSampleSize を使用して、ターゲット ビューの寸法に合わせてダウンサンプリングします。Glide や Coil などの画像読み込みライブラリでは、境界付きのターゲット ビューサイズを指定すると、このダウンサンプリングが自動的に実行されます。

たとえば、ImageDecoder でビットマップをデコードするときは、出力ディメンションをターゲット ビューのサイズに縮小する OnHeaderDecodedListener を渡します。

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

並列デコードによる高い同時保持

個々のビットマップのサイズが適切で、有効期間が短い場合でも、多くの画像を並行してデコードすると、メモリ使用量が急増する可能性があります。たとえば、オーガナイザー画面またはギャラリー画面で、アイコンやサムネイルを同時にデコードするために、バウンドなしのスレッド プールで 30 個のタスクをディスパッチすると、30 個の非圧縮ピクセル バッファとデコーダ スクラッチ バッファが同時に RAM を占有します。

この高い同時保持により、ネイティブ ヒープのピーク時のフットプリントが増加し、バッチが完了する前に lmkd の強制終了がトリガーされる可能性があります。Dispatchers.IO.limitedParallelism(2) などの制限付きスレッドプール、セマフォ、コルーチン ディスパッチャを使用してデコードの同時実行を制限し、一度にデコードされるビットマップの数を少なくします。

フレーム処理ループ内の再利用されていない一時ビットマップ

Android 8.0 以降では、Java Bitmap ラッパー オブジェクトは Java ヒープで約 56 バイトしか占有しませんが、そのピクセル バッファはネイティブ ヒープに存在し、数メガバイトを占有する可能性があります。この分割は、Android Studio の Memory Profiler または AHAT で確認できます。各 Bitmap インスタンスには、数メガバイトのネイティブ サイズとともに、約 56 バイトのシャロー Java サイズが表示されます。また、Native Allocations(Bitmap (malloced))の dumpsys meminfo でも確認できます。

カメラのフレーム分析、OCR、ML 推論ループなどの高頻度パイプラインでは、回転や切り抜きのために ImageProxy.toBitmap() と Bitmap.createBitmap() を呼び出すことで、フレームごとに新しいビットマップを割り当てることがよくあります。置き換えられたフレームへの参照をリサイクルせずに削除すると、ネイティブ メモリが膨張する可能性があります。小さな Java ラッパーは Java ヒープの占有率をほとんど増やさないため、NativeAllocationRegistry がネイティブ ピクセル バッファを再利用する前に、数百メガバイトのネイティブ ピクセル バッファが蓄積されるのを防ぐのに十分な速さでガベージ コレクションをトリガーしません。

タイトなループでフレームを処理する場合は、可能であれば事前に割り当てられたバッファを再利用するか、各フレームの処理が完了したらすぐに一時的な中間ビットマップで bitmap.recycle() を明示的に呼び出します。

実践型演習: ビットマップの探索

これらのコンセプトを検証するために、BitmapLab サンプルアプリを使用します。

1. dumpsys meminfo で測定する

BitmapLab を起動し、[ALLOCATE 10MB ARGB_8888] をタップします。次のコマンドを実行します。

adb shell dumpsys meminfo -s com.android.bitmaplab

最新の Android バージョンでは、[ネイティブ割り当て] セクションを探します。これらは、一般的なアプリの概要よりもビットマップの属性をはるかに正確に特定します。

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • ビットマップ(malloced): プロセスのネイティブ ヒープで割り当てられたビットマップ。Android 8.0 以降では、ほとんどの標準ビットマップがここに存在します。
  • Bitmap (nonmalloced): ハードウェア ビットマップや共有ビットマップ(ashmem または memfd 経由)などの専用メモリを使用するビットマップ。

BitmapLab で Shared Bitmap を割り当てると、Bitmap (nonmalloced) に反映されます。

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

共有ビットマップのトラッキング

一部の Android バージョンとカーネル構成では、dumpsys meminfo は、ファイル記述子を介してプロセスのアドレス空間にマッピングされたビットマップの高解像度トラッキングも提供します。

デフォルトでは、共有ビットマップは一般的な名前(「bitmap」)を使用します。詳細なアトリビューションと一意のビットマップ トラッキング(異なるプロセス間で共有されるビットマップの識別)を有効にするには、次のシステム プロパティを有効にする必要があります。

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

有効にすると、/proc/<pid>/smaps の ashmem リージョンにわかりやすい名前が付けられます。meminfo はこの利点を活用し、結果は次のようになります。

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • Mapped: ビットマップ関連のすべてのメモリ マッピングの合計サイズ。
  • Unique: 一意のビットマップのサイズ(同じ基盤となる共有ビットマップのピクセルデータの 2 つ以上のマッピングは 1 回のみカウントされます)。

2. AHAT のビットマップ

AHAT は、ビットマップの優れた可視化を提供します。

  1. BitmapLab で、いくつかのビットマップを割り当てます。
  2. -b フラグを使用してヒープダンプをキャプチャします(ネイティブ ビットマップ データを含める場合)。

    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. localhost:7100 を開き、サイドバーで [Bitmaps] リンクを探すか、Bitmap クラスを検索します。

  4. AHAT は実際にブラウザでビットマップをレンダリングするため、どの画像がメモリを消費しているかを簡単に特定できます。

レンダリングされたビットマップを表示する AHAT

3. Perfetto のビットマップ トラック

Perfetto は、ビットマップの割り当てとカウントを時間の経過とともに追跡できます。これらのカウンタは、特定のアプリで gfx atrace カテゴリが有効になっている場合に、Android フレームワークによって出力されます。

  1. トレースを開始します。gfx カテゴリを含め、-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. BitmapLab で、[割り当て] ボタンと [クリア] ボタンを繰り返しタップします。

  3. [Parcel/Unparcel Bitmap] もタップします。

  4. ui.perfetto.dev でトレースを分析します。

com.android.bitmaplab のプロセス セクションには、次の情報が表示されます。 * Bitmap Count: アクティブなビットマップの数を示すカウンタ。* ビットマップ メモリ: ビットマップで使用されている合計バイト数を示すカウンタ。

高レベルのスライス(Perfetto SDK)

BitmapLab は、Perfetto SDK を使用して、ビットマップ オペレーションのハイレベル スライスも出力します。トレースで BitmapLab_ を検索して、以下を見つけます。 * BitmapLab_parcelUnparcel: パーセル化とアンパーセル化のロジックをカバーするスライス。* BitmapLab_postNotification: 通知投稿フローをカバーするスライス。

通知フローのトラッキング

[Post Notification] をタップすると、アプリは現在のビットマップを含む通知を作成し、システムに送信します。この処理を担当するフレームワーク コードは、マーシャリング(Binder IPC で送信される Parcel にビットマップを書き込む)とアンマーシャリング(受信側の Parcel からビットマップを読み取る)を接続するフロー イベントを含む Perfetto スライスを出力します。

下のスクリーンショットでは、通知を投稿するために Binder トランザクションで使用される大きなビットマップをアプリがパーセル化し、対応するアンパーセル化が system_server プロセスで行われていることがわかります。

Notification を介して BitmapLab から system_server へのフローを示す Perfetto

Perfetto を使用すると、同じ通知ビットマップがスレッドやプロセス間で伝播する様子を追跡することもできます。たとえば、system_server(INotificationManager Binder サーバーを実装)のバインダ スレッドから system_server ワーカー スレッドに伝播し、そのワーカー スレッドが同じビットマップを com.android.systemui に転送して通知シェードに表示する、といった具合です。

システムアプリの課題

SystemUI(通知)や Launcher などのシステムアプリは、次のような独自の課題に直面しています。

  1. 境界のないコンテンツ: 通知やウィジェットは多数表示される可能性があります。それぞれが大きなビットマップを保持している場合、システムはすぐにメモリ不足になります。
  2. 重複: 同じアプリアイコンが、ランチャーのキャッシュ、SystemUI の通知領域、設定アプリに保持されている可能性があります。
  3. ハードウェア バッファを介した共有: これを軽減するため、システム コンポーネントは、プロセス間で HardwareBuffer インスタンスを共有する一元化された「画像オフロード」サービスに移行しています。
  4. DMABuf 属性: ハードウェア ビットマップはヒープ領域を節約しますが、DMABuf メモリを使用します。これは、標準のメモリツールで特定のプロセスに帰属させるのが困難です。

    adb shell dmabuf_dump を使用して、システム全体の DMABuf 割り当てを確認します。このツールは、バッファのプロセスごとの内訳を提供します。

     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: プロセスにマッピングされている場合のバッファの合計サイズ。
    • Pss: 比例サイズ(RSS をバッファを共有するプロセスの数で割った値)。これは会計に最適な指標です。
    • nr_procs: 現在このバッファへの参照を保持しているプロセスの数。
    • エクスポータ: バッファを割り当てたドライバ(Cuttlefish の virtio_gpu、ハードウェアのベンダー固有の Ion/DMA-BUF ヒープなど)。

    adb shell dmabuf_dump -b を使用して、すべてのバッファの概要とシステム全体の DMA-BUF の合計使用量を取得することもできます。


← Java | ↑ 上 | Native →