На конференции Google I/O 2022 показали инструмент Baseline Profiles, с помощью которого можно ускорить запуск приложений после установки.

Мы попробовали его у себя в Дринките и получили прирост до 20% при холодном запуске приложения!
В этой статье расскажу, как внедрить инструмент, оценить его работу на production приложении, немного погружу в историю компиляторов в целом и рассмотрю более продвинутые сценарии для генерации Profile.
Демонстрировать это я буду на нашем приложении Дринкит. Поехали!
Что такое Baseline Profile и причём тут компиляторы
Baseline Profiles — это список классов и методов, которые компилируются заранее и устанавливаются вместе с приложением. Всё это улучшает время запуска и общую производительность приложения для пользователей.
Чтобы понять, как Baseline Profiles позволяет ускорить запуск приложения, давайте сначала посмотрим, как устроена компиляция байткода в Android.
Суть такова, что внутри .apk, который мы устанавливаем на устройство, лежат .dex файлы с байткодом. С готовым байткодом умеет работать виртуальная машина Андроида.
До версии 5.0 Android работал на виртуальной машине Dalvik. На ней использовался JIT (Just-In-Time)-компилятор: код компилировался в рантайме, снижалось потребление оперативной памяти, при этом значительно снижалась производительность компиляции во время работы.
Начиная с версии 5.0 начали использовать ART (Android Runtime) — улучшенную виртуальную среду. Вместе с ней — АОТ-компиляцию (ahead-of-time compilation), которая обеспечивает лучший показатель производительности благодаря предварительной компиляции всего кода. Из-за этого затраты на RAM достигали максимума. И при каждом обновлении системы пользователи наблюдали диалог, который сообщал об происходящей оптимизации приложений.
В качестве оптимизированного подхода с Android 7.0 используется комбинация из обоих миров.
Компилятор по умолчанию проводит JIT-компиляцию байткода, но если в процессе работы приложения будут обнаружены часто используемые участки кода, то они AOT-скомпилируются с помощью утилиты dex2oat, записывая результат компиляции в бинарные .oat файлы.
То, что содержит в себе список классов и методов, которые следует скомпилировать в машинный код, называется Profile.
Получается, что при первом запуске у нас нет скомпилированных кусков кода и JIT компилирует всё, что ему надо для работы, постепенно записывая в Profile то, что нужно для AOT-компиляции – критические, часто встречаемые фрагменты приложения.
Каждый последующий запуск приложения переиспользует ранее скомпилированные куски кода и не компилирует их каждый раз заново. Тем самым каждый последующий запуск становится быстрее предыдущего.
С Android 9.0 появились облачные профили, т.е. пользователи запускают приложения и по мере использования созданные локально профили загружаются в облако и становятся доступны всем, кто скачивает приложение из Google Play. И с этого момента новые пользователи получают быстрый старт приложения при установке из стора.
Но у этого есть небольшой минус: если в облаке ещё недоступны профили, то при первом запуске пользователи будут дольше находиться на экране загрузки.
Исправить этот скачок в времени старта можно, если с приложением уже будут поставляться готовые профили — те самые Baseline Profiles.
В этом и заключается принцип работы Baseline Profiles: мы заранее генерируем файлы, которые скажут Андроиду, что надо скомпилировать AOT — тогда первый запуск будет быстрее, примерно такой, как после 10–20 запусков.
Теперь рассмотрим, как генерировать Baseline Profiles.
Генерируем Baseline Profile
Итак, в первую очередь нам нужно создать в проекте новый модуль benchmark (впрочем, вы можете выбрать любое имя модуля, которое захотите) с типом бенчмаркинга macrobenchmark.
У нас создался модуль с шаблонным макробенчмарк-тестом, который мы пока не трогаем. Теперь создаём новый BaselineProfileGenerator и копируем все из Google codelab.
@RunWith(AndroidJUnit4::class) class BaselineProfileGenerator { @get:Rule val baselineProfile = BaselineProfileRule() @Test fun generate() { baselineProfile.collectBaselineProfile(packageName = /* Указываем packageName */) { // Тут пишем любой свой флоу приложения, // который должен прогоняться для компилирования в машинные команды startActivityAndWait() } } }
Далее, если вы всё ещё читаете это на момент стабильной версии androidx.benchmark:benchmark-macro-junit4:1.1.*, то вам необходимо запустить этот тест на рутовом девайсе. Для этого подходит эмулятор без Google Services. Во время его работы нужно выполнить в терминале:
adb root
Если же вы используете более новую версию бенчмаркинга, начиная с версии1.2.0-alpha06,
androidx.benchmark:benchmark-macro-junit4:1.2.0-alpha*
то сгенерировать Baseline Profile можно даже на реальном устройстве — при этом даже не потребуются root-права.
Всё!
Когда вы запустите тест, то на девайсе будет создан *.txt файл с сгенерированным скомпилированным кодом, который с помощью adb pull (подсказка есть в результате работы теста) можно поместить в проект в /app/scr/main
Как измерить скорость первого запуска
Пришло время замерить, насколько быстрее начало запускаться приложение после старта
Чтобы включить профили в приложение во время тестирования, нужно подключить к app-модулю библиотеку. В общем-то, это всё, что требуется для его установки, помимо наличия самого профиля в /app/src/main
implementation project("androidx.profileinstaller:profileinstaller")
Сам тест для сравнения времени холодного старта будет выглядеть примерно вот так:
@RunWith(AndroidJUnit4::class) class BaselineProfileBenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun startupNoCompilation() { startup(None()) } @Test fun startupBaselineProfile() { startup( Partial( baselineProfileMode = Require ) ) } fun startup(compilationMode: CompilationMode) { benchmarkRule.measureRepeated( packageName = /* Указываем packageName */, metrics = listOf(StartupTimingMetric()), iterations = 10, compilationMode = compilationMode, startupMode = COLD ) { pressHome() startActivityAndWait() } } }
Смысл этого теста в том, что замеряются метрики, переданные параметром metrics для приложения с заданным packageName. Переменным в тесте является compilationMode: в одном случае чистый запуск без baseline-профилей, а второй — с установкой профилей на старте приложения.
Запускаем наш бенчмарк-тест: делаем несколько запусков, чтобы усреднить значения в зависимости от переданного значения iterations:
BaselineProfileBenchmark_startupNoCompilation timeToInitialDisplayMs min 925.8, median 1,047.9, max 1,199.5 Traces: Iteration 0 1 2 3 4 5 6 7 8 9
BaselineProfileBenchmark_startupBaselineProfile timeToInitialDisplayMs min 761.5, median 871.2, max 1,113.8 Traces: Iteration 0 1 2 3 4 5 6 7 8 9
По медианным значениям между двумя тестами можно сразу заметить, что запуск приложения с профилями достигает прироста в скорости на 20%
А это всего лишь самый базовый флоу для генерации Baseline Profile. Google советует описать его подробно для критичного сценария пользователя. Но даже с такими показателями можно проверить, как профили поведут себя на устройствах реальных пользователей.
Чтобы добавить Profile в своё приложение, никаких дополнительных действий делать не нужно — это происходит автоматически, когда копируете их в папку /app/src/main.
Дальше сборку и отправляем в стор. Спустя время можно смотреть графики.
Вспомним, как выглядит график времени запуска в зависимости от количества запусков, где не используются профили:

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

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

Время старта после обновления уменьшилось, чего мы и добивались.
Можно попробовать предположить, почему зелёный график – с профилями – находится даже ниже среднего времени старта. По идее, он должен быть как синий.
Мы пока точно не знаем, но есть такие версии:
первые клиенты обновляются более новые и мощные устройства — у них в среднем всё быстрее; рандом, случайность.
А какие у вас версии? Напишите в комментарии!
Делаем продвинутый сценарий для Дринкит
Следующим шагом в оптимизации времени запуска и прокачке профилей будет описание расширенного сценария. Здесь нам понадобилось реализовать работу с картой, диалогами разрешений, скроллом и ветвлению в сценарии.
Для чего это нужно? Так как Profile содержит AOT-скомпилированные машинные команды, то пользователь во время сценария с меньшей вероятностью столкнётся с проблемами производительности, если сценарий уже будет скомпилирован заранее.
Для генерации Baseline Profile мы выбрали следующий флоу:
-
при запуске приложения пользователь видит карту и диалоги разрешений;
-
он предоставляет разрешения, выбирает кофейню и переходит в меню;
-
в меню немного проскроллит список и перейдёт к авторизации.

Разберём по шагам, как закодить этот сценарий.
Шаг 1: Runtime Permissions

Есть ситуация, когда UI-тест не может найти элемент на экране из-за находящегося поверх экрана системного диалога разрешений. Как вариант, это можно прокликивать руками, но так придётся делать на каждый запуск теста, поэтому проще это автоматизировать!
Сначала хотели сделать всё красиво и без лишних кликов, но метод, автоматически предоставляющий разрешение, как @Rule совсем не работал. Возможно, мы что-то делали не так.
@get:Rule @JvmField val permissionRule: GrantPermissionRule = GrantPermissionRule.grant( android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.POST_NOTIFICATIONS, )
Поэтому пришлось прокликивать каждый диалог отдельно.
В месте, где потенциально может возникнуть разрешение, помещаем такой код. В прочем, несложно на всякий exception об отсутствии элемента на экране делать fallback на проверку разрешений, но для простоты было сделано так, как написано ниже
@Test fun generate() { baselineProfile.collectBaselineProfile(packageName = "ru.drinkit.stage") { startActivityAndWait() // Тут ожидаем появления разрешений grantPermission() /* Продолжение сценария */ } }
private const val ALLOW_PASCAL_CASE_TEXT = "Allow" private const val ALLOW_UPPERCASE_TEXT = "ALLOW" private const val ALLOW_ONLY_WHILE_USING_THE_APP_TEXT = "Allow only while using the app" private const val WHILE_USING_THE_APP_TEXT = "While using the app" private fun grantPermission() { with(InstrumentationRegistry.getInstrumentation()) { // Seeking for allow permission button // If nothing found, has a fallback to permission dialog with only Allow option. // android.Manifest.permission.POST_NOTIFICATIONS is an example val allowPermissionButton = allowPermissionExtended().takeIf { it.exists() } ?: allowPermissionSimple().takeIf { it.exists() } ?: return allowPermissionButton.click() // Рекурсивно проверяем новые Permission диалоги grantPermission() } } private fun Instrumentation.allowPermissionSimple() = UiDevice.getInstance(this) .findObject(UiSelector().text(ALLOW_PASCAL_CASE_TEXT)) private fun Instrumentation.allowPermissionExtended() = UiDevice.getInstance(this) .findObject( UiSelector().text( when { VERSION.SDK_INT == Build.VERSION_CODES.M -> ALLOW_PASCAL_CASE_TEXT VERSION.SDK_INT <= Build.VERSION_CODES.P -> ALLOW_UPPERCASE_TEXT VERSION.SDK_INT == Build.VERSION_CODES.Q -> ALLOW_ONLY_WHILE_USING_THE_APP_TEXT else -> WHILE_USING_THE_APP_TEXT }, ), )
Информацию об этом способе нашли в статье.
Шаг 2: Разный начальный экран для первого запуска и последующих
Новые пользователи, у которых не выбрана кофейня на старте, при запуске попадают на экран карты. Если кофейня уже выбрана, то гость сразу попадает в меню со вкусными напитками и красивыми картинками. Как организовать это ветвление в сценарии?
Ветвление в UI-тесте делается просто. Ищем элемент, который есть на одном экране и которого нет на другом, и ориентируемся на его наличие. Вот и всё!
if (device.findObject(map).waitForExists(EXIST_TIMEOUT)) { startFlowFromMap() } else { startFlowFromMenu() }
А внутри уже пишем сценарий, специфичный для экрана: выберем кофейню, нажмём на корзину, проскроллим список или перейдём на другой экран
Шаг 3: Проскроллим меню
Когда мы начинаем сценарий с меню, нужно выполнить небольшой скролл вниз-вверх, а затем уже переходить в авторизацию по нажатию на кнопку.
Для того чтобы сделать скролл, нужно найти список на экране, а затем вызвать для него метод, который выполнит скролл. Мы используем метод fling, потому что он довольно простой в использовании.
private fun MacrobenchmarkScope.startFlowFromMenu() { scrollMenuPageVertically() clickSignIn() } private fun MacrobenchmarkScope.scrollMenuPageVertically() { val list = device.findObject( By.res("${device.currentPackageName}:id/viewProductSlotList") ) device.flingElementsDownUp(list) } private fun UiDevice.flingElementsDownUp(list: UiObject2) { list.setGestureMargin(displayWidth / 5) list.fling(DOWN) waitForIdle() list.fling(UP) }
Шаг N: Вперёд к лучшему!
Продолжаем модифицировать свой Baseline критического сценария, чтобы предоставить пользователю самый лучший и быстрый опыт использования приложения при первом старте!
Результат
В итоге наш сценарий, имеющий в себе только startActivityAndWait(), перерастает в нечто большее и уже осмысленное по поведению пользователя:
private const val EXIST_TIMEOUT = 500L private const val EXPLORE_MENU_TEXT = "Explore menu" private const val SIGN_IN_TEXT = "sign in" private const val GOOGLE_MAP_DESCRIPTION = "Google Map" private const val ORDER_NOW_TEXT = "Order now" private const val ALLOW_PASCAL_CASE_TEXT = "Allow" private const val ALLOW_UPPERCASE_TEXT = "ALLOW" private const val ALLOW_ONLY_WHILE_USING_THE_APP_TEXT = "Allow only while using the app" private const val WHILE_USING_THE_APP_TEXT = "While using the app" @RunWith(AndroidJUnit4::class) @Suppress("ANNOTATION_TARGETS_NON_EXISTENT_ACCESSOR") class BaselineProfileGenerator { @get:Rule val baselineProfile = BaselineProfileRule() @Test fun generate() { baselineProfile.collectBaselineProfile(packageName = "ru.drinkit.stage") { startActivityAndWait() // Тут ожидаем появления разрешений grantPermission() val map = UiSelector().descriptionContains(GOOGLE_MAP_DESCRIPTION) if (device.findObject(map).waitForExists(EXIST_TIMEOUT)) { startFlowFromMap() } else { startFlowFromMenu() } } } private fun MacrobenchmarkScope.startFlowFromMap() { clickMarkersUntilLeaf() } private fun MacrobenchmarkScope.startFlowFromMenu() { device.waitForIdle() scrollMenuPageVertically() clickSignIn() } private fun MacrobenchmarkScope.scrollMenuPageVertically() { val list = device.findObject( By.res("${device.currentPackageName}:id/viewProductSlotList") ) device.flingElementsDownUp(list) } private fun UiDevice.flingElementsDownUp(list: UiObject2) { list.setGestureMargin(displayWidth / 5) list.fling(DOWN) waitForIdle() list.fling(UP) } private fun MacrobenchmarkScope.clickSignIn() { val signIn = device.findObject(UiSelector().text(SIGN_IN_TEXT)) signIn.clickAndWaitForNewWindow() } private fun MacrobenchmarkScope.clickMarkersUntilLeaf() { val orderNow = device.findObject(UiSelector().text(ORDER_NOW_TEXT)) var orderNowExists = orderNow.waitForExists(EXIST_TIMEOUT) val exploreMenu = device.findObject(UiSelector().text(EXPLORE_MENU_TEXT)) var exploreMenuExists = exploreMenu.waitForExists(EXIST_TIMEOUT) while (!orderNowExists && !exploreMenuExists) { clickOnMarker() orderNowExists = device .findObject(UiSelector().text(ORDER_NOW_TEXT)) .waitForExists(EXIST_TIMEOUT) exploreMenuExists = device .findObject(UiSelector().text(EXPLORE_MENU_TEXT)) .waitForExists(EXIST_TIMEOUT) } if (orderNowExists) { clickOnViewMenu(ORDER_NOW_TEXT) } if (exploreMenuExists) { clickOnViewMenu(EXPLORE_MENU_TEXT) } } private fun MacrobenchmarkScope.clickOnViewMenu(textOnButton: String) { device.findObject(UiSelector().text(textOnButton)) .apply { waitForExists(EXIST_TIMEOUT) clickAndWaitForNewWindow() } } private fun MacrobenchmarkScope.clickOnMarker() { val marker = device.findObject( UiSelector() .descriptionContains(GOOGLE_MAP_DESCRIPTION) .childSelector(UiSelector().instance(0)), ) marker.waitForExists(EXIST_TIMEOUT) marker.clickAndWaitForNewWindow() } private fun grantPermission() { with(InstrumentationRegistry.getInstrumentation()) { // Seeking for allow permission button // If nothing found, has a fallback to permission dialog with only Allow option. // android.Manifest.permission.POST_NOTIFICATIONS is an example val allowPermissionButton = allowPermissionExtended().takeIf { it.exists() } ?: allowPermissionSimple().takeIf { it.exists() } ?: return allowPermissionButton.click() // Рекурсивно проверяем новые Permission диалоги grantPermission() } } private fun Instrumentation.allowPermissionSimple() = UiDevice.getInstance(this) .findObject(UiSelector().text(ALLOW_PASCAL_CASE_TEXT)) private fun Instrumentation.allowPermissionExtended() = UiDevice.getInstance(this) .findObject( UiSelector().text( when { VERSION.SDK_INT == Build.VERSION_CODES.M -> ALLOW_PASCAL_CASE_TEXT VERSION.SDK_INT <= Build.VERSION_CODES.P -> ALLOW_UPPERCASE_TEXT VERSION.SDK_INT == Build.VERSION_CODES.Q -> ALLOW_ONLY_WHILE_USING_THE_APP_TEXT else -> WHILE_USING_THE_APP_TEXT }, ), ) }
Резюмируя
Baseline Profiles ускоряет первый запуск приложения за счёт того, что с приложением поставляется AOT-скомпилированный код, который выполняется при старте.
Наш опыт показал, что использование Baseline Profile в приложении сокращает время старта до 20%, а при обновлении пользователям больше не приходится долго ждать запуска.
Внедрить инструмент абсолютно несложно – минимальными усилиями вы сможете сделать ваши проекты лучше.
Полезные ссылки:
Android Developers – Making apps blazing fast with Baseline Profiles
Документация Google про создания Baseline Profiles
Пошаговый Google Codelab про создание Baseline Profiles
Runtime Permissions – UI Testing
В канале Dodo Mobile мы рассказываем про разработку приложений Додо Пиццы, Дринкит и Донер 42. Подписывайтесь, чтобы узнавать новости раньше всех (ну, почти).
ссылка на оригинал статьи https://habr.com/ru/companies/dododev/articles/739064/
Добавить комментарий