Привет, меня зовут Вера, я Android‑разработчик в Яндекс Диске. Сейчас мы активно работаем над переездом на Compose с использованием дизайн‑системы. Про стабильность в Compose есть немало статей, однако от ошибок это не уберегает, поэтому решила поделиться своим опытом в формате ликбез‑статьи.

Многие, кто начинает писать на Compose, делают это интуитивно. Почему? Чтобы привыкнуть, понять, осознать подход к декларативному UI, нужно много времени, и поэтому к пониманию стабильности приходят, как правило, сильно позже. Однако это важно, ведь правильно организованная стабильность уменьшает количество рекомпозиций, что улучшает производительность и плавность работы приложения.

Говорят, по гороскопу разработчики делятся на четыре типа:

Если вы узнали себя в первых трёх типах, то найдёте много нового для себя в этой статье (и сможете быстро продвинуться к четвёртому типу). Если вы ощущаете себя на четвёртый тип, многое будет для вас известно, однако почему бы просто не освежить знания.

Рекомпозиция и пропускаемость

Позволю себе — просто 30 секунд или одну минуту — маленькую историческую справку дать. Вы не против? Для начала разберём несколько базовых определений и рассмотрим конкретный пример.

Рекомпозиция — это перерисовка UI при изменении входных данных.

Умная рекомпозиция — это обновление только тех частей UI, для которых данные изменились. 

Вот наш код:

Column {
  Row { Text(text) }
  Icon(icon)
}
Вот его композиционное дерево:

Давайте подадим на вход новые данные для иконки, но те же данные для текста.

Фиолетовые блоки — рекомпозиция пропущена, оранжевые блоки — рекомпозиция произошла.
Фиолетовые блоки — рекомпозиция пропущена, оранжевые блоки — рекомпозиция произошла.

Когда мы подаём на вход новые данные для иконки, рекомпозиция для текста происходить не будет. Она будет пропущена.

«‎Это очень легко, зачем мне всё это рассказывают?‎‎» — спросите вы. Рассмотрим другой пример. Будем передавать такую модель, в такое композиционное дерево.

data class Person(
    val name: String, 
    val children: List<String>
)

Подадим данные, в которых мы изменим только значение name, и воспользуемся Layout Inspector для отслеживания рекомпозиций.

Красным подсвечиваются элементы, в которых рекомпозиция произошла. Есть два столбца с цифрами: первый указывает на количество рекомпозиций, а второй — на количество пропущенных рекомпозиций.

Мы видим, что нода Children тоже подсвечивается красным, а число рекомпозиций растёт. То есть дерево выглядит так:

Несмотря на то что список children не изменился, — рекомпозиция для ветви со списком произошла. Почему? Расскажем позже. Это неочевидный момент, если не знать. Таких моментов немало, и наша задача в этой статье — их разобрать.

Итак, пропускаемость — это способность функции пропускать рекомпозицию, получив на вход те же самые данные. Такая функция помечается компилятором как skippable.

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

Для каждой skippable‑функции генерируется больше кода, чем для non‑skippable. Этот код отслеживает её состояние и принимает решение о рекомпозиции, а значит выполняет больше проверок и незначительно увеличивает размер apk. Пусть вас это не смущает: преимущества skippable‑функций в виде уменьшения ненужных рекомпозиций чаще всего значительно перевешивают эти небольшие накладные расходы. Но нам действительно необязательно добиваться skippable, если ваша функция:

  1. Только вызывает другие функции.

  2. Рекомпозируется очень редко, практически никогда.

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

Во всех остальных функциях мы хотим получить skippable.

Функция помечается компилятором как skippable, если все её параметры неизменяемы и стабильны.

И вот мы подходим к концу краткой исторической справки и отвечаем на один из главных вопросов:

Обсудим стабильность и как её отслеживать

Самое простое определение для стабильности: компилятор сам помечает стабильными только те типы, про которые он может гарантированно понять, что вы не измените их без создания нового объекта.

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

Стабильный

Нестабильный

Примитивы (Int, Float, Boolean)

String 

Immutable коллекции
(kotlinx.collections.immutable)

Collection (List, Set, Map)

Классы с неизменяемыми и стабильными параметрами

Классы с нестабильными и var‑параметрами

Классы и интерфейсы помеченные аннотациями @Stable и @Immutable

Классы из модулей без compose

Лямбды

Вернёмся к нашему примеру выше: несмотря на то что список children не изменился, рекомпозиция произошла, потому что List — это нестабильный параметр.

Вырезка из документации:

However, standard collection classes such as Set, List, and Map are ultimately interfaces. As such, the underlying implementation may still be mutable.

Понимаем ли мы причины? Да. Осуждаем ли мы? Нам никогда не нужен был повод.

Что предлагается для стабильности списка? Две вещи:

  1. Kotlin Immutable Collections — однако в этом случае потребуется тянуть дополнительную библиотеку.

  2. Оборачивание в класс с аннотацией Immutable.

@Immutable
data class Wrapper(
  val children: List<String>
)
  1. SnapshotStateList  — это специальный тип списка, который отслеживает изменения и автоматически инициирует рекомпозицию компонентов.

Но я предпочитаю ленивый подход — о нём расскажем в рубрике «‎Лайфхаки».

Как видите — приходится делать трюк, чтобы оптимизировать рекомпозицию. Момент неочевидный, и отсюда вытекает наш главный вопрос: «Сколько ещё таких неочевидных моментов и как же мне проверить, что я всё сделал правильно?»

Layout Inspector

Мы уже коснулись использования Layout Inspector. Он позволяет отслеживать рекомпозиции и их пропуск.

Чтобы он работал для вашего модуля:
buildFeatures {
   compose true
}

И проследите, что вы не исключили мету для 'META INF/androidx.compose.*.version'

Если у вас все же исключается мета, включить её можно так:

debug {
  packagingOptions {
    pickFirst 'META-INF/androidx.compose.*.version'
  }
}

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

Сompose compiler reports

Подключение займёт пару минут, нужно только прописать путь, куда генерировать отчёт.

Первым делом запускаем команду ./gradlew assembleRelease (для консистености результатов надо запускать на релизной сборке).  

У нас генерируется четыре файла. Про все файлы подробнее можно почитать тут, я расскажу подробно только про два. В файле module-classes.txt лежит информация про классы в модуле. Смотрите, какая информация лежит про Person:

unstable class Person {
  stable val name: String
  unstable val children: List<String>
  <runtime stability> = Unstable
}

Класс помечен как unstable. Причина в том, что у него есть нестабильный параметр.

В файле module-composables.txt лежит информация о наших composable-функциях:

restartable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  unstable person: Person
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun Children(
  unstable children: List<String>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Name(
  stable name: String
)

Функция Name помечена как skippable — она принимает в себя String, а он стабилен. Однако у Content и Children такой маркировки нет. Давайте последуем одному из советов и заменим List на ImmutableList:

data class Person(
  val name: String,
  val children: ImmutableList<String>
)
stable class Person {
  stable val name: String
  stable val children: ImmutableList<String>
}

Вуаля — наш класс стал стабильным, и наши функции пометились как skippable. Браво! Вы вернули стабильность в этот город. Но вы легко можете отобрать её, добавив var вместо val

unstable class Person {
  stable var name: String
  stable val children: ImmutableList<String>
  <runtime stability> = Unstable
}

Параметр name — это String, а значит он стабилен. Однако var лишает класса этой стабильности.

Уже было, но ещё раз: компилятор сам помечает стабильными только те классы, про которые он может гарантированно понять, что вы не измените их без создания нового объекта. 

Однако вы можете сказать ему: «‎Ты не уверен, а вот я уверен, и говорю тебе работать с этим классом как со стабильным, добавив аннотацию stable или immutable». Подробнее про то, какую аннотацию где использовать, можно прочитать тут

Добавим аннотацию:

@Immutable
data class Person(
    var name: String,
    val children: ImmutableList<String>
)
stable class Person {
  stable var name: String
  stable val children: ImmutableList<String>
}

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

В исходниках компилятора можно увидеть типы, которые пометили стабильными по дефолту:

val stableTypes = mapOf(
    Pair::class.qualifiedName!! to 0b11,
    Triple::class.qualifiedName!! to 0b111,
    Comparator::class.qualifiedName!! to 0,
    Result::class.qualifiedName!! to 0b1,
    ClosedRange::class.qualifiedName!! to 0b1,
    ClosedFloatingPointRange::class.qualifiedName!! to 0b1,
    // Guava
    "com.google.common.collect.ImmutableList" to 0b1,
    "com.google.common.collect.ImmutableEnumMap" to 0b11,
    "com.google.common.collect.ImmutableMap" to 0b11,
    "com.google.common.collect.ImmutableEnumSet" to 0b1,
    "com.google.common.collect.ImmutableSet" to 0b1,
    // Kotlinx immutable
    "kotlinx.collections.immutable.ImmutableCollection" to 0b1,
    "kotlinx.collections.immutable.ImmutableList" to 0b1,
    "kotlinx.collections.immutable.ImmutableSet" to 0b1,
    "kotlinx.collections.immutable.ImmutableMap" to 0b11,
    "kotlinx.collections.immutable.PersistentCollection" to 0b1,
    "kotlinx.collections.immutable.PersistentList" to 0b1,
    "kotlinx.collections.immutable.PersistentSet" to 0b1,
    "kotlinx.collections.immutable.PersistentMap" to 0b11,
    // Dagger
    "dagger.Lazy" to 0b1,
    // Coroutines
    EmptyCoroutineContext::class.qualifiedName!! to 0,
)

Среди них как раз есть Kotlin Immutable Collection.

Debug

C помощью Layout Inspector вы можете увидеть, какая функция рекомпозируется. С помощью дебага — что является причиной. Если вы знаете, какая функция рекомпозируется, вы можете поставить в неё точку остановы и заглянуть в поле Recomposition State.

В нём будут все параметры, которые принимает функция:

  • Changed: аргумент изменился и стал причиной рекомпозиции.

  • Unchanged: аргумент не изменился.

  • Uncertain: Compose ещё вычисляет, изменился ли аргумент или его стабильность.

  • Static: аргумент всегда остаётся неизменным, поэтому его можно пропустить при проверке изменений.

  • Unknown: аргумент имеет нестабильный тип, и стал причиной рекомпозиции.

Composition Tracing

Когда рекомпозиция вызывается слишком часто, дебаг может быть не очень удобным способом. Если вы видите, что ваш UI тормозит, а предыдущие способы не помогли локализировать проблему, вам подойдёт трассировка методов. Это не совсем относится к теме стабильности, поэтому, если вы столкнулись с проблемами перфоманса, рекомендую вам посмотреть этот доклад.

Неочевидные моменты

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

Интерфейсы

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

Давайте это проверим. Создадим интерфейс и реализуем его двумя способами (да, в названиях классов лежит небольшой спойлер).

class Unstable(var unstableParameter: String) : SomeInterface {
    override fun someFunction() = Unit
}

class Stable : SomeInterface {
    override fun someFunction() = Unit
}

И напишем такую функцию:

@Composable
fun Content(
    stable: SomeInterface,
    unstable: SomeInterface,
    count: Int,
    increaseCounter: () -> Unit
) {
    Column {
        StableInterfaceConsumer(stable)
        UnstableInterfaceConsumer(unstable)
        Button(onClick = { increaseCounter() }) {
            Text(text = "Count $count")
        }
    }
}

Запускаем наш отчёт и видим что-то неконсистентное:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  stable: SomeInterface
  unstable: SomeInterface
  stable count: Int
  stable increaseCounter: Function0<Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableInterfaceConsumer(
  some: SomeInterface
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UnstableInterfaceConsumer(
  some: SomeInterface
)
unstable class Unstable {
  stable var unstableParameter: String
  <runtime stability> = Unstable
}
stable class Stable {
  <runtime stability> = Stable
}

Вроде всё понятно, но не до конца. Попросим Layout Inspector разрешить наш конфликт. Нажимаем на кнопку и провоцируем рекомпозицию:

Composable‑функция пропустила рекомпозицию для нестабильной реализации. Заглядываем в исходники компилятора и видим вот такую строчку:

if (declaration.isInterface) {
  return Stability.Unknown(declaration)
}

Судя по исходникам, можно сделать вывод: несмотря на то, что Stability.Unknown и Stability.Runtime это разные маркеры, ведут они себя одинаково.

Если isUncertain возвращает true, у типа — вычисляемая стабильность. Отсюда мы делаем вывод, что интерфейс не является гарантированно стабильным или нестабильным.

Модели в других модулях

Создадим два модуля — один с подключённым Compose, другой — без. В каждый из них положим две модели.

data class DataInComposeModule(val name: String)

data class DataInNonComposeModule(val name: String)

 Существует ли что-то более стабильное? Давайте проверим.

restartable scheme("[androidx.compose.ui.UiComposable]") fun Content(
  dataInComposeModule: DataInComposeModule
  unstable dataInNonComposeModule: DataInNonComposeModule
  stable count: Int
  stable increaseCounter: Function0<Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ComposeModuleConsumer(
  dataInComposeModule: DataInComposeModule
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun NonComposeModuleConsumer(
  unstable dataInNonComposeModule: DataInNonComposeModule
)

Смотрим отчёт и видим, что даже самая стабильная модель в noncompose‑модуле будет считаться нестабильной.

А что с моделью, которая лежит в compose‑модуле? Почему не написана стабильность для него? Компилятор будет распознавать стабильность на этапе компиляции, и в таких случаях она будет вычисляться в рантайме.

// supporting incremental compilation, where declaration stubs can be
// in the same module, so we need to use already inferred values
stability = if (isKnownStable && declaration.isInCurrentModule()) {
  Stability.Stable
} else {
  Stability.Runtime(declaration)
}

Через Layout Inspector удостоверимся, что рекомпозиция для него будет пропускаться.

Лямбды

Рассмотрим такой пример:

ComposeTheme {
  var counter by remember { mutableIntStateOf(0) }
  val viewModel = remember { ExampleViewModel() }
  ContentLambda(
    count = counter,
    lambdaUseStable = { counter++ } ,
    lambdaUseUnstable = { viewModel.soSmth() }
  )
}

В отчёте наши лямбды помечены как стабильные:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ContentLambda(
  stable count: Int
  stable lambdaUseStable: Function0<Unit>
  stable lambdaUseUnstable: Function0<Unit>
  stable <this>: LambdaExmapleActivity
)

Но есть нюанс:

Так почему же рекомпозиция происходит? Пройдёмся дебагом, и в поле Recomposition State увидим:

Помимо count изменился и lambdaUseUnstable, но почему, если мы ничего не изменяли?

Если мы декомпилируем наш экран (Tools → Kotlin → Show Kotlin Bytecode → Decompile или Decomposer), то увидим следующее:

int var10001 = invoke$lambda$1(counter$delegate);
$composer.startReplaceableGroup(-180925510);
invalid$iv = $composer.changed(counter$delegate);  // проверяет если изменился ли counter
$i$f$cache = false;
it$iv = $composer.rememberedValue(); // получаем закэшированное значение
var10 = false;
Object var10002;
if (!invalid$iv && it$iv != Composer.Companion.getEmpty()) {  // если не изменился и проинициализирован не создаем новый экземпляр, а используем тот что мы взяли из кэша
  var10002 = it$iv;
} else {
  int var15 = var10001;
  LambdaExmapleActivity var14 = var25;
  var11 = false;
  Function0 var16 = <undefinedtype>::invoke$lambda$5$lambda$4;
  var25 = var14;
  var10001 = var15;
  Object value$iv = var16;
  $composer.updateRememberedValue(value$iv);  // кэшируем экземпляр лямбды
  var10002 = value$iv;
}

Function0 var18 = (Function0)var10002;
$composer.endReplaceableGroup();
var25.ContentLambda(var10001, var18, <undefinedtype>::invoke$lambda$6, $composer, 0);

Ничего не понятно, попробуем упростить. Выходит что-то вроде:

ComposeTheme {
  val counter = remember { mutableIntStateOf(0) }
  val viewModel = remember { ExampleViewModel() }
  ContentLambda(
    count = counter.intValue,
    lambdaUseStable = remember(counter) { { counter.intValue++ } } ,
    lambdaUseUnstable = { viewModel.soSmth() }
  )
}

Компилятор оборачивает лямбды, использующие стабильный тип в remember.

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

Стабильные типы сравниваются через equals → на вход подавался новый экземпляр лямбды → equals возвращал false → рекомпозиция происходила.

Если вы хотите такого избежать — нужно самим оборачивать такие лямбды в remember. Или освободить свою прекрасную голову от забот, и заглянуть в рубрику «‎Лайфхаки».

Лайфхаки

Strong skipping mode

Такой небольшой, но значительно упрощающий жизнь флаг. Давайте включим его и посмотрим, что он умеет.

composeCompiler {
   enableStrongSkippingMode = true
}

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

Ну и самое прекрасное. Он уменьшит вашу боль от нестабильных параметров. Функции с нестабильными параметрами становятся skippable. Однако, в отличие от стабильных параметров, которые сравниваются через ==, нестабильные сравниваются через ===.

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

На днях вышел Kotlin 2.0.20, в котором этот флаг включен по дефолту. Видимо, со временем мы придём к тому, что это поведение действительно станет нормой, и примерно четверть из того, что вы сегодня прочитали (как минимум лямбды), можно будет забыть.

Stability configuration file

Теперь можно создать файл, и перечислить всё, что вы хотите считать стабильным. И не нужно больше устраивать танцы, чтобы ваш List считался стабильным.

Выводы

  1. Не всё то стабильно, что таковым кажется.

  2. Нестабильность необязательно спровоцирует проблемы перфоманса на простом экране, но если у вас большой список, скролл, анимации — вероятность возрастает.

  3. Подключите себе Compose Compiler Report для анализа стабильности. Это быстро, а предупредить будущие ошибки очень легко.

  4. Для отслеживания рекомпозиций и их пропуска — Layout Inspector.

  5. Чтобы узнать, что стало причиной рекомпозиции, используйте дебаг, и загляните в поле Recomposition State — параметр ставший причиной будет помечен как Unstable или Changed.

  6. Strong skipping mode и stability config — ваши друзья, но на друга надейся, а сам не плошай и продолжай писать код с учётом стабильности.

Полезные материалы

Комментарии (0)