Как мы на 20% повысили скорость запуска приложения с помощью Baseline Profiles

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

48360ea13484fa29158c0f4d27fc9f26.png

Мы попробовали его у себя в Дринките и получили прирост до 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 на новую версию. Происходит перекомпиляция всех приложений, установленных на телефоне.

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

В качестве оптимизированного подхода с Android 7.0 используется комбинация из обоих миров.

Компилятор по умолчанию проводит JIT-компиляцию байткода, но если в процессе работы приложения будут обнаружены часто используемые участки кода, то они AOT-скомпилируются с помощью утилиты dex2oat, записывая результат компиляции в бинарные .oat файлы.

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

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

Каждый последующий запуск приложения переиспользует ранее скомпилированные куски кода и не компилирует их каждый раз заново. Тем самым каждый последующий запуск становится быстрее предыдущего.

С Android 9.0 появились облачные профили, т.е. пользователи запускают приложения и по мере использования созданные локально профили загружаются в облако и становятся доступны всем, кто скачивает приложение из Google Play. И с этого момента новые пользователи получают быстрый старт приложения при установке из стора.

Сравнение всех типов компиляции с конференции Google I/O 2022

Сравнение всех типов компиляции с конференции Google I/O 2022

Но у этого есть небольшой минус:  если в облаке ещё недоступны профили, то при первом запуске пользователи будут дольше находиться на экране загрузки.

График с презентации Google про Baseline Profiles. Зависимость времени старта приложения от новых версий

График с презентации Google про Baseline Profiles. Зависимость времени старта приложения от новых версий

Исправить этот скачок в времени старта можно, если с приложением уже будут поставляться готовые профили — те самые 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.

Дальше сборку и отправляем в стор. Спустя время можно смотреть графики.

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

eccfb8dbaf4b71d04372893055b2d702.png

Теперь возьмём нашу ближайшую версию до появления Baseline Profiles в продакшене.

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

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

c6c6d00300e77e20259823a66d202724.png

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

4971ea5018f5cf5cb848ee4d8da524b7.png

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

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

Мы пока точно не знаем, но есть такие версии:

первые клиенты обновляются более новые и мощные устройства — у них в среднем всё быстрее; рандом, случайность.

А какие у вас версии? Напишите в комментарии!

Делаем продвинутый сценарий для Дринкит

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

Для чего это нужно? Так как Profile содержит AOT-скомпилированные машинные команды, то пользователь во время сценария с меньшей вероятностью столкнётся с проблемами производительности, если сценарий уже будет скомпилирован заранее.

Для генерации Baseline Profile мы выбрали следующий флоу:

  • при запуске приложения пользователь видит карту и диалоги разрешений;

  • он предоставляет разрешения, выбирает кофейню и переходит в меню;

  • в меню немного проскроллит список и перейдёт к авторизации.

f2ab8249b1a3da510c6ad61e543fc126.png

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

Шаг 1: Runtime Permissions

907aa477fd67cf494d545ede9bee9249.png

Есть ситуация, когда 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. Подписывайтесь, чтобы узнавать новости раньше всех (ну, почти).

© Habrahabr.ru