Привет! Я Александр Попсуенко, руководитель инфраструктурной команды мобилок Маркета. Сегодня я хочу рассказать, как мы ускоряли наше приложение на Android.

Тут должно быть красивое описание, почему в приложениях важна скорость работы и как она влияет на всякие умные метрики. Но думаю всем и так понятно, что быстрое приложение лучше медленного. Все мы не только разрабатываем продукты, но и пользуемся ими.

Скорость работы приложений важна. Погнали к сути.

Метрики

Чтобы что-то ускорять, это что-то нужно замерить. Важные моменты по метрике:

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

Замеры только конкретный узкий сценарий. Нужно выкидывать замеры открытия экрана, если:

  • показалась ошибка или empty state;

  • показался блокирующий попап, который стоит на пути показа контента экрана;

  • при открытии экрана пользователь свернул приложение, ушёл с экрана;

  • приложения открывается по диплинкам или пуш-уведомлениям.

Если посылать все сценарии в одну копилку, то метрики будут недостоверными. Например, сообщение об ошибке отобразится быстрее, чем контент.

Замерять можно разное время. Здесь я имею в виду разные состояния приложения. Показали хоть что-то — отправили метрику. Показали большое количество контента — отправили метрику. Показали весь контент — снова отправили метрику. Дали пользователю возможность не только смотреть, но и кликнуть — вы уже знаете, что делать.

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

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

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

Замер старта приложения

На схеме отражены основные этапы, которые проходит приложение при старте на примере приложения Яндекс Маркета.

Так стартует Яндекс Маркет
Так стартует Яндекс Маркет

Давайте рассмотрим некоторые этапы детальнее:

Создание процесса. От клика пользователя на иконку и до вызова onCreate у основного инстанса Application. Почему основного? Потому что могут быть другие инстансы Application в других процессах. Получить время, потраченное на создание процесса, можно через вызов метода:

public class MarketApplication extends Application {

  @Override
  public void onCreate() {
    Long processCreationDuration = SystemClock.elapsedRealtime();
    super.onCreate();
    ...
    // Фу, Java. Понимаю. Но переписывать на Kotlin этот класс никто не захотел. И правильно.
  }
}

Важно зафиксировать время как можно раньше. То есть до вызова super.onCreate(). Можно в статическую переменную записать. Это ещё раньше вызова onCreate.

По поводу метода снятия времени есть неплохая серия статей о времени старте приложения.

Инициализация Content Provider. Если в приложении задекларирован ContentProvider, то он инициализируется до вызова onCreate у Application. Некоторые библиотеки используют этот хак, чтобы проводить необходимые им работы на старте, чтобы не утруждать разработчика — например, Firebase.

Инициализация Application. Здесь я говорю о промежутке от вызова onCreate у основного инстанса Application до вызова метода onCreate у основной Activity. Тут выполняется написанный нами код, чтобы что-то сделать на старте. Библиотеки чаще всего инициализируются тут.

Создание Activity. Это создание Activity системой, а также super.onCreate.

Инициализация зависимостей Activity. В блоке onCreate начинаем создавать всё, что нам нужно для работы Activity и дальнейших шагов.

Создание табов и других элементов. Тут создаются табы, добавляются корневые фрагменты, проверяются онбординги — всё то, что считается важным для показа до главного экрана.

Открытие главного экрана. Здесь сосредоточены все те работы, которые присутствуют при открытии любого другого экрана. Об этом дальше.

Мы уже поняли, что нам нужно замерять конкретный сценарий. Если мы замеряем холодный старт (запуск при мёртвом процессе), то нужно игнорировать другие сценарии. Ведь приложение может запускаться не только по клику по иконке, а ещё из пуша, через диплинк, из недавних приложений, при вызове BroadcastReceiver, Service, ContentProvider. А ещё в процессе открытия (пока метрика считается, но не отправилась) приложение могут свернуть, выйти из него и снова зайти, заблокировать телефон, быстро открыть другой экран, пока главный не показался.

Пара примеров, как эти кейсы влияют на метрики:

Как метрика тикает пока пользователь не нажмёт на пуш или не откроет приложение (из-за того, что процесс жив)
Как метрика тикает, пока пользователь не нажмёт на пуш или не откроет приложение. Так происходит из-за того, что процесс жив
Как метрика тикает пока пользователь свернул приложение. А когда развернёт, то отправится огромная цифра
Как метрика тикает, пока пользователь свернул приложение. А когда развернёт, то отправится огромная цифра

Довольно сложно отловить все эти сценарии. Нам помогла стейт-машина.

При различных событиях меняется стейт. А разные стейты и метрику шлют по-разному.

class PushService: FirebaseMessagingService {

  override fun onMessageReceived(message: RemoteMessage) {
    state.onPushReceived()
    // При получении пуш-уведомления стейт поменяется на PushReceivedState
  }
}
override fun onTrimMemory(level: Int) {
  super.onTrimMemory(level)
  if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
    state.onUiHidden()
    // При сворачивании приложения стейт поменяется на UiHiddenState
  }
}

По умолчанию при Application.onCreate стейт устанавливается в ColdStartState. В свою очередь, он меняется на HotStartState при таких событиях:

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

  • Когда открываемся по диплинку. Метрика открытия будет неверной, так как не ждём показа главного экрана, а сразу открываем другой экран.

  • Когда отправили метрику. Переключаемся в стейт горячего запуска. При живом процессе, все запуски горячие.

class ColdStartState: BaseState {

    override fun onUiHidden(): BaseStartAppState {
        cancelAllEvents()
        // отменяем отправку всех событий, так как они будут нерепрезентативными
        return UiHiddenState(this)
    }
    
    override fun onMainScreenShowed(): BaseStartAppState {
        sendMainScreenOpenedMetrica()
        // отправляем метрику скорости открытия главного экрана
        return HotStartState(this)
        // пока процесс жив, то дальнейшие открытия главной будут считаться горячим запуском
    }
}
class UiHiddenState: BaseState {

    override fun onUiHidden(): BaseStartAppState {
        return this // ничего предпринимать не нужно. Мы уже в данном состоянии
    }
    
    override fun onMainScreenShowed(): BaseStartAppState {
        // не отправляем метрику скорости открытия главного экрана,
        // так как открытие прерывали, свернув приложение
        return HotStartState(this)
        // пока процесс жив, то дальнейшие открытия главной будут считаться горячим запуском
    }
}

Замер скорости открытия экрана

Открытие экранов замерять намного проще в отличие от старта приложения.

Основные этапы открытия экрана в приложении Маркета
Основные этапы открытия экрана в приложении Маркета

Код замеров

В случае Маркета можно выделить следующие сущности:

  • Router — класс навигации. Знает, что за экран открыт сейчас и как открыть другой.

  • SpeedService — синглтон класс, который хранит и отправляет метрики скорости.

  • MetricaSender — класс, отвечающий за отправку метрик с конкретного экрана. А ещё он нужен для того, чтобы легко из любого места в коде можно было отправить детальную метрику к экрану. Например, сколько выполнялся запрос за данными на уровне обёртки для сетевого слоя.

Отправка метрик с помощью общих классов
Отправка метрик с помощью общих классов

Помните, в начале статьи мы подчеркнули, что стартовать замер нужно при взаимодействии пользователя? Иногда проще в навигацию положить код. Но если у вас есть явные сценарии, когда до навигации что-то происходит, то лучше стартовать метрику руками (не в коде навигации).

Для чего нужны эти сущности:

Стартовать замер. При навигации на экран, Router записывает в SpeedService время начала открытия → router.navigateTo(TargetScreen()).

Завершить замер. MetricaSender конфигурируется контекстом экрана. Разработчик просто вызывает метод, когда хочет завершить замер. MetricaSender с информацией об экране идёт в SpeedService, берёт время начала открытия для экрана и фиксирует текущее время — metricaSender.finishScreenOpening().

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

Отменить замер. MetricaSender говорит SpeedService, что нужно удалить время старта метрики для данного экрана — metricaSender.cancelScreenOpening().

Ускорение

Теперь перейдём к самому вкусному — разберёмся, где можно набрать скорости.

Ускорение старта приложения

Создание процесса. На этом этапе мы мало что можем улучшить. Хотя ребята из Яндекс Браузера рассказали, как они смогли повлиять и на это.

Инициализация Content Provider. В блоке про метрики отдельное внимание было уделено тому, что Application.onCreate() вызывается после ContentProvider.onCreate(). Даже если у вас нет своих контент-провайдеров, возможно они есть у библиотек, которые вы используете. Например, при подключении FIrebase её не нужно инициализировать в Application.onCreate(), потому что она делает это через Content Provider.

Но проблема в том, что это занимает некоторое время. И в это время ничего больше не происходит. А телефоны у нас многоядерные.

Firebase инициализируется в контент-провайдере
Firebase инициализируется в контент-провайдере

Решение очень простое: переопределяем Content Provider FIrebase и выключаем его. Но это, конечно же, если он вам не нужен на старте Application.

<provider

  android:name="com.google.firebase.provider.FirebaseInitProvider"

  android:authorities="${applicationId}.firebaseinitprovider"

  android:enabled="false"

  android:exported="false" />

А библиотеку инициализируем на старте в фоновом потоке:

FirebaseApp.initializeApp(application)

Только выкидывание Firebase в фон дало нам ускорение около 300 миллисекунд на слабых девайсах.

Не основные процессы. Некоторые библиотеки (у нас это Passport и AppMetrica) работают в собственных процессах. Но класс (не инстанс) Application на всех один. Поэтому в процессе библиотек может проводиться работа, которая там совсем не нужна.

Пример, как определяется процесс и возвращается делегат для выполнения в Application.onCreate():

@NonNull
private ApplicationDelegate delegate() {
  if (delegate == null) {
    final ApplicationDelegate processDelegate;
    switch (MarketProcess.getId()) {
      case MAIN:
      case ERROR_REPORT:
        processDelegate = createMainDelegate(MarketProcess.isInForeground());
        break;
      case PASSPORT:
        processDelegate = new PassportApplicationDelegate(this);
        break;
      case METRICA:
        processDelegate = new MetricaApplicationDelegate(this);
        break;
      default:
        Timber.e("Create stub delegate for unknown process");
        processDelegate = new StubApplicationDelegate();
    }
    delegate = processDelegate;
  }
  return delegate;
}

Кейс довольно редкий. Далеко не во всех приложениях используются несколько процессов. Но нам это сэкономило сотни миллисекунд на старте приложения.

Инициализация Application. Выносим в фоновые потоки то, что не критично для запуска,, чтобы использовать многоядерность.

Вынести можно инициализации библиотек (карты к примеру), выполнение каких-то проверок и т.п.

Если в Application.onCreate у вас много чего, то пробуйте вынести что-то неважное на фоновый поток.

Выпиливаем SplashActivity. Когда-то давно у нас была Activity, которая после показа сплеш-скрина проверяла на наличие Force Update. Она ещё предлагала выбрать регион и показывала онбординг. Но для системы запуск Activity — не самая дешёвая задача, поэтому мы пришли к подходу Single Activity и рады этому.

Блокирующий онбординг. Онбординг показывается один раз за всё время использования приложения. Зачем проверять, что нужно его показать каждый раз? Правильно — незачем. Проверка подобного на старте приложения должно отнимать миллисекунды, не более.

Открывал приложение 100 раз, а прошёл ли онбординг — всё ещё проверяется
Открывал приложение 100 раз, а прошёл ли онбординг — всё ещё проверяется

Ускорение экранов

Ускорение создания объектов. Мы используем библиотеку Dagger 2 для управления зависимостями. И так как приложение очень большое, то и классов и, соответственно, создаваемых объектов очень много.

Мы применили практику аспектно ориентированного программирования и сделали инструмент, который показывает: время, потраченное на инициализацию объекта, и количество инстансов на каждый класс. С его помощью мы видели в реальном времени, насколько много создаётся объектов.

В итоге мы:

  • развесили аннотации @Reusable на классы, у которых нет состояния или зависимостей;

  • внедрили правила по использованию dagger.Lazy, чтобы не инициализировать зависимости класса, которые не потребуются здесь и сейчас;

  • реализовали механизм по созданию Rx-цепочки в фоновом потоке;

  • выкинули для многих классов лишнее из конструктора.

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

Создание тяжёлых объектов в фоне. Иногда всё-таки приходится создавать тяжёлые объекты, и их никак не оптимизируешь (например, они библиотечные). Мы реализовали обёртку, которая делает это в фоне, а не блокирует main thread.

private val itemsFactoryHeavy = Heavy.initInBackground { itemsFactoryProvider.get() }
private val itemsFactory by lazy { itemsFactoryHeavy.get() }

Кеширование тяжёлых данных. Критичные для запуска данные можно хранить не в долгих хранилищах, а кешировать их в быстром. Протухают они не так часто, зато не тормозят приложение.

Кеширование и шаринг Rx-цепочек. Довольно часто в приложениях есть данные, на которые смотрят множество подписчиков. Чтобы не создавать Rx-цепочку для каждого такого подписчика, мы используем библиотеку RxReplayingShare. Она позволяет: не уничтожать цепочку, когда от неё все отписались, и отдавать последние данные новому подписчику.

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

Ускорение Presenter. Если предыдущий вариант дорог в разработке, то можно применить более простой, но менее эффективный.

Для реализации MVP мы используем Moxy. Метод onFirstViewAttach у презентера вызывается довольно поздно — при вызове onResume у фрагмента. Если у вас много что происходит при создании экрана (сложный UI, тяжёлые зависимости), то время от onCreate до onResume фрагмента может быть не маленьким.

Можно попробовать создать контракт, который позволяет запустить выполнение операций в презентере. По сути нам нужно запустить загрузку контента раньше, чем вызовется onResume у фрагмента.

Тут, как мне кажется, стоит учесть несколько моментов:

  • не стоит менять реализацию и контракт MVP и вызывать onAttach презентера до onResume фрагмента;

  • стоит придумать метод у презентера, который позволит делать то, что хочется;

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

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

Для быстрого показа карточки товара используем данные с поисковой выдачи
Для быстрого показа карточки товара используем данные с поисковой выдачи

Показ экрана будет мгновенным. В реализации - не rocket science, а эффект очень крутой.

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

В Маркете, например, список товаров в корзине хранится в памяти. При открытии можно сразу показать его. А потом уже дополнительные данные.

Слева - данные из памяти. Справа - из сети
Слева - данные из памяти. Справа - из сети

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

Мы такое применяем на старте приложения. Пока создаётся Activity, зависимости для неё и для других экранов, можно на этапе Application.onCreate стартануть запрос за данными для главного экрана. В зависимости от того, сколько этот запрос выполняется, на столько можно ускорить старт приложения.

Старт запроса максимально заранее. При открытии экрана основную часть времени до показа контента, как правило, занимают сетевые запросы. Их, конечно, можно ускорять, но, скорее всего, они не станут быстрее, чем остальные операции. Логично, если стартовать запрос как можно раньше, то он и отработает раньше, а значит контент покажется пользователю быстрее. Таким образом можно сэкономить 100+ миллисекунд.

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

Один запрос на экран. Можно сэкономить много времени, если вместо нескольких запросов сделать один. Это позволит снизить нагрузку на сеть и повысить эффективность показа контента. Тут, конечно, многое зависит от бекенда. Если коллеги не сделают единую ручку на экран, то будет сложно что-то предпринять.

Пагинирование экранов. Даже если экран не предусматривает пагинацию, не стоит делать из него большой ScrollView, который будет долго загружаться и отрисовываться. Вместо этого создайте RecyclerView и добавляйте данные по мере скролла.

Если найдёте у себя в проекте ScrollView, а в нём много-много view (на несколько экранов), то смело рефакторите.

Лёгкая первая страница. Чтобы максимально быстро показать пользователю контент, можно загрузить минимальное количество данных, необходимое для заполнения экрана. Если экран пагинируется, то вместо 24 больших сниппетов можно загрузить всего 5. Таким образом, пользователь сразу же увидит первую страницу контента, а после этого будет загружать вторую.

Очень простой в реализации способ, который даёт ускорение в десятки процентов от текущих показателей.

Освобождение main thread. Как-то мы заметили, что данные были получены, но при переходе на главный поток проходило какое-то время — у нас около 100 мс. Выяснилось, что main thread был загружен и долго не освобождался. Мы оптимизировали его и проблема с задержкой в отображении данных исчезла.

А оптимизировали мы его за счёт следующего пункта.

Ускорение отрисовки. Тут можно посоветовать много и более предметно. Но опишу несколько советов:

  • упрощайте сложную вёрстку. На onMeasure и onLayout могут уходить десятки миллисекунд;

  • переиспользуйте view holder у RecyclerView;

  • предсоздавайте view holder в фоне, пока грузятся данные;

  • создавайте view в фоне с помощью AsyncLayoutInflater.

Асинхронное создание view - это уже сложновато и могут быть баги. Но иногда можно найти неоптимальные места в вёрстке и очень легко поправить. Тем самым ускорив и скорость показа контента и скорость скролла.

Кеширование запросов. Популярная практика. Можно использовать стандартные механизмы OkHttp, можно накрутить своё кеширование с блекджеком и ... ну сами понимаете.

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

Экран будет показываться практически мгновенно. И ещё нагрузка на сеть понизится, за счёт чего другие запросы не будут стоять в очереди.

HTTP ETag. Если у вас есть кеширование запросов, то можно настроить технологию, которая позволит сэкономить на отправке данных с бэкенда и парсинге ответа. Она работает так:

  1. При первом запросе сервер отдаёт ответ и идентификатор этого ответа.

  2. Вы кешируете ответ и идентификатор.

  3. При следующем таком же запросе посылаете идентификатор.

  4. Если ответ такой же, то сервер не возвращает ответ, а присылает код 304, который говорит, что данные не изменились, используйте свой кеш.

Если ваш бекенд поддерживает эту технологию и у вас есть кеш, то обязательно попробуйте. Включить не сложно, а сэкономит сотни миллисекунд (особенно на медленном интернете)

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

В Маркете мы так подгружали первую страницу каталога. Так как это довольно популярный экран. За счёт этого экран открывался и показывал контент моментально.

Моментальная реакция на действие. Иногда можно не крутить анимацию ожидания после нажатия пользователя на кнопку, а сразу показать, что действие выполнено. В фоне можно отправить запрос. Если всё прошло успешно, то пользовательский интерфейс не изменится. Но в случае отсутствия сети или ошибки произойдёт откат UI в исходное состояние и будет показана ошибка.

В Маркете так работает кнопка добавления товара в корзину. Реализация не сложная, а эффект очень заметный.

Инструменты

Timeline

Когда есть большой и длительный процесс (например, запуск приложения), в котором участвует много составляющих, хочется понять, как они взаимосвязаны и каким образом они влияют на длительность процесса. Так можно обнаружить пустые промежутки во времени, на которые необъяснимо зачем потрачено время.

Чтобы построить такую временную шкалу, наш разработчик написал библиотеку Timeline. С помощью удобного API она позволяет собирать, фильтровать, преобразовывать временные интервалы, шарить собранные данные и строить временную шкалу на их основе.

Пример таймлайна из README
Пример таймлайна из README

Мы используем этот инструмент не только для профилирования, но и для того, чтобы сотрудники, у которых есть к debug-меню, могли просмотреть информацию и отправить разработчику данные о своём случае при использовании приложения, чтобы понять, почему открытие экрана занимает так много времени.

Chrono

Помимо Timeline наш талантливый разработчик реализовал ещё одну очень полезную при профилировании библиотеку Chrono. В лог автоматически пишется ссылка на код, который замерялся.

Пример лога из README.md
Пример лога

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

Пример лога из README.md
Пример лога

Профайлер

Ну и конечно, никак не обойтись без стандартного профайлера из Android Studio. Профайлер хоть и долго работает, зато детально показывает участки кода, которые отнимают больше всего времени.


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

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


  1. ris58h
    15.06.2023 10:37
    +8

    Скорость работы приложений важна.

    Донесите эту мысль до команды Yandex Go, пожалуйста. 4 секунды смотреть на загрузочный экран - так себе удовольствие.


    1. ganzmavag
      15.06.2023 10:37
      +4

      Сначала подумал, что вы преувеличиваете, но открыл Яндекс Go и да, реально так оно и есть. Причём устройство далеко не слабое, один из прошлых флагманов на снэпе 865.


  1. Rusrst
    15.06.2023 10:37

    Почему macrobemchmark не рассматриваете? Он для этих кейсов подходит идеально же.


    1. mefi100fell Автор
      15.06.2023 10:37

      Не пробовали такое решение.

      Это, я так понимаю, в качестве альтернативы Timeline.

      Мы используем Timeline и в проде, чтобы пользователи, у кого есть доступ к меню разработчика, могли делиться таймлайнами с внутренней информацией о запросах и т.п.

      Для прода Macrobenchmark не особо подходит.

      Но для своих кейсов - норм.


      1. Rusrst
        15.06.2023 10:37
        +1

        Да, если вы хотите смотреть прям в проде не вариант, но вроде firebase performance что-то такое может собирать, но сам не щупал, точно не скажу.

        Macrobemchmark позволяет локально на физ устройстве прогонять тесты по нужным кейсам, смотреть время старта, медленные кадры и прочее - что Android vitals отслеживает.


  1. qoj
    15.06.2023 10:37

    Расскажите подробнее про то, как отслеживаете количество инстансов созданных даггером и время на их создание.