Новости
Как мы сократили время запуска Android-приложения на 77%
В этой статье мы хотели бы поделиться нашим опытом по сокращению времени запуска приложения Turo для Android. Мы расскажем, каких улучшений нам удалось достичь, какие шаги мы предприняли и как мы их измеряем.
Мобильная производительность в Turo
Время запуска мобильного приложения является одним из важнейших показателей его производительности и оказывает значительное влияние на пользовательский опыт. Быстро загружающееся приложение не только создает положительное первое впечатление, но и увеличивает вовлеченность и удержание, снижает отток пользователей и может улучшить видимость в рейтингах магазинов приложений.
В этой статье мы хотели бы поделиться нашим опытом по сокращению времени запуска приложения Turo для Android. Мы расскажем, каких улучшений нам удалось достичь, какие шаги мы предприняли и как мы их измеряем.
Мы сократили время холодного запуска приложения Turo Android в среднем на 77%.
Эта же метрика была улучшена на 84% в 50-м процентиле. На рисунке ниже показано сравнение показателей до и после того, как мы улучшили время запуска приложения Turo для Android. Все значения измеряются в миллисекундах.
На рисунке ниже показано, как выглядит и ощущается приложение до и после улучшения, которое уже доступно для 50% наших пользователей.
Улучшения
Прежде чем перейти к конкретным улучшениям, которые мы внесли, стоит рассмотреть процесс запуска приложения Turo до оптимизации.
Процесс запуска приложения состоял из следующих этапов:
- Инициализация приложения. Сюда входит инициализация процесса приложения, который разработчики практически не контролируют. Кроме того, сюда входит весь код, который приложение выполняет перед отрисовкой первого кадра пользовательского интерфейса. Android Vitals сообщает о завершении этого этапа как о завершении запуска.
- Синхронные сетевые запросы. Приложение выполняло ряд сетевых запросов, одновременно показывая сплэш-скрин, прежде чем позволить пользователю увидеть главный экран.
- Пользовательская анимация заставки. После завершения сетевых запросов приложение запускало пользовательскую анимацию заставки, которая длится около 1 секунды. После этого приложение переходило от SplashActivity к HomeActivity.
- Скелет Home. На этом этапе пользователь может частично взаимодействовать с приложением, ожидая загрузки содержимого главного экрана и наблюдая анимацию мерцания. Хотя этот этап уже был кэшируемым, здесь также требовались некоторые улучшения.
Splash screen
Одним из простых способов улучшить время запуска приложения было удаление пользовательской анимации заставки, которая отображалась при каждом запуске. Хотя анимация была частью языка дизайна, она запускалась только после выполнения всех синхронных сетевых запросов при старте, что приводило к задержке общего времени запуска. Убрав анимацию, мы смогли сэкономить примерно 1 секунду.
Теперь, когда мы разобрались с этим пунктом, давайте перейдем к более интересным с технической точки зрения аспектам улучшения запуска.
Изначально мы использовали специальную SplashActivity для выполнения всей работы по запуску, прежде чем перенаправить приложение к HomeActivity. Однако последние руководства не рекомендуют использовать такой подход. Поэтому мы исключили избыточную SplashActivity и перенесли всю логику запуска в нашу корневую активность, используя Splash Screen API. Это позволило создать единый опыт для всех версий Android и, в качестве бонуса, улучшило наши показатели запуска в консоли Android Vitals в Google Play Console, как показано на схеме ниже.
Если приложение отображает пользовательское изображение или анимацию в качестве заставки, метрика времени запуска в Android Vitals может неточно отражать фактическое время запуска. Это происходит потому, что время отображения пользовательского экрана не учитывается как часть процесса запуска. Для решения этой проблемы использование Splash Screen API может помочь четко определить момент завершения процесса запуска, что позволит получить более точные отчеты.
Отсрочка синхронных сетевых запросов
Наше приложение выполняло серию сетевых запросов во время отображения заставки, чтобы получить необходимые данные, прежде чем пользователи смогут взаимодействовать с ним. Это приводило к заметному замедлению процесса запуска приложения, особенно при плохом сетевом соединении.
Кроме того, приложение выполняло разное количество стартовых сетевых запросов в зависимости от того, используется ли приложение в guest, host или unauthenticated режимах. Это приводило к непоследовательным действиям для различных типов пользователей.
Например, если приложение используется в гостевом режиме, несколько запросов помогают решить, какой экран должен быть показан после заставки. По умолчанию это главный экран. Однако есть особые случаи, когда пользователь может быть перенаправлен либо прямо на активное бронирование автомобиля, либо на экран обратной связи, где он может оценить свой последний опыт бронирования автомобиля.
Поскольку в большинстве случаев это будет главный экран, мы не хотим замедлять каждый запуск приложения, проверяя меньшинство сценариев. Поэтому теперь мы по умолчанию открываем домашний экран, делая эти сетевые запросы асинхронно. Если требуется какое-либо перенаправление, соответствующий экран отображается поверх главного экрана.
Получение флагов функций
Хотя во многих случаях это относительно простая оптимизация, существуют сетевые запросы, которые требуют особой обработки. При получении флагов функций из сети недостаточно просто асинхронно выполнить сетевой запрос.
Если флаг функции не запрашивается сразу после запуска приложения, содержимое главного экрана отображается мгновенно и без проблем.
Однако что, если нам нужно использовать флаг функции на главном экране? В этом случае сетевой запрос в большинстве случаев не успеет выполниться. Это приводит к использованию значения из кэша, которое во многих случаях может быть устаревшим или отсутствовать, что, в частности, может привести к неверным результатам A/B-тестов.
Чтобы решить эту проблему, мы начинаем получать флаги функций в фоновом режиме прямо при запуске приложения. Если какой-либо флаг функции обязательно требуется до завершения запроса, такой вызов продолжает выполнение до тех пор, пока он не будет получен с сервера. Тем временем мы показываем скелет домашнего экрана с анимацией мерцания. Все последующие считывания флагов функций в приложении происходят мгновенно, как показано на рисунке ниже.
Чтобы избежать показа загружаемого скелета при каждом последующем запуске приложения, мы используем краткосрочный кэш для флагов функций. Это может быть несколько часов, день или несколько дней. Мы все еще экспериментируем с точными значениями срока действия кэша.
Базовые профили
После удаления всех синхронных сетевых запросов продолжительность запуска стала более детерминированной, и теперь имеет смысл применить Baseline Profiles. Это функция, которая помогает заранее скомпилировать пути кода запуска и улучшить время запуска приложения.
Применение базовых профилей к нашему приложению привело к улучшению примерно на 15%, согласно результатам Macrobenchmark (по сравнению с запуском без компиляции).
Оптимизация операций ввода-вывода для диска
Исследуя способы улучшения времени запуска нашего приложения, мы заметили, что во время запуска оно выполняет множество операций ввода-вывода на диск.
Нарушения StrictMode. StrictMode — это инструмент, который, помимо прочего, помогает обнаружить случайные операции дискового ввода-вывода, выполняемые в основном потоке. Такие операции особенно нежелательны во время запуска приложения, и рекомендуется отложить их или переместить в фоновый поток. Этот инструмент следует использовать только для отладочных сборок. Когда он настроен, он будет публиковать в logcat события, похожие на приведенный ниже пример, и включать трассировку стека, которая поможет определить источник проблемы.
StrictMode policy violation; ~duration=107 ms: android.os.strictmode.DiskReadViolation
Это помогло нам определить кучу чтений из локального хранилища в потоке пользовательского интерфейса, которые можно переместить в фоновый режим.
Shared preferences. При исследовании логов из StrictMode мы обнаружили, что ряд проблем, в частности, был вызван чтением из SharedPreferences. Поэтому некоторые операции были перенесены в фоновый поток. Другие, однако, должны были быть выполнены до отображения главного экрана, поэтому код, обращающийся к ним, был отложен до самого конца инициализации приложения.
Важно подчеркнуть, что SharedPreferences начинают считываться с диска в момент инициализации объекта, согласно документации context.getSharedPreferences.
Часто эти объекты инициализируются немедленно и вводятся в конструкторы с помощью инъекции зависимостей, что может привести к раннему чтению с диска в потоке пользовательского интерфейса.
Ленивая инициализация
Быстрое решение проблемы с SharedPreferences заключается в том, чтобы обеспечить их ленивую инициализацию. Если Dagger используется в проекте как фреймворк для инъекции зависимостей, то dagger.Lazy может помочь достичь этой цели, как показано в примере ниже.
class LocalDataSource(val prefs: dagger.Lazy<SharedPreferences>) { fun getData(): String? { return prefs.get().getString("data", null) } }
Кроме того, ленивая инъекция может быть использована для других типов классов, которые могут выполнять большую работу в UI-потоке во время своей инициализации.
Обратные вызовы жизненного цикла приложения
Еще одним фактором, который может негативно повлиять на время запуска, являются обратные вызовы, связанные с жизненным циклом приложения:
- Application.ActivityLifecycleCallbacks — и особенно его функции onActivityCreated, onActivityStarted и onActivityResumed.
- FragmentManager.FragmentLifecycleCallbacks — и особенно его функции onFragmentCreated, onFragmentViewCreated и onFragmentResumed.
Их легко пропустить, но они могут содержать тяжелый код инициализации, который, очевидно, замедлит время запуска приложения. Более того, эти обратные вызовы могут неявно использоваться другими сторонними SDK.
Сторонние SDK
Кстати, о SDK сторонних разработчиков. Многие из них предлагают инициализировать их в Application.onCreate. К сожалению, в некоторых случаях логика их инициализации может быть не самой эффективной с точки зрения производительности. В то время как некоторые из них довольно легко исправить, просто переместив их инициализацию в фоновый поток или даже отложив ее до тех пор, пока они действительно не будут использоваться, другие могут быть более сложными и потребовать больше усилий для их устранения или адаптации.
Например, мы используем SDK, который должен быть привязан к жизненному циклу каждой активности. Более того, он неявно инициализируется под капотом при первом вызове onActivityCreated, выполняя дисковый ввод-вывод и другие операции в потоке UI, замедляя запуск приложения. Хотя можно улучшить его производительность во время инициализации, это требует дополнительных усилий, и с такими случаями нужно быть готовым иметь дело.
Инициализация главного экрана
Хотя приложение Turo для Android уже использовало локальный кэш для содержимого главного экрана, мы обнаружили проблему, которая заставляла приложение показывать загрузочный скелет при каждом запуске приложения. Оказалось, что один из сетевых запросов в какой-то момент начал возвращать нулевое значение там, где на клиенте ожидалось не нулевое поле. Это вызвало ошибку сериализации, но она была подавлена одним из операторов RxJava, например onErrorResumeNext. В конце концов, это привело к тому, что часть содержимого главного экрана постоянно запрашивалась из сети, вызывая задержку загрузки, так как оно постоянно не успевало кэшироваться.
Измерение улучшений
Улучшение производительности — это, безусловно, важная задача, но измерять ее крайне необходимо.
Настройка A/B-теста
Описанное улучшение запуска уже доступно 50% наших пользователей в рамках текущего A/B-теста. Поскольку мы избавились от старой SplashActivity и перенесли всю логику запуска в HomeActivity, старая конфигурация запуска имитируется в последней с помощью Splash Screen API, которая закрывает экран, пока выполняются старые сетевые запросы и операции ввода-вывода.
Для того чтобы решить, должен ли пользователь быть помещен в control (медленный запуск) или treatment (быстрый запуск), мы не используем традиционный фиче-флаг, запрашиваемый с удаленного сервера. Это связано с тем, что его нужно запрашивать на очень ранней стадии жизненного цикла приложения, что может привести к проблемам с согласованностью. Вместо этого мы используем довольно простой метод разделения пользователей на экспериментальные группы локально на устройстве, обеспечивая разделение 50/50, как показано ниже.
val deviceId: String = <locally generated and cached UUID> val variant = when (abs(deviceId.hashCode() % 2)) { 0 -> Variant.SLOW_STARTUP 1 -> Variant.FAST_STARTUP else -> error("unreachable branch") }
Этот код сужает строку до одного из двух возможных чисел: 0 или 1, где каждое из них связано с соответствующим вариантом. В случае если строка является случайно сгенерированным UUID, гарантируется разделение 50/50. В нашем случае мы используем локально сгенерированный и кэшированный идентификатор устройства.
В настоящее время эксперимент продолжается, и мы активно собираем и оцениваем данные.
Измерение времени запуска
Кроме того, нам нужно было измерить время запуска самого приложения. Мы не могли полагаться на такие инструменты, как Android Vitals, поскольку нам нужно было четко различать оба варианта A/B-теста. Вместо этого нам нужен был простой инструмент, позволяющий определить время холодного запуска и записать его в аналитику под соответствующим вариантом эксперимента.
Для того чтобы измерить время запуска, нам нужно было определить 2 точки во времени: начало и конец запуска.
Начало запуска. Началом измерения мы считаем момент, когда система вызывает ContentProvider.onCreate. Почему именно контент-провайдер? Просто потому, что контент-провайдеры — это одно из первых написанных разработчиком вещей, которые инициализируются в приложении, даже до класса приложения.
Мы также отслеживаем время с момента запуска процесса с помощью Process.getStartUptimeMillis. Однако большая часть инициализации процесса приложения не находится под контролем разработчика, поэтому подсчет времени запуска с этого момента может быть несколько избыточным. Поэтому в рамках данной статьи мы полагаемся на первое значение.
Конец запуска. Поскольку мы используем Splash Screen API, он может пригодиться при определении момента завершения запуска. У него есть колбек setKeepOnScreenCondition, который вызывается, когда заставка вот-вот будет закрыта. Под капотом это просто OnPreDrawListener. Самый простой способ сделать это показан ниже.
Мы сообщаем время запуска приложения как разницу между конечной и начальной точками, а значение временной метки для каждой из них измеряется с помощью вызова SystemClock.uptimeMillis.
Если вы хотите узнать больше о том, какие метрики следует выбирать для измерения времени запуска приложения Android или как определить холодный запуск, мы настоятельно рекомендуем ознакомиться с серией статей в блоге Пьера-Ива Рико: 1, 2, 3.
Проблема?
Независимо от того, какая метрика используется в качестве начала запуска, проанализировав полученные данные, мы заметили, что приложение сообщало множество чрезвычайно больших значений (например, в некоторых случаях приложение сообщало, что запуск занимает минуты, часы или даже дни). Как оказалось, в некоторых случаях процесс запуска приложения может быть запущен задолго до того, как пользователь нажмет на значок приложения для его запуска.
Проанализировав исходный код существующих решений для мониторинга запуска приложений на Android, таких как Firebase, мы увидели, что эта проблема решается простым игнорированием любого значения, превышающего 60 секунд. Поэтому мы использовали аналогичный подход.
Проверка данных
Но как мы можем подтвердить, что отфильтровывание всех значений, превышающих 60 секунд, сохраняет точность представленных показателей?
Проверить это нам помог тот факт, что мы запустили A/B-тест в 2 итерациях. Читатель, возможно, помнит, что первым изменением, которое мы сделали, было удаление анимации для заставки, которая занимает примерно 1 секунду. Это было единственное изменение для первой итерации эксперимента, и оно обеспечило статическую разницу во времени запуска между “контрольной” и “обработанной” версиями.
Мы проанализировали полученные показатели, отфильтровав все значения, превышающие 60 секунд. При этом мы наблюдали статическую разницу в 1 секунду в каждом процентиле между экспериментальными группами. С другой стороны, когда фильтрация не применялась, разница заметно увеличивалась, чем ближе мы продвигались к 99-му процентилю. Таким образом, мы подтвердили, что фильтрация значений, превышающих 60 секунд, дает правильный результат.
Выводы
Существует множество причин, по которым процесс запуска приложения может замедлиться. Важно начинать совершенствование с тех задач, которые требуют наименьших усилий и дают максимальный результат. Переходя к другой стороне уравнения, всегда важно оценить каждый шаг и понять, стоит ли он затраченных усилий по сравнению с возможным улучшением, которое он дает.
Благодаря значительной разнице в показателях производительности между состоянием до и после, этот проект помог разжечь интерес к другим областям мобильной производительности и производительности разработчиков на платформах Android и iOS в компании Turo.
Хотя улучшение времени запуска является сложной задачей, его измерение сопряжено с дополнительными трудностями. На Android не существует простого API, который мог бы это сделать. Поиск правильного способа представления показателей времени запуска требует проб и ошибок, в то время как тестирование каждой идеи или гипотезы замедляется процессом выпуска продукта и его постепенным распространением среди пользователей.
Во многих случаях инженеры сталкиваются с конфликтом между распределением времени и ресурсов либо на работу над продуктом, либо на оптимизацию производительности. Однако рассмотрение производительности как отдельной функции продукта является неизбежным для его успеха.
Ресурсы
- Улучшение времени запуска приложений на Android: уроки Facebook*
- Как мы сократили время запуска нашего iOS-приложения на 60%
- Как один Android-разработчик за месяц сократил время запуска приложения Lyft 21%
- App startup time — developers.android.com
- Baseline Profiles — developers.android.com
- StrictMode — developers.android.com
- Android Vitals. Why did my process start? — blog by P.Y. Ricau
- Android Vitals. When did my app start? — blog by P.Y. Ricau
- Android Vitals. First draw time — blog by P.Y. Ricau
- firebase/firebase-android-sdk — github.com