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 では [割り当て済み] と表示されます)。

  • プライベート メモリ フットプリント(PMF): 匿名 RSS とスワップ(zRAM)の合計。PMF は、アプリがシステムに課す実際の削除不可メモリの負荷を反映します。

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

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

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

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

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

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

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

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

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

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

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

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

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 のリーク: WebViewsActivities が一定のままで、プロセス 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.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 パフォーマンスのデバッグとプロファイリングについて詳しくは、以下のリソースをご覧ください。