Оптимизация под контролем: инструменты и метрики для Аndroid-приложений

930a2bbb55bd3d93dc3badcc331b3240.png

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

Меня зовут Григорий Рылов, я занимаюсь мобильной разработкой под Android 9 лет, увлекаюсь темой производительности. Работаю старшим разработчиком ВКонтакте, в проекте VK Клипы. В этой статье поговорим про оптимизацию времени запуска Android-приложения, разберём основные метрики старта и инструменты, с помощью которых можно анализировать производительность.

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

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

  • Приложения, которые запускаются быстро,  будут выделяться на фоне конкурентов и привлекать больше пользователей.

  • Оптимизация приложения улучшает его рейтинг в сторах.

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

Какие метрики производительности мы можем собирать

Начать можно с базовых:

  • TTID (Time To Initial Display) — время с момента запуска приложения до отрисовки самого первого кадра. Скорее всего, в приложении будут видны некоторые контролы и заглушки без реальных данных.

  • TTFD (Time To Fully Drawn) — время с момента запуска приложения до отрисовки целевого контента с реальными данными, полученными из сети или из кеша.

Если с TTID Android справляется сам, то в случае с TTFD система не знает, когда именно в приложении наступает момент с отрисовкой реальных данных. Тут нужно немного помочь, вызвав метод ComponentActivity.reportFullyDrawn().

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

Давайте немного затронем тему старта Android-приложения. Он может быть трёх видов.

Холодный старт:

  • Приложение стартует с нуля.

  • Создаётся процесс приложения.

  • Запускается Application.onCreate(), где инициализируются различные библиотеки.

  • Запускается главная Activity.

  • Происходит инициализация, измерение, расстановка виджетов на экране.

  • Происходит отрисовка первого кадра.

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

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

Тёплый старт:

  • Процесс приложения уже создан, но может возникнуть ситуация, когда система выгружает приложение из памяти. Тогда происходит его пересоздание, и в этом случае вызывается Application.onCreate().

  • Запускается Activity, метод onCreate содержит savedInstanceState не равный null.

  • Происходит инициализация, измерение, расстановка виджетов на экране.

  • Происходит отрисовка кадра.

 Горячий старт:

  • Процесс приложения уже создан.

  • Activity не была уничтожена.

  • У Activity вызывается onStart, onResume.

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

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

Какие выводы можно сделать:

  • Нужно разделять метрики, связанные с холодным и тёплым стартами.

  • Холодный старт — самый продолжительный по времени.

  • Меньше всего времени уходит на горячий старт, поэтому при оптимизации лучше сконцентрироваться на улучшении холодного и тёплого.

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

Какие ещё метрики будет полезно собирать

Перечисленные выше метрики важны и доступны в Android Vitas или в Android Studio, но охватывают довольно большие куски кода и разные компоненты. В случае ухудшения метрик будет сложно понять, в каком именно месте произошла просадка: может, при инициализации новой библиотеки, а может, при отрисовке нового View на экране. 

Чтобы немного сузить круг, было бы полезно ввести дополнительные метрики.

Как точку отсчёта обычно берут конструктор Application, а далее можно измерить:

  • Время Application.onCreate().

  • Время ContentProvider.onCreate().

  • Время Activity.onCreate().

  • Время Activity.onResume().

  • Время Inflate и onMeasure у View, которые участвуют в отображении первого кадра.

Следующие метрики не относятся напрямую ко времени старта, но тем не менее влияют на пользовательский опыт — плавность анимации и скролла:

  • Наличие фризов (кадров, которые не успели отрисоваться за 16 миллисекунд).

  • Агрегация продолжительности отрисовки кадров.

  • Если есть RecyclerView, полезно отслеживать продолжительность таких методов, как onCreateViewHolder и onBindViewHolder.

Как получить метрики

В самом простом случае практически ничего не нужно программировать — некоторые метрики доступны «из коробки». В Android Studio во вкладке Logcat можно отфильтровать по слову Displayed и увидеть метрику TTID.

Пример вывода TTID:

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms

ActivityManager: Displayed com.android.myexample/.StartupTiming: +3s534ms (total +1m22s643ms)

Иногда в выводе может отображаться total— такое происходит, когда в старте приложения задействовано несколько Activity, но некоторые не успели отрисоваться. Время фиксируется c первой отрисованной Activity.

Чтобы посмотреть метрику TTFD, нужно поискать в Logcat Fully drawn.

system_process I/ActivityManager: Fully drawn {package}/.MainActivity: +1s54ms 

Если по каким-то причинам не хочется смотреть в Android Studio вышеописанные события, можно воспользоваться терминалом и запустить приложение следующей командой:

adb [-d|-e|-s ] shell am start -S -W
com.example.app/.MainActivity
-c android.intent.category.LAUNCHER
-a android.intent.action.MAIN

Если не хватает этих основных метрик, можно обратиться к инструменту от Google Jetpack Benchmark. Он состоит из двух частей: Macrobenchmark и Microbenchmark.

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

  • Microbenchmark делает атомарные замеры и позволяет определить, как быстро выполняется какой-то конкретный код приложения.

  • Разработка ведётся за счёт инструментальных JUnit-тестов.

Библиотека Jetpack Benchmark интегрирована в Android Studio. В библиотеке есть API для измерения производительности Android-приложений. Тут собираются как минимум основные метрики TTID, TTFD. Также вы сможете увидеть время измерения, расстановки и отрисовки View. Результаты будут агрегироваться: минимум, максимум и медианное значение.

Для задачи мониторинга всего старта приложения нам пригодится Macrobenchmark.

Во время его запуска автоматически производится несколько прогонов. После становятся доступны записанные Systrace.

Если же вы захотите добавить свои метрики, можете использовать Systrace.beginSection, Systrace.endSection.

Добавление модуля для записи с помощью Marcobenchmark

Добавление модуля для записи с помощью Marcobenchmark

Если же вам мало того, что позволяет Macrobenchmark и Microbenchmark, переходите на следующий уровень — ручной сбор метрик. 

Замеряете с помощью SystemClock.elapsedRealtimeNanos() время перед выполнением измеряемого кода и после того, как он выполнился. Далее необходимо вычислить разность, сохранить её либо отправить на сервер, где находится статистика использования вашего приложения. Важно преобразовать результат в миллисекунды или наносекунды. Последние удобны тем, что некоторые методы могут быть быстрее миллисекунды: при замере в миллисекундах в результатах будут нули. Также полезно сохранить возможность вывести в Logcat, но лучше это сделать под билд-флагом или фича-флагом, чтобы усложнить конкурентам возможность сравнивать результаты старта :)

И если решите выводить в Logcat, то лучше использовать Log.e либо другой аналог, который не вырезается ProGuard. Чуть позже я расскажу, почему это нужно.

Пример кода, как можно замерять метрики

Пример кода, как можно замерять метрики

Давайте посмотрим, как можно замерить метрику TTID, то есть время первой отрисовки кадра.

Пример замера TTID

Пример замера TTID

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

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

Теперь давайте посмотрим, как замерять фризы.

Есть полезный инструмент — FrameMetricsAggregator. На рисунке ниже можно увидеть, как им пользоваться.

Использование FrameMetricsAggregator для записи данных об отрисовке кадров 

Использование FrameMetricsAggregator для записи данных об отрисовке кадров 

FrameMetricsAggregator получает агрегированную информацию о количестве кадров с определённой продолжительностью. Так можно увидеть, были ли у вас фризы.

У инструмента есть один нюанс — он делает замеры для текущего Window.

Нам в VK Клипах пришлось форкнуть этот класс и добавить уже метод, который будет брать конкретный Window, например с диалога, и делать замеры для нужного Window. Ставьте лайки в Issue на доработку.

Вышеописанные способы позволяют локально посмотреть результаты замеров, но лучше собрать статистику с реальных пользователей, у них самая правильная картина. Поэтому лучше отправлять данные в системы сбора статистики, например Tracer, AppMetrica, Firebase Performance Monitoring.

Профилировщики

Итак, мы определились с метриками, научились брать данные с пользователей. Но видим общую картину по производительности приложения и понимаем: что-то пошло не так, что-то замедлилось. Что делать дальше? Скорее всего, придётся воспользоваться профилировщиком, чтобы увидеть, что на самом деле пошло не так.

Сколько бы ни было метрик, они достаточно дискретные, и между стартом Activity и отрисовкой первого кадра выполняется очень много кода. Без профилировщика не обойтись.

Android Studio поставляет свой профилировщик, который доступен сразу. Он позволяет наблюдать потребление памяти, энергии, а в нашем случае для анализа производительности старта приложения полезно посмотреть раздел CPU, где можно записывать call-семплы в формате Simpleperf — семплирующий профайлер.

Семплирующий профайлер собирает данные путём периодического «семплирования» состояния программы в определённые моменты времени.

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

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

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

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

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

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

Eсть трассировка всех методов Java/Kotlin. В таком случае абсолютно все методы видны на записанном графике вызовов методов. Минус замера в том, что он сильно тормозит приложение: если у вас есть быстрый метод со множеством быстрых вызовов, замер может длиться дольше, чем более медленный метод с меньшим количеством вызовов.

В Android Studio Profiler есть уже немного устарелый семплирующий профилировщик Java/Kotlin. В отличие от Simpleperf, он измеряет только вызовы Java и Kotlin (JVM). В то время как Simpleperf позволяет увидеть выполнение нативных методов (NDK) и системные вызовы.

Также в Android Studio Profiler могут собираться графики вызовов с Systrace, на которых можно увидеть некоторые системные вызовы, размеченные в Android SDK с помощью Systrace.beginSection и Systrace.endSection. Ничего не мешает разметить таким образом свои методы, чтобы увидеть их на записанном графике вызовов.

В чём отличие Systrace от других способов записи графиков вызовов? Разметка участков кода с помощью Systrace меньше всего влияет на производительность (я бы сказал не влияет, если не переборщить с количеством таких секций). Одна секция Systrace — это не обязательно один метод, вы можете разметить таким образом произвольный кусок кода, хоть от Application.onCreate до Activity.onDestroy(). Обратная сторона этого подхода — данные будут слишком дискретные, и вы не сможете увидеть, что пошло не так внутри блока между Systrace.beginSection и Systrace.endSection. Можно было бы с помощью модификации байткода промаркировать все методы, но тогда приложение будет тормозить, как при записи графика вызовов с помощью полной трассировки всех методов.  

Внешний вид Android Studio Profiler

Внешний вид Android Studio Profiler

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

Какие у нас есть альтернативы? Можете присмотреться к профайлеру YAMP.

f3e7f8fa8841fd567a6d87f8728fd67f.png

По сравнению со студийным он работает намного быстрее и более плавно. К сожалению, пока он не умеет записывать Simpleperf-трейсы, но способен работать как просмотрщик для них, с полной трассировкой и с Legacy JVM семплирующим профайлером.

Кратко поговорим о плюсах:

  • Работает намного быстрее студийного профайлера, легко позволяет работать с графиками вызовов 400+ Мбайт (готовьте оперативку на ПК).

  • Больше шансов записать информацию со старта.

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

  • Намного больше горячих клавиш для навигации.

  • Поиск по всем потокам с возможностью переключаться на результаты в других потоках.

  • Возможность деобфусцировать названия методов с помощью маппинг-файла.

  • Есть возможность сравнивать два графика вызовов (либо отдельные куски) с наглядным представлением, какие методы перестали вызываться, а какие добавились (удобно использовать с результатами полной трассировки JVM). На данный момент в плагине это не работает, доступно только в отдельном приложении. Подробнее можно ознакомиться по ссылке. 

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

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

Последнюю версию можно скачать по ссылке.

Сам я давно перешёл на YAMP. Для поиска регресса профилирую с помощью JVM Sample Profiler с сэмплированием 3 000 микросекунд, а для анализа записываю полную трассировку методов. С недавних пор в YAMP доступен просмотр Simpleperf, но записывать придётся с помощью Android Studio или командной строки. Подробнее можно почитать по ссылке.

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

7b41fa18d83835d82786c1a2fbe9965c.png

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

В этом плане лучше использовать реальные устройства. Не зря библиотека Macrobenchmark перед стартом проверяет, что запускается именно физическое устройство.

Давайте подумаем, что лучше — debug-сборка или release-сборка. В debug-сборке:

  • Содержится отладочная информация.

  • У неё больше размер APK.

  • Проходят дополнительные проверки во время выполнения кода, которых нет в release-сборке.

  • Производительность ниже  разработчику проще отлаживать приложение.

В release-сборке:

  • Есть оптимизация компилятором, которой нет в debug, поэтому код у пользователей будет немного отличаться от кода debug-сборки.

  • Размеры APK меньше.

  • Отсутствует отладочная информация.

  • Производительность намного быстрее, чем в debug-сборке.

Если вы будете делать замеры в debug-сборке, то можете получать результаты, непропорциональные тем, что получились в release-сборке.

Debug-сборки непропорционально медленнее release-сборок

Debug-сборки непропорционально медленнее release-сборок

На рисунке можно посмотреть на замеры одного и того же приложения, сделанные на телефоне с фиксированной частотой, и разные куски кода. Например, время измерения в ConstraintLayout. В debug-сборке — 56 мс, в release-сборке — 16 мс, то есть в 3,5 раза быстрее.

Но если посмотреть время MainActivity.onCreate, то в debug-сборке в 4,8 раза медленнее, чем в release. Получается, замедление происходит непропорционально, и нельзя сказать, что debug-сборка будет работать в X раз медленнее. Разные куски кода могут замедляться по-разному. Eсли вы видите какое-то аномальное замедление в debug-сборке, то не факт, что эта проблема будет в release. Профилировать debug-сборки лучше для анализа поведения, а если вы хотите понять, с какой скоростью работают различные участки кода, то лучше профилировать release-сборки.

При замерах на реальных устройствах могут появиться проблемы с повторяемостью результатов: в современных смартфонах есть big- и little-ядра, и код может выполняться на быстрых или медленных ядрах. Также со временем нагревается телефон, происходит троттлинг процессора, и дальнейшие замеры могут показать более длительное время, чем на ранних итерациях записи.

Чтобы побороть эту проблему, было бы полезно использовать рутованные устройства. Можно установить сервис, например Kernel Adiutor, в котором зафиксировать частоту и отключить или включить big- и little-ядра. Я обычно фиксирую одинаковую частоту для них.

Если же нет рутованного телефона, можно использовать в AndroidManifest.xml тег , что позволит записывать Simpleperf-файлы (начинает работать с 29 API). Не забудьте добавить в proguard-rules.pro -dontobfuscate, чтобы методы были читабельные, без необходимости искать маппинг-файл для деобфускации.

Внешний вид Kernel Adiutor в режиме управления ЦП

Внешний вид Kernel Adiutor в режиме управления ЦП

Итак, мы посмотрели основные метрики, узнали, что делать, если они просели, как найти просадку и действовать дальше.

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

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

© Habrahabr.ru