За последнее время Jetpack Compose и его кроссплатформенный собрат Compose Multiplatform прошел большой путь от ранних альфа-релизов и скептического отношения комьюнити до статуса главного UI-фреймфорка под Android и production-ready состояния на iOS.
Тем не менее по одной из важных тем, касающихся runtime-производительности фреймворка, все еще есть немало темных пятен, и это тема stability. Это может показаться странным, ведь на эту тему сделано множество статей и докладов. Проблема заключается в том, что стремительное развитие фреймворка очень быстро делает эти статьи, доклады и даже документацию устаревшими, а новые материалы на эту тему вторят старым, продолжая распространение ложной информации о том, как работает рекомпозиция, пропускаемость (skippability) и перезапускаемость (restartability).
Использование LLM для получения актуальной информации тоже не очень поможет. Простой пример - спросим у ChatGPT, будет ли перезапущена Composable функция, если она имеет нестабильные аргументы, и получим неправильный ответ:

В этой статье мы не будем углубляться в определения того, что такое рекомпозиция, Stable, Immutable типы, а также skippable и restartable функции. Для ознакомления с подробным (и, как мы увидим ниже, частично устаревшим) описанием этих тем стоит ознакомиться с этими источниками:
1) https://habr.com/ru/companies/ozontech/articles/742854/
2) https://github.com/JetBrains/kotlin/blob/master/plugins/compose/design/compiler-metrics.md
И все же кратко освежим в памяти базовые концепты.
Как было
До версии Kotlin 2.0.20 (когда Compose Compiler переехал в репозиторий Kotlin) ситуация была следующей.
1) При рекомпозиции родительской Composable-функции рекомпозиция дочерней может быть пропущена (skipped), если все ее параметры являются стабильными и ни один из них не поменялся (изменения отслеживаются по методу equals).
2) Тип может быть стабильным по одной из нескольких причин.
автоматическое определение стабильности (тип объявлен в модуле, который обрабатывается Compose Compiler плагином и удовлетворяет ряду условий: data class с строго иммутабельными полями без коллекций, enum класс и т. д.)
определение стабильности с помощью аннотации (тип помечен аннотацией Immutable или Stable, в этом случае разработчик "обещает" компилятору, что он берет управление рекомпозициями под более ручное управление)
определение стабильности по конфигурационному файлу (когда тип объявлен во внешнем модуле, который не обрабатывается плагином и к которому разработчик не имеет доступа: библиотеки, классы Android SDK и т. д.)
3) В противном случае (если функция имеет хотя бы один аргумент нестабильного типа) она будет перезапускаться (restarted) всегда, когда будет перезапускаться ее родительская функция. Это приведет к повторному вызову всех этапов рекомпозиции (compose, layout, draw) и может привести к пропуску кадров.
Как стало
Начиная с версии Kotlin 2.0.20, в Compose Compiler по умолчанию был включен Strong Skipping Mode. Правила перезапуска Composable функций сильно изменились.
1) При рекомпозиции родительской функции рекомпозиция дочерней может быть пропущена всегда. Больше нет условий, при которых дочерняя функция будет непропускаемой (restartable non-skippable). И в этом ключевое отличие: если раньше нужно было хорошо постараться, чтобы функция была пропускаемой (skippable), то теперь наоборот очень трудно сделать ее полностью перезапускаемой (restartable).
2) Дочерняя функция будет перезапущена только если хотя бы один из ее аргументов поменялся. Для стабильных аргументов идентичность проверяется по-старому (equals), для нестабильных - по адресу в памяти. То есть, если вы передадите в Composable-функцию аргумент типа Context или Activity, не упомянув его в Compiler-конфиге, функция все равно будет пропущена, если инстанс в памяти между рекомпозициями остался тем же. К коллекциям это тоже относится (они по определению нестабильны, но если адрес в памяти тот же - никаких рекомпозиций).
3) Лямбды тоже будут проверяться по адресу, но при этом Compose автоматически "запомнит" их (как будто мы сами обернули их в remember). Подробнее об этом процессе ниже.
Таким образом, теперь разработчику гораздо меньше нужно думать о скорости его Compose-кода и заботиться о стабильности. Но это все на бумаге звучит так хорошо. А что на практике?
Симуляция MVI-сеттинга
Чтобы проверить реальную работу Strong Skipping на чем-то осязаемом, представим, что у нас MVI-приложение (состояние экрана представлено единым классом с полями в нем) и напишем его совершенно кощунственным с точки зрения стабильности образом:
const val EXAMPLE_STRING = "some string"
data class ComponentState(
val timerCounter: Int = 0, // стабильное поле, иммутабельный примитив
val label: String = EXAMPLE_STRING, // стабильное поле, иммутабельная строка
val singleOrder: OrderData = OrderData(), // нестабильное поле, нестабильный класс
val orderData: List<OrderData> = listOf(OrderData()), // нестабильное поле, коллекция
val callbackHandler: CallbackHandler = CallbackHandler(), // нестабильное поле, нестабильный класс
)
// мутабельное поле делает тип нестабильным
class CallbackHandler {
var handler: () -> Unit = {
println("Callback triggered")
}
fun onCallback() {
handler()
}
}
// мутабельное поле и коллекция делает тип нестабильным
data class OrderData(
var orderId: String = Uuid.random().toString(),
val orderUsers: List<User> = emptyList()
)
Теперь передадим этот класс в простую Composable-функцию Child, которая будет дальше по параметрам отдавать поля класса в GrandChild:
@Composable
fun Child(
state: ComponentState
) {
Text(state.timerCounter.toString())
GrandChild(
stableParam = state.label, // стабильный аргумент, строка
unstableParam = state.callbackHandler, // нестабильный класс и аргумент
unstableParam2 = state.orderData, // нестабильный класс и аргумент
unstableParam3 = state.singleOrder // нестабильный аргумент, коллекция
)
}
Во вьюмодели, где хранится состояние, напишем простейший таймер, обновляющий состояние каждую секунду:
class SyntheticViewModel : ViewModel() {
private val _state = MutableStateFlow(ComponentState())
val state = _state.asStateFlow()
init {
viewModelScope.launch {
while (isActive) {
delay(1.seconds)
_state.update {
it.copy(timerCounter = it.timerCounter + 1)
}
}
}
}
}
Теперь запустим LayoutInspector и заглянем в таблицу рекомпозиций. И вот магия: несмотря на ежесекундную рекомпозицию Child, GrandChild полностью пропускается (третья колонка - сколько раз рекомпозиция пропушена):

Получается, что даже многократное нарушение рекомендаций по стабильности не делает наш UI медленным, ведь перезапускается только оболочка, родительская функция, а все дочерние не перерисовываются.
А если без Strong Skipping?
Чтобы убедиться, что наш пример отрабатывает корректно и наша функция действительно нестабильна, заставим Compose работать "по-старому": отключим флаг StrongSkipping в настройках компилятора:
composeCompiler {
featureFlags.set(
listOf(ComposeFeatureFlag.StrongSkipping.disabled())
)
}
Теперь посмотрим на счетчик рекомпозиций в LayoutInspector:

Итак, действительно наш GrandChild перезапускается буквально каждый раз, когда перезапускается его родитель Child. В нашем синтетическом примере это не вызовет больших проблем в скорости, но в реальных проектах с множеством вложенных лейаутов, чтением CompositionLocals и прочим это может стать проблемой производительности. Поэтому не стоит отключать этот флаг без крайней необходимости.
А что с коллекциями?
Из привычных правил стабильности Compose все мы знаем, что все коллекции считаются нестабильными, и причины этого ясны: интерфейс List<T>
может реализовываться классом LinkedList<T>
, который может меняться. В дивном новом мире с включенным Strong Skipping рекомпозиции больше не будет происходить. Что делать в этом случае прикладному разработчику?
изменять список по принципу ReferentialEqualityPolicy, создавая новый инстанс списка, если нужно его поменять:
_state.update {
it.copy(
orderData = it.orderData.toMutableList().apply {
add(OrderData())
}
)
}
использовать SnapshotStateList, специально созданный для отслеживания состояния списка в Compose:
private val _orderData = mutableStateListOf<OrderData>()
val orderData: List<OrderData> get() = _orderData
// при добавлении элемента
_orderData.add(OrderData())
Плюсы и минусы обоих подходов достаточно очевидны. В первом случае мы постоянно создаем новый список, излишне нагружая GC, во втором - завязываемся на Compose Runtime во ViewModel, что может быть нежелательно по архитектурным соображениям (например, такое состояние не получится отслеживать из SwiftUI, если вы не идете all-in в Compose Multiplatform).
А что с лямбдами?
При включенном Strong Skipping лямбды тоже отслеживаются на основании адреса в памяти, как анонимные объекты. При этом они автоматически оборачиваются в remember, причем если лямбда захватывает посторонние объекты, объекты становятся ключами в remember-блоке.
Простой пример: если мы добавим в нашу функцию GrandChild параметр с типом lamda: (ComponentState) -> Unit
, и передадим лямбду вида lambda = { println(it.label) }
, то под капотом она будет преобразована в:
val lambda: (ComponentState) -> Unit = remember {
{ println(it.label) }
}
При этом, если захватить в лямбду переменную из внешнего скоупа, Compose Compiler добавит в remember ключ:
@Composable
fun Child(
state: ComponentState,
orderData: List<OrderData>,
lambda: (ComponentState) -> Unit
) {
val label = state.label
Text(state.timerCounter.toString())
GrandChild(
stableParam = state.label,
unstableParam = state.callbackHandler,
unstableParam2 = orderData,
unstableParam3 = state.singleOrder,
lambda = {
println(state.label)
}
)
}
// лямбда после компиляции
val lambda: (ComponentState) -> Unit = remember(state) {
{ println(state.label) }
}
Заметили подвох?
Получается, при любом изменении нашего ComponentState лямбда будет пересоздаваться, вызывая рекомпозицию GrandChild. Но ведь в лямбде нам важно только актуальное значение label! Такой маленький нюанс снова делает нашу функцию GrandChild фактически непропускаемой, как будто Strong Skipping выключен:

Как же это лечить? А очень просто. Нужно прочитать label в отдельную переменную и ссылаться на нее:
@Composable
fun Child(
state: ComponentState,
orderData: List<OrderData>,
lambda: (ComponentState) -> Unit
) {
val label = state.label
Text(state.timerCounter.toString())
GrandChild(
stableParam = state.label,
unstableParam = state.callbackHandler,
unstableParam2 = orderData,
unstableParam3 = state.singleOrder,
lambda = lambda,
lambda2 = {
println(it.label)
},
lambda3 = {
println(label)
}
)
}
// лямбда после компиляции
val lambda: (ComponentState) -> Unit = remember(label) {
{ println(label) }
}
В этом случае уже в рантайме Composer прочитает label из нашего ComponentState и проверит, изменился ли он, в чем можно убедиться, если заглянуть в байткод:
String var10000 = state.getLabel();
// ...
invalid$iv = $composer.changed(label);
К сожалению, это тонкость, которую легко пропустить и на которую не акцентирует внимание документация по Strong Skipping.
Но это, увы, не все по лямбдам. Описание работы Strong Skipping с лямбдами будет неполноценным, если не обсудить еще один корнер-кейс: захват в лямбду значения, полученного из делегата State<T>.getValue
. В этом случае пересоздания лямбды не произойдет, и механизм будет очень похож на использование derivedStateOf
:
val state by viewModel.state.collectAsState()
// в этом случае state не будет ключом в remember, потому что
// state мы читаем из делегата.
Child(state, viewModel.orderData) {
println(state.label)
}
А, и еще кое-что: вы можете запретить мемоизацию лямбды в конкретном случае (по сути отключите Strong Skipping для конкретной лямбды) при помощи аннотации DontMemoize:
@Composable
fun Child(
state: ComponentState,
orderData: List<OrderData>,
lambda: (ComponentState) -> Unit
) {
val label = state.label
Text(state.timerCounter.toString())
GrandChild(
stableParam = state.label,
unstableParam = state.callbackHandler,
unstableParam2 = orderData,
unstableParam3 = state.singleOrder,
lambda = @DontMemoize {
println(label)
}
)
}
В этом случае новый объект будет создаваться при каждой рекомпозиции родительской Composable-функции, вызывая рекомпозицию дочерней. На уровне байткода - никакой магии, просто создание нового объекта при каждом вызове:
GrandChildKt.GrandChild(
state.getLabel(),
state.getCallbackHandler(),
orderData,
state.getSingleOrder(),
new Function1() {
public final void invoke(ComponentState it) {
Intrinsics.checkNotNullParameter(it, "it");
System.out.println(label);
}
// $FF: synthetic method
// $FF: bridge method
public Object invoke(Object p1) {
this.invoke((ComponentState)p1);
return Unit.INSTANCE;
}
},
$composer,
4672
);
Трудно придумать пример, в котором это могло бы пригодиться в реальных проектах на Compose, но тем не менее такая возможность есть.
А как меняются Compose Metrics и Compose Reports с включенным Strong Skipping?
Не сильно. Отчеты все так же будут кричать об unstable параметрах, но функции при этом будут помечены как skippable:
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun GrandChild(
stable stableParam: String
unstable unstableParam: CallbackHandler
unstable unstableParam2: List<OrderData>
unstable unstableParam3: OrderData
stable lambda: Function1<ComponentState, Unit>
)
Это интересный момент, который может вызвать недоумение, если не знать о принципах работы Strong Skipping. Изменение элегантное, ведь критерии стабильности никуда не деваются, меняется только закономерность между пропускаемостью и нестабильностью.
Так что же делать?
Как всегда - следить за изменениями и быть готовыми к ним. А если конкретно - я бы сформулировал основные выводы для каждого прикладного разработчика на Jetpack Compose и Compose Multiplatform так:
Compose стал более агрессивно оптимизировать пропуск Composable функций, когда параметры не изменились.
Акцент в отдладке Compose сдвигается от метрик и отчетов к отладке на основе LayoutInspector и других инструментов, потому что метрики и отчеты говорят слишком мало о реальной производительности компонентов.
Чтобы обновить UI, используя нестабильные аргументы, нужно создать новый инстанс такого аргумента в памяти, и метод copy дата-классов для этого отлично подходит.
При работе с коллекциями для оптимизации рекомпозиций больше не нужно использовать Immutable Collections, однако придется обновлять их с созданием нового инстанса. Альтернатива использовать - SnapshotStateList.
При работе с лямбдами для максимальной производительности нужно захватывать только те объекты, которые действительно нужны: если нужно только одно поле класса, лучше прочитать поле в отдельную переменную и сослаться на нее.
Код экспериментов к этой статье можно найти здесь: https://github.com/gleb-skobinsky/ComposeStability