アプリのメモリ予算を設定する

アプリのメモリ予算を使用すると、アプリは独自のメモリ予算を宣言できます。これにより、アプリが設定された予算を超えてメモリを使用すると、メモリ使用量を削減するようシステムに指示できます。これは、システムアプリやバンドルアプリ、またはメモリに制約のあるデバイスを対象とするアプリで特に役立ちます。デベロッパーは、想定されるメモリ ワーキング セット(ユーザーが現在行っている操作にアプリが積極的に必要とするメモリ)を把握し、アプリがシステムの共有 RAM リソースを過度に使用しないようにすることができます。

この予算は、メモリの強制排除とスワップを使用して、最近使用されていないメモリページを削除し、アプリのメモリ使用量を現在のワーキング セットに集中させることで、バランスを保ちます。アプリが宣言された予算を超えると、オペレーティング システムは特にそのアプリを対象に再利用を行います。

  1. クリーンなファイルベースのページ(非アクティブなコードやマッピングされたアセットなど)は、必要に応じてストレージから再読み取りできるため、最初に強制排除されます。
  2. ダーティなファイルバック ページはストレージに書き戻され、強制排除されます。
  3. 匿名メモリページ(ヒープ割り当てなど)は圧縮され、zRAM にスワップされます。

ワーキング セットが予算を超えない限り、アプリは設定された予算内のメモリを使用しながら適切に動作します。オペレーティング システムは、未使用のメモリを強制排除し、非アクティブなヒープページをスワップに圧縮して、プロセスを終了せずにメモリ割り当てを制限します。

アプリが予算を超過した場合

メモリ予算を超えても、システムがアプリを終了したりクラッシュさせたりすることはありません。代わりに、アプリが予算を超えるメモリを使用すると、OS はアプリがしばらく使用していないメモリを見つけて、再び必要になるまで安全に保管します。これにより、新しいアプリのメモリ割り当てが可能になり、アプリ全体のメモリ使用量が予算内に収まります。

アプリが特定の時点で必要とするもの(ワーキング セット)と設定された予算の間に余裕がある限り、アプリは通常どおり動作し続けます。アプリがワーキング セットよりも小さい予算を設定すると、実行時のアプリのパフォーマンスが低下する可能性があります。

OS がメモリを追跡して再利用する方法については、メモリアーキテクチャ ガイドをご覧ください。

Android マニフェストで予算を宣言する

AndroidManifest.xml を使用して、定常状態のメモリ予算を宣言します。これは、アプリが通常の使用中に動作するベースライン予算です。マニフェスト宣言は、ランタイム コードを必要とせず、プロセスの起動時に有効になり、foreground、perceptible、background の各状態間で予算を切り替えます。PackageManager.getMemoryBudgets は、静的な <application> 予算宣言を公開します。マニフェストに commit する前に、リモート ロールバックを使用して本番環境で候補予算を検証するには、Remote Config を使用して予算を段階的にデプロイするをご覧ください。

<memory-budget> 宣言は、Android 17 QPR2(API レベル 37.2)以降を搭載したデバイスで有効になります。以前の Android バージョンでは、プラットフォーム マニフェスト パーサーは認識されない XML 要素を無視するため、下位互換性に影響を与えることなく <memory-budget> を採用できます。

ビルドを設定する

アプリのメモリ予算を採用するために、minSdk や targetSdk を引き上げる必要はありません。<processes> 内でプロセスごとの予算を宣言するには、Android Gradle プラグイン 9.5.0 以降が必要です。<memory-budget> マニフェスト属性、MemoryBudgetManager、Build.VERSION_CODES_FULL.CINNAMON_BUN_2 をコンパイルするには、モジュールの build.gradle.kts ファイルまたは build.gradle ファイルで minorApiLevel = 2 を使用して compileSdk を API レベル 37 に設定します。

Kotlin

android {
    compileSdk {
        version = release(37) {
            minorApiLevel = 2
        }
    }
}

Groovy

android {
    compileSdk {
        version = release(37) {
            it.minorApiLevel = 2
        }
    }
}

ベースライン予算を宣言する

ほとんどのアプリでは、アプリケーションに単一の予算を定義するだけで十分です。<application> タグ内に <memory-budget> 要素を直接宣言します。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Baseline budget for the application -->
        <memory-budget android:maxMb="256" />

    </application>
</manifest>

これにより、パッケージのすべてのプロセスと状態に 256 MB の常駐メモリ予算が設定されます。アプリのメモリ使用量が 256 MB を超えると、オペレーティング システムは、エビクションとスワップを使用して、アクティブでないメモリページをトリミングします。

プロセス状態に応じて予算を変更する

アプリに必要なメモリ量は、ユーザーの可視性によって異なります。

  • フォアグラウンド: プロセスは、ユーザーとやり取りする表示可能なアクティビティをホストしています。この状態は通常、アクティブな UI とグラフィックにより、フットプリントが最大になります。
  • 知覚可能: ユーザーが知覚できるプロセスですが、表示可能なウィンドウをホストしていません(メディア再生フォアグラウンド サービス、アクティブなバックグラウンド ダウンロード、ターンバイターン方式のナビ、アクティブな入力方法など)。
  • バックグラウンド: プロセスはバックグラウンド ジョブ、レシーバ、データ同期を実行しています。フットプリントを最小限に抑えることが期待されます。

複数の <memory-budget> 句を宣言して、次の状態を照合できます。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Default budget for visible foreground UI -->
        <memory-budget android:maxMb="200" />

        <!-- Tighter budget when playing audio in background -->
        <memory-budget
            android:maxMb="120"
            android:state="perceptible" />

        <!-- Minimal budget when fully in background -->
        <memory-budget
            android:maxMb="48"
            android:state="background" />

    </application>
</manifest>

フォアグラウンドまたは省略状態の句は必要ありません。perceptible 句または background 句のみを宣言すると、foreground などの重要度の高い状態はアプリの予算によって制約されないままになります。android:state を省略すると、デフォルトで foreground になります。これは、後続の句でこれらの状態をオーバーライドしない限り、perceptible と background にカスケードされます。句の順序の詳細については、解決ルール: 最後に適用される句が有効になるをご覧ください。

各状態の予算を調整する場合は、ピーク時の常駐メモリ(RSS の最高値)ではなく、その状態のアクティブなワーキング セットをターゲットにします。

  • スムーズなフレーム レンダリングのために foreground の予算を調整する: 同期的な再利用によってフレーム レンダリングが遅延しないように、フォアグラウンド UI のアクティブなワーキング セットに十分なヘッドルームを確保します。アプリにメモリを大量に消費するスタンドアロン ワークフロー(高解像度の画像や動画のエディタなど)が含まれている場合は、そのアクティビティを専用プロセスに移動することを検討してください(マルチプロセス アプリを参照)。これにより、そのアクティビティの割り当てが個別に予算化され、ユーザーが終了したときに破棄されます。
  • ヘッドレス処理の background 予算を厳しく設定する: フィールド テレメトリーでバックグラウンド RSS が高い場合、多くの場合、フォアグラウンドを離れた後に保持された UI 割り当て(メモリを大量に消費する UI とバインドされたサービスを同じ場所に配置するとイベントに応じてメモリを解放するを参照)や、バックグラウンドのインデックス登録と同期中の 1 回限りのファイル読み取りが反映されています。ヘッドレス バックグラウンド処理にはフレームの期限がないため、バックグラウンド同期が予算を超えて一時的に割り当てられたときに、1 回限りのファイル キャッシュ ページを強制排除し、非アクティブなヒープページを zRAM にスワップしても、ユーザーに表示されるジャンクは発生しません。また、バックグラウンドの急増によってユーザーのフォアグラウンド アプリが強制排除されることもありません。

詳しいワークフローについては、ワーキング セットを測定して予算を調整するをご覧ください。

予算の状態とプロセスの状態のマッピング

プラットフォームは、 RunningAppProcessInfo.importance に基づいて、マニフェストの予算状態をランタイム プロセス状態にマッピングします。 は、アクティビティ マネージャーが、プロセス独自のアクティブなコンポーネントと、他のアプリからの 受信サービス バインディングまたはコンテンツ プロバイダ クエリの両方から計算します。

  • foreground: アクティブで目に見えるユーザー インタラクション。たとえば、再開された アクティビティのホスト、画面がオフになったときに最上位アプリの状態を維持すること、 最上位のフォアグラウンド アプリ によってアクティブにバインドまたはクエリされるサービスまたはコンテンツ プロバイダのホストなど。
  • perceptible: ウィンドウが表示されていなくてもユーザーが認識できるワークロード。たとえば、アクティブなメディア再生、ターンバイターン方式ナビ、カメラまたはマイクのキャプチャ、アクティブなバックグラウンド ダウンロード、データ同期フォアグラウンド サービス、BIND_NOT_FOREGROUND などのフラグを使用してフォアグラウンド クライアントによってバインドされたサービスなど(BIND フラグによる継承の制御を参照)。内部プラットフォームの指標では、これらのワークロードの一部に PROCESS_STATE_IMPORTANT_FOREGROUND というラベルが付けられることがありますが、この内部定数は可視ウィンドウを意味するものではなく、perceptible バジェットに則って管理されます。
  • background: バックグラウンド ジョブ、アラーム、ブロードキャスト レシーバなど、ユーザーがすぐに認識できない作業。

次の表に、ランタイムの重要度レベルとマニフェストの状態のマッピングを示します。

マニフェスト android:state ランタイムの重要度(RunningAppProcessInfo) 一般的なコンポーネント
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
再開された可視アクティビティ、画面がロックされた最上位アプリ、最上位アプリにバインドされたサービスまたはコンテンツ プロバイダ
perceptible IMPORTANCE_FOREGROUND_SERVICE
IMPORTANCE_VISIBLE
アクティブなメディア再生、ナビゲーション、ダウンロード、同期のフォアグラウンド サービス、フォアグラウンド サービスまたは可視フラグにバインドされたサービス
background IMPORTANCE_PERCEPTIBLE
IMPORTANCE_CANT_SAVE_STATE
IMPORTANCE_SERVICE
IMPORTANCE_CACHED
バックグラウンド ジョブ、レシーバ、バックグラウンド同期、キャッシュに保存されたプロセス

クライアント バインディングがサービスまたはプロバイダのホストプロセスを昇格させる方法と、クライアントがバインドを解除するか画面外に移動した後にプロセスが background または cached に戻る方法について詳しくは、サービス バインディングとプロセスの状態をご覧ください。

アクティブなコンポーネントがない状態でプロセスがキャッシュされた状態(IMPORTANCE_CACHED)になると、プラットフォームは、キャッシュされたプロセスのフリーズと再利用に加えて、background メモリ予算を引き続き適用し、フリーズや圧縮が発生しない場合でもプロセスを制限します。

アプリのプロセス状態と予算を検査する

開発中にアプリのアクティブなプロセス状態と重要度を検査する方法の一つは、ADB を使用して Activity Manager にクエリを実行することです。

adb shell dumpsys activity processes <package-name>

次の省略された例は、フォアグラウンド サービスを実行しているアプリのプロセス レコードと OOM 制御エントリを示しています。

ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)
  All known processes:
  *APP* UID 10123 ProcessRecord{edf056c 3919:com.example.app/u0a123}
    pid=3919
    oom adj: max=1001 curRaw=200 setRaw=200 cur=200 set=200
    curProcState=4 mRepProcState=4 setProcState=4 lastStateTime=-42s360ms
    hasStartedServices=true
    mHasForegroundServices=true forcingToImportant=null
...
  Process OOM control (48 total):
    Proc #22: prcp  F/S/FGS  ---NFU-TI  t: 0 3919:com.example.app/u0a123 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS  lastRss=0.00 lastCachedRss=0.00

この出力で:

  • curProcState=4 と state: cur=FGS は、プロセスがフォアグラウンド サービスの状態であることを反映しています。アプリがアクティブな可視アクティビティをホストしている場合、これは TOP として表示されます。重要なフォアグラウンド コンポーネント(ダウンロードや同期など)を実行している場合は、IMPF として表示されます。
  • プロセス OOM 制御テーブルの prcp は、プロセスが知覚可能な優先度階層で評価されることを示します。これは perceptible 予算に対応します。

アプリの現在の常駐メモリ使用量を調べるには:

adb shell dumpsys meminfo <package-name>

マルチプロセス アプリ

アプリケーションが複数のプロセスに作業を分割する場合は、<processes> 内の <process> タグを使用して専用のプロセス予算を構成します。

たとえば、音楽ストリーミング アプリ(com.example.radio)について考えてみましょう。

  1. メインプロセス: 表示される UI と音声再生エンジン(mediaPlayback フォアグラウンド サービスを含む MediaSessionService)をホストします。表示されている場合、プロセスは 180 MB のフォアグラウンド バジェットで動作します。音楽の再生中にユーザーがアプリを離れると、プロセスは perceptible 状態になり、再生エンジンとオーディオ バッファには 64 MB の予算で十分です。
  2. 同期プロセス(:sync): バックグラウンド メタデータの同期とダウンロード インデックス登録を実行する専用プロセス。このプロセスはバックグラウンドでのみアクティブになるため、state="background" を明示的に宣言する必要はありません。1 つの予算が適用されます。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.radio">

    <application
        android:label="@string/app_name">

        <!-- Package baseline: main process with UI and audio playback -->
        <memory-budget android:maxMb="180" />

        <!-- Tighter budget when audio plays in the background -->
        <memory-budget
            android:maxMb="64"
            android:state="perceptible" />

        <processes>
            <!-- Dedicated background sync process -->
            <process android:process=":sync">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <service
            android:name=".playback.AudioPlayerService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="false" />

        <service
            android:name=".sync.PlaylistSyncService"
            android:process=":sync"
            android:exported="false" />

    </application>
</manifest>

マニフェストで <processes> ブロックを宣言する場合は、<memory-budget> を宣言するプロセスに対してのみ <process> エントリをリストします。特定のサブプロセスのみを予算化し、アプリの残りの部分をデバイスのシステム上限に設定する場合は、<application> の直下にある <memory-budget> 要素を省略できます。

サブプロセスでのメモリ使用量は、そのプロセスの予算と、それを含むパッケージの予算の両方にカウントされます。プロセスは、プロセス予算またはパッケージ予算のいずれかのしきい値に先に達すると、実行時にメモリ負荷が発生します。

高密度ディスプレイ向けに予算を調整する

一度に描画するピクセル数に応じてメモリ使用量が大幅に変化するアプリ(ウィンドウ サイズのビットマップをキャッシュに保存するフォト ギャラリー アプリなど)の場合、Android には、ディスプレイの仕様に応じて予算を動的にスケーリングする 2 つの代替メカニズムが用意されています。

  • ディスプレイ密度バケット(android:additionalMbPerDensity)でスケーリングする: 2D ラスター ドローアブルと UI アセットは幅と高さの両方でスケーリングされるため、mdpi(1.0x / 160 dpi)に対するディスプレイの密度比の 2 乗に比例してメガバイトを追加します。

    <!-- Baseline 180MB + 16MB multiplied by (density ratio)^2 -->
    <memory-budget
        android:maxMb="180"
        android:additionalMbPerDensity="16" />
    

    mdpi ディスプレイ(1.0 倍)の場合、予算は 180 + 16 × 12 = 196 MB です。xxhdpi ディスプレイ(3.0x)では、バジェットは 180 + 16 × 32 = 324 MB にスケーリングされます。

  • ウィンドウのピクセル数でスケーリングする(android:additionalBytesPerDisplayPixel): アクティブなウィンドウの境界(WindowMetrics.getBounds 幅 × 高さ)にピクセルあたりのバイト数を追加します。これは、フルウィンドウのグラフィック サーフェス、レンダリング バッファ、高解像度の写真キャッシュを割り当てるアプリに適しています。これらのアプリでは、メモリ消費量が UI 密度バケットではなく、未加工のピクセル数に直接比例します。

    <!-- Baseline 128MB + 16 bytes per window pixel -->
    <!-- For example, a 4-byte RGBA full-window buffer with double or quadruple
         buffering -->
    <memory-budget
        android:maxMb="128"
        android:additionalBytesPerDisplayPixel="16" />
    

    全画面表示の 1080p ウィンドウ(1080 × 2400 ≒ 2.59M ピクセル)では、ベースライン予算に 41,472,000 バイト(≒ 39.6 MB、1 MB = 1,048,576 バイト)が追加されます。1440p ウィンドウ(1440 × 3120 &approx; 4.49M ピクセル)では、71,884,800 バイト(&approx; 68.6 MB)が追加されます。

これら 2 つの属性は代替属性です。アプリの主なスケーリング ファクタに一致する属性を選択し、同じ句で両方を組み合わせないようにします。

デバイスのフォーム ファクタに特化する

スマートフォン、タブレット、Wear OS に APK を配布する場合は、android:feature 属性を使用して、さまざまなハードウェア ターゲットの予算を調整します。

Wear OS スマートウォッチでは RAM が制限されるため、アプリの UI と機能セットははるかにシンプルになります。watch 機能に特化した、より厳格な予算を宣言できます。

<!-- General phone and tablet baseline -->
<memory-budget android:maxMb="180" />

<!-- Wear OS override: simpler UI and constrained hardware -->
<memory-budget
    android:maxMb="48"
    android:feature="watch" />

解決ルール: 最後に適用された条項が有効になります。

アプリまたはプロセスに複数の <memory-budget> 要素を定義すると、システムはマニフェストに表示される順序でそれらを評価し、最後に適用可能な予算句を適用します。

android:state を省略すると、デフォルトで foreground になります。これは、後続の句でオーバーライドされない限り、perceptible と background にもカスケードされます。foreground(または省略された android:state)から perceptible、background の順に句を並べ、一致する状態グループ内に android:feature オーバーライドを配置して、末尾の省略された状態のオーバーライドが前の perceptible 句または background 句をシャドーイングしないようにします。

XML 属性リファレンス

すべてのメガバイト(MB)属性はバイナリ単位(1 MB = 1,048,576 バイト)を使用し、Linux cgroup memory.current チャージ(Zygote からの共有メモリを除く)にマッピングされます。

属性 形式 デフォルト 説明
android:maxMb 整数(0 より大きい) 必須 ベースラインの常駐メモリの予算上限(MB 単位)。
android:state 列挙型 foreground この予算が適用されるプロセス状態(foreground、perceptible、background)。省略された場合、デフォルトの foreground になり、後続の句でオーバーライドされない限り、perceptible と background にカスケードされます。
android:additionalMbPerDensity 整数(0 以上) 0 追加のメガバイト数に、mdpi(1.0 倍)に対するディスプレイ密度比の 2 乗を掛けた値。
android:additionalBytesPerDisplayPixel 整数(0 以上) 0 ウィンドウ ピクセル(WindowMetrics.getBounds 幅 × 高さ)ごとに割り当てられる追加のバイト。サーフェス バッファとビットマップに役立ちます。
android:feature 文字列 すべて 特定のハードウェア機能(watch、automotive、leanback)を宣言するデバイスに句を制限します。

ワーキング セットを測定して予算を調整する

適切な予算は、制約のないピーク時ではなく、特定の状態のアプリのワーキング セットをわずかに上回る程度です。そのポイントを見つけるには、現実的なユーザーフローを実行し、予算を段階的に下げて、アプリが支払いを開始する瞬間を監視します。

ワーキング セットと常駐メモリの違いを理解する

バジェットがない場合、アプリの常駐フットプリント(memory.current)は次の 2 種類のメモリを保持します。

  • ワーキング セット: Java、Kotlin、ネイティブ ヒープ、現在のユーザーフローが繰り返しアクセスするホットなファイルバック ページ(DEX コード、ネイティブ ライブラリ、アセット)
  • コールド スラック: 以前の画面のビットマップ キャッシュ、1 回限りの起動バッファ、割り当てバースト後に残ったヒープ スラック、1 回読み取られただけで再びアクセスされることのないファイルページ

制約のないピーク時の予算では、コールド スラックは回収されません。ワーキング セットを下回るバジェットでは、カーネルがヒープページをスワップアウトし、アプリがすぐにフォールトバックするコードページを強制排除します。このようなワーキング セットの再フォールトは、CPU 時間とバッテリーを消費し、UI スレッドに到達するとフレームも消費します。

ここで探しているのは、ニーです。これは、再利用によってコールド スラックのみが削除される最低の予算です。そのため、再フォールト、CPU 時間、フレーム時間は、予算がない場合と同じままです。

テスト環境を準備する

本番環境と同様に動作する、繰り返し可能なテストを設定します。

  • リリースする R8 用に最適化されたデバッグ不可のリリースビルドを調整します。デバッグビルドはより多くのメモリを使用するため、デバッグビルドで調整された予算はリリースには高すぎます。
  • Android 17 QPR2(API レベル 37.2)以降を搭載したデバイスまたはエミュレータでテストします。adb shell から cgroup ファイルをサンプリングする場合、userdebug ビルドまたは Google Play のないエミュレータ イメージが便利です。シェルからアプリの cgroup ディレクトリを読み取るには、adb root が必要になることがあるためです。アプリは、製品版ビルドでも、MemoryBudgetManager を介して独自のプロセス cgroup の使用状況をインプロセスで読み取ることができます。
  • Jetpack Macrobenchmark、UI Automator、または adb shell input を使用して、予算を計画する状態ごとに 1 回、30 ~ 60 秒のユーザーフローをスクリプト化します。アイドル状態のアプリは、ワーキング セットについて何も教えてくれません。
    • foreground: 密度の高いリストやメディア グリッドをスクロールする、メインのデスティネーション間を移動する、メニューやダイアログを開く、アニメーションを実行する
    • perceptible: 認識可能な処理(音声再生やダウンロードなど)が続行されている間に表示ウィンドウを離れ、10 ~ 15 秒間実行してからアプリに戻る
    • background: アプリに表示される UI がないときに、同期、WorkManager ジョブ(adb shell cmd jobscheduler run -f <package-name> <job-id> など)、開始されたサービス、またはブロードキャスト レシーバをトリガーします。

background の予算は、プロセスがキャッシュされた状態でアイドル状態(IMPORTANCE_CACHED)になっているときではなく、バックグラウンド処理がアクティブに実行されているときに測定します。background の予算はキャッシュされている間も有効ですが、アイドル状態のキャッシュされたプロセスはバックグラウンド処理を実行しておらず、バックグラウンド ジョブまたはレシーバのワーキング セットを反映しません。

ベースラインを測定する

まずは、予算がまったくない実行から始めて、通常の状態を把握します。<memory-budget> 宣言のないビルドを使用し、各実行を新しいプロセス(adb shell am force-stop <package-name>)から開始します。

各アプリプロセスには、独自の cgroup v2 メモリ コントローラがあります。パスをハードコードするのではなく、/proc/<PID>/cgroup の 0:: 行からパスを検索します。各デバイス コマンドを単一引用符で囲んだ文字列として adb shell に渡します。これにより、デバイスシェルは引用符を認識します。

PID=$(adb shell pidof -s <package-name>)
CG=/sys/fs/cgroup$(adb shell "sed -n 's/^0:://p' /proc/$PID/cgroup")
adb shell "cat $CG/memory.current $CG/memory.swap.current"
adb shell "grep -E '^(anon|file|workingset_refault_.*) ' $CG/memory.stat"

これらのファイルの読み取りは負荷が低く、アプリの動作を妨げないため、実行中に 1 秒ごとにサンプリングできます。

  • memory.current: プロセスに割り当てられた常駐メモリ(バイト単位)。 これは <memory-budget> が制限する数です。
  • memory.swap.current: zRAM スワップに圧縮されたメモリ(バイト単位)
  • memory.stat の anon と file: 常駐の匿名メモリとファイルバック メモリ(バイト単位)
  • memory.stat の workingset_refault_anon と workingset_refault_file: アプリで再度必要になった、スワップアウトまたは強制排除されたページの実行中のカウント。これらのカウンタは、予算が厳しすぎることを示す主な指標です。

adb shell からアプリの cgroup ファイルを読み取るときに権限エラーが発生した場合は、userdebug ビルドまたは Google Play のないエミュレータ イメージで adb root を実行してから、もう一度お試しください。adb root が利用できない製品版の user ビルドでは、アプリ内から MemoryBudgetManager.processCurrentUsageBytes をクエリするか、adb shell dumpsys meminfo <package-name> を使用して大まかなビューを取得します。App Summary では、Java Heap、Native Heap、Stack はほとんどが匿名メモリであり、Code はファイルベースです。ソフトウェア ビットマップは Native Heap と予算にカウントされますが、ハードウェア ビットマップと Graphics はカウントされません。フレームのタイミングを測定している間は、dumpsys meminfo をループで実行しないでください。アプリでガベージ コレクションが強制的に実行され、すべてのメモリ マッピングが走査されるため、測定対象が変化します。

スイープ候補の予算

アプリを再ビルドせずに、候補となる予算を試すことができます。am memory-limiter manual を使用して、実行中のプロセスに予算を設定し、none を使用して予算を削除します。

adb shell am memory-limiter manual <PID> 120MB
adb shell am memory-limiter manual <PID> none

手動オーバーライドは、プロセス状態の変更をまたいで、そのプロセス インスタンスの存続期間中継続するため、次に進む前にクリアするか、プロセスを再起動してください。また、プロセスのハードリミット(memory.max)を同じ値に設定します。そのため、アプリのワーキング セットを下回る候補バジェットでは、プロセスが遅くなるだけでなく、強制終了される可能性があります。これは予算の失敗として扱われます。

インストルメンテーション引数から候補値を読み取り、MemoryBudgetManager.processBudgetBytes を設定することで、ベンチマーク ビルドからスイープすることもできます。ランタイム予算はマニフェスト予算を厳しくすることしかできないため、<memory-budget> 宣言なしでビルドをスイープします。また、clearProcessBudget を呼び出すまで、プロセスの状態が変化しても有効なままになります。

ベースラインから最も高い memory.current から開始し、一度に 10 ~ 15 MB ずつ下げます。各候補予算について、次の操作を行います。

  1. 新しいプロセスを開始し、ターゲット状態に移行して、予算を適用します。
  2. ウォームアップとしてユーザーフローを 1 回実行します。予算を適用すると、コールド スタートアップ ページが 1 回強制排除されます。この遷移ではなく、定常状態を測定する必要があります。
  3. フレーム統計情報をリセットし、開始時の refault カウンタとプロセスの CPU 時間(utime と stime、クロックティック単位)を記録します。

    adb shell dumpsys gfxinfo <package-name> reset
    adb shell "grep -E '^workingset_refault_(anon|file) ' $CG/memory.stat"
    adb shell "cut -d ' ' -f 14,15 /proc/$PID/stat"
  4. ユーザーフローの測定パスを実行します。

  5. memory.current、anon、file、リフォルト カウンタ、CPU 時間を再度記録し、フレーム統計情報を収集します。dumpsys gfxinfo は同じ統計情報を複数回出力することがあるため、アプリ全体をカバーする最初の一致を取得します。

    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 'Janky frames:'
    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 '95th percentile'

各候補を少なくとも 3 回繰り返し、1 つの候補のすべての繰り返しを連続して実行するのではなく、候補をインターリーブします。各結果を、1 回の実行ではなく、ベースライン実行のばらつきと比較します。

結果を確認する

予算が減るにつれて、アプリは次の 3 つの体制を通過します。

  • 膝上: memory.current は、コールド file ページとアイドル状態のヒープを破棄することで、予算を削減します。anon は横ばいで、再フォールトはベースライン付近にとどまり、CPU 時間とフレーム時間は変化しません。これが目指すべき状態です。
  • 膝の下: コールドページがなくなると、カーネルはアプリがまだ使用しているヒープページのスワップアウトを開始します。実行ごとにリフォルトが数百ページまたは数千ページ増加し、CPU 時間が増加します。フレーム時間は、アイドル状態のテストデバイスではほとんど変化しない可能性がありますが、アプリは独自のワーキング セットの圧縮と解凍に CPU 時間とバッテリーを消費するようになります。
  • 膝を大きく下回る: 再利用とスワップインのフォールトが UI スレッドと RenderThread に発生し、ジャンク、フレーム時間、ウォーム レジューム時間がすべて増加します。

制約のないベースラインと比較して、次の条件をすべて満たす候補予算が合格となります。これは、妥当な出発点となります。

  • プロセスは実行後も存続する
  • 再障害のデルタはベースラインに近いまま
  • プロセスの CPU 時間の上昇が 5% 以下
  • ジャンクのあるフレームの増加が 1 パーセント ポイント以下
  • P95 フレーム時間の上昇が 3 ミリ秒以下
  • ウォーム レジューム時間(adb shell am start -W から TotalTime)の上昇が 15% 以下(perceptible と background の予算の場合)

ニーは、合格する候補予算の中で最も低いものです。たとえば、138 MB の制約のないピークを持つ仮想アプリのフォアグラウンド スイープは次のようになります。

候補予算 memory.current anon file リフォルト(ページ) P95 フレーム時間 Regime
なし 138 MB 75 MB 63 MB 0 10.5 ミリ秒 ベースライン
125 MB 121 MB 74 MB 47 MB 10 10.5 ミリ秒 膝上
110 MB 110 MB 75 MB 35 MB 15 10.8 ミリ秒 膝
95 MB 95 MB 74 MB 21 MB 176 11.2 ミリ秒 膝下
85 MB 85 MB 68 MB 17 MB 2,050 12.8 ミリ秒 膝下
70 MB 70 MB 54 MB 16 MB 8,771 16.5 ミリ秒 膝よりかなり下

予算を選択する

android:maxMb は、ユーザーデータ、言語 / 地域、割り当てバーストの違いによってアプリがニーを下回らないように、ニーより 10% ~ 20% 高く設定します。上記の例では、ニーは 110 MB であるため、125 MB が適切な選択です。

次に、新しい <memory-budget> 宣言を含むリリースビルドをインストールし、ユーザーフローを再度実行して、合格基準が引き続き満たされていることを確認します。実行時に有効な予算を確認するには、MemoryBudgetManager.processBudgetBytes または MemoryBudgetManager.packageBudgetBytes をクエリします。

ウォーム リターンでフォアグラウンド以外の予算を検証する

アプリがその状態にある間は、perceptible または background の予算が厳しくても問題ないように見えますが、ユーザーが戻ってきたときにコストがかかります。復路もテストします。

  1. フォアグラウンド フローを実行し、アプリをターゲット状態に移動して、am memory-limiter manual で候補の予算を適用します。
  2. 知覚可能な作業またはバックグラウンド作業を 10 ~ 15 秒間実行します。
  3. オーバーライドをクリア(adb shell am memory-limiter manual <PID> none)してから、すぐに adb shell am start -W でアプリを戻し、TotalTime を記録します。代わりに MemoryBudgetManager を使用して候補を設定する場合は、アプリが戻る前に clearProcessBudget を呼び出します。
  4. 短いフォアグラウンド インタラクションを実行し、合格基準に照らしてリフォルトとフレーム統計情報を確認します。

アプリがその状態にあるときとウォーム リターン時の両方に合格する最も低い予算を選択し、同じ 10% ~ 20% のヘッドルームを追加します。

ディスプレイのスケーリングを調整する

android:additionalBytesPerDisplayPixel を使用する場合は、1080 × 2400(約 259 万ピクセル)と 1440 × 3120(約 449 万ピクセル)など、解像度の異なる 2 つのディスプレイでフォアグラウンド ニーを見つけます。2 つのニーの差(バイト単位)をピクセル数の差で割って、android:additionalBytesPerDisplayPixel を取得します。小さいディスプレイのピクセルあたりの量をニーから差し引いて、解像度に依存しないベースを取得し、ヘッドルームを追加して android:maxMb を取得します。

ランタイム API

Android には、宣言型の <memory-budget> マニフェスト句に加えて、次のタスク用のランタイム SDK API が用意されています。

  • 候補となる予算を AndroidManifest.xml に commit する前に、Remote Config の背後にあるステージにデプロイして検証します。
  • cgroup のメモリ使用量と有効な予算をプロセス内でクエリします。
  • 軽量タスク中にプロセスまたはパッケージの予算を動的に引き締める。
  • オペレーティング システムがメモリを再利用するときに、予算超過イベントをリッスンしてキャッシュをトリミングします。

リモート構成を使用して予算を段階的にデプロイする

MemoryBudgetManager で設定されたランタイム予算は、宣言された <memory-budget> マニフェストの上限内でしか制限を厳しくできないため(ランタイム時に厳しいマニフェスト予算を引き上げることはできません)、サーバーフラグから緩和することを期待して、AndroidManifest.xml で積極的な試験運用版の <memory-budget> 値をリリースしないでください。代わりに、次の 2 つのフェーズで新しい予算をデプロイします。

  1. MemoryBudgetManager とリモート構成で検証する: AndroidManifest.xml に <memory-budget> を含めずに、または 1 つの緩い安全上限(perceptible または background マニフェスト句を厳しくせずに)を指定して APK をリリースします。サーバーサイドのテストまたはリモート構成フラグの背後で候補の予算をゲートし、ライフサイクル遷移(ProcessLifecycleOwner など)全体にターゲット状態の予算を適用しながら、システムまたはウィンドウ スケーリングの上限が低いデバイスで IllegalArgumentException をキャッチし、フラグがオフになったときに clearProcessBudget または clearPackageBudget を呼び出して、APK を更新せずにロールバックできるようにします。
  2. 実証済みの予算を AndroidManifest.xml に移行する: フィールド テレメトリーでデバイス階層全体の安定性が確認されたら、安定状態の foreground、perceptible、background の予算を AndroidManifest.xml の <memory-budget> 句に移行します。マニフェスト句は、プロセスの起動から制限を適用し、アプリコードなしでプロセス状態間で予算を切り替え、PackageManager.getMemoryBudgets(<processes> エントリや動的 MemoryBudgetManager 呼び出しではなく、静的 <application> マニフェスト宣言のみを読み取る)を介して検査可能な状態を維持します。

Android SDK API(MemoryBudgetManager)

MemoryBudgetManager システム サービスは、Android 17 QPR2(マイナー SDK リリース、API レベル 37.2 / Build.VERSION_CODES_FULL.CINNAMON_BUN_2)以降、Kotlin と Java で作成されたアプリで使用できます。

サービスを取得する

MemoryBudgetManager にアクセスする前に、SDK_INT_FULL を確認して、デバイスが Android 17 QPR2 以降を搭載していることを確認します。アプリの minSdk が 36 未満の場合は、まず SDK_INT を確認して、Android 15(API レベル 35)以前を搭載したデバイスが SDK_INT_FULL の読み取り時に NoSuchFieldError をスローしないようにします。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
    Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN_2
) {
    val budgetManager = context.getSystemService(MemoryBudgetManager::class.java)
}

クエリの使用状況と予算

// Query current memory charged to this process and the package UID
val processUsageBytes = budgetManager.processCurrentUsageBytes
val packageUsageBytes = budgetManager.packageCurrentUsageBytes

// Query process-scope and package-scope limits (LIMIT_IS_DISABLED if unset)
val processBudgetBytes = budgetManager.processBudgetBytes
val packageBudgetBytes = budgetManager.packageBudgetBytes

予算を動的に設定またはクリアする

実行時に、より厳しい予算を設定して、軽量タスク中のメモリを制限したり、タスクの終了時にメモリをクリアしたりできます。

// Set a tighter dynamic budget on the current process (for example, 96 MB)
try {
    budgetManager.processBudgetBytes = 96L * 1024L * 1024L
} catch (e: IllegalArgumentException) {
    // Thrown if the budget is <= 0 or exceeds a manifest or system limit.
    Log.e(TAG, "Requested budget exceeds manifest or system ceiling", e)
}

// Clear the dynamic process budget to restore the manifest limit (or unconstrained baseline)
budgetManager.clearProcessBudget()

foreground、perceptible、background の状態に個別の制限を指定する <memory-budget> マニフェスト句とは異なり、MemoryBudgetManager で設定された動的予算は、コードが更新またはクリアするまで、単一のランタイム上限を適用します。ランタイム API を使用してフィールド テストを実行する場合や、状態固有の上限を管理する場合は、アダプティブ画像エディタの例に示すように、ProcessLifecycleOwner やアクティビティのライフサイクル コールバックなどを使用して、ライフサイクルの移行全体で動的予算を更新またはクリアします。

パフォーマンスとべき等性

実行時に MemoryBudgetManager を使用する場合は、次の特性に注意してください。

  • べき等性: すべての予算変更 API はべき等です。clearProcessBudget() または clearPackageBudget() を繰り返し呼び出すか、動的予算が有効でないときに呼び出すことは、安全な no-op です。同様に、setProcessBudgetBytes() または setPackageBudgetBytes() を同じ値に連続して設定しても、冗長なシステム再構成はトリガーされません。
  • 呼び出し頻度: 予算の設定、更新、クリアを行うと、システム サービスへのプロセス間呼び出しが行われ、プロセスのオペレーティング システム メモリ コントローラが更新されます。軽量ですが、完全に無料ではありません。これらの API は、個別のライフサイクル遷移やタスクのマイルストーン(アクティビティの開始や終了、バックグラウンド プロセスの開始や完了、音声リクエストの処理など)の際に呼び出します。タイトなループ、音声処理スレッド、フレームごとのレンダリング ルーチンでは呼び出さないでください。
  • クエリの頻度: クエリ プロパティ processCurrentUsageBytes、packageCurrentUsageBytes、processBudgetBytes、packageBudgetBytes は、システム サービスを呼び出すのではなく、ローカル ネイティブ呼び出しを介して、ライブ cgroup の制限と使用状況をインプロセスで読み取ります。これらのクエリは高速ですが、タイトなループでポーリングするのではなく、オンデマンドで(タスクの移行中や OnOverBudgetListener コールバック内などで)呼び出します。

予算超過のプレッシャー コールバックをリッスンする

アプリは、メモリ使用量が予算のしきい値を超えたときに通知を受け取るリスナーを登録できます。これにより、オペレーティング システムがメモリを再利用してプロセスまたはパッケージを予算内に収めている間に、アプリはアプリレベルのプロアクティブなクリーンアップ(メモリ内ビットマップ キャッシュのクリアなど)を実行できます。

val cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }

val listener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
    Log.w(TAG, "Process exceeded memory budget of $budgetBytes bytes")
    // Proactively evict thread-safe caches on the background Looper
    imageTileCache.evictAll()
}

// Register on a background Looper before tightening a dynamic budget
budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, listener)

// When done (for example, in onStop)
budgetManager.unregisterProcessOverBudgetListener(listener)

予算超過のコールバックに関するベスト プラクティス:

  • バックグラウンド Looper を使用する: 可能な限り、バックグラウンド HandlerThread Looper を registerProcessOverBudgetListener または registerPackageOverBudgetListener に渡します。バックグラウンド スレッドでキャッシュの強制排除またはファイルのクリーンアップを実行すると、プロセスがメモリ不足のときに UI スレッドでのフレーム レンダリングとの競合を回避できます。Looper.getMainLooper は、メインスレッドにバインドされた UI 状態のみを操作するリスナー用に予約します。
  • 繰り返しコールバックが想定される: 予算超過コールバックは 1 回限りの通知ではありません。使用量が予算を超えると、オペレーティング システムはまずコールドページを再利用します。プレッシャーが続く場合、システムはメモリの再利用を続け、最大で 1 秒に 1 回リスナーを呼び出します。コールバックをべき等にします。アプリで必要な通知が 1 つだけの場合や、クリーンアップ パス間のクールダウンを長くしたい場合は、コールバック内で unregisterProcessOverBudgetListener(または unregisterPackageOverBudgetListener)を呼び出すか、クールダウン タイムスタンプまたは AtomicBoolean を使用してクリーンアップを保護します。
  • 予算を削減する前に登録する: setProcessBudgetBytes または setPackageBudgetBytes を呼び出したときに、現在のメモリ使用量がすでに新しい予算を超えている場合、新しい予算が適用されるとすぐに予算超過イベントがトリガーされます。予算を設定する前にリスナーを登録して、アプリが最初の予算超過通知を見逃さないようにします。
  • 迅速に対応する: 再利用オペレーションでは、直ちに救済措置を提供する必要があります。プレッシャー時の複雑な計算はパフォーマンスを低下させます。
  • 割り当てを避ける: コールバック内で新しいオブジェクトを割り当てたり、新しいスレッドを開始したりしないでください。オペレーティング システムの直接再利用がすぐにトリガーされる可能性があります。
  • 効果の高いターゲットに焦点を当てる: 大きなビットマップやレンダリング バッファを削除したり、メモリマップ ファイルを閉じたりする方が、多くの小さなオブジェクトを解放するよりもはるかに効果的です。

Runtime API の例

次の例は、Kotlin で Android SDK API を使用してランタイム API を実装する方法を示しています。

Android SDK の例: アダプティブ画像エディタ

次の例は、マニフェストで 256 MB の上限を宣言して、マルチレイヤ キャンバスに対応する画像編集アプリ(com.example.imageeditor)を示しています。

<manifest ... >
    <application ... >
        <!-- Manifest ceiling accommodates the heaviest editing workload -->
        <memory-budget android:maxMb="256" />
    </application>
</manifest>

ユーザーが軽量のサムネイル ギャラリーを閲覧しているとき、アプリは Kotlin の Android SDK API を使用して、プロセス予算を 96 MB に動的に引き締めます。ユーザーがマルチレイヤ編集キャンバスを開くと、アプリは動的予算をクリアして、256 MB のマニフェスト上限を完全に復元します。また、バックグラウンドの HandlerThread Looper に OnOverBudgetListener を登録してから、ギャラリーの予算を適用して、UI スレッドをブロックすることなく、負荷がかかった状態でキャッシュに保存されたプレビュー ビットマップを強制排除します。

package com.example.imageeditor.ui

import android.app.Activity
import android.app.MemoryBudgetManager
import android.graphics.Bitmap
import android.os.Bundle
import android.os.HandlerThread
import android.util.Log
import android.util.LruCache

class ImageEditorActivity : Activity() {

    private lateinit var budgetManager: MemoryBudgetManager
    private lateinit var cleanupThread: HandlerThread

    // Thread-safe in-memory cache for rendered preview tiles (32MB limit)
    private val previewCache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
    }

    private val overBudgetListener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
        Log.w(TAG, "Process memory pressure detected (budget: ${budgetBytes / 1048576}MB). Evicting preview cache.")
        previewCache.evictAll()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        budgetManager = getSystemService(MemoryBudgetManager::class.java)
        cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }
    }

    override fun onStart() {
        super.onStart()
        // Register listener on a background Looper before tightening the budget
        budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, overBudgetListener)
        // Constrain memory during lightweight gallery browsing
        applyGalleryBudget()
    }

    override fun onStop() {
        super.onStop()
        budgetManager.unregisterProcessOverBudgetListener(overBudgetListener)
        // Clear the activity-scoped gallery budget when leaving the activity.
        budgetManager.clearProcessBudget()
    }

    override fun onDestroy() {
        cleanupThread.quitSafely()
        super.onDestroy()
    }

    /**
     * Called when the user enters the high-resolution editing canvas.
     * Clears the dynamic budget, restoring the full 256MB manifest ceiling.
     */
    fun enterEditingCanvas() {
        // Clear the tighter dynamic budget to restore the full manifest ceiling (256MB)
        budgetManager.clearProcessBudget()
        Log.i(TAG, "Restored manifest budget ceiling (256MB) for editing canvas")
    }

    /**
     * Called when the user exits the editor back to the thumbnail gallery.
     * Re-applies the tighter dynamic budget.
     */
    fun exitToGallery() {
        previewCache.trimToSize(8 * 1024 * 1024)
        applyGalleryBudget()
    }

    private fun applyGalleryBudget() {
        try {
            // Dynamically tighten budget to 96MB for the lightweight gallery view
            budgetManager.processBudgetBytes = 96L * 1024L * 1024L
            Log.i(TAG, "Tighter dynamic budget applied for gallery: 96MB")
        } catch (e: IllegalArgumentException) {
            Log.e(TAG, "Could not apply dynamic budget", e)
        }
    }

    companion object {
        private const val TAG = "ImageEditor"
    }
}