Compose выглядит обманчиво просто: пишете функцию, которая ничего не возвращает, меняете переменную — экран сам обновляется. Ровно до того момента, когда список начинает подтормаживать на скролле, анимация роняет кадры, а Layout Inspector показывает, что половина экрана перекомпонуется на каждый тик таймера. Дальше советы уровня «оберни в remember» перестают помогать, потому что непонятно, что именно они чинят.
Разберём Compose на трёх уровнях глубины: сначала как это выглядит из кода, потом через фазы кадра, и наконец — что реально дописывает компилятор в вашу @Composable-функцию.
За основу взята статья Sagar Malhotra Understanding Jetpack Compose: Internal Implementation and Working на ProAndroidDev, оттуда же схемы слот-таблицы. Статье полтора года и местами она упрощает до неверного — такие места я отметил отдельно, а версии и сигнатуры сверил с исходниками androidx и официальной документацией.
Уровень 1: композабл — это функция, которую зовут заново
С обычной функцией всё понятно: вызвали, она выполнилась сверху вниз, вернула результат, забыли. Композабл выглядит так же:
@Composable
fun Greeting() {
var currentName by remember { mutableStateOf("Мир") }
Column {
Text(text = "Привет, $currentName")
Button(onClick = { currentName = "Compose" }) {
Text(text = "Сменить")
}
}
}
Разница в том, что происходит после возврата. В XML вы бы сами нашли нужный TextView и позвали setText. Здесь вы меняете currentName — и функция выполняется ещё раз, сама.
Полный цикл выглядит так: приложение стартует → композабл вызывается впервые (это initial composition, «первичная композиция») → на экране начальные данные → пользователь жмёт кнопку → меняется состояние → Compose выполняет функцию заново (рекомпозиция) → UI обновлён → экран уходит, композабл покидает композицию.
Уточнение к оригиналу. Автор называет рекомпозицию рекурсией. Это не рекурсия:
Greetingне вызываетGreeting. Заново функцию вызывает рантайм — и не всю иерархию с корня, а только тот участок, который он пометил как невалидный.
Разница не терминологическая. Если бы это была рекурсия от корня, каждое нажатие кнопки перевыполняло бы весь экран. На практике перевыполняется тело Greeting, а Text и Button внутри могут быть пропущены целиком, если их аргументы не изменились. Половина статьи дальше — про то, как рантайм это решает.
Отсюда первое практическое следствие: композабл может быть вызван сколько угодно раз, в произвольном порядке и не обязательно на главном потоке. Никаких побочных эффектов в теле — только описание UI.
Уровень 2: три фазы кадра
Чтобы из ваших функций получились пиксели, Compose проходит три фазы:
- Композиция — «что показывать». Выполняются композаблы, строится и обновляется дерево узлов.
- Макет — «где показывать». Каждый узел меряет детей и назначает им позиции.
- Отрисовка — «как это выглядит». Узлы рисуют себя на канве.

Ключевая идея: фазы независимы. Не любое изменение состояния запускает все три.
Уточнение к оригиналу. Автор пишет, что композиция — самая дорогая фаза, потому что каждый раз проходит всё дерево композаблов и перестраивает его. Дерево при рекомпозиции не перестраивается: оно обновляется на месте, обход начинается с ближайшего невалидированного участка, а композаблы с неизменившимися аргументами пропускаются целиком.
Верной остаётся другая формулировка: для одного и того же куска UI композиция дороже макета и отрисовки. Поэтому если изменение по смыслу касается только позиции или прозрачности, доводить его до композиции незачем.
Управляется это тем, на какой фазе читается состояние. Сравните:
// offset читается в композиции: каждый кадр анимации — композиция, макет и отрисовка
Box(Modifier.offset(x = animatedOffset.value.dp))
// offset читается в фазе макета: композиция не запускается вообще
Box(Modifier.offset { IntOffset(animatedOffset.value, 0) })
То же с прозрачностью — Modifier.alpha(value) читает состояние в композиции, а Modifier.graphicsLayer { alpha = value } откладывает чтение до фазы отрисовки, вычёркивая и композицию, и макет.
Уточнение к оригиналу. Совет «аккуратнее с модификаторами, они иммутабельны и при изменении заново попадают в дерево композиции» сформулирован путано: иммутабельность здесь ни при чём. Лямбда-версия модификатора не дешевле сама по себе — она просто откладывает чтение состояния до той фазы, где оно реально нужно.
Уровень 3: что дописывает компилятор
@Composable — не аннотация-процессор
Compose — плагин компилятора Kotlin, а не KAPT или KSP. Он не генерирует отдельные файлы рядом с вашими: он переписывает промежуточное представление ваших же функций.
@Composable при этом ведёт себя не как обычная аннотация, а как часть типа — ровно как suspend:
// объявление функции
suspend fun myFun() { }
// лямбда
val myLambda = suspend { }
// тип функции
fun other(param: suspend () -> Unit) { }
// объявление функции
@Composable fun MyFun() { }
// лямбда
val myLambda = @Composable { }
// тип функции
fun other(param: @Composable () -> Unit) { }
И там и там обычная функция не может вызвать помеченную: нужен контекст вызова, который передаётся неявным параметром. У suspend это Continuation, у @Composable — объект по имени Composer.
Здесь аналогия заканчивается.
suspend— это CPS-трансформация: тело режется на состояния конечного автомата, чтобы функцию можно было приостановить и возобновить.@Composableничего не режет. Он добавляет параметры и оборачивает тело в группу, чтобы рантайм мог сопоставить текущий вызов с предыдущим и при необходимости не выполнять тело вовсе.
Composer и битовая маска $changed
Возьмём счётчик:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text = "Count: $count",
onPress = { count += 1 },
)
}
Из-за аннотации компилятор дописывает в тело дополнительный параметр и вызовы. В самом упрощённом виде — так:
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember($composer) { mutableStateOf(0) }
Button(
$composer,
text = "Count: $count",
onPress = { count += 1 },
)
$composer.end()
}
123 — целочисленный ключ, сгенерированный на этапе компиляции. Composer протаскивается во все вложенные вызовы: именно поэтому композабл нельзя позвать из обычной функции — передавать нечего.
Реальный результат чуть сложнее. Добавим параметр, чтобы стало видно главное:
@Composable
fun Greeting(name: String) {
Text("Привет, $name")
}
// упрощённо: точная раскладка битов зависит от версии компилятора
fun Greeting(name: String, $composer: Composer?, $changed: Int) {
$composer = $composer.startRestartGroup(-1234567)
var $dirty = $changed
if ($changed and 0b0110 == 0) {
// вызывающий не знал — сравниваем сами
$dirty = $dirty or if ($composer.changed(name)) 0b0100 else 0b0010
}
if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) {
Text("Привет, $name", $composer, 0)
} else {
$composer.skipToGroupEnd()
}
$composer.endRestartGroup()?.updateScope { next, _ ->
Greeting(name, next, updateChangedFlags($changed or 0b0001))
}
}
Что здесь важно:
$changed— это способ вызывающего сказать вызываемому: «вот этот аргумент я передаю тебе тот же самый». По три бита на параметр: «точно не менялся», «точно менялся», «не знаю». В последнем случае функция сравнивает сама через$composer.changed(name).startRestartGroup/endRestartGroup— обёртка тела в группу, которую рантайм имеет право перезапустить отдельно от всего остального.skipToGroupEnd()— та самая пропускаемость. Если про все аргументы известно, что они не менялись, и внутри нет невалидированного состояния, тело не выполняется вообще: рантайм просто перематывает указатель за конец группы.
Slot table и gap buffer
Указатель — куда? Composer хранит состояние композиции не деревом объектов, а плоским массивом — слот-таблицей, устроенной как gap buffer. Приём тот же, что в текстовых редакторах: непрерывный «пузырь» свободного места, который стоит там, где сейчас идёт правка.

Первая композиция идёт сверху вниз и заполняет массив группами, состояниями и запомненными значениями. Курсор двигается вперёд, gap съёживается:

Рекомпозиция начинается со сброса курсора в начало. Дальше рантайм идёт по массиву и для каждого слота решает: обновить значение или пропустить целиком.

Пока структура UI не меняется, это всё операции с постоянным временем: прочитать слот, сдвинуть курсор, перезаписать значение. Но если в дереве появилось что-то новое — сработало if и добавился ещё один композабл, — свободное место сначала нужно подвинуть к точке вставки:

и только потом писать:

Перенос gap — операция за O(n), все остальные — get, insert, delete, move курсора — константные. Ставка тут такая: UI меняет значения часто, а структуру редко, и когда структура всё-таки меняется, она меняется крупными кусками. Один проход за O(n) на такое событие — приемлемая цена за константу во всех остальных случаях.
Теперь посмотрим, во что превращается Counter из примера выше:

composer.startкладёт группу с ключом 123 — это самCounter;rememberкладёт свою группу 456, а рядом — экземплярState, который вернулmutableStateOf;Buttonкладёт группу 789 и следом каждый свой аргумент: строку"Count: 0"и лямбдуonPress. Всё, что внутриButton, укладывается туда же обходом в глубину;composer.endзакрывает группу 123.
Числа 123, 456 и 789 — те самые компиляторные ключи, и они же подпись структуры. Если на этой позиции рантайм ожидал группу 456, а composer.start пришёл с 789 — значит структура UI изменилась, и участок нужно не обновлять, а перестраивать.
remember и позиционная мемоизация
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.contains(query) }
// ...
}
Фильтр здесь отработает при каждой рекомпозиции — даже если ни список, ни строка не изменились. Лечится обёрткой:
val results = remember(items, query) { items.filter { it.contains(query) } }
remember умеет ходить в слот-таблицу. Он смотрит на свою позицию: ключей ещё нет — выполняет лямбду, кладёт рядом ключи и результат. Ключи есть — сравнивает их со свежими аргументами; совпали — возвращает сохранённый результат, лямбду не трогая; разошлись — пересчитывает и перезаписывает.
Настоящая сигнатура — это не одна функция, а набор перегрузок:
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T
@Composable
inline fun <T> remember(key1: Any?, crossinline calculation: @DisallowComposableCalls () -> T): T
// ...аналогично на два и на три ключа, и в конце
@Composable
inline fun <T> remember(vararg keys: Any?, crossinline calculation: @DisallowComposableCalls () -> T): T
В оригинале приведён только vararg-вариант. Перегрузки на один, два и три ключа существуют отдельно, чтобы не аллоцировать массив на каждый вызов, а @DisallowComposableCalls запрещает звать композаблы внутри лямбды — иначе они попали бы в слот-таблицу условно, в зависимости от того, выполнилась лямбда или нет.
Версия без ключей — та самая, которая «вычислить один раз»:
@Composable
fun App() {
val x = remember { Math.random() }
// ...
}
Math.random() выполнится при первой композиции, значение ляжет в слот и будет возвращаться при всех последующих рекомпозициях — до тех пор, пока App не покинет композицию.
Такая мемоизация называется позиционной, и это стоит проговорить: ключ здесь не аргументы, а место в слот-таблице. Один и тот же remember { mutableStateOf(0) }, встреченный в двух разных ветках дерева, даст два независимых состояния. И наоборот: если переставить элементы списка местами, Compose сопоставит их по позиции и состояние «переедет» не туда. Отсюда key() в LazyColumn — способ сказать рантайму, что идентичность элемента задаётся не позицией.
Кто на самом деле запускает рекомпозицию
Мы нигде не пишем цикл перезапуска. Где он?
$composer.endRestartGroup()?.updateScope { next, _ ->
Greeting(name, next, updateChangedFlags($changed or 0b0001))
}
Вот он. На выходе из группы компилятор регистрирует лямбду, которая умеет позвать эту функцию заново. Роль примерно та же, что у лямбды, которую вы передаёте в LiveData.observe.
Обратите внимание на ?. — результат nullable, и это содержательно:
Если внутри композабла не читается ни одно наблюдаемое состояние,
endRestartGroup()вернётnull, лямбда перезапуска не зарегистрируется, и такой композабл в принципе не может быть перекомпонован сам по себе — только вместе с родителем.
Кто дёргает эту лямбду — snapshot-система. mutableStateOf возвращает не обычную обёртку, а объект, который умеет сообщать о чтениях и записях. Чтение внутри активного участка регистрирует зависимость, запись помечает этот участок невалидным, а Recomposer на следующем кадре перевыполняет только помеченные участки — каждый со своей позиции в слот-таблице.
Отсюда главное практическое правило всей статьи:
Границы участка перезапуска определяют объём работы при изменении состояния. Читайте state как можно ниже по дереву — там, где он действительно нужен.
// состояние читается в теле Screen → перезапускается весь Screen
@Composable
fun Screen(state: MyState) {
Header()
Text("Счёт: ${state.count}")
Footer()
}
При каждом изменении count тело Screen выполняется заново целиком. Header() и Footer() при этом, скорее всего, будут пропущены — но только после того, как рантайм до них дойдёт и сверит аргументы.
// чтение спущено внутрь → перезапускается только CounterLabel
@Composable
fun Screen(state: MyState) {
Header()
CounterLabel(count = { state.count })
Footer()
}
@Composable
fun CounterLabel(count: () -> Int) {
Text("Счёт: ${count()}")
}
Здесь Screen не читает count вообще — читает лямбда внутри CounterLabel. При изменении счётчика тело Screen даже не начинает выполняться.
Что изменилось с марта 2024: strong skipping
Оригиналу полтора года, и с тех пор в компиляторе поменялось важное.
Раньше пропускаемость требовала стабильности: чтобы композабл мог не выполнять тело, все его параметры должны были быть стабильными типами. Один List<String> в аргументах — интерфейс из stdlib, компилятор не может гарантировать, что за ним не стоит MutableList, — и функция теряла пропускаемость целиком. Отсюда @Stable, @Immutable, обёртки над списками и прочая борьба.
Strong skipping mode включён по умолчанию начиная с Kotlin 2.0.20. Он меняет три вещи:
- все перезапускаемые композаблы становятся пропускаемыми — независимо от стабильности параметров;
- стабильные аргументы по-прежнему сравниваются через
equals, нестабильные — по ссылке (===); - лямбды внутри композаблов автоматически оборачиваются в
rememberс захваченными переменными в качестве ключей — то, что раньше приходилось писать руками.
Отказаться для конкретной функции можно аннотацией @NonSkippableComposable.
При этом @Stable и @Immutable не отменены. Они переключают сравнение с === на equals, а это ровно разница между «пропустим» и «не пропустим» в частом случае, когда объект пересоздаётся с теми же данными.
Что с этим делать на практике
- Сначала померить. Счётчики рекомпозиций в Layout Inspector и composition tracing показывают, что именно перекомпонуется. Оптимизировать вслепую по списку советов — потеря времени.
- Спускать чтение состояния вниз по дереву — до того композабла, которому оно нужно.
- Передавать лямбду вместо значения там, где значение меняется часто: анимации, прогресс, скролл.
- Откладывать чтение до нужной фазы:
Modifier.offset { }вместоModifier.offset(),graphicsLayer { }вместоalpha(). - Ставить
key()в списках — иначе состояние сопоставляется по позиции. - Не заворачивать всё подряд в
remember. Он тоже занимает слоты и сравнивает ключи; для дешёвого выражения это чистый убыток.
Шпаргалка
| Сущность | Что это |
|---|---|
@Composable |
не аннотация-процессор, а маркер для плагина компилятора; ведёт себя как часть типа, наравне с suspend |
Composer |
скрытый параметр, который компилятор протаскивает через все композаблы; точка входа в слот-таблицу |
$changed |
битовая маска: вызывающий сообщает, какие аргументы точно не менялись |
| restart-группа | обёртка тела функции; даёт рантайму право перезапустить именно её |
| слот-таблица | плоский массив групп, состояний и запомненных значений, устроенный как gap buffer |
| gap | непрерывный участок свободного места; его перенос — O(n), остальные операции — константа |
| компиляторные ключи | целые числа при группах; расхождение ключа означает, что структура UI изменилась |
remember |
позиционная мемоизация: ключ — позиция в слот-таблице, а не аргументы |
| snapshot-система | подписывает участок на чтении состояния и помечает его невалидным на записи |
Recomposer |
перевыполняет помеченные участки на следующем кадре |
| strong skipping | с Kotlin 2.0.20: пропускаемость независимо от стабильности параметров плюс авто-remember лямбд |
Если запомнить одно: Compose не перерисовывает UI, он сверяет текущий вызов с записью в слот-таблице и трогает только то, что разошлось. Почти все советы по производительности сводятся к одному — сузить участок, который придётся сверять.
В основе разбора — статья Sagar Malhotra Understanding Jetpack Compose: Internal Implementation and Working на ProAndroidDev (март 2024), оттуда же взяты схемы слот-таблицы; сами слайды — из доклада Understanding Compose (Android Dev Summit ’19), который автор указывает в списке источников. Анимация фаз — из документации Android. Дополнительно: Under the hood of Jetpack Compose Леланда Ричардсона, видео Jetpack Compose: Debugging recomposition и разделы документации про фазы и strong skipping. Сигнатуры remember сверены с исходниками androidx.