Перейти к содержимому
Все записи

Recomposition в Jetpack Compose: что на самом деле его вызывает и почему я перестал гадать

Практическое руководство по recomposition в Jetpack Compose в 2026 году — стабильность типов, нестабильные лямбды, ключи LazyColumn и чтение отчёта метрик компилятора.

MFKAPPS 5 мин чтения

У подтормаживающего экрана на Compose почти никогда нет очевидной причины. Ни стека вызовов, ни краша, ни предупреждения линтера — просто список, который ощущается чуть медленнее, чем должен, или текстовое поле, отстающее на полкадра от каждого нажатия клавиши. Обычный виновник — recomposition, выполняющийся на большей части дерева UI, чем нужно, и самое неприятное в том, что Compose никогда не сообщает, когда это происходит. Он просто молча делает больше работы, чем необходимо, кадр за кадром, пока вы сами не пойдёте это искать. Вот что на самом деле решает, будет ли composable пропущен или перезапущен, и два инструмента, которые заменяют догадки чтением.

Что такое recomposition на самом деле

Compose отслеживает чтения state, а не переменные. Когда composable-функция читает State<T> — напрямую или через remember { mutableStateOf(...) } — рантайм записывает это чтение относительно текущего scope композиции. Когда значение state меняется, только те scope, которые действительно его читали, планируются к повторному запуску. Всё остальное дерево, в теории, остаётся нетронутым. Слово, которое делает всю работу в этой фразе, — «scope, которые действительно его читали», и выяснить, какие это scope, — как раз то место, где всё идёт не так.

Настоящий вопрос компилятора: стабилен ли этот тип

Каждый параметр composable-функции классифицируется компилятором Compose как стабильный или нестабильный. Стабильный тип — это тип, о котором компилятор может доказать, что он не изменится без уведомления композиции, — так что если значение равно (equals()) прошлому, Compose полностью пропускает повторный запуск функции. Нестабильному типу так доверять нельзя, поэтому composable, использующий его, перезапускается при каждой recomposition родителя — независимо от того, изменилось ли то, что он на самом деле читает.

// Стабильно: каждое свойство — val, а String/Int стабильны по определению.
data class PantryItem(val id: Long, val name: String, val quantity: Int)

// Нестабильно: свойство var означает, что компилятор не может доказать,
// что этот экземпляр не изменится, пока на него держит ссылку composable.
data class PantryItemDraft(var name: String, var quantity: Int)

Версия с var — это не придирка к стилю, а разница между composable, который Compose может пропустить, и тем, который не может. Та же проблема возникает с обычными параметрами List<T> и Map<K, V>: компилятор считает интерфейс List нестабильным, потому что ничто не мешает вызывающей стороне передать MutableList и позже изменить его. data class, полный стабильных полей val, обёрнутый в обычный List, всё равно остаётся нестабильным параметром — и это самое частое место, где стабильность незаметно ломается в реальной кодовой базе.

Где это незаметно просачивается

Два паттерна объясняют большую часть лишних recomposition, которые я отследил на собственных экранах:

Не вынесенные («не hoisted») лямбды. Лямбда, определённая инлайн внутри тела composable, — это новый объект при каждой recomposition родителя, даже если захваченные ею значения не изменились. Переданная дочернему composable, эта новая инстанция не проходит проверку на равенство и заставляет дочерний элемент перекомпоноваться, независимо от того, изменилось ли то, что он на самом деле читает.

// Пересоздаётся при каждой recomposition PantryScreen — сводит на нет skip в ItemRow.
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })

// Вынесена один раз, стабильна на протяжении recomposition PantryScreen.
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)

Коллекции, передаваемые напрямую из репозитория. Flow<List<PantryItem>>, собираемый через collectAsStateWithLifecycle(), даёт новый экземпляр List при каждой эмиссии, даже если содержимое идентично — это часто случается сразу после того, как запрос Room перезапускается из-за записи в несвязанную таблицу. Если этот список передаётся нескольким дочерним composable, все они перекомпоновываются при каждой эмиссии. Обёртывание стороны чтения в derivedStateOf решает это, когда важно вычисленное значение, а не сам сырой список:

val isEmpty by remember {
    derivedStateOf { items.isEmpty() }
}

derivedStateOf запускает recomposition только тогда, когда меняется его вычисленный результат, а не когда меняются входные данные — так что список, выросший с 40 до 41 элемента, не перезапускает composable, которому важно только то, пуст ли список.

Списки: ключ, ограничивающий recomposition по строкам

По умолчанию LazyColumn использует позицию элемента как ключ идентичности. Переупорядочьте, вставьте или удалите элемент где угодно, кроме конца, — и каждая строка после этого индекса будет считаться «изменённой», потому что Compose отслеживает позицию, а не содержимое. Указание явного стабильного ключа решает это, ограничивая recomposition строками, содержимое которых действительно изменилось:

LazyColumn {
    items(items = pantryItems, key = { it.id }) { item ->
        PantryItemRow(item)
    }
}

Это именно тот паттерн, что лежит в основе списка кладовой в Stocky, который регулярно содержит несколько сотен строк из сканирований штрихкодов и импорта чеков — без стабильного ключа редактирование количества одного товара перекомпоновывало бы весь видимый список вместо одной изменившейся строки.

Чтение собственного отчёта компилятора

Гадать, какие composable нестабильны, не нужно — компилятор Compose может сказать это напрямую. Добавление флага метрик в сборку Gradle создаёт отчёт, перечисляющий каждую composable-функцию, можно ли её пропустить, и какой именно параметр сделал её нестабильной:

// build.gradle.kts
composeCompiler {
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
    reportsDestination = layout.buildDirectory.dir("compose_metrics")
}

Сгенерированный отчёт *-composables.txt называет нестабильный параметр в каждой отмеченной функции, превращая вопрос «почему этот экран медленный» из сессии профилирования в один grep. Я запускаю это перед выпуском любого экрана со списком или часто обновляемым значением — это дёшево, точно и ловит ошибку с var в data class примерно за то же время, что нужно на открытие файла.

Вывод

Recomposition сам по себе не является проблемой производительности — Compose спроектирован так, чтобы дёшево и часто перезапускать функции. Проблема — в неограниченной recomposition: целый экран перезапускается, потому что один нестабильный параметр, одна не вынесенная лямбда или один отсутствующий ключ списка сказали компилятору, что он не может доказать безопасность пропуска. Исправьте эти три вещи — стабильные типы с полями val, вынесенные лямбды и явные ключи на каждом элементе LazyColumn — и большая часть того, что выглядит как проблема производительности Compose, оказывается уже решённой.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции

Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.

#android #engineering #kotlin