Как стать автором
Обновить

Оптимизируй или сдохни: профилирование и оптимизация Jetpack Compose

Время на прочтение14 мин
Количество просмотров13K

Привет! На связи Сергей Панов, разработчик мобильных приложений в IceRock. Сегодня я разберу на примере нашего приложения «Кампус», как делать профилирование и оптимизацию Jetpack Compose​.

«Кампус» — это приложение для просмотра расписания занятий. Главная его фича — это экран расписания, состоящий из двух pager’ов для недели и дня. Свайпы по ним вызывали зависания у пользователей, и мы это исправили.

В статье рассмотрим:

  1. Recomposition Counts​: локализуем лишние рекомпозиции

  2. Compose Compiler Metrics​: ищем причины лишних рекомпозиций

  3. Профилирование CPU: находим «тяжелые» методы и разгружаем процессор

  4. Профилирование GPU: узнаем, какие компоненты долго отрисовываются

  5. Еще советы по устранению ошибок, найденных с помощью инструментов профилирования

Recomposition Counts​: локализуем лишние рекомпозиции

Проблема. Первое понятие, на которое нужно обратить внимание, — рекомпозиция.

Рендер экрана в Compose состоит из прохода по графу composable-функций. Если состояние вершины графа меняется (меняются аргументы composable-метода, значения MutableState или анимации), то вызывается перестроение подграфа, то есть происходит повторный вызов функций. Это перестроение называется рекомпозицией.

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

Это главная особенность Compose и его подводный камень. Ведь разработчик может допустить ошибки, которые могут привести к излишним рекомпозициям, например к пересозданию объекта вместо переиспользования. Это приведет к ненужным вычислениям и проблемам с производительностью.

Решение. На больших проектах эти ошибки можно не заметить невооруженным глазом, поэтому Android Studio предлагает вооружить наш глаз тулзой Recomposition Counts.

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

Применение. На примере «Кампуса» проверим, есть ли у нас лишние рекомпозиции. Мы запустили проект в симуляторе и перешли в Layout Inspector. После добавления Recomposition Counts у нас появилось два столбца с числом рекомпозиций и пропусков каждого composable-метода. Теперь мы видим, что при cвайпе на следующий день методы ScheduleDayPage и RemoteStateContent рекомпозируются по три раза вместо одного и не пропускаются вовсе.

Таким образом нам удалось локализовать проблему, мы зафиксировали метод, которому следует уделить пристальное внимание:

@Composable
fun ScheduleDayPage(
    state: RemoteState<ScheduleDay>,
    onItemClick: (ScheduleDetails) -> Unit,
    viewAds: (ScheduleDay.Lesson) -> Unit,
    clickOnAds: (ScheduleDay.Lesson) -> Unit
) {
    ...
}

Compose Compiler Metrics​: ищем причины лишних рекомпозиций

Стабильные типы данных​

Проблема. Чтобы понять, почему метод рекомпозируется несколько раз, нужно познакомиться с понятием стабильности.

Стабильность гарантирует компилятору, что тип не будет изменяться либо уведомит о своем изменении. Компилятор не будет запускать проверку на изменение состояния composable-метода, если все его аргументы будут стабильными.

Следовательно, стабильными типами данных называют типы, инстансы которых неизменяемы, либо уведомляют композицию об изменении своего состояния. Помимо стабильных и нестабильных типов данных, выделяют третий тип — иммутабельный. Это более строгий класс, который гарантирует, что объект не будет изменен вообще.

В строгом смысле стабильный тип должен соответствовать следующим требованиям:

  1. Результат вызова equals для двух инстансов будет всегда одинаков.

  2. Если публичное поле класса изменяется, то композиция об этом узнает.

  3. Все публичные поля имеют стабильный тип.

Чтобы третье условие соблюдалось, требуется наличие стабильных типов, которые разработчики могут использовать для создания собственных типов данных. Jetpack Compose Compiler считает стабильными следующие типы: примитивные типы, String, функциональные типы, перечисления.

Для более глубокого понимания определений рекомпозиции и стабильных типов данных я рекомендую статью Дениса Голубева. В ней есть следующий пример:

// Все поля класса неизменяемы. StableClass1 стабильный
class StableClass1(
    val immutableValue: Int
)

// Композиция узнает об изменении состояния благодаря MutableState. StableClass2 стабильный
class StableClass2(
    val mutableState: MutableState<String>
)

// Имеются изменяемые поля. UnstableClass1 нестабильный
class UnstableClass1(
    var immutableValue: String
)

// Тип поля unstableTypeField нестабильный. UnstableClass2 нестабильный
class UnstableClass2(
    val unstableTypeField: UnstableClass1
)

Также разработчики могут помечать классы аннотациями @Stable и @Immutable.

Решение. Для выявления стабильных и нестабильных типов, а также пропускаемых и перезапускаемых методов, которые мы обнаружили с помощью Recomposition Counts, можно воспользоваться тулзой Compose Compiler Metrics.

Для получения статистики по проекту нужно добавить таску в app/build.gradle.kts и запустить релизную сборку с активным флажком, как описано в статье.

В результате в папке build/compose_metrics мы обнаружим четыре файла со следующим содержанием:

  • app_release-classes.txt — информация о стабильности классов:

unstable class MigrationScreen {
  unstable val navController: NavController
  <runtime stability> = Unstable
}
stable class ExpandedStateStrings {
  stable val expandString: String
  stable val collapseString: String
  <runtime stability> = Stable
}
  • app_release-composables.txt — информация о том, рекомпозируемый метод или пропускаемый:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TopAppBarTitle(
  stable modifier: Modifier? = @static Companion
  stable name: String
  stable date: String
  stable weekName: String?
  stable menuExpanded: MutableState<Boolean>
)
  • app_release-composables.csv — те же сведения, только в виде таблицы;

  • app_release-module.json — общая информация по проекту:

{
"skippableComposables": 693,
"restartableComposables": 838,
"readonlyComposables": 0,
"totalComposables": 882,
...
}

Применение. Вернемся к «Кампусу»: нам был интересен метод ScheduleDayPage. Для поиска информации о нем перейдем в файл app_release-composables.txt:

restartable scheme("[androidx.compose.ui.UiComposable]") fun ScheduleDayPage(
unstable state: RemoteState<ScheduleDay>
stable onItemClick: Function1<ScheduleDetails, Unit>
stable viewAds: Function1<Lesson, Unit>
stable clickOnAds: Function1<Lesson, Unit>
)

Как мы поняли, этот метод не является skippable и будет рекомпозироваться при любом удобном случае. Также видим, что аргумент state является нестабильным.

Чтобы поправить это, можем добавить аннотацию @Immutable к классам RemoteState и ScheduleDay, убедившись, что данные классы не будут меняться после создания.

Не следует «вешать» эту аннотацию на классы с var-полями или полями, содержащими списки.

Это решит проблему нестабильного класса, но с этим методом еще не все окончено. Он уже помечен в метрике как skippable, однако в Layout Inspector мы все еще замечаем ненужные рекомпозиции.

Нестабильные списки​

Проблема. Есть способ переопределить стабильность классов с помощью аннотаций, но это не решит проблему стабильности списков, сетов и словарей.

Решение. Крис Уорд предлагает решение данной проблемы с помощью библиотеки kotlinx-collections-immutable, которая позволяет явно указать, что composable-метод должен принимать неизменяемый список.

@Composable
fun StableGrid(
    values: ImmutableList<GridItem>
) {
    ...
}

Нестабильные лямбды​

Проблема. У нашего класса ScheduleDayPage в качестве аргументов также есть функции, с которыми нужно быть осторожными в Compose.

Перейдем к месту инициализации метода:

@Composable
internal fun ScheduleScreenContent(
    selectedDate: LocalDate,
    onDateSelected: (LocalDate) -> Unit,
    onItemClick: (ScheduleDetails) -> Unit,
    viewAds: (LocalDate, ScheduleDay.Lesson) -> Unit,
    clickOnAds: (LocalDate, ScheduleDay.Lesson) -> Unit,
    scheduleDayForDate: (LocalDate) -> StateFlow<RemoteState<ScheduleDay>>
) {
    CalendarDayPager(
        selectedDate = selectedDate,
        onDateSelected = onDateSelected,
        dayContent = { date ->
            val scheduleDayFlow: StateFlow<RemoteState<ScheduleDay>> = remember(date) {
                scheduleDayForDate(date)
            }
            val scheduleDay: RemoteState<ScheduleDay> by scheduleDayFlow.collectAsState()
            ScheduleDayPage(
                state = scheduleDay,
                onItemClick = onItemClick,
                viewAds = { lesson -> viewAds(date, lesson) },
                clickOnAds = { lesson -> clickOnAds(date, lesson) }
            )
        }
    )
}

Обратите внимание на то, как передаются функции в наш метод ScheduleDayPage.

В Compose есть такое понятие, как нестабильные лямбды, которое хорошо описал Джастин Брейтфеллер.

В его статье следует обратить внимание на то, как компилятор обрабатывает лямбды, а именно создает анонимный класс с методом invoke(), в котором находится содержимое лямбды. Иными словами, каждый раз при передаче лямбды мы создаем объект анонимного класса, который при этом не имеет хеша, по которому компилятор сравнил бы его на шаге перестройки. Следовательно компилятор посчитает, что состояние узла графа поменялось и нужно провести рекомпозицию.

Следовательно Compose Compiler Metrics не отметит лямбды как unstable (нестабильные), но рекомпозиция проведена будет.

Кроме того, у лямбды, помимо передаваемых аргументов, есть аргументы извне (например, контекст), из-за которых сгенерированные компилятором классы будут отличаться.

Решение. В той же статье приводится четыре способа решить данную проблему.

1. Ссылки на методы. Используя ссылки на методы вместо лямбды, мы предотвратим создание нового класса. Ссылки на методы являются stable-функциональными типами и будут оставаться эквивалентными между композициями.

// Вместо лямбды
{ lesson ->
    viewModel.playHooky(lesson)
}
// использовать ссылку на метод
viewmodel::playHooky

2. Remember. Другой вариант — запоминать экземпляр лямбды между композициями. Это гарантирует, что точно такой же экземпляр лямбды будет повторно использоваться при дальнейших композициях.

// Создать запоминаемый объект и передавать его при инициализации
val playHookyRemember: (Lesson) -> Unit = remember { { viewModel.playHooky(it) } }

В документации Android рекомендуется не забывать про remember.

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

4. @Stable в лямбде. Пока выражение в лямбде затрагивает только другие стабильные типы, оно не будет перестраиваться компилятором при рекомпозиции графа.

var skippedLessons by remember { mutableStateOf(listOf("Biology", "Geography", "Chemistry")) }
Schedule(
    playHooky = { lesson ->
        skippedLessons += lesson
    }
)

Применение. Вернувшись к «Кампусу», используя полученные знания, можем исправить неправильные передачи лямбды следующим образом:

@Composable
internal fun ScheduleScreenContent(
    selectedDate: ComposeDate,
    onDateSelected: (LocalDate) -> Unit,
    onItemClick: (ScheduleDetails) -> Unit,
    viewAds: (LocalDate, ScheduleDay.Lesson) -> Unit,
    clickOnAds: (LocalDate, ScheduleDay.Lesson) -> Unit,
    scheduleDayForDate: (LocalDate) -> StateFlow<RemoteState<ScheduleDay>>
) {
    CalendarDayPager(
        selectedDate = selectedDate,
        onDateSelected = onDateSelected,
        dayContent = { date ->
            val scheduleDayFlow: StateFlow<RemoteState<ScheduleDay>> = remember(date) {
                scheduleDayForDate(date.toLocalDate())
            }
            val scheduleDay: RemoteState<ScheduleDay> by scheduleDayFlow.collectAsState()

            // Вынесли лямбду в отдельный объект, используя remember 
            val viewAdsRemember: (ScheduleDay.Lesson) -> Unit =
                remember(date) { { lesson -> viewAds(date.toLocalDate(), lesson) } }

            // Вынесли лямбду в отдельный объект, используя remember 
            val clickOnAdsRemember: (ScheduleDay.Lesson) -> Unit =
                remember(date) { { lesson -> clickOnAds(date.toLocalDate(), lesson) } }

            ScheduleDayPage(
                state = scheduleDay,
                onItemClick = onItemClick,
                viewAds = viewAdsRemember,
                clickOnAds = clickOnAdsRemember
            )
        }
    )
}

Профилирование CPU: находим «тяжелые» методы и разгружаем процессор

CPU Profiler

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

Вы можете использовать профилировщик для проверки использования CPU вашим приложением и активности потоков во время взаимодействия с вашим приложением.

Применение. Как запустить профилирование, хорошо описано в статье Такеши Хакигуры. Разберем, какие возможности дает нам эта статистика, на примере «Кампуса». Запустим проект на симуляторе и перейдем во вкладку Profiler.

После запуска приложения появятся графики CPU, Memory, Energy. Нас интересует статистика CPU, в ее детальном виде вы должны увидеть следующее:

Чтобы создать запись статистики, щелкните Record, повзаимодействуйте с приложением и нажмите Stop.

После создания записи вы должны увидеть следующее окно с графиком загрузки процессора на записанном интервале CPU Usage (1), статистикой взаимодействий с приложением Interaction (2), потоками Threads (3) и детальной статистикой потока Analysis (4).

Flame Chart​

Нас дальше будет интересовать вкладка Flame Chart, в которой находится график, представляющий собой граф вызовов функций с занимаемым ими временем работы процессора. С помощью него удобно находить выделяющиеся по времени процессы, которые можно оптимизировать.

Для начала выберем интересующий нас интервал в окне CPU Usage. Далее его можно зафиксировать более детально в окне Threads. Выберем наиболее явно выраженные столбцы.

Что нас здесь интересует:

1. Время отрисовки компонентов. Стандартная частота обновления для большинства дисплеев смартфонов составляет 60 Гц. То есть изображение на дисплее полностью обновляется 60 раз в секунду, или каждые 16,67 миллисекунды.

Именно столько должна занимать отрисовка компонента, чтобы UI был плавным. Так что обращайте внимание на наиболее «тяжелые» методы.

Показатель времени отрисовки следует считать в соотношении: данные во Flame Chart к количеству секунд в выбранном интервале. Точное время интервала указано во вкладке Summary.

2. Загруженность процессора. Старайтесь сделать так, чтобы большую часть времени процессор находился в ожидании, «отдыхал».

3. Компоненты, на которые мы можем повлиять. Такие методы можно выбрать в поиске. К примеру, произведем поиск по имени нашего проекта, и профилировщик выделит части «пламени» с нашими методами цветом, а текст в полосах с методами — жирным.

4. Методы, которые мы можем перенести в другой поток. Некоторые «тяжелые» задачи можно перенести в другой поток. Например, работу с базами данных.

Более подробную информацию о возможностях профилировщика CPU можно найти в статье.

Применение. Вернемся к «Кампусу». В нашем случае записаны свайпы по экрану расписания, которые хотелось бы оптимизировать. Выберем один из них в окне CPU Usage и выберем главный поток в окне Threads. В поиске найдем наш проект: сразу видны три метода, на которые процессор тратит много времени.

Разберем один из них. На метод WeekPage тратится аж 400 миллисекунд на отрезке в 4 секунды. Для получения более точного значения следует выбрать усреднение по нескольким значениям. Запомним примерное значение времени процессора на данный метод — 95 миллисекунд.

@Composable
fun WeekPage(
    startOfWeek: LocalDate,
    selectedDate: LocalDate,
    currentDate: LocalDate,
    onDateSelected: (LocalDate) -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        DayOfWeek.values().forEach { dayOfWeek ->
            val date: LocalDate = startOfWeek.plus(DatePeriod(days = dayOfWeek.ordinal))

            val simpleDateFormat = SimpleDateFormat("EE", Locale.getDefault())
            val dayOfWeekName = simpleDateFormat.format(date.toJavaDate()).uppercase()

            val shape = RoundedCornerShape(8.dp)
            Box(...) { ... }
        }
    }
}

Обратившись к коду, можем заметить явный косяк: SimpleDateFormat инициализируется в цикле для каждого элемента Row. Можем поправить это, вынеся инициализацию из Row и использовав remember.

После исправления проверим результат. Такими действиями нам удалось сократить время на отрисовку WeekPage до 60–70 миллисекунд (на изображении выбран интервал около 1 секунды):

System Trace​

Кроме того, вы можете получить статистику по загруженности CPU, сконцентрированную только на composable-методах. Для этого вам нужно воспользоваться инструментом Jetpack Compose Composition Tracing.

Jetpack Compose Composition Tracing доступен начиная со следующих версий технологий:

– Android Studio Flamingo Canary 1;
– Compose UI 1.3.0-beta01;
– Compose Compiler 1.3.0.

Compose Composition Tracing позволяет отображать composable-функции Jetpack Compose в профилировщике System Trace Android Studio Flamingo. Следуя инструкции в статье Бена Тренгроува, вам необходимо установить подходящую версию Android Studio и добавить зависимость в app/gradle.kts.

Применение. В CPU Profiler выберите конфигурацию System Trace, щелкните Record, повзаимодействуйте с приложением и нажмите Stop.

После создания записи вы должны увидеть следующее окно:

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

У нас появилось несколько новых вкладок: Display (1), Frame Lifecycle (2), CPU Cores (3), Process Memory (4), а вкладка с потоками немного изменилась: в ней появился граф composable-функций.

В этих вкладках можно найти объем активности на каждом ядре, объем физической памяти, используемой в настоящее время приложением, и другое.

Во вкладке Threads находится граф, а в детальной статистике по потокам также находится Flame Chart, в котором будет приведена статистика только по composable-методам.

В Threads можно нажать на метод и выполнить его поиск по всему записанному интервалу, чтобы увидеть, как часто этот метод вызывался и сколько времени занял в среднем и по каждому вызову.

Более подробную информацию о возможностях System Trace можно найти в официальной документации.

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

Немаловажным инструментом профилирования является профилировщик GPU.

Как сказано в документации, инструмент Profile GPU Rendering отображает в виде гистограммы прокрутки то, сколько времени требуется для рендеринга кадров окна пользовательского интерфейса относительно контрольного показателя в 16,67 миллисекунды на кадр.

Чтобы воспользоваться этим профилировщиком, вам понадобится девайс с версией Android 4.1 (API level 16) или выше. Инструкция по подключению есть в документации.

Горизонтальная зеленая линия соответствует 16,67 миллисекунды. Чтобы достичь 60 кадров в секунду, вертикальная полоса для каждого кадра должна оставаться ниже этой линии. Каждый раз, когда полоса превышает эту линию, в анимации могут возникать паузы.

В документации указано, за что отвечают столбцы по цветам.

Применение. На примере «Кампуса» можно обратить внимание на выраженные столбцы голубого, светло- и темно-зеленого цветов.

Следовательно много времени используется для создания и обновления списков (голубой цвет), возможно, много кастомных вью или много работы методов onDraw. Много времени тратится на обработку методов onLayout и onMeasure (светло-зеленый), может быть, отрисовывается сложная иерархия вью. Также много времени уходит на аниматоры, которые выполняются для вью, и на обработку входных колбэков (темно-зеленый); биндинги во время скроллинга, например RecyclerView.Adapter.onBindViewHolder(), обычно происходят в этом сегменте и являются наиболее распространенным источником замедлений в нем.

На следующем изображении показан график для приложения после различных оптимизаций, описанных ранее.

Судя по результатам, оптимизировать еще есть куда.

Еще советы по устранению ошибок, найденных с помощью инструментов профилирования

Несколько советов взял из официальной документации и статьи Мукеша Соланки.

1. Всегда используйте remember в своих composable-методах. Декомпозиция может сработать в любое время по целому ряду причин. Если у нас есть значение, которое должно было пережить рекомпозицию, то remember поможет вам сохранить его при рекомпозиции.

2. Используйте lazy layouts только по необходимости. LazyRow для списка из пяти элементов может существенно затормозить рендер.

3. Откажитесь по возможности от ConstraintLayout. Используйте вместо него Column и RowConstraintLayout представляет собой систему линейных уравнений, что требует больше вычислений, нежели построение элементов одного за другим.

Если вам было полезно, подписывайтесь на наш телеграм-канал. Там вы не пропустите новые статьи.

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии3

Публикации

Истории

Работа

iOS разработчик
20 вакансий
Swift разработчик
28 вакансий

Ближайшие события

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область