Как управлять памятью WebView и диагностировать ее

WebView выполняет нативный код в нескольких процессах, чтобы отрисовывать веб-контент в приложении Android. Если не управлять экземплярами WebView, это может привести к утечкам памяти, сбоям из-за ее нехватки (OOM) и снижению производительности приложения.

В этом документе описывается многопроцессная модель памяти WebView, рассказывается, как правильно управлять ее жизненным циклом, чтобы предотвратить утечки, и приводятся практические рабочие процессы для диагностики проблем с памятью.

Как устроена архитектура памяти WebView

Чтобы эффективно управлять памятью WebView, нужно понимать, как Android распределяет ресурсы для веб-контента.

  • Многопроцессное выполнение. В Android 8.0 (уровень API 26) и более поздних версиях WebView разделяет веб-контент и основные функции приложения на несколько процессов. На устройствах с небольшим объемом ОЗУ может использоваться один процесс.

    • Процесс хоста (браузера). Основной процесс приложения, в котором выполняется код Activity, Java или Kotlin.
    • Изолированный процесс визуализации. Отдельный процесс в изолированной среде (SandboxedProcessService), который анализирует HTML и CSS, выполняет JavaScript и отрисовывает веб-страницы.
  • Объем потребляемой нативной памяти. Большая часть памяти WebView, включая отрисованную графику, дерево DOM и память среды выполнения JavaScript, выделяется в нативной памяти, а не в куче Java. Дамп кучи Java (.hprof) показывает только облегченный объект-оболочку Java и не фиксирует фактический объем памяти, используемый веб-контентом.

  • Влияние нативной памяти на систему. В отличие от выделения памяти для кучи Java, которое ограничено лимитом maxHeap приложения и быстро завершается с ошибкой OutOfMemoryError, нативная память может незаметно увеличиваться до гигабайтов. По мере того как неосвобожденная нативная память заполняет физическое ОЗУ и swap-пространство (zRAM), Low Memory Killer (LMK) в Android начинает завершать фоновые процессы, чтобы освободить память. Это ухудшает общую многозадачность устройства, а затем приводит к закрытию активного приложения.

Управление жизненным циклом 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, очищает иерархии View и останавливает фоновую работу в интернете. Однако вы можете заметить, что физическая память процесса (Resident Set Size) не сразу падает до базового уровня, который был до WebView.

Это нормальное поведение. Нативные кеши среды выполнения, общие библиотеки и выделенные страницы памяти остаются в процессе до тех пор, пока операционная система не освободит их или процесс не завершится. Основная цель destroy() – предотвратить накопление утечек памяти Activity, когда пользователи переходят между экранами, работающими на основе веб-технологий.

Основные показатели отладки

При анализе потребления памяти WebView сосредоточьтесь на следующих показателях:

  • Размер резидентного набора (RSS). Общий объем физической ОЗУ, сопоставленной с процессом, включая общий код и библиотеки (в Android Studio Profiler обозначается как Total).

  • Анонимный RSS (RssAnon). Память, выделенная непосредственно процессом, которая не поддерживается файлом на диске (например, нативная куча и выделения среды выполнения JavaScript). Это основной объем памяти, который занимает ваш веб-контент (в Профилировщике Android Studio он обозначен как Allocated).

  • Объем потребляемой памяти (PMF). Сумма анонимных данных о RSS и swap-пространстве (zRAM). PMF отражает фактическую нагрузку на память, которую ваше приложение накладывает на систему.

  • PMF браузера и PMF средства визуализации. Память, используемая основным процессом приложения, и память, используемая изолированным процессом средства визуализации. Тяжелый веб-контент вызывает скачки в основном в процессе визуализации.

  • Количество активных объектов (WebViews, Activities, Views). Количество активных экземпляров UI, Context и 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 и Anonymous RSS) и отслеживать общий рост потребления памяти. Обратите внимание, что при выделении памяти в нативном движке WebView не создаются стеки вызовов в инструменте профилирования кучи Perfetto. Используйте инструменты разработчика Chrome, чтобы проверять снимки кучи JavaScript и распределение DOM в веб-контенте.

Проверка количества активных объектов

Чтобы определить, вызван ли рост объема памяти сохранением объектов фреймворка Java (например, компонентов пользовательского интерфейса) или нативными выделениями, изучите раздел 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 в первую очередь сосредоточьтесь на 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. Найдите идентификатор процесса (PID) изолированного сервиса визуализации:

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

    В выходных данных будет показана запись изолированного процесса и его идентификатор 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 Выделение памяти для деревьев DOM, буферов отрисовки, кучи JavaScript V8 и выполнения WebAssembly в WebView. Да (высокий). Загрузка тяжелых веб-страниц, DOM с большим количеством медиаконтента или отсутствие вызова destroy() для удаленных экземпляров WebView напрямую увеличивает этот тег.
[anon:scudo...] или [anon:libc_malloc] Нативные распределители кучи Android (Scudo / jemalloc) Общие нативные выделения C/C++, используемые библиотеками NDK, мостами JNI и нативными графическими конвейерами. Да (средний или высокий). Рост происходит, когда нативные оболочки 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 можно найти в следующих ресурсах: