入門ガイド

メモリ効率の優先: Android 17 の重要な手順

所要時間 10 分
3 人の著者
Alice Yuan, Ajesh Pai, Fung Lam

アプリのパフォーマンスは、スムーズな UI と起動時間の短縮に結び付けられることが多いですが、メモリは、これらの目に見える指標の基盤となる重要な要素です。デバイスのメモリがこれまで以上に重要になっていることは周知の事実です。Android 17 では Android のメモリ最適化が大幅に進歩しただけでなく、今年の後半に厳しくなるメモリ要件に対応できるよう、ツールと API のサポートも提供しています。

デバイスの安定性を確保するため、Android 17 以降では、デバイスの合計 RAM 容量に基づくアプリのメモリ上限が適用されます。アプリがこの上限を超えると、Android は関連するスタック トレースなしでプロセスを強制終了します。

このような強制終了だけでなく、メモリ使用量が最適化されていないと、ユーザー エクスペリエンスが低下します。アプリがヒープメモリの上限に近づくと、ガベージ コレクションが頻繁にトリガーされ、UI のカクつきが目立つようになります。さらに、デバイスで使用可能なメモリが不足すると、システムはページを再利用しようとして CPU に負荷がかかり、UI のレイテンシが発生し、バッテリーの消耗につながります。メモリ不足が深刻すぎると、Low Memory Killer(LMK)イベントが発生し、バックグラウンド プロセスが突然終了し、アプリのコールド スタートが遅くなり、ユーザーの状態が失われる可能性があります。

パフォーマンスの高いアプリを構築し、このような強制終了を回避するには、次のメモリ最適化戦略を採用することをおすすめします。

  1. R8 でバイトコードの最適化を最大化する
  2. 画像の読み込みを最適化する
  3. Android Studio でメモリリークを検出して修正する
  4. アプリが可視状態を離れたときにメモリをトリミングする
  5. ProfilingManager による高度なメモリの可観測性

このブログ投稿の要約版を動画でご覧いただけます。ぜひご覧ください。

Android 17 のアプリのメモリ上限について

Android 17 では、1 つの不正な行為者がユーザーのデバイス全体のマルチタスク処理と安定性を損なうのを防ぐため、アプリのメモリ上限が導入されます。

このアーキテクチャの変更を推進する理由は次のとおりです。

  • カスケード強制終了の防止: アプリが肥大化したり、特権状態(フォアグラウンド サービスを実行しているなど)でメモリリークが発生したりすると、最初はシステムの Low Memory Killer(LMK)から保護されます。この 1 つのアプリがチェックされずに増大し、RAM を占有すると、LMK は、メモリを占有するアプリの領域を再利用するために、数十個の小規模で正常なキャッシュされたアプリとバックグラウンド ジョブを強制終了することで補う必要があります。
  • マルチタスク処理とユーザーの状態の維持: 1 つのリーク プロセスに対応するために、システムがキャッシュされたアプリを強制的に削除すると、マルチタスク処理のエクスペリエンスが大幅に低下します。ユーザーが以前にキャッシュされたアプリに戻ると、ほぼ瞬時のウォーム再開ではなく、遅いコールド スタートが発生します。この非効率性により、CPU に負荷がかかり、バッテリーの消耗が早まります。また、最近使用したアプリのユーザー コンテキスト(スクロール位置、ナビゲーション スタック、ゲームの進行状況など)が失われる可能性もあります。

アプリのセッションがこれらの制約の影響を受けたかどうかを判断するには、ApplicationExitInfo 内でgetDescription() を呼び出します。システムが上限を適用した場合、終了理由は REASON_OTHER として報告され、説明文字列には「MemoryLimiter:AnonSwap」が含まれます。TRIGGER_TYPE_ANOMALY を使用して トリガーベースのプロファイリング を活用し、メモリ上限に達したときにヒープダンプを自動的にキャプチャすることもできます。さらに、Android は、Google Play Console 内で、フィールド内のメモリ指標をデベロッパーに表示するよう積極的に取り組んでいます。

また、メモリ上限に関するドキュメントを拡張し、ローカル デバッグ コマンドを追加しました。これにより、ローカル環境でメモリ制約をシミュレートし、メモリ上限の適用下でのアプリの動作を検証できます。

R8 でバイトコードの最適化を最大化する

アプリのメモリ使用量を減らす非常に効果的な方法は、R8 オプティマイザを有効にすることです。R8 は、クラス、メソッド、フィールドを短い名前に縮小し、未使用のコードとリソースを削除することで、実行時に必要な常駐コードの量を最小限に抑え、アプリのメモリ使用量を大幅に削減します。

R8 は常駐コードを最小限に抑え、メモリ使用量を縮小し、LMK による強制終了のリスクを軽減します。これにより、遅いコールド スタートよりも頻繁にウォーム スタートが発生します。また、バイトコードを効率化することで、メインスレッドの CPU オーバーヘッドが削減され、ANR 率が直接的に低下し、よりスムーズなユーザー エクスペリエンスが実現します。たとえば、デジタルバンクの Monzo は R8 の最適化を完全に有効にしたことで、ANR 率が 35% 削減され、コールド スタート率が 30% 向上し、アプリ全体のサイズが 9% 削減されました。

pic1-IO26_113_TSV-monzo-casestudy.jpg
デジタルバンクの Monzo は R8 の最適化を完全に有効にし、パフォーマンス指標を最大 35% 向上させました。

build.gradle ファイルで R8 を適切に構成するには:

  • isShrinkResources = trueisMinifyEnabled = true を設定します。
  • 最適化を妨げる従来の proguard-android.txt ではなく、proguard-android-optimize.txt を使用します。proguard-android.txt は Android Gradle プラグイン 9 ではサポートされなくなりました。
  • gradle.properties から android.enableR8.fullMode = false を削除します。

コードベースでリフレクションを使用している場合は、R8 がコードのその部分を最適化しないように、keep ルール を追加します。最適化を最大限に活用するには、keep ルールの範囲を絞ってください。

最適化を最大限に活用するには、keep ルール ファイルで次のベスト プラクティスに従ってください。

  • R8 がコードベース全体を最適化しないようにするグローバル オプション(-dontoptimize-dontshrink-dontobfuscate など)を削除する
  • Activity、Services、Views、Broadcast レシーバなどの Android コンポーネントの最適化を妨げる keep ルールを削除する。
  • パッケージ全体の広範な keep ルールを絞り込み、特定のクラスまたはメソッドのみを対象とする。

その他のベスト プラクティスについては、 keep ルールに関するドキュメント をご覧ください。

ライブラリ デベロッパー向けの R8 ベスト プラクティス

ライブラリ デベロッパーの場合は、コンシューマーが必要とするルールを consumer-rules file に厳密に配置し、ライブラリの内部保護ルールを proguard-rules.pro ファイルに保持します。ライブラリの最適化方法について詳しくは、ライブラリ作成者向けの最適化をご覧ください。

R8 構成アナライザー

R8 の最適化を監査するには、構成アナライザー を使用します。 構成アナライザーには、難読化、最適化、縮小化のスコアで最適化の現在の状態が表示されます。 構成アナライザーを使用すると、各 keep ルールによって最適化が妨げられているクラス、メソッド、フィールドの数を確認することもできます。これらの広範なパッケージ全体の keep ルールを絞り込み、最適化を最大限に活用します。

構成アナライザーを使用すると、他の keep ルールを包含する keep ルール、冗長な keep ルール、未使用の keep ルールを特定することもできます。

pic2-r8-config-analyzer.png
構成アナライザーには、難読化、最適化、縮小化のスコアで最適化の現在の状態が表示されます。

R8 エージェントのスキル

Android Studio エージェントやその他の AI ツールでR8 エージェントのスキル を活用して、構成の誤りを解決し、ルールを絞り込むことで、アプリのパフォーマンスを向上させることもできます。 (AI 主導のスキルからの分析情報には技術的な検証が必要です)

画像の読み込みを最適化する

ビットマップは通常、アプリのメモリに存在する最も一般的なオブジェクトです。JPEG や PNG などの圧縮ファイルがデコードされて表示用の未加工のピクセルデータになる、画像読み込みプロセスの最終段階を表します。つまり、メモリ消費量は画像のピクセル寸法と色深度によって決まるため、100 KB の小さな圧縮画像が数 MB の RAM に膨れ上がる可能性があります。ビットマップ オペレーションはフレームの描画のクリティカル パスで頻繁に行われるため、最適化されていない画像はメモリの肥大化と UI のカクつきを引き起こします。

Google では、Kotlin ファーストのプロジェクトでは画像読み込みライブラリ Coil を、特に Jetpack Compose で開発する場合は Glide を使用することをおすすめします。

次の 5 つのベスト プラクティスを採用する

  1. 画像をダウンサンプリングする: ビットマップを手動で読み込む場合は、大きな画像を小さなサムネイル ビューに読み込まないでください。_inSampleSize_ を使用して小さいバージョンを読み込みます。Glide と Coil はデフォルトで画像をダウンサンプリングします。このダウンサンプリング戦略は、それぞれ DownsampleStrategy および ImageLoader を使用して構成できます。
  2. トリミング:  レターボックス表示のために、パディングを画像ファイルに直接埋め込むことは避けてください(画像の寸法を拡大するための透明な枠線を作成するなど)。これらの枠線を焼き付けるのではなく、_InsetDrawable_を使用するか、ビットマップを含む View または Composable 内に直接パディングを適用します。
  3. 構成: 適切なピクセル形式を選択して、メモリと品質のバランスを取ります。透明度が必要ない場合は RGB_565 を使用します。これは、デフォルトの ARGB_8888 形式の半分のメモリを使用します。Glide では DecodeFormat を使用して構成できます。Coil では bitmapConfig プロパティを使用できます。
  4. ベクター ドローアブルを優先する: 基本的な幾何学的アセットの場合は、ラスタライズされたビットマップをデコードする軽量な代替手段として ShapeDrawable を活用します。XML でこれらのアセットを一度定義することで、リソース駆動型のメモリの肥大化を効果的に排除しながら、すべての表示密度でシームレスにスケーリングできます。
  5. 再利用: アプリがビットマップを手動で管理している場合は、メモリのチャーンを最小限に抑えるため、ビットマップが不要になったら、アプリは bitmap.recycle() を呼び出して Bitmap 参照をすぐに破棄する必要があります。Glide や Coil などの画像読み込みライブラリを使用する場合は、ビットマップをライブラリの管理対象プールに戻します。将来のメモリニーズに対応するために既存のバッファを提供することで、プールは新しい割り当てのオーバーヘッドを効果的に回避します。

詳しくは、画像のパフォーマンスの最適化に関するドキュメントをご覧ください。

Android Studio のツール

Android Studio Narwhal 4 を使用して、冗長なビットマップを削除することもできます。簡単な 5 つの手順で冗長なビットマップを見つける方法は次のとおりです。

  1. Android Studio で [Profiler] タブを開きます。
  2. [Heap Dump](または [Analyze Memory Usage])をクリックし、[Record] をクリックして、アプリの現在のメモリ状態のスナップショットを取得します。
  3. 分析結果で黄色の警告三角形 ⚠️ をスキャンします。これは、Android Studio が重複するビットマップが複数回保存されていることを示すために使用します。または、プロファイラ ヘッダーに移動し、[Filter by:] を選択して、[Duplicate Bitmaps] 設定を選択します。
  4. フラグが設定されたエントリをクリックして [Bitmap Preview] ペインを開き、どの画像が重複しているかを確認します。
  5. その視覚的な確認を使用して、コード内の冗長な読み込みロジックを追跡し、より優れたキャッシュ戦略を実装します。
pic3-IO26_113_TSV -dup-bitmaps-cropped.jpg
Android Studio Profiler を使用する場合は、ヒープダンプで黄色の警告三角形 ⚠️ を探します。

Android Studio でメモリリークを検出して修正する

Android でメモリリークが発生するのは、コードがオブジェクトの参照を、そのライフサイクルが終了してからずっと保持している場合です。これにより、ガベージ コレクター(GC)がそのメモリを再利用できなくなり、最終的にパフォーマンスの低下や OutOfMemoryError(OOM)が発生します。

Android Studio Panda 3 には専用の LeakCanary プロファイラ タスクが用意されており、デベロッパーはリアルタイムのメモリリークを分析し、IDE 内でトレースを直接マッピングできます。

Android Studio の LeakCanary プロファイラ タスクは、メモリリーク分析をデバイスから開発マシンに積極的に移行します。これにより、デバイス上のリーク分析と比較して、リーク分析フェーズでのパフォーマンスが大幅に向上します。

pic4-android-studio-leaks.png
 デバッグ用に宣言に移動する LeakCanary メモリリーク分析

また、リーク分析が IDE 内でコンテキスト化され、ソースコードと完全に統合されるようになりました。宣言に移動する機能やその他の便利なコード接続機能が提供され、メモリリークの調査と修正に必要な手間と時間が大幅に削減されます。  

一般的なメモリリークの例

メモリリークは、オブジェクトが意図した寿命を超えてメモリに保持される場合に発生します。通常、次のことが原因で発生します。

  • 使用されなくなったフラグメント、アクティビティ、ビューへの参照を保持する。
  • コンテキスト参照の管理を誤る。
  • オブザーバー、リスナー、レシーバの登録解除を適切に行わない。
  • ライフサイクルが短いコンポーネントにバインドされているオブジェクトへの静的参照を作成する。

次にいくつか例を示します。

シナリオCompose ベースの例ビューベースの例
コンテキストのリーク

例:
LocalContext.current を ViewModel に渡す

修正:
コンテキスト依存ロジックを UI レイヤ内に保持します。UI 以外のレイヤの場合は、依存性注入を使用するか、Kotlin フローを使用して UI の状態を監視するようにリファクタリングします。

例: 
コンパニオン オブジェクトまたは静的変数に Activity を保存する。

修正:
UI コンポーネントへの静的参照を保持しないでください。依存性注入 を使用するか、Kotlin フロー を使用して UI の状態を監視するようにリファクタリングします。

リスナーのリーク

例:
 を使用してリスナーを開始するが、 を空のままにする。DisposableEffectonDispose

修正:
 ブロック内で登録解除とクリーンアップ ロジックを実行します。onDispose

例:
SensorManager の更新を登録し、登録解除を忘れる。

修正:
 ライフサイクルで unregisterListener() または onStop() を手動で呼び出します。onDestroy()

ビューのリーク

例:
リリース戦略なしで、従来の View 内の AndroidView への参照を保持する。


修正:
`release` ブロックを使用して、従来の `View` をクリーンアップします。AndroidView

例:
 が破棄された後も、ビュー バインディング オブジェクトへの参照を保持する。

Fragment

修正:
バインディング変数をnullライフサイクル メソッド内でに設定します。onDestroyView()

アプリが可視状態を離れたときにメモリをトリミングする

Android では、メモリ管理の概要で説明されているように、重要なタスクにメモリを解放するために、必要に応じてアプリからメモリを再利用したりアプリを完全に停止したりできます。通常、Android は、アプリがユーザーに表示されていないときに、アプリのコードとデータページの一部をメモリから破棄したり、ヒープ割り当てを圧縮したりして、アプリからメモリを再利用します。ユーザーがアプリを再開し、アプリが再利用されたメモリにアクセスしようとすると、OS はそのメモリをオンデマンドでスワップバックします。このスワップ動作は遅く、アプリで予期しないカクつきやスタッターが発生する可能性があります。

アプリから再利用するメモリを OS に任せると、アプリを再開した直後に必要になるメモリが OS によって再利用されることがあります。代わりに、アプリは後でオンデマンドで低コストで再生成できるメモリ割り当てを自発的に破棄できます。これを行うには、ComponentCallbacks2 インターフェースを実装します。ActivityFragmentService、またはカスタムの Application クラスで onTrimMemory を実装できます。Application クラスで使用すると、グローバル キャッシュ管理に非常に効果的です。

用意されている onTrimMemory() コールバック メソッドは、アプリがメモリ使用量を自発的に削減する絶好の機会となるライフサイクル イベントまたはメモリ関連イベントをアプリに通知します。

メモリのライフサイクル管理に関して、実装では TRIM_MEMORY_UI_HIDDENTRIM_MEMORY_BACKGROUNDのみ 焦点を当てる必要があります。Android 14 以降、Android 15 で正式に非推奨となった他の従来の定数の通知は配信されなくなりました。

TRIM_MEMORY_UI_HIDDEN: このシグナルは、アプリの UI がユーザーのビューから移行したことを示します。これにより、インターフェースに厳密に結び付けられた大量のメモリ割り当て(ビットマップ、動画再生バッファ、複雑なアニメーション リソースなど)を解放できます。

TRIM_MEMORY_BACKGROUND: このレベルでは、プロセスはバックグラウンドに存在し、システムのグローバル メモリニーズを満たすために強制終了の候補となります。プロセスがキャッシュされた状態を維持する期間を延長し、アプリのコールド スタートの回数を減らすには、ユーザーがセッションを再開したときに簡単に再構築できるリソースを積極的に解放する必要があります。

import android.content.ComponentCallbacks2
// Other import statements.

class MainActivity : AppCompatActivity(), ComponentCallbacks2 {

    /**
     * Release memory when the UI becomes hidden or when system resources become low.
     * @param level the memory-related event that is raised.
     */
    override fun onTrimMemory(level: Int) {

        if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
            // Release memory related to UI elements, such as bitmap caches.
        }

        if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            // Release memory related to background processing, such as by
            // closing a database connection.
        }
    }
}

注: onTrimMemory の統合は SDK のサポートによって異なる場合があります。たとえば、一部のゲームでは、ゲームエンジンにこの機能を有効にしています。ゲームのメモリ最適化に関するドキュメントをご覧ください。

ProfilingManager による高度なメモリの可観測性

ローカルで再現できないフィールド内のメモリの問題をキャッチして診断するには、ProfilingManager API を活用する必要があります。Android 15 で導入されたこの高度な可観測性 API を使用すると、実際のユーザーの Perfetto プロファイルをプログラムで収集できます。

パフォーマンス アーティファクトを管理してホストするための専用インフラストラクチャがないチーム向けに、Crashlytics はこのワークフローを効率化するための専用ソリューションを検討しています。デベロッパーに フィードバックの提供 をお願いしています。

Android 17 では、新しいイベント駆動型トリガーが導入されました。最も注目すべきは TRIGGER_TYPE_OOM と TRIGGER_TYPE_ANOMALY です。

  • OOM トリガー は、OutOfMemoryError クラッシュが発生した瞬間に Java ヒープダンプを自動的に収集し、正確な割り当て状態を提供します。収集された OOM プロファイルは、アプリが次に起動して registerForAllProfilingResults コールバックを登録したときに提供されます。
  • Anomaly トリガー は、バインダのスパムの過剰な送信やメモリ上限の超過など、深刻なパフォーマンスの問題を検出します。メモリの異常が発生すると、システムがアプリを強制終了する直前にヒープダンプが配信されます。
    val profilingManager = 
applicationContext.getSystemService(ProfilingManager::class.java)
    val triggers = ArrayList<ProfilingTrigger>()  


    triggers.add(ProfilingTrigger.Builder(
                 ProfilingTrigger.TRIGGER_TYPE_ANOMALY))
    val mainExecutor: Executor = Executors.newSingleThreadExecutor()
    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (profilingResult.errorCode != ProfilingResult.ERROR_NONE) {
            // upload profile result to server for further analysis          
            setupProfileUploadWorker(profilingResult.resultFilePath)
        } 

    profilingManager.registerForAllProfilingResults(mainExecutor, resultCallback)
    profilingManager.addProfilingTriggers(triggers)

ヒープダンプを収集したら、サーバーからプロファイルをダウンロードするか、adb pull を使用してローカルでダウンロードし、ファイルを Perfetto UI にドラッグ&ドロップします。メモリ デバッグ ワークフローを効率化するには、Heap Dump Explorer を使用します。これは、Perfetto UI のヒープダンプの新しいデフォルト ビューです。このツールは、Java ヒープダンプを検査するための直感的なインターフェースを提供し、オブジェクト割り当て階層の視覚化、保持されたメモリサイズの計算、ガベージ コレクション ルートからの最短パスの特定を可能にします。Heap Dump Explorer を活用することで、メモリリーク、肥大化した保持オブジェクト(ビットマップの過剰な割り当てなど)を迅速に特定し、ヒープ オブジェクトの割り当てを 1 か所で分析できます。

pic5-perfettoheapdump-analyzer.png
Heap Dump Explorer に埋め込まれたフレームグラフを使用して、ヒープ割り当てが最も多いオブジェクトを視覚的に検査して移動します。

まとめ

R8 でのバイトコードの最適化、画像読み込みのベスト プラクティスの採用、メモリリークの解決は、プレッシャー下でリソースを効果的に管理しながら、高品質のユーザー エクスペリエンスを提供するための重要なステップです。これらのプロアクティブな対策を採用することで、アプリの安定性とパフォーマンスを維持し、予期しない強制終了を防ぎながら、ユーザー コンテキストを保護できます。パフォーマンスに関する専門知識をさらに深めるには、改訂された メモリに関するガイダンスをご覧ください。

作成者:
続きを見る