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 ラッパー オブジェクトのみが表示され、ウェブ コンテンツで使用される実際のメモリはキャプチャされません。

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

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

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

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

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

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

  1. 親コンテナ(ViewGroup)から WebView を削除します。
  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: アプリのメインプロセスで使用されるメモリと、分離されたレンダラ プロセスで使用されるメモリ。重いウェブ コンテンツは、主にレンダラ プロセスでスパイクを引き起こします。

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

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

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

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

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

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

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

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

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

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

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

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 の診断では、主に Activities と WebViews に重点を置きます。

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

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

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

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

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

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

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

    出力には、分離されたプロセス レコードとその PID RENDERER_PID(例: 22155)が表示されます。

    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.getMemoryInfo や ActivityManager.getProcessMemoryInfo など)は、呼び出しプロセスのみを測定します。マルチプロセス モードでは、これらの API は、分離されたレンダラ プロセスによって消費されたメモリをキャプチャできません。正確な合計メモリ評価を行うには、dumpsys meminfo、Perfetto、Android Studio Profiler などのシステムツールを使用してください。

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

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

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

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

    • 観察結果: Java ヒープは上限が設定されたまま正常な状態を維持しています(使用中に急増し、ガベージ コレクション後に減少しています)。しかし、Private Other と Native 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 のパフォーマンスのデバッグとプロファイリングの詳細については、次のリソースをご覧ください。