или почему WebView-пререндер — не костыль, а инвестиция в UX и бизнес

Вечный бой: Android-разработчик vs WebView

Если вы когда-нибудь трогали WebView в боевом проекте — вы уже знаете это чувство.

Ты такой всё красиво посчитал: App Startup, TTFB, время рендеринга…

А потом:

“Экран "X" иногда открывается моментально, а иногда — секунд через пять.”

QA в недоумении, пользователи в отзывах пишут про «белый экран», а ты смотришь в логи и не понимаешь — кто вообще виноват: сеть, бэкенд, фронтенд, Chromium или твоя карма.

WebView — самый капризный компонент Android:

  • долго инициализируется (особенно при первом запуске);

  • прожорлив по памяти;

  • любит показывать белый экран;

  • и коварный момент: сложно поймать ту самую секунду, когда пользователь реально увидел контент, а не просто белое полотно.

Это та самая «серая зона», где Android и Web встречаются и начинают устраивать сюрпризы.

Мы решили с этим завязать и вместо очередного «кажется стало быстрее» сделать нормальную измеримую инфраструктуру.

Что мы хотели получить

Две понятные цели:

  1. Пререндеринг WebView — чтобы экран открывался визуально мгновенно.

  2. Метрика Time To Visual Ready (TTVR) — чтобы можно было не верить ощущениям, а смотреть в числа.

Чтобы это заработало, мы собрали три кирпича:

  • WebViewPreloader — сервис, который греет и пререндерит WebView заранее.

  • WebViewReadyDetector — мини-детектор «страница реально отрисовалась».

  • CoreComposeWebView — умный контейнер над AndroidView(WebView) с пулом, детектором и метриками.

⚙️ Архитектура 

1. WebViewPreloader —  «разогрей, пока никто не видит»

Идея простая:

пока пользователь куда-то жмёт, мы в фоне уже создаём WebView, инициализируем Chromium и грузим нужный URL.

Когда он доходит до экрана — мы не начинаем всё с нуля, а просто достаём готовый экземпляр.

Под капотом WebViewPreloader делает примерно следующее:

  • создаёт WebView в невидимом контейнере;

  • прогревает движок (пустой WebView + инициализация);

  • вызывает что-то вроде prerenderUrlAsync(url, options, callback);

  • по завершении сохраняет инстанс в Map<String, WebView>;

  • дальше этот WebView можно забрать через takePreloaded(url) и вставить на экран.

При этом у нас есть cookie-политика:

  • UsePreloaded — можно показать немного устаревший контент, зато быстро. Окей для не критичных к auth штук.

  • DropAndFresh — игнорируем пререндер, если auth-cookie поменялся. Медленнее, но меньше шансов показать «не того пользователя».

Пример вызова при старте приложения в AppStartup Initializer'e

class WebViewPreloadInitializer : Initializer<Unit>, KoinComponent {
    override fun create(context: Context) {
        val jobs = listOf(
            PrerenderJob(url = "https://habr.com", cookies = "кука/куки")
        )
        webViewPreloader.preloadWebviews(jobs)
    }
}

Важно: WebViewPreloader не привязан к App Startup. Его можно запускать:

  • из ViewModel;

  • из фичи;

  • из эксперимента;

  • из onboarding-флоу.

То есть любой «важный» WebView-экран можно подогреть одной строкой, когда вы понимаете, что пользователь вот-вот туда придёт.

1.1.Что за prerenderUrlAsync в AndroidX WebKit и как его правильно готовить?

prerenderUrlAsync — экспериментальный (alpha) API из AndroidX WebKit.

Он позволяет заранее подготовить рендер страницы, ещё до того, как у вас есть реальный видимый WebView на экране.

Примерно так это живёт:

  1. Запрос на пререндер

    Вы вызываете prerenderUrlAsync(url, options, callback) где-то в фоне.

    WebView на экране ещё нет.

  2. Фоновая работа

    Движок под капотом создаёт изолированный контекст, начинает грузить страницу, крутит свои таймеры, выполняет часть логики.

  3. Готовность

    В колбэк прилетает «готово» (или ошибка/таймаут).

    Это сигнал: если сейчас показать страницу — первый кадр отрисуется очень быстро.

  4. Активация

    Когда пользователь открывает экран, вы:

    • либо используете уже готовый WebView (если API позволяет вернуть его),

    • либо «подключаете» подготовленный рендер к вашему WebView.

  5. Отмена

    Пользователь передумал и не дошёл до экрана? Пререндер можно и нужно отменить, чтобы не держать лишнюю память.

Нюанс: поведение зависит от версии Android System WebView / Chromium. Где-то он ведёт себя елегантно, где-то — не очень. Поэтому:

  • ставим таймауты,

  • всегда имеем fallback на «обычную» загрузку,

  • собираем метрики: сработал prerender, был таймаут, не поддерживается и т.д.

Есть два базовых подхода:

Подход

Плюсы

Минусы

Держать невидимый WebView 

Предсказуемо и работает везде; вы контролируете экземпляр

Нужен реальный View в иерархии/контексте (даже offscreen); риск утечек; overhead по памяти

prerenderUrlAsync

Системный путь: движок сам решает, что и когда готовить; меньше шансов на баги уровня View; потенциально лучше по памяти

Новое API (alpha), поддержка не везде; поведение зависит от версии WebView; нужно аккуратно кодить fallback

На практике это не «или–или», а «и то, и другое»:

мы смотрим, поддерживается ли prerenderUrlAsync, по фичефлагу включаем его, а при проблемах падаем на проверенный сценарий с обычным прогретым WebView.

Практические советы по пререндеру

То, что бы я себе самому написал в README:

  • Таймаут 6–10 секунд

    Если страница не успела подготовиться — не героизируем, логируем и уходим во fallback.

  • Не пререндерим всё подряд

    Выбираем топовые экраны: главный WebView, часто посещаемые сцены, критичные флоу.

  • Следим за cookie

    Поменялись auth-данные — старый пререндер либо не используем, либо считаем его stale и честно маркируем в метриках.

  • Логируем стратегию

    REASON=API, REASON=FALLBACK, REASON=TIMEOUT, REASON=UNSUPPORTED.

    Потом это сильно помогает понять: регрессия в сети, во фронте или в самом пререндере.

  • QA-панель

    Маленькая debug-строчка «Prerender: API/Fallback/Skipped» в overlay — и сразу ясно, что именно сейчас тестируется.

2.  WebViewReadyDetector — как понять, что страница «живая», а не просто белая

А теперь главный вопрос: когда считать, что WebView реально что-то показал?

По onPageFinished() верить нельзя — там всё, что угодно, кроме реальной «готовности визуала».

Поэтому мы сделали простую, но рабочую штуку — WebViewReadyDetector.

Он делает:

  • offscreen-рендер WebView в маленький Bitmap, условно 48×48;

  • считает долю не белых пикселей;

  • как только заполненность превышает порог (например, 2%) несколько кадров подряд — считаем, что контент визуально готов.

Отсюда рождается метрика:

TIME_TO_VISUAL_READY_MS — время от события “открыли экран” до момента, когда WebView перестал быть «просто белой картинкой».ё

Три типичных грабли:

  1. Белые UI-страницы

    Если у вас дизайн → белый фон, белый skeleton и прозрачные элементы, детектор такой: “ну да, всё ещё белое”.

    Решение: небольшой порог (2%), подряд несколько кадров, и тюнинг под реальные скрины + логирование ratio.

  2. SPA и лоадеры

    Быстрый skeleton создаёт иллюзию скорости, но пользователь всё равно ждёт контент.

    Мы делаем фиксированную тёмную заливку фона у контейнера + отдельный порог для «полезного» контента.

  3. Разные девайсы / кастомный WebView

    Где-то рендерятся шрифты иначе, где-то включена/выключена аппаратная акселерация.

    Без логов и парочки feature gate тут никуда.

3. CoreComposeWebView — WebView как нормальный компонент, а не особенный ребёнок

CoreComposeWebView — это наша надстройка над AndroidView(WebView) в Compose, которая делает за экран почти всю грязную работу.

Он умеет:

  • попробовать взять готовый WebView из WebViewPreloader;

  • если нет — создать fresh-экземпляр;

  • подключить WebViewReadyDetector;

  • аккуратно разрулить cookie-политику (UsePreloaded / DropAndFresh);

  • самостоятельно отправить метрики в аналитику.

Для экранов это выглядит как обычный composable, а внутри уже:

  • берётся нужная стратегия (preload / fallback),

  • замеряется TIME_TO_VISUAL_READY_MS,

  • всё уезжает в Clickhouse → Grafana.

И в итоге любой WebView-экран:

  • открывается заметно быстрее;

  • сам сообщает, насколько он был «быстрым» для пользователя.

Что получилось?

После того, как мы раскатили пререндер и детектор, в Grafana появились нормальные графики по WebView:

  • для каждого экрана — TIME_TO_VISUAL_READY_MS;

  • разбивка по устройствам, версиям Android, сети, версиям WebView/Chromium.

Пример из боевых данных:

Source

Median TTVR

p90

Без пререндеринга

3100 ms

4900 ms

С пререндерингом

1200 ms

1900 ms

? В среднем экран загружается в 2,5 раза быстрее.?

А это уже не “кажется стало лучше”, а вполне себе аргумент для продукта и бизнеса.

Как это продать бизнесу?

Разработчикам достаточно увидеть падение p90 на графике.

Бизнесу нужен перевод на понятный язык

(цифры, ресурсы, GMV, конверсии)

Мы говорим так:

“Мы экономим пользователю около 2 секунд при каждом открытии WebView-экрана.”

Если экран открывается, скажем, 5 млн раз в месяц — это уже:

  • ~2 800 часов сэкономленного пользовательского времени;

  • меньше оттока на «белом экране»;

  • выше конверсия в целевые действия (оплаты, авторизации, заполнение форм и т.д.).

И это уже звучит не как «поигрались с WebView», а как оптимизация ключевого флоу.

A/B-методология (чтобы потом не спорить на митингах)

Чтобы не спорить в стиле «ну у нас выборка другая», мы сделали нормальный A/B-сетап:

  • Делим трафик на устройстве

    Персистентный флаг, чтобы не спутать прогрев движка между экспериментом и контролем.

  • Сегментируем

    • по сети (Wi-Fi / Cellular, RTT, если есть),

    • по устройству (классы CPU/RAM),

    • по версии SDK / WebView / Chromium.

  • Холгоут

    Часть трафика оставляем без пререндера даже после раскатки, чтобы видеть регрессии и долгосрочный эффект.

  • Смотрим медиану и p90 отдельно

    Медиана нужна, чтобы понимать «типичный» UX, p90 — чтобы видеть, что происходит в хвосте, где как раз и живёт боль.

  • Не смешиваем яблоки и апельсины

    Разные провайдеры, регионы, типы экранов — отдельно. Иначе всё усредняется до ничего.

Почему этот подход масштабируется

  • Новый WebView-экран включается одной строкой.

  • Вся аналитика централизована в Clickhouse → Grafana.

  • Код не зависит от конкретного ChromeClient или JS-интеграций.

  • В любой момент можно:

    • подкрутить детектор;

    • изменить cookie-политику;

    • отключить пререндер для проблемных девайсов.

По сути, WebView из «нестабильного монстра» превращается в обычный компонент, у которого:

  • есть чёткие метрики;

  • понятная архитектура;

  • контролируемый UX;

Итог

Мы перестали на глазок гадать, быстро ли грузится WebView.

Теперь у нас есть:

  • WebViewPreloader — чтобы подогреть WebView заранее;

  • WebViewReadyDetector — чтобы честно понимать момент отображения контента;

  • CoreComposeWebView — чтобы экраны не думали про всё это вообще;

  • TIME_TO_VISUAL_READY_MS — одна цифра, которой можно объяснить и разработчику, и аналитику, и продакту, что именно мы улучшили.

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

Спасибо, что дочитали до этого места ?

Если хотите обсудить детали, обменяться кодом или графиками — пишите мне в LinkedIn :)

Комментарии (6)


  1. Hubr2025
    12.11.2025 23:41

    То ли я еще не проснулся и тороможу, то ли статья - лютая нейронка без редактирования. А тема то интересная...


    1. timkaopensoul Автор
      12.11.2025 23:41

      Тема очень интересная. Пока создавал инфраструктуру для такой задачи не нашел похожих статей вот и решил написать. Работая с prerenderUrlAsync съел куча собак вот и решил поделиться


      1. Hubr2025
        12.11.2025 23:41

        Перечитал ещё раз. Беру свои слова назад.

        Статья хорошая и по делу - это я тогда не проснулся.

        Просто присутствие ГПТ-шного стиля, и отсутствие ссылки на исходники навело на мысль о простом словошлепстве. Но вижу, что это не так.


  1. Spyman
    12.11.2025 23:41

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

    Так ещё и памяти кушает скорее всего немерянно (держать комплект открытых webView в мапе недёшево).

    В таком сценарии интересно было бы копнуть в другую сторону - если ресурс который надо открыть контролируем мы, то попробовать подгрузить заранее js/css/картинки со всех страниц в кэш браузера, а дальше уже запрос ограничивать чисто страницей.


    1. timkaopensoul Автор
      12.11.2025 23:41

      Js динамичный же. Можно чисто прогреть движок конечно, сильного ускорения обычно это не дает


      1. Spyman
        12.11.2025 23:41

        Что вы подразумеваете? У вас импорты js файлов меняются в реальном времени в зависимости от окружения? Даже если и так - нет проблемы просто загрузить их все - не нейросетью же они на лету пишутся) Или у вас страница рендерится на клиенте (html собирается js-ом из ajax запросов без рендеринга на сервере для первого открытия)? Если так - то логичнее эту проблему сначала решить т.к. она тормоза у всех клиентов, включая настольных, вызывает.

        Когда я последний раз изучал вопрос - самые долгие операции в загрузке страницы были пачки запросов ресурсов (из за пингов). Условно загрузка страницы 100мс, там пак из 20-30 файлов (css/js/шрифты/иконки/картинки), ещё 200мс на загрузку, и уже отрисовка - 40мс.

        Вот выкинув второй этап (правильно настроив кэши) результат получается впечатляющий