Jetpack Compose как unbundled-библиотека. Скорость UI vs. Гибкость разработки
Введение
Jetpack Compose может работать у вас медленнее, чем система View, из-за своей архитектуры и дизайна. Но точно имеет бóльшую гибкость.
Дело в том, что Jetpack Compose — это unbundled-библиотека. В этой статье я расскажу о том, что это значит и почему оно влияет на производительность, на примере нашего переезда на Compose.
В статье мы вместе ужаснёмся тому, как долго открываются экраны после переезда на Compose. Но порадуемся за то, что теперь мы не скованы версией Андроида и можем писать Compose-код с расправленными крыльями!
Это был краткий спойлер. А теперь погнали, расскажу подробнее!
История
Я буду рассказывать историю миграции с View на Compose на примере приложения Дринкит. Это диджитал-кофейня с «умной» выдачей и мобильным приложением, в котором можно кастомизировать свой напиток как угодно. Приложение выглядит так:
Как можно увидеть на скриншотах, мы уделяем огромное внимание UI и дизайну. По всему приложению у нас разбросана куча видео.
Для нас UI-перформанс — чрезвычайно важная вещь, мы стараемся постоянно за ним следить. Возможно, какие-то из описанных в статье вещей вам покажутся незначительными, а мы — замороченными на дизайне. Но для нас эти детали очень важны.
В 2023 году мы закончили пилить нашу первую большую фичу на Jetpack Compose. Тогда к нам пришёл продакт и сказал:
«Эй, ребят, а что так долго открывается вот этот новый экранчик, который вы сделали? Ну, прям очень долго.»
Я поясню, что это значит, через картинку:
Слева наш главный экран «Меню», который написан на старой доброй системе View. Мы тапаем на кофе и переходим на новый экран, который полностью написан на Compose. К слову, этот экран имеет сложную UI-логику, он нагружен анимациями, кастомными элементами и зависимостями их положений от скролла и других действий.
Так вот, этот переход длился 1–2 секунды на средних девайсах: не самых медленных и не самых последних и быстрых. Думаю, для многих это огромная задержка, для нас так точно. У нашего продакт-менеджера дёргался глаз, как на этой гифке:
Но мы сразу поняли, что долгим был только первый переход на Compose-экран. Нажав «Назад» и перейдя на такой же Compose-экран, пусть и с другими данными, мы увидели быстрый переход и плавное открытие экрана. Проблема была только в первом переходе на Compose-экран после запуска приложения — все остальные переходы были быстрыми.
Unbundled library
Так происходит, потому что Jetpack Compose — это unbundled библиотека. Цитата из официальной документации:
Since Jetpack Compose is an unbundled library, it doesn’t benefit from Zygote that preloads the View system’s UI Toolkit classes and drawables.
Т.е. как unbundled-библиотека Jetpack Compose не получает никаких преимуществ от процесса Zygote, который предзагружает все классы и ресурсы системы View.
Давайте разберёмся, что это значит. В Андроиде существует родительский процесс под названием Zygote. Каждое наше приложение создаётся в процессе, который был форкнут от процесса Zygote. Т.е. всё, что было в Zygote, уже есть в наших процессах и, соответственно, в наших приложениях.
Если мы посмотрим на файл ZygoteInit.java в исходниках Андроида, то найдём метод preload
. В этом методе происходит куча всего: загружаются классы и ресурсы, прогревается WebView и многое другое. Нас интересует метод preloadClasses.
static void preload(TimingsTraceLog bootTimingsTraceLog) {
beginPreload();
...
preloadClasses();
...
Resources.preloadResources();
...
nativePreloadAppProcessHALs();
...
maybePreloadGraphicsDriver();
...
preloadSharedLibraries();
preloadTextResources();
...
WebViewFactory.prepareWebViewInZygote();
endPreload();
warmUpJcaProviders();
...
sPreloadComplete = true;
}
В этом методе мы читаем файл PRELOADED_CLASSES
:
InputStream is;
try {
is = new FileInputStream(PRELOADED_CLASSES);
} catch (FileNotFoundException e) {
Log.e(TAG, "Couldn't find " + PRELOADED_CLASSES + ".");
return;
}
...
Что такое файл PRELOADED_CLASSES
? Это константа, объявленная таким образом:
private static final String PRELOADED_CLASSES = "/system/etc/preloaded-classes";
Вы можете посмотреть содержимое этого класса. В нём более 17 тысяч строк, а каждая из них — это полное имя класса Андроид-фреймворка. В том числе системы View.
Далее мы читаем строчку за строчкой и загружаем каждый класс Андроид-фреймворка в память через метод Class.forName
:
while ((line = br.readLine()) != null) {
// Skip comments and blank lines.
...
try {
// Load and explicitly initialize the given class. Use
// Class.forName(String, boolean, ClassLoader) to avoid repeated stack lookups
...
Class.forName(line, true, null);
count++;
} catch (ClassNotFoundException e) {
...
}
Таким образом, процесс Zygote уже имеет в памяти все классы Андроид-фреймворка. Это значит, что все классы системы View уже загружены в нашем приложении ещё до нашего Application::onCreate
, до любых Content Provider
или Initializer
.
Именно поэтому классы системы View имеют преимущество перед классами Jetpack Compose, т.к. они уже загружены в память.
Ок, мы разобрались, что Jetpack Compose медленнее системы View by design, потому что при первом использовании Compose мы загружаем кучу классов в рантайме. Отчасти именно поэтому мы видели такую большую задержку при первом открытии экрана, а при последующих — нет.
Но не всё пропало. Можно с этим работать и пробовать оптимизировать. Я расскажу, что делали мы и что для нас сработало, а что не сработало. Но прежде чем что-то оптимизировать, нам надо решить, как мы будем измерять успех. Нам надо договориться, на какую метрику мы будем смотреть. Ведь если мы не измеряем, то мы просто гадаем!
Замеряем UI-перформанс
UI-перформанс замерить не так очевидно и просто, как кажется. Эта задача имеет свои особенности.
В своей прошлой статье «Пишем транзишинометр для Андроид» я рассказал, что метрики UI-перформанса можно разделить на 2 категории:
метрики по кадрам, позволяющие оценить плавность и отзывчивость UI;
линейные метрики, замеряющие время выполнения конкретной операции, например, открытия экрана.
Плавность и отзывчивость UI мы хотим оценивать хотя бы потому, что хотим понять, не сделали ли хуже. Поэтому мы будем замерять метрику по кадрам. Замерять упомянутые продактом 1–2 секунды до открытия экрана мы будем с помощью линейных метрик из второго типа.
Итого, чтобы видеть полную картину, мы решили смотреть на эти 2 метрики:
метрику по кадрам через Macrobenchmark FrameTimingMetric;
время открытия экрана: от создания экрана до последнего
onDraw
колбэка. Я дал этой метрике имя Until Last Draw в своей статье «Пишем транзишинометр для Андроид».
Macrobenchmark: FrameTimingMetric
В этой статье я не буду рассказывать, как настроить и замерить FrameTimingMetric. Об этом можно узнать в официальной документации. Давайте представим, что мы настроили эту метрику, замерили и получили такие значения (они сразу разбиты по перцентилям):
frameDurationCpuMs | frameOverrunMs | |
P50 | 4.6 | -9.7 |
P90 | 25.1 | 79.7 |
P95 | 76.3 | 189 |
P99 | 153.8 | 221.2 |
Сейчас я разберу, что означают эти числа:
frameDurationCpuMs — это сколько времени в ms занимает у процессора отрисовка кадра;
frameOverrunMs — это превышение положенного времени для отрисовки кадра. Например, если кадр должен был отрисоваться за 16 ms, но по факту он отрисовался за 20 ms, то он превысил своё положенное время на 4 ms.
Полученные числа наглядно показывают, почему нам не хватит одной метрики по кадрам. Потому что не совсем понятно, о чём эти числа нам говорят. Возьмём значение 153.8 ms. Это много или мало? Какую чиселку мне нужно здесь увидеть, чтобы я пошёл и сказал менеджеру «смотри, мы всё поправили»? Как она связана с теми самыми 1–2 секундами при открытии экрана?
Никак не связана! Единственное, что мы понимаем: чем ниже число, тем лучше. Мы можем воспринимать эти измерения как baseline, на который будем смотреть и следить, что метрика не идёт вниз.
Until Last Draw
Until Last Draw метрика будет замерять время от onCreate
фрагмента и до последнего колбэка onDraw
. Чем такой способ лучше замеров до первого onDraw, я описал в своей предыдущей статье.
Но для такой метрики есть ограничение. Если вы, как и мы, будете замерять до последнего onDraw
, учтите, что последнего onDraw
может и не быть. Могут быть разные ситуации, например:
у вас есть постоянные анимации на UI-элементах. Например, кнопка подпрыгивает раз в секунду;
вы используете проигрывание видео через TextureView, который постоянно перерисовывается на каждый кадр (это, кстати, был именно наш случай);
в вашем приложении используются любые другие постоянные или переодические анимации.
Для этого вам надо предусмотреть специальный режим, в котором вы можете отключить все ненужные анимации, когда замеряете эту метрику. Мы именно так и делали. У нас для build type benchmark
были специальные условия, где мы отключали часть анимаций, видео и всё остальное, что не являлось частью транзишина на новый экран.
Для замеров мы взяли один из самых популярных девайсов наших пользователей — не самый слабый, не самый мощный. Делали по 30 измерений, считали разные перцентили и считали среднеквадратичное отклонение. Получилось примерно следующее.
Можно заметить, насколько первый переход дольше последующих:
Оговорюсь, что размер ошибки по метрике FrameTimingMetric мы не учитывали — Macrobenchmark её не предоставляет. Делать дополнительные замеры для FrameTimingMetric мы не хотели, но посчитали величину ошибки для нашей метрики Until Last Draw — вышло 4%. Так что к любым изменениям ниже этого значения нужно относиться с осторожностью.
Оптимизации
Метрики и инструменты для замеров мы обсудили. Поговорим о способах оптимизации — их у нас 4:
оптимизация рекомпозиций;
обновление Compose;
Baseline profiles;
«прогрев» Compose.
Оптимизация рекомпозиций
Этот процесс я описывать не буду, про него уже сказано и написано немало. Например, в этих статьях подробно описано, как оптимизировать рекомпозиции:
Я лишь перечислю, что делали мы:
работали со стабильными типами;
работали со Skippable и Restartable функциями;
убирали все тяжёлые вычисления из Composable функций;
убирали всё лишнее, что не нужно прямо сейчас в начальной композиции.
Про последний пункт, однако, я хочу немного рассказать, потому что для меня он стал инсайтом. Посмотрите на гифку ниже:
Здесь изображена логика работы экрана. Слева показано, как выглядит UI для пользователя, а справа — как работает UI-логика. Видите, там есть такая панелька Customize, она ездит вверх-вниз. Изначально мы её добавляли в самую первую композицию, хотя по факту она была не видна. Но мы подумали, что лучше её добавить сразу, чтобы потом было проще показать её и скрыть через анимацию. Эта панелька была непростой: у неё было много дочерних элементов, внутренней UI-логики и анимаций.
Когда мы решили удалить её (и другие подобные кейсы) из начальной композиции, мы увидели сильный прирост производительности. Это показательный пример того, как добавляя слишком много всего в начальную композицию, можно нанести вред перформансу. Это стало для нас настоящим открытием. Но теперь мы стараемся следовать этому правилу и не добавлять в композицию того, что не должно быть видно.
Посмотрим, как это повиляло на метрики:
FrameTimingMetric
frameDurationCpuMs: 221.2 ms → 200.2 ms, -21 ms, -9.4%;
frameOverrunMs: 153.8 ms → 141.9 ms, -11.9 ms, -7.7%.
Until Last Draw
first launch: 1320.65 ms → 1213.5 ms, -107.15 ms, -8.1%;
not a first launch: 823.2 ms → 665.25 ms, 157.95 ms, -19.2%.
Видно, что оптимизация рекомпозиций нам помогла. Как для улучшения FrameTimingMetric, которые показали, что лагов стало меньше, так и для улучшения нашей главной метрики — Until Last Draw. Рост ~10% — неплохо для начала.
Обновление Compose
Когда мы занимались этой проблемой в 2023 году, вышла новая версия Compose 1.5. В ней команда Jetpack Compose увеличила производительность Modifiers до 80% ко времени композиции.
Modifiers see large performance improvements, up to 80% improvement to composition time, in this release.
Мы тут же побежали обновляться и замерять производительность. Посмотрим на результаты:
FrameTimingMetric
frameDurationCuMs: 200.2 ms → 191.6 ms, -8.6 ms, -4.3%;
frameOverrunMs: 141.9 ms → 141.1 ms, -0.8 ms, -0.6%.
Until Last Draw
first launch: 1213.5 ms → 1201.4 ms, -12.1 ms, -1%;
not a first launch: 665.25 ms → 596.1 ms, -69.15 ms, -10.4%.
По метрике frameDurationCuMs мы зафиксировали улучшение на 4.3%. По нашей метрике Until Last Draw для первого запуска мы увидели только улучшение в рамках погрешности, а для последующих запусков — ускорение сразу на 10.4%. Скорее всего, оптимизация работы модифайеров одинаково повлияла на первый и последующий запуски. Основная часть задержки при первом запуске вызвана другими факторами.
Baseline profiles
Baseline profiles у нас уже был. Мы успешно использовали его на старте приложения, увеличив скорость запуска на 20%. Написали об этом статью, кстати.
Но текущая проблема — в середине пользовательского флоу. Поэтому нам нужно расширить сценарий использования Baseline profiles, добавив в него медленный транзишн.
Создать Baseline profiles можно по инструкции из официальной документации, а добавить дополнительные действия в уже существующий «профиль» легко. У нас это выглядит так: открываем наш экран и делаем некоторые стандартные действия — свайпы, флинги и т.д.
private fun MacrobenchmarkScope.productCardFlow() {
openProductCard()
waitForProductCardToLoad()
flingRootListUpAndDown()
clickSizesInSizeSelector()
flingRootListDown()
openAndCloseStories()
closeProductCard()
}
Нам нужен именно тот Baseline profiles, который включает важные для нас «лагучие» участки кода. Смотрим на метрики:
FrameTimingMetric
frameDurationCpuMs: 191.6 ms → 186.6 ms, -5 ms, -2.6%;
frameOverrunMs: 141.1 ms → 130.2 ms, -10.9 ms,-7.7%.
Until Last Draw
first launch: 1201.4 ms → 1073.2 ms, -128.2 ms, -10.7%;
not a first launch: 596.1 ms→ 549 ms, -47.1 ms, -7.9%.
По обеим FrameTimingMetric мы видим прирост: хороший по frameOverrunMs и не очень по frameDurationCpuMs. Главное, что наша метрика Until Last Draw показывает крутой прирост как для первого, так и для последующих запусков — на 10.7% и на 7.9% соответственно. Это классный результат. Двигаемся дальше!
Warmup Compose
Как мы выяснили, View — это bundled-библиотека, поскольку её классы загружаются в память заранее. Мы попробовали сымитировать что-то подобное и загрузить классы заранее.
Самый простой способ — создать пустой ComposeView
на этапе запуска приложения. Например, в Initializer
(он запускается через Content Provider
):
class ComposeInitializer : Initializer {
override fun create(context: Context) {
ComposeView(context)
}
override fun dependencies(): MutableList>> = mutableListOf()
}
Посмотрим на результаты:
FrameTimingMetric
frameDurationCpuMs: 186.6 ms → 186.2 ms, -0.4 ms, -0.2%;
frameOverrunMs: 130.2 ms → 131.6 ms, +1.4 ms, +1.1%.
Until Last Draw
first launch: 1073.2 ms → 964.75 ms, -108.45 ms, -10.1%;
not a first launch: 549 ms → 544.2 ms, -4.8 ms, -0.9%.
Значимый результат показала только самая важная для нас метрика Until Last Draw на первый запуск — прирост сразу на 10.1%. Как мы и планировали, «прогрев» Compose сказался именно на первом запуске!
Также мы снова попытались «закосплеить» процесс Zygote, загрузив все классы вручную. Мы взяли список всех Compose-классов и предзагрузили их на старте приложения.
private fun getComposeClasses(): List {
return listOf(
...
"androidx.compose.ui.layout.LayoutCoordinates",
"androidx.compose.ui.layout.VerticalAlignmentLine",
"androidx.compose.ui.layout.HorizontalAlignmentLine",
"androidx.compose.ui.layout.IntermediateMeasureScope",
"androidx.compose.ui.layout.LookaheadScope",
"androidx.compose.ui.layout.IntrinsicMeasureScope",
// тут все классы Compose
...
)
}
...
private fun warmupCompose() {
val classes = getComposeClasses()
classes.forEach { nextClass ->
try {
Class.forName(nextClass);
} catch (e: ClassNotFoundException) {
println("ClassNotFoundException, $e")
}
}
}
Каких-либо заметных результатов по сравнению с созданием пустого ComposeView
в Initializer
мы не получили. Да и поддерживать этот способ сложнее — при обновлении Compose весь список классов тоже пришлось бы обновлять. Так что от косплея Zygote мы отказались.
Warmup+ Compose
Последнее, что мы попробовали сделать, — внаглую открыть и закрыть «проблемный» экран, пока отображается сплэш. Да, звучит как хак и костыль, но мы хотели посмотреть, какой прирост в производительности получим.
И вот результаты:
FrameTimingMetric
frameDurationCpuMs: 186.2 ms → 185.9 ms, -0.3 ms, -0.2%;
frameOverrunMs: 131.6 ms → 131.9 ms, -0.3 ms, -0.2%.
Until Last Draw
first launch: 964.75 ms → 824.14 ms, 140.61 ms, -14.6%;
not a first launch: 544.2 ms → 543.55 ms, 0.64 ms, -0.1%.
Метрика Until Last Draw на первое открытие выросла ещё на 14.6% — это круто! Мы долго думали, оставлять ли этот костыль. В итоге решили его убрать, вернувшись на шаг назад. Но имейте ввиду: такой хак может стать временным решением, пока вы не придумаете что-то получше.
Результаты измерений
Давайте посмотрим на изменение метрик в динамике и разберёмся, что сработало, а что нет. Начнём с FrameTimingMetric:
Наибольший буст для FrameTimingMetric мы получили при оптимизации рекомпозиций и при добавлении сценариев в Baseline profiles. В остальных случаях метрики поменялись незначительно или вовсе не поменялись.
Теперь посмотрим на метрику Until Last Draw:
Здесь можно выделить 2 момента:
значительные улучшения первого запуска мы увидели на Baseline profiles и на прогреве Compose, хотя и оптимизация рекомпозиций внесла свой вклад;
для последующих запусков хорошо сработала оптимизация рекомпозиций и обновление на Compose 1.5.
Мы ускорили первый запуск на 30%, перешагнули психологическую отметку в 1 секунду и достигли баланса между результатом и трудозатратами. Последующие дни работы давали бы нам всё меньше профита, хотя до идеала было ещё далеко. Плюс мы сделали ставку на будущее: проблема будет уходить сама собой с увеличением количества Compose c первого экрана. Забегая вперёд: так и произошло.
Тормоза vs Гибкость
Написанное выше покажется ужасом для тех, кто заботится о производительности UI в своих приложениях. Но так ли всё ужасно с ней в Compose? Нет, и вот почему.
Описанное выше справедливо не столько для Compose, сколько для нашего конкретного сценария. Его можно изобразить так:
Запустив приложение, пользователь оказывается на экранах, которые написаны на View. Только потом он перейдёт на Compose-экран. Сделает он это медленно, что на схеме и изображает эмодзи улиточки.
Но если у вас первый экран написан на Compose или вашего пользователя встречает микс из View и Compose, то у вас таких проблем не будет. Ведь лаги и задержки — это точная и измеримая вещь с одной стороны, но с другой — она важна, только если её заметит пользователь. В таком сценарии, скорее всего, вся задержка будет на запуске приложения, где пользователь её не заметит. А если у вас поверх показывается Splash, то вы вообще в шоколаде — никто ничего не заметит.
Появляется вопрос:, а почему Compose вообще сделали unbundled-библиотекой? Неужели нельзя было сделать так же, как и с View? Почему мы должны ждать загрузку всех этих классов при первом открытии экрана?
А серьёзный ли это недостаток? Давайте порассуждаем:
во-первых, мы сталкиваемся с ним не всегда. Только при первом переходе на Compose-экран в середине пользовательского флоу;
во-вторых, даже с такими кейсами можно работать. Например, использовать различные способы оптимизации, которые я описывал;
в-третьих, не во всех приложениях высокие требования к UI-перформансу, чтобы жертвовать удобством ради скорости открытия конкретных экранов.
Но такое дизайн-решение — сделать Compose именно unbundled-библиотекой — даёт нам огромные преимущества. Вспомните, как часто мы пишем проверки версий Андроид. Например, такие:
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
...
} else {
...
}
Мы это делаем, потому что конкретная версия фреймворка предзагружена в Андроид ОС. Пользуясь unbundled-библиотеками (на самом деле, как и почти любой внешней библиотекой), мы можем не обращать внимания на версию Андроида. Это ли не прекрасно?
Моё мнение: на заре Андроида предзагрузка View-системы была обусловлена оптимизацией работы под менее мощные устройства того времени. Сейчас же такая проблема уходит, и мы на неё можем наткнуться в редких случаях (которые я описал в статье). Сейчас на первый план выходит скорость и гибкость разработки.
Выводы
Не забывайте: Jetpack Compose — это unbundled-библиотека. В отличие от системы View она не предзагружена в память процесса заранее. И это может приводить вас к неожиданным лагам.
Но не стоит переживать. Проблемы могут и не появиться. А если появились, то с ними можно работать с помощью разных способов: обязательно оптимизируйте рекомпозиции, а также прокачайте свой Baseline profiles и попробуйте «прогреть» Compose.
На пути оптимизации не забывайте замерять UI-перформанс. Для этого вы можете использовать Macrobenchmark и ручной замер отрисовки экрана «до последнего onDraw» или «до первого onDraw». Мы выбрали первый вариант, но второй — проще.
Вам не придётся вечно оптимизировать первый переход на Compose-экран. Когда ваше приложение (или хотя бы его первые экраны) переедет на Compose, проблема решится сама собой.
Отнеситесь к этому как к отличной новости, а не как к проблеме. Теперь вы можете работать c UI более гибко и пользоваться любыми версиями Compose, не обращая внимания на версию Android.
Спасибо, что дочитали статью! Если вам интересен мой опыт, но лень читать большие тексты, подписывайтесь на Telegram-канал «Мобильное чтиво». Там я в формате постов делюсь своими мыслями про Android-разработку и не только.