管理和诊断 WebView 内存

WebView 会跨多个进程运行原生代码,以便在 Android 应用中呈现 Web 内容。如果 WebView 实例未受管理,可能会导致内存泄漏、内存不足 (OOM) 崩溃和应用性能下降。

本文档介绍了 WebView 多进程内存模型,说明了如何正确管理其生命周期以防止泄漏,并提供了用于诊断内存问题的实用工作流。

了解 WebView 内存架构

如需有效管理 WebView 内存,请了解 Android 如何为 Web 内容分配资源:

  • 多进程执行:在 Android 8.0(API 级别 26)及更高版本中,WebView 会跨多个进程将 Web 内容与应用的核心功能分开(在低 RAM 设备上,它可能会回退到单个进程):

    • 宿主(浏览器)进程:运行 Activity 和 Java 或 Kotlin 代码的主应用进程。
    • 隔离的渲染器进程:一个单独的沙盒化进程 (SandboxedProcessService),用于解析 HTML 和 CSS、执行 JavaScript 以及渲染网页。
  • 原生内存占用:大多数 WebView 内存(包括渲染的图形、DOM 树和 JavaScript 运行时内存)都是在原生内存中分配的,而不是在 Java 堆中分配的。Java 堆转储 (.hprof) 仅显示轻量级 Java 封装容器对象,不会捕获 Web 内容使用的真实内存。

  • 原生内存对系统的影响:与 Java 堆分配不同,原生内存可以静默增长到数 GB,而 Java 堆分配受应用的 maxHeap 限制,并且会因 OutOfMemoryError 而快速失败。随着未释放的原生内存填满物理 RAM 和交换空间 (zRAM),Android 的低内存终止守护程序 (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 上下文、清理视图层次结构,并停止 Web 后台工作。不过,您可能会发现,进程的物理内存(常驻内存大小)不会立即降至 WebView 之前的基准。

这是正常现象。原生运行时缓存、共享库和分配的内存页会一直驻留在进程中,直到操作系统回收它们或进程终止。destroy() 的主要目标是防止用户在进入和退出由 Web 驱动的屏幕时出现累积的 Activity 内存泄漏。

关键调试指标

分析 WebView 内存消耗时,请重点关注以下指标:

  • 常驻内存大小 (RSS):映射到进程的总物理 RAM,包括共享代码和库(在 Android Studio 性能分析器中标记为总计)。

  • 匿名 RSS (RssAnon):由进程直接分配且没有磁盘文件支持的内存(例如原生堆和 JavaScript 运行时分配)。这代表 Web 内容的主要内存开销(在 Android Studio 性能分析器中标记为已分配)。

  • 私有内存占用 (PMF):匿名 RSS 和交换空间 (zRAM) 的总和。PMF 反映了应用对系统造成的实际不可逐出内存负担。

  • 浏览器 PMF 与渲染程序 PMF:应用的主进程使用的内存与隔离的渲染程序进程使用的内存。繁重的 Web 内容主要会导致渲染器进程出现峰值。

  • 活动对象计数(WebViews、Activities、Views):内存中保留的活动界面、上下文和 WebView 实例的数量。通过跟踪这些对象,可以确定内存增长是由保留的 Java 引用还是仅限原生的分配引起的。

  • Private Other 和 Native Heap:在 dumpsys meminfo 中,原生 C/C++ 分配和自定义内存映射(例如 Chromium PartitionAlloc 或嵌入式 JavaScript 运行时堆)会显示在 Native Heap 和 Private Other 下,而不是 Java Heap 下。

如需详细了解进程内存计数器及其类别,请参阅进程内存术语表。

实用的诊断工作流

由于 WebView 跨多个进程运行并分配原生内存,因此请使用以下工具和技术来检查其占用空间:

性能分析和诊断工具

如需检查内存分配和诊断泄漏,请使用以下工具:

  • Android Studio 内存分析器:使用内存分析器直观呈现原生内存分配、跟踪内存类别随时间的变化,并检测跨屏幕转换时的 Activity 泄漏。

  • 使用 Perfetto 进行内存跟踪:使用 Perfetto 记录系统级内存计数器(例如 RSS 和匿名 RSS),以观察整体内存增长情况。请注意,WebView 原生引擎分配不会在 Perfetto 的堆分析工具中生成调用堆栈。使用 Chrome 开发者工具检查 Web 内容中的 JavaScript 堆快照和 DOM 分配。

检查实时对象数量

如需确定内存增长是由保留的 Java 框架对象(例如界面组件)还是原生分配引起的,请检查 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 和私有其他持续攀升,则泄漏源于未释放的原生资源、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:"

下表列出了常见的匿名内存标记及其与内存增长的相关性:

内存标记 子系统 与应用和 Web 内容的相关性 内存增加的常见原因?
[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 性能分析器等系统工具。

对混合应用中的高内存用量进行分类

在诊断重复 WebView 互动(例如打开网页链接或浏览由网页提供支持的信息流)期间无法解释的内存增长时,请使用以下分类工作流来确定泄漏是源自 Java 层还是原生引擎:

  1. 隔离泄漏类型(Java 与原生):在重复的用户转换(例如打开和关闭 Web 文章或滑动浏览信息流)前后运行 dumpsys meminfo -a <var>PACKAGE_NAME</var> | grep -A 10 "Objects"。

    • 观察结果:如果 Activities 和 WebViews 计数保持稳定(例如,1-2 个活跃实例),则应用不会泄露 Activity 上下文或 Java WebView 实例。
  2. 衡量多次互动之间的内存增量(时序跟踪):在多次用户互动中捕获 dumpsys meminfo 快照,以计算每次转换的分配率:

    • 观察结果:Java 堆保持在上限内且处于健康状态(在使用期间出现峰值,在垃圾回收后下降),但专用其他和原生堆在每次转换时都会稳步攀升几兆字节。这证明泄漏完全发生在 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 性能,请参阅以下资源: