WebView のメモリを管理、診断する

WebView は、複数のプロセスでネイティブ コードを実行して、Android アプリにウェブ コンテンツをレンダリングします。WebView インスタンスを管理しないと、メモリリーク、メモリ不足(OOM)によるクラッシュ、アプリのパフォーマンス低下につながる可能性があります。

このドキュメントでは、WebView のマルチプロセス メモリモデルについて説明し、リークを防ぐためにライフサイクルを適切に管理する方法と、メモリの問題を診断するための実践的なワークフローについて解説します。

WebView のメモリ アーキテクチャを理解する

WebView のメモリを効果的に管理するには、Android がウェブ コンテンツにリソースを割り当てる方法を理解する必要があります。

  • マルチプロセス実行: Android 8.0(API レベル 26)以降では、WebView は複数のプロセスにわたってウェブ コンテンツをアプリのコア機能から分離します(RAM の少ないデバイスでは、単一のプロセスにフォールバックする場合があります)。

    • ホスト(ブラウザ)プロセス: Activity と Java または Kotlin コードが実行されるメインアプリ プロセス。
    • 分離されたレンダラ プロセス: HTML と CSS を解析し、JavaScript を実行してウェブページをレンダリングする、別のサンドボックス化されたプロセス(SandboxedProcessService)。
  • ネイティブ メモリ フットプリント: レンダリングされたグラフィック、DOM ツリー、JavaScript ランタイム メモリなど、ほとんどの WebView メモリは、Java ヒープではなくネイティブ メモリに割り当てられます。Java ヒープダンプ(.hprof)には、軽量の Java ラッパー オブジェクトのみが表示され、ウェブ コンテンツで使用される実際のメモリはキャプチャされません。

  • ネイティブ メモリのシステムへの影響: アプリの maxHeap 上限によって制限され、OutOfMemoryError で迅速に失敗する Java ヒープ割り当てとは異なり、ネイティブ メモリはギガバイト単位で静かに増加する可能性があります。解放されていないネイティブ メモリが物理 RAM とスワップ領域(zRAM)を埋めると、Android の Low Memory Killer(LMK)がバックグラウンド プロセスを終了してメモリを再利用し始めます。これにより、最終的にフォアグラウンド アプリが強制終了される前に、デバイスのマルチタスク処理が全体的に低下します。

WebView のライフサイクルを管理する

メモリリークを防ぐには、適切なライフサイクル管理が不可欠です。よくある間違いは、レイアウトから WebView を削除するか、Activity を自動的に終了させると、そのメモリが解放されると考えることです。

Java コンテキスト参照とネイティブ レンダリング リソースの両方を完全にクリーンアップするには、ホスト コンポーネントのライフサイクル(onDestroy() など)で明示的に破棄シーケンスを調整し、アクティブなページ実行を停止し、ビューをコンテナから切り離して、ネイティブ バインディングを解放する必要があります。

WebView インスタンスをクリーンアップする

Activity または Fragment が破棄されたときに適切にシャットダウンしてリソースを解放するには、次の操作を行います。

  1. WebView を親コンテナ(ViewGroup)から削除します。
  2. アクティブな読み込みを停止し、ナビゲーション履歴をクリアします。
  3. destroy() を呼び出します。
  4. null への参照をクリアします。

次の例は、WebView を適切にクリーンアップする方法を示しています。

Kotlin

override fun onDestroy() {
    myWebView?.let {
        // Remove the WebView from its parent ViewGroup.
        (it.parent as? ViewGroup)?.removeView(it)
        // Stop active loading and clear history.
        it.stopLoading()
        it.clearHistory()
        // Destroy the instance.
        it.destroy()
    }
    myWebView = null
    super.onDestroy()
}

Java

@Override
protected void onDestroy() {
    if (myWebView != null) {
        // Remove the WebView from its parent ViewGroup.
        if (myWebView.getParent() instanceof ViewGroup) {
            ((ViewGroup) myWebView.getParent()).removeView(myWebView);
        }
        // Stop active loading and clear history.
        myWebView.stopLoading();
        myWebView.clearHistory();
        // Destroy the instance.
        myWebView.destroy();
    }
    myWebView = null;
    super.onDestroy();
}

破棄後のメモリについて

destroy() を呼び出すと、システムは Activity コンテキストを解放し、ビュー階層をクリーンアップして、ウェブのバックグラウンド処理を停止します。ただし、プロセスの物理メモリ(Resident Set Size)が `WebView` のベースラインにすぐに低下しないことがあります。

これは正常な動作です。ネイティブ ランタイム キャッシュ、共有ライブラリ、割り当てられたメモリページは、オペレーティング システムが再利用するか、プロセスが終了するまでプロセスに常駐します。destroy() の主な目的は、ユーザーがウェブを利用した画面に出入りするときに、Activity のメモリリークが累積しないようにすることです。

主なデバッグ指標

WebView のメモリ使用量を分析する場合は、次の指標に注目してください。

  • Resident Set Size(RSS): 共有コードとライブラリを含む、プロセスにマッピングされた物理 RAM の合計(Android Studio Profiler では [Total] と表示されます)。

  • 匿名 RSS(RssAnon): ディスク上のファイルによってバックアップされていない、プロセスによって直接割り当てられたメモリ(ネイティブ ヒープや JavaScript ランタイムの割り当てなど)。これは、ウェブ コンテンツの主なメモリコストを表します(Android Studio Profiler では [Allocated] と表示されます)。

  • プライベート メモリ フットプリント(PMF): 匿名 RSS とスワップ(zRAM)の合計。PMF は、アプリがシステムに課す実際の強制終了できないメモリ負荷を反映します。

  • ブラウザ PMF とレンダラ PMF: アプリのメインプロセスで使用されるメモリと、分離されたレンダラ プロセスで使用されるメモリ。ウェブ コンテンツが多いと、主にレンダラ プロセスでスパイクが発生します。

  • ライブ オブジェクト数(WebViewsActivitiesViews): メモリに保持されている アクティブな UI、Context、WebView インスタンスの数。これらを追跡することで、メモリの増加が保持されている Java 参照によるものか、ネイティブのみの割り当てによるものかを特定できます。

  • Private Other と Native Heap: dumpsys meminfo では、ネイティブ C/C++ の割り当てとカスタム メモリ マッピング(Chromium PartitionAlloc や埋め込み JavaScript ランタイム ヒープなど)は、Java ヒープではなく Native Heap と Private Other に表示されます。

プロセスのメモリカウンタとそのカテゴリの詳細については、 プロセスのメモリに関する用語集をご覧ください。

実践的な診断ワークフロー

WebView は複数のプロセスで動作し、ネイティブ メモリを割り当てるため、次のツールと手法を使用してフットプリントを検査します。

プロファイリング ツールと診断ツール

メモリ割り当てを検査してリークを診断するには、次のツールを使用します。

  • Android Studio Memory Profiler: Memory Profiler を使用してネイティブ割り当てを可視化し、メモリカテゴリを時間経過とともに追跡して、画面遷移時のActivity リークを検出します。

  • Perfetto によるメモリ追跡: Perfetto を使用して、システムレベルの メモリカウンタ(RSS や匿名 RSS など)を記録し、メモリの全体的な 増加を観察します。WebView ネイティブ エンジン割り当てでは、Perfetto の ヒープ プロファイリング ツールで コールスタックが生成されないことに注意してください。 Chrome DevTools を使用して、ウェブ コンテンツ内の JavaScript ヒープ スナップショットと DOM 割り当てを検査します。

ライブ オブジェクト数を検査する

メモリの増加が、保持されている Java フレームワーク オブジェクト(UI コンポーネントなど)によるものか、ネイティブ割り当てによるものかを判断するには、Objects セクションのdumpsys meminfoを検査します。

adb shell dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"

出力にはライブ オブジェクト数が表示されます。

 Objects
               Views:     142         ViewRootImpl:        1
         AppContexts:       3           Activities:        1
              Assets:      12        AssetManagers:        0
       Local Binders:      32        Proxy Binders:       45
       Parcel memory:      15         Parcel count:       30
    Death Recipients:       2             WebViews:        1

このセクションには、アクティブなフレームワーク オブジェクト、IPC ハンドル、Parcel 割り当ての数が表示されます。WebView の診断では、主に ActivitiesWebViews に注目します。

ターゲット ユーザーの操作(ウェブ画面の開閉など)を繰り返し行い、数を比較します。

  • インスタンス リーク: ナビゲーションごとに WebViews または Activities が増加し、ベースラインに戻らない場合、アプリは Java WebView インスタンスまたはホスト Activity をリークしています(ViewGroup.removeView() がない、リスナー参照が保持されているなど)。リークした Activity は、ビューツリー全体とデコードされた画像リソースをメモリに固定するため、繰り返しアクセスすると Java ヒープがすぐに使い果たされ、OutOfMemoryError クラッシュが発生します。

  • ネイティブまたは DOM リーク: プロセス全体の RSS と [Private Other] が増加し続けているのに、WebViewsActivities が一定のままである場合、リークは解放されていないネイティブ リソース、DOM 要素、または JavaScript エンジン バインディングに起因しています。これらの割り当てはネイティブ メモリに存在し、ART ガベージ コレクタをバイパスするため、標準の Java リーク検出ツールでは検出されず、オペレーティング システムがアプリを終了するまで蓄積されます。

CLI を使用して分離されたレンダラ プロセスをプロファイリングする

アプリのパッケージ名で dumpsys meminfo を実行すると、メインのホスト プロセスのメモリのみが出力されます。ウェブページがレンダリングされる分離されたレンダラ プロセスを検査するには:

  1. 分離されたレンダラ サービスのプロセス ID(PID)を確認します。

    adb shell dumpsys activity processes <var>PACKAGE_NAME</var> | grep "Isolated.*SandboxedProcessService"

    出力には、分離されたプロセス レコードとその PID RENDERER_PID22155 など)が表示されます。

    Isolated #5: ProcessRecord{... 22155:com.google.android.webview.debug:sandboxed_process0:...}
    
  2. PID を使用して、レンダラ プロセスのメモリの内訳を検査します。

    adb shell dumpsys meminfo <var>RENDERER_PID</var>
  3. ホストアプリ プロセスを検査して、ブラウザ側のフットプリントを評価します。

    adb shell dumpsys meminfo <var>PACKAGE_NAME</var>

メモリマップと割り当てを検査する

どのネイティブ サブシステムまたはアロケータが匿名メモリを占有しているかを確認するには、プロセスのメモリマップを検査します。

adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"

次の表に、一般的な匿名メモリタグと、メモリの増加との関連性を示します。

メモリタグ サブシステム アプリとウェブ コンテンツとの関連性 メモリ増加の一般的な原因
[anon:partition_alloc] Chromium PartitionAlloc WebView での DOM ツリー、レンダリング バッファ、V8 JavaScript ヒープ、WebAssembly 実行の割り当て。 あり(高): ウェブページの読み込み、メディア リッチ DOM、破棄された WebView インスタンスで destroy() を呼び出さないと、このタグが直接増加します。
[anon:scudo...] または [anon:libc_malloc] Android ネイティブ ヒープ アロケータ(Scudo / jemalloc) NDK ライブラリ、JNI ブリッジ、ネイティブ グラフィック パイプラインで使用される一般的な C/C++ ネイティブ割り当て。 あり(中~高): ネイティブ JNI ラッパーまたはサードパーティの C++ 依存関係が、ナビゲーション間で解放されていない割り当てを保持すると、増加します。
[anon:...][anon:quickjs_heap...] など) カスタム スクリプトまたはネイティブ ランタイム 埋め込み JavaScript エンジン、カスタム WebAssembly ランタイム、カスタム ネイティブ バッファプール。 あり(コンテキストによって異なる): ネイティブ ビューとともにスクリプト エンジンを実行し、ランタイム バインディングをクリーンアップしないハイブリッド アプリでよく見られます。

アプリ内メモリ API の制限事項

アプリ内メモリ API(Debug.getMemoryInfoActivityManager.getProcessMemoryInfo など)は、呼び出しプロセスのみを測定します。 マルチプロセス モードでは、これらの API は分離されたレンダラ プロセスで使用されるメモリをキャプチャできません。正確な合計メモリ評価を行うには、dumpsys meminfo、Perfetto、Android Studio Profiler などのシステムツールを使用します。

ハイブリッド アプリでメモリ使用量が多い場合のトリアージ

WebView の繰り返し操作(ウェブリンクを開く、ウェブを利用したフィードを操作するなど)中に、原因不明のメモリ増加を診断する場合は、次のトリアージ ワークフローを使用して、リークが Java レイヤとネイティブ エンジンのどちらに起因しているかを特定します。

  1. リークタイプを特定する(Java とネイティブ): ユーザーの遷移(ウェブ記事の開閉、フィードのスワイプなど)を繰り返す前後に、dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects" を実行します。

    • 観察結果: ActivitiesWebViews の数が安定している場合(アクティブなインスタンスが 1 ~ 2 個など)、アプリは Activity コンテキストまたは Java WebView インスタンスをリークしていません。
  2. インタラクション間のメモリ差分を測定する(時系列トラッキング): 複数のユーザー操作で dumpsys meminfo スナップショットをキャプチャして、遷移ごとの割り当て率を計算します。

    • 観察結果:Java ヒープは上限に達して正常な状態を維持していますが(使用中にスパイクが発生し、ガベージ コレクション後に低下します)、Private OtherNative Heap は遷移ごとに数メガバイトずつ着実に増加しています。 これは、リークが ART ランタイム外のネイティブ メモリに完全に存在することを示しています。 標準の Java ヒープダンプ(.hprof)では問題は表示されません。
  3. 匿名メモリマップを検査する: ADB を使用してプロセスのメモリマップを調べます (メモリマップと割り当てを検査するをご覧ください)。

    adb shell "cat /proc/$(pidof <var>PACKAGE_NAME</var>)/maps" | grep "anon:"
    • 観察結果:メモリの増加は [anon:partition_alloc] または埋め込みスクリプト エンジン ヒープに集中しており、JNI グローバル参照が徐々に増加しています。 これは、Java ビューは置き換えられたものの、基盤となるネイティブ ページ オブジェクトまたは JavaScript バインディングが解放されなかったことを示しています。
  4. 再発防止対策:

    • リサイクルまたは破棄されたすべての WebView が、アクティブなスクリプトを明示的に停止(stopLoading())し、履歴をクリアして、destroy() を呼び出すようにします。
    • 破棄されたビューに関連付けられているカスタム JavaScript ブリッジ コールバックまたは JNI グローバル参照を破棄します。
    • ナビゲーション遷移後に Private Other とプロセス RSS が安定していることを確認します。

参考情報

メモリと WebView のパフォーマンスのデバッグとプロファイリングの詳細については、次のリソースをご覧ください。