Кейс: как я ускорил холодный старт Android-приложения в 10 раз - с 17 до 1.7 секунд

На моей последней работе у нас была проблема долгой загрузки, особенно первой загрузки Android-приложения. ~18% людей уходило до того, как приложение открывалось. Мне поставили задачу исправить эту ситуацию и добиться загрузки приложения быстрее, чем за 2 секунды.

На первый взгляд, задача казалась невозможной, потому что приложение на старте ходит на бэкенд больше четырех раз, регистрирует нового анонимного пользователя, обменивается ключами для push-уведомлений, инициализирует три разных SDK для аналитики, скачивает удаленную конфигурацию, скачивает фича флаги, скачивает первую страницу ленты домашнего экрана, скачивает несколько видео, которые проигрываются на старте приложения во время скролла ленты, инициализирует сразу несколько экзоплееров, отправляет данные в Firebase и скачивает ассеты (звуки, картинки и т.д.), нужные для первой игры. Как можно такой огромный объём работы уместить в меньше чем две секунды?!

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

Аудит и планирование

Я провел полный аудит кодовой базы и всей логики, связанной со стартом приложения, профилировал всё, что делает приложение на старте, используя инструментарий Android Studio, прогнал бенчмарки, написал автотесты и разработал полный план по тому, как достичь времени загрузки в 2 секунды и при этом ничем не пожертвовать из того, что я описал выше.

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

Что я сделал

1. Переход с кастомного Splash Screen на Android Splash Screen API

Мы перешли с кастомного сплэш-скрина, который был отдельной Activity, на официальный Android Splash Screen API и интегрировались с системным сплэш-скрином. Я уже много раз писал в своих постах и часто говорю в ответ на вопросы, или когда вижу, что разработчики опять пытаются затащить кастомное Activity со сплэш-скрином, или какой-то отдельный экран в навигации, где они подгружают что-то: это антипаттерн.

У нас эта Splash Activity содержала огромную ViewModel на тысячи строк, стала God Object, куда разработчики просто скидывали весь мусор, который им нужно было использовать, и заставляла ждать всю остальную логику приложения, пока оно загрузится. Проблема кастомных Activity в том, что они блокируют жизненный цикл, навигацию и занимают время на создание и разрушение. К тому же, они выглядят для пользователя как резкий дёрганый переход с системной анимацией, которую Android добавляет при переходе между Activity. Это создаёт пользовательский опыт, который увеличивает не только реальную загрузку, но и то, как она воспринимается пользователем.

Мы полностью выпилили Splash Activity и убрали все две тысячи строк кода из неё. Мы перешли на Splash Screen API, который позволил нам интегрироваться с системным Splash Screen, который Android показывает начиная с 8-й версии, добавить туда крутейшую анимацию и свой собственный бэкграунд.

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

2. Разработка системы стартовых фоновых задач

Для того, чтобы избавиться от этой огромной Splash Activity, мне нужно было разработать сложную кастомную систему стартовых джоб, которые выполнялись, когда приложение запускается. В любом приложении, в том числе в Respawn, есть очень много вещей, которые нужно делать на старте: обновление удаленной конфигурации асинхронное, что-то читать, инициализировать SDK, фича-флаги, отправлять в аналитику какие-то данные девайса или о сессии, загружать сервисы, проверять статус фоновых задач, проверять пуш-уведомления, синхронизировать данные с бэкэндом, авторизация.

Для этого я сделал интеграцию с DI, где умный Scheduler собирает все джобы из всех DI-модулей в приложении и эффективно выполняет их с батчингом, ретраем и обработкой ошибок, отправкой аналитики и замером перформанса всего этого. Мы мониторили, какие джобы много времени занимают в фоне после этого или какие часто отваливаются, диагностировали и правили косяки.

Еще один архитектурный плюс системы, которую я разработал, в том, что разработчики теперь не должны были сваливать все в одну кучу в Splash Activity ViewModel. Они получили доступ к регистрации фоновых джоб из любого места в приложении, из любого фича-модуля, например. Я считаю, что проблемы с поведением приложения - это не вопрос скилла разработчиков, это вопрос организации системы. Таким образом, я помог бизнесу на многие годы вперед сделать эффективную систему выполнения работы на старте, которая полностью асинхронна и масштабируется до сотен задач.

3. Переход на реактивную модель загрузки данных

У нас исторически использовались старые паттерны императивного программирования и одноразовой загрузки данных с бэкэнда. Это был, пожалуй, самой сложной частью рефакторинга. Но, к счастью, у нас не так много всего было именно завязано на императивную загрузку данных при старте приложения:

  1. Я мигрировал на асинхронную загрузку данных с помощью Jetpack DataStore. У них очень красивое асинхронное API с поддержкой корутин, неблокирующее, и это значительно ускорило загрузку конфигурации, загрузку пользовательских данных, токенов авторизации.

  2. Дальше я мигрировал на реактивную систему управления юзерами. Это было самое сложное на этом этапе. У нас объект юзера читался из префов на главном потоке, а если его нет, то каждый экран должен был обращаться к Splash Screen, чтобы заблокировать все процессы до того, как создастся или будет получен с бэкэнда аккаунт пользователя и обновятся токены.

Я переделал эту систему на асинхронный поток обновлений для пользовательского аккаунта, который автоматически начинает их загрузку при первом обращении как можно раньше на старте приложения. И поменял всю логику с блокирующих вызовов функций, которые получают пользователя, на наблюдение за этим потоком.

Таким образом, благодаря ещё и тому, что мы используем FlowMVI - реактивную архитектуру, мы получили возможность делегировать отображение статуса загрузки отдельным элементам на экране. Например, аватарка пользователя на главном экране загружалась самостоятельно, пока загружается основной контент асинхронно, и не блокировала показ основного контента. А также, например, регистрация пушей могла в фоне подождать, пока ей придёт User ID с бэкэнда, прежде чем отсылать токен, вместо того, чтобы блокировать весь процесс загрузки.

В фоне у нас также скачивались игровые ассеты: разные картинки и звуки, но они были закрыты Splash-скрином, потому что они требовались для первого запуска игры. Но мы не знали, сколько человек будет скроллить видео, прежде чем он решит сыграть в первую игру. И у нас есть куча свободного времени, чтобы скачать эти ассеты асинхронно и блокировать запуск игры, а не запуск приложения. Таким образом, общее время ожидания уменьшается в десятки раз конкретно в скачивании ассетов. Я переделал архитектуру загрузки ассетов на свежеразработанную систему фоновых job, а логику загрузки самой игры - на асинхронное ожидание окончания скачивания этих ассетов, используя корутины.

4. Работа с бэкэндом

По результатам моего профилирования у нас были очень медленные вызовы к бэкэнду, конкретно при загрузке ленты видео на главном экране. Я работал с командой бэкэнда и разработал для них план и проследил за его выполнением по компрессии запросов. Мы также перешли на HTTP/3, TLS 1.2 (для ускорения соединения) и реализовали новую схему для запроса главной страницы, которая уменьшила объем передаваемых данных вдвое.

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

Таким образом, мы всё оптимизировали под медленное или нестабильное соединение, благодаря чему ширина потока, который мы могли себе позволить для распараллеливания задач, и скорость загрузки данных, возросли более, чем в 2.3 раза.

5. Другие оптимизации

Я оптимизировал и все другие аспекты, такие как:

  1. Прекомпиляция кода: настроил Baseline Profiles, Startup Profiles, Dex Layout Optimizations.
  2. Перевёл на более легкие layout в Compose, чтобы уменьшить нагрузку на рендеринг,
  3. Сделал умную систему кэширования ExoPlayer, которые асинхронно создаются по запросу и хранятся в общем пуле.
  4. Реализовал локальный кэш для пагинированных данных, который позволил мгновенно показать контент, с умной заменой ещё непросмотренных элементов свежими из ответа бэка.

На другом проекте в дополнение к этому я ещё успел перенести загрузку библиотек аналитики, в особенности Firebase, на фоновый поток, что срезало там еще ~200 миллисекунд.

Результаты

Таким образом, я смог уменьшить время холодного старта приложения более чем в 10 раз. Холодный первый старт приложения с 17 секунд превратился в ~1.7.

После этого я проследил за влиянием этого изменения на бизнес, и результаты были налицо. Мы вместо того, чтобы терять 18% наших пользователей до начала онбординга, стали терять меньше 1.5%.


Оптимизация времени старта приложений - довольно деликатная работа и сильно персонализирована под конкретные нужды бизнеса и имеющиеся узкие места. Всё это делать с нуля может занять у команд много времени и привести к неожиданным регрессиям в проде, поэтому я помогаю командам оптимизировать старт приложения в формате краткосрочного аудита. После анализа (2-5 дней) бизнес получает чёткий план по пунктам, который сразу можно дать разработчикам/агентам + все подводные камни, на которые стоит обратить внимание. Я также могу реализовать предложенные изменения по необходимости.

Если вам тоже хочется достичь подобных результатов, отправьте название приложения на [email protected], и я отвечу тремя персонализированными возможностями оптимизации старта для вашего случая.