Как Jetpack Compose работает внутри — composer, slot table и рекомпозиция

· 12 минут чтения

kotlinandroidcompose

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 проходит три фазы:

  1. Композиция — «что показывать». Выполняются композаблы, строится и обновляется дерево узлов.
  2. Макет — «где показывать». Каждый узел меряет детей и назначает им позиции.
  3. Отрисовка — «как это выглядит». Узлы рисуют себя на канве.

Три фазы кадра в 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))
    }
}

Что здесь важно:

Slot table и gap buffer

Указатель — куда? Composer хранит состояние композиции не деревом объектов, а плоским массивом — слот-таблицей, устроенной как gap buffer. Приём тот же, что в текстовых редакторах: непрерывный «пузырь» свободного места, который стоит там, где сейчас идёт правка.

Пустая слот-таблица: все слоты помечены EMPTY, весь свободный участок обозначен как Gap, курсор в начале

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

Четыре заполненных слота с номерами 0–3, курсор на границе, ниже остаётся gap

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

Курсор снова прошёл слоты 0 и 1, значение в слоте 1 обновлено и подсвечено другим цветом

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

Gap перенесён к позиции курсора: слоты с кругом и ромбом уехали в конец массива

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

В освободившееся место вставлены два новых слота, нумерация оставшихся сдвинулась на 4 и 5

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

Теперь посмотрим, во что превращается Counter из примера выше:

Слоты Counter: группа с ключом 123, внутри группа 456 и State(0) от remember, дальше группа 789, строка Count: 0 и лямбда от Button

Числа 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. Он меняет три вещи:

Отказаться для конкретной функции можно аннотацией @NonSkippableComposable.

При этом @Stable и @Immutable не отменены. Они переключают сравнение с === на equals, а это ровно разница между «пропустим» и «не пропустим» в частом случае, когда объект пересоздаётся с теми же данными.

Что с этим делать на практике

Шпаргалка

Сущность Что это
@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.