Рекомпозиції у Jetpack Compose. Як виявити зайві та виправити

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Усім привіт, мене звати Євген, я Android-розробник в компанії Welltech. В цій статті хочу розповісти про проблему зайвих рекомпозицій в Jetpack Compose, та як ми її вирішували. Стаття буде корисною для тих, хто уже має досвід роботи з compose, або планує його використовувати.

Що таке рекомпозиція

Розпочнемо з того, що таке рекомпозиція. Простими словами — це повторний виклик Composable функції, в результаті чого відповідний елемент перемальовується. Звісно, виклик функції — це робота, і якщо в результаті на екрані нічого не змінилося, то можна вважати цю роботу зайвою.

Але ж Compose розумний, та вміє пропускати виклик функцій, аргументи яких не змінилися? Так. Майже. Точніше сказати, що Compose намагається пропускати функції. Якщо фреймворк не впевнений, що аргументи змінилися, то він слідує принципу «краще перемалювати, ніж не перемалювати». Це і є зайва рекомпозиція, якої ми хочемо уникнути.

Далі ми розглянемо, в яких випадках Compose може викликати зайві рекомпозиції. Важливо: Compose постійно оновлюється та покращується, можливо, деякі поради можуть бути неактуальними, коли ви читатимете цю статтю, на момент написання статті поточна версія compose-compiler 1.3.0.

State, як спосіб зменшення кількості рекомпозицій

Розглянемо процес рекомпозиції на такому простому прикладі:

@Composable
fun SampleScreen() {
   Log.i("TAG", "call sample screen function")

   var count by remember {
       mutableStateOf(0)
   }

   SampleCounter(
       count = count,
       onIncrement = { count++ }
   )
}

@Composable
private fun SampleCounter(
   count: Int,
   onIncrement: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier,
       horizontalAlignment = Alignment.CenterHorizontally
   ) {
       Log.i("TAG", "call counter/column function")
       Button(onClick = onIncrement) {
           Log.i("TAG", "call button function")
           Text(text = "Increment. (current count $count)")
       }
   }
}

Тут є рутова функція SampleScreen та один віджет SampleCounter. При першому запуску ми, звісно, побачимо у логах виклик всіх функцій:

I/TAG: call sample screen function
I/TAG: call counter/column function
I/TAG: call button function

Тепер натиснемо на кнопку «Increment» та подивимось логи:

I/TAG: call sample screen function
I/TAG: call counter/column function
I/TAG: call button function

Ми бачимо, що всі функції були повторно викликані. Чому? Compose відслідковує всі місця, де ми читаємо значення з об’єкта State, і коли цей стан змінюється, відбувається повторний виклик відповідної функції.

В нашому прикладі ми читаємо State(count) всередині SampleScreen, тому при зміні значення рекомпозиція починається з цієї функції. Звісно, в такому простому прикладі не має сенсу щось оптимізувати, але в реальних додатках рутова функція зазвичай викликає багато дочірніх функцій, так що спробуємо для прикладу позбутися зайвих викликів. Найпростіший спосіб — читати State безпосередньо там, де він нам потрібен:

@Composable
private fun SampleCounter(
   count: State<Int>,
   onIncrement: () -> Unit,
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier,
       horizontalAlignment = Alignment.CenterHorizontally,
   ) {
       Log.i("TAG", "call counter/column function")
       Button(onClick = onIncrement) {
           Log.i("TAG", "call button function")
           Text(text = "Increment. (current count ${count.value})")
       }
   }
}

Замість Int ми використовуємо State<Int> та читаємо його всередині button. Тепер в логах ми побачимо тільки button:

I/TAG: call button function

Такий підхід не дуже зручний, але оптимальний для ситуацій, коли є часто змінюваний параметр, наприклад, прогрес анімації чи позиція скролу. До речі, такого ж результату можна досягти, використовуючи лямбду () -> Int замість State.

Stable аргументи

Розглянемо інший приклад: простий віджет, що показує тривалість та має дві кнопки: збільшити та зменшити на 1с:

@Composable
private fun DurationWidget(
   title: String,
   duration: Duration,
   onIncrement: () -> Unit,
   onDecrement: () -> Unit,
   modifier: Modifier = Modifier
) {
   Log.i("TAG", "DurationWidget: recompose $title")
   Row(
       modifier = modifier,
       horizontalArrangement = Arrangement.spacedBy(12.dp),
       verticalAlignment = Alignment.CenterVertically
   ) {
       Text(text = "$title: ${duration.seconds}s")
       Spacer(modifier = Modifier.weight(1f))
       Button(onClick = onDecrement) {
           Text(text = "-")
       }
       Button(onClick = onIncrement) {
           Text(text = "+")
       }
   }
}

Використаємо 2 таких віджети на одному екрані:

@Composable
@Preview
private fun DurationPreview() {
   SampleTheme {
       Column(
           modifier = Modifier.padding(16.dp),
           verticalArrangement = Arrangement.spacedBy(16.dp)
       ) {
           var duration1 by remember {
               mutableStateOf(Duration.ZERO)
           }
           var duration2 by remember {
               mutableStateOf(Duration.ZERO)
           }

           DurationWidget(
               title = "first duration",
               duration = duration1,
               onIncrement = { duration1 = duration1.plusSeconds(1) },
               onDecrement = { duration1 = duration1.minusSeconds(1) }
           )

           DurationWidget(
               title = "second duration",
               duration = duration2,
               onIncrement = { duration2 = duration2.plusSeconds(1) },
               onDecrement = { duration2 = duration2.minusSeconds(1) }
           )
       }
   }
}

Попри те, що ми змінюємо лише один віджет, другий все одно перемальовується. Як і в попередньому прикладі, тут рекомпозиція починається з функції DurationPreview, оскільки ми читаємо стан саме в цій функції, отже обидва віджети будуть викликані.

Тут ми можемо уникнути проблеми, замінивши Duration на State<Duration>, але такий підхід не завжди спрацює. Річ у тому, що compose нічого не знає про клас java.time.Duration, тому не може бути впевнений, що вхідний аргумент не змінився. В такому випадку compose ніколи не пропускає виклик такої функції. Щоб compose працював ефективно, потрібно слідкувати, щоб всі аргументи функцій були стабільними.

Стабільний клас — це такий клас, екземпляри якого відповідають наступним вимогам:

  • equals() повинен повертати true для двох однакових екземплярів;
  • якщо якесь поле змінюється, про це потрібно сповістити композицію (іншими словами це значить, що змінні повинні бути MutableState);
  • публічні поля класу повинні бути стабільними.

Примітивні типи та enum є стабільними за замовчуванням. В процесі компіляції компілятор намагається самостійно визначити, які класи є стабільними, та помічає їх. Важливо розуміти, що компілятор робить це тільки в тих модулях, в яких увімкнено compose. Ми можемо самостійно помітити клас як стабільний, використовуючи анотації @Stable чи @Immutable у тому випадку, якщо компілятор не зміг зробити це самостійно (але потрібно бути впевненим що усі вимоги виконані, інакше може бути невизначена поведінка).

Окремо слід згадати про колекції. В деяких джерелах пишуть, що інтерфейси колекцій (List, Set, Map) нестабільні та рекомендують використовувати immutable обгортку чи бібліотеку. В основному так і є, хоча я нерідко стикався с випадками, коли використання List не призводить до зайвих рекомпозицій. Також цікава ситуація з лямбда-аргументами — в деяких випадках вони є причиною зайвих рекомпозицій. Проблеми можна уникнути, якщо замість лямбд використовувати посилання на метод. Тобто замість:

CustomButton(
   onClick = {
       viewModel.buttonClicked()
   }
)

Використовувати:

CustomButton(
   onClick = viewModel::buttonClicked
)

Я рекомендую використовувати посилання усюди, де це можливо.

derivedStateOf

Не можу не згадати про derivedStateOf, завдяки якому можна уникнути зайвих рекомпозицій при апроксимації значення стану (тобто коли нам потрібен не весь діапазон можливих значень стану). Повернімось до першого прикладу і припустімо, що нам потрібно показувати тільки кожен десятий count:

val count = remember {
   mutableStateOf(0)
}

SampleCounter(
   count = count.value / 10 * 10,
   onIncrement = {
       count.value++
   }
)

В такому випадку рекомпозиція буде кожного разу при зміні count, не зважаючи на те, що ми зображаємо тільки числа, які кратні 10. За допомогою derivedStateOf ми можемо легко трансформувати стан:

val count = remember {
   mutableStateOf(0)
}

val approximatedCount = remember {
   derivedStateOf {
       count.value / 10 * 10
   }
}

SampleCounter(
   count = approximatedCount.value,
   onIncrement = {
       count.value++
   }
)

Тепер рекомпозиція буде проходити тільки коли зміниться approximatedCount, а він змінюється тільки коли count кратний 10.

Виявлення зайвих рекомпозицій

Ми розглянули основні причини, які можуть викликати зайві рекомпозиції, але в реальних продуктах інтерфейс набагато складніший, а непередбачених кейсів — ціла купа. Це означає, що є потреба постійно слідкувати за тим, чи правильно працює написаний код.

У прикладах я використовував логи для відслідковування рекомпозицій. В реальному проєкті можна втомитися додавати логи в кожен метод. Google пропонує recomposition counter в Layout inspector, але в мене не дуже позитивний досвід роботи з цим інструментом (на версіях AS Chipmunk та нижче).

Ми вирішили розробити плагін для відстежування рекомпозицій. Крім автоматичного логування рекомпозицій він також вміє підсвічувати функції, які проходять рекомпозицію в цей момент. Це допомагає швидко виявити проблемні функції.

Нещодавно ми виклали цей плагін на Github, його дуже просто під’єднати та використовувати. Він має мінімальний вплив на швидкість додатка в дебаг режимі. В релізному білді плагін взагалі не додає ні логи, ні підсвітку, тому зовсім не впливає на релізний білд (за замовчуванням).

Підсумок

  • Використовуйте стабільні класи в compose функціях.
  • Читайте стан якомога ближче до місця, де він необхідний.
  • Користуйтесь derivedStateOf для трансформації стану.
  • Слідкуйте за рекомпозиціями та виправляйте проблеми до того, як вони почнуть впливати на швидкодію.

Більше цікавої інформації стосовно оптимізації compose можна знайти в офіційній документації тут або тут.

Та в інших статтях:

Quick Note on Jetpack Compose Recomposition.
What is «donut-hole skipping» in Jetpack Compose?
Gotchas in Jetpack Compose Recomposition.
Jetpack Compose: Debugging Recomposition.

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хороша стаття. Дякую

Автор в статье взаимозаменяемо использует понятие рекомпозиция и перересовка, но это две совершенно разные фазы ЖЦ. Все подходы по оптимизации, описанные в статье, касаются исключительно рекомпозиции.Ну а перерисовка поисходит уже как следствие изменения структуры.

Не можна не згадати з цього приводу статтю ще одного нашого співвітчизника skyyo.medium.com/...​pack-compose-9a85ce02f8f9

Тільки навчився xml розмітку нормальну робити

Підписатись на коментарі