На конференции 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%, а при обновлении пользователям больше не приходится долго ждать запуска.
Внедрить инструмент абсолютно несложно – минимальными усилиями вы сможете сделать ваши проекты лучше.
Полезные ссылки:
В канале Dodo Mobile мы рассказываем про разработку приложений Додо Пиццы, Дринкит и Донер 42. Подписывайтесь, чтобы узнавать новости раньше всех (ну, почти).
Комментарии (8)
Lev3250
01.06.2023 13:33Я вдруг понял, что ничего не понимаю...
Очень-очень тупой вопрос: когда я скачиваю exe на компьютер он же уже скомпилированный? Я запускаю бинарник и работаю.
На андроид не так? Приложение компилируется на конечном устройстве?
kartollika Автор
01.06.2023 13:33+3На андроид не так, как с .exe
Для приложений на Android исходный код программы компилируется в байт-код, который затем выполняется на виртуальной машине. При установке приложения на устройство Android, APK-файл с байт-кодом приложения загружается на устройство, и затем байт-код выполняется на самом устройстве.
Сам процесс компиляции будет зависеть от типа виртуальной машины и какой тип компилятора используется
Если это, например, ART и используется чистая AOT-компиляция, то весь код приложения будет скомпилирован в процессе установки его на устройство, то есть буквально на конечном устройстве, как вы и говорите
Если другой тип компилятора, то в мелочах по другому, это в статье я описал :)
Rusrst
Графики из android vitals? Прирост на всех устройствах и версиях?
kartollika Автор
Графики из Firebase Performance, app_start событие. Устройства и версии мы не отслеживали отдельно, но по графикам видно улучшение
Rusrst
А в play console android vitals на cold start значения уменьшились?
kartollika Автор
Мы не опирались на значение Android Vitals, потому что они оказались нерепрезентативными для нас. Там показываются не все холодные запуски, а только медленные холодные запуски, и для нашей небольшой аудитории графики рисуются рваные
Rusrst
Там же график показывает время запуска приложения...
В любом случае спасибо, было полезно.