На конференции 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 и не только. В планах много буду писать про Compose. Заглядывайте!
В канале Dodo Mobile мы рассказываем про разработку приложений Додо Пиццы, Дринкит и Донер 42. Подписывайтесь, чтобы узнавать новости раньше всех (ну, почти).