Привет, Хабр! Jetpack Compose в 2026 году стал стандартом разработки UI на Android, но в проектах регулярно повторяется одна и та же история: на экране со списком в пару сотен элементов прокрутка идёт рывками, профайлер показывает скачки кадров до 200 миллисекунд, а команда чешет голову и предлагает откатиться обратно на RecyclerView.
Проблема почти всегда не в Compose, а в том, как написан UI: recomposition спроектирован как дешёвая операция, но эта дешевизна работает только при соблюдении ряда правил, которые в документации описаны рассыпанно и часто игнорируются.
Разберём пять ошибок, из-за которых производительность Compose-экранов проседает заметно для глаза, и покажем, как их находить и чинить.
Лямбды без remember пересоздаются на каждой рекомпозиции
Часто встречающийся код, который выглядит логично:
@Composable
fun UserList(users: List<User>, onUserClick: (User) -> Unit) {
LazyColumn {
items(users) { user ->
UserCard(
user = user,
onClick = { onUserClick(user) }
)
}
}
}
В каждой рекомпозиции UserList лямбда { onUserClick(user) } создаётся заново, и Compose видит её как новый объект. Если UserCard принимает onClick: () -> Unit без remember, Compose считает, что параметры изменились, и рекомпозит карточку, даже когда сам user остался прежним.
Решение, которое разработчики применяют чаще всего, выглядит так:
items(users, key = { it.id }) { user ->
val onClick = remember(user) { { onUserClick(user) } }
UserCard(user = user, onClick = onClick)
}remember(user) кэширует лямбду, пересоздавая её только при смене пользователя. Это работает, но загромождает код. Более чистая альтернатива: внутри UserCard принимать не () -> Unit, а (User) -> Unit и сам объект пользователя, тогда лямбда верхнего уровня стабильна сама по себе и не требует remember.
Compose Compiler с включёнными compiler reports подсвечивает такие места: в выводе появляются строки про unstable parameters, и можно сразу видеть, где Compose не может пропустить recomposition. Включается флагом composeCompiler { reportsDestination = ... } в build.gradle, и через десять минут анализа из отчёта вытаскивается список проблемных точек.
Нестабильные типы заставляют Compose не доверять параметрам
Compose делит типы на stable и unstable.
Stable типы (Int, String, enum, data class с immutable полями стабильных типов) Compose сравнивает по equals и пропускает рекомпозицию, если параметр не изменился. Unstable типы (List, Map, Set, обычные классы с var-полями) Compose не считает безопасными для пропуска: даже если содержимое не менялось, рекомпозиция случится.
Пример:
data class ProductFilters(
val categories: List<String>,
val priceRange: IntRange,
val brands: List<Brand>
)
@Composable
fun FilterPanel(filters: ProductFilters) {
// Этот composable будет рекомпозиться при любой рекомпозиции родителя
}List<String> это интерфейс, под которым может скрываться mutable-реализация. Compose не знает, что внутри ArrayList, который теоретически могут изменить снаружи, поэтому помечает весь ProductFilters как unstable.
Решений два.
Первое: использовать kotlinx.collections.immutable, который даёт ImmutableList, PersistentList и аналоги. Эти типы помечены как stable аннотацией внутри библиотеки, и Compose их пропускает корректно.
data class ProductFilters(
val categories: ImmutableList<String>,
val priceRange: IntRange,
val brands: ImmutableList<Brand>
)Второе: пометить класс аннотацией @Immutable или @Stable, если есть уверенность, что после создания объект не меняется.
@Immutable
data class ProductFilters(
val categories: List<String>,
val priceRange: IntRange,
val brands: List<Brand>
)@Immutable это контракт: разработчик гарантирует Compose, что объект и всё, на что он ссылается, не изменится после создания. Если контракт нарушен (где-то в коде мутируется список внутри), Compose будет показывать устаревшие данные. Поэтому @Immutable ставится осознанно на типы, у которых физически нет mutable-поверхности.
Чтение state в верхнем composable рекомпозит всё дерево
Состояние в Compose читается через .value или через делегат by. Любой composable, который читает state, попадает в зону отслеживания: при изменении state именно этот composable будет рекомпозиться, и все его дочерние, если параметры до них дойдут изменёнными.
Антипаттерн:
@Composable
fun ScreenContent(viewModel: ScreenViewModel) {
val state by viewModel.state.collectAsState()
Column {
Header(title = state.title)
UserSection(user = state.user)
ContentList(items = state.items)
Footer()
}
}При любом изменении любого поля в state рекомпозится весь ScreenContent, потому что чтение состояния происходит наверху. Compose попытается пропустить дочерние composables, у которых параметры не изменились (skippable), но это работает только если их параметры стабильные. Для state.items с типом List<Item> пропуска не произойдёт.
Решение: переместить чтение state как можно ниже по дереву, ближе к месту использования.
@Composable
fun ScreenContent(viewModel: ScreenViewModel) {
Column {
Header(viewModel = viewModel)
UserSection(viewModel = viewModel)
ContentList(viewModel = viewModel)
Footer()
}
}
@Composable
private fun Header(viewModel: ScreenViewModel) {
val title by viewModel.state.map { it.title }.collectAsState(initial = "")
Text(title)
}Каждый composable читает только своё подмножество state, и при изменении одного поля рекомпозится только тот composable, который этим полем пользуется. Альтернатива через lambda-параметры:
@Composable
fun ScreenContent(viewModel: ScreenViewModel) {
Column {
Header(titleProvider = { viewModel.state.value.title })
// ...
}
}
@Composable
private fun Header(titleProvider: () -> String) {
Text(titleProvider())
}Lambda как параметр откладывает чтение state до момента вызова, что даёт похожий эффект: рекомпозиция Header произойдёт только при изменении title.
Вычисления без derivedStateOf приводят к лишним рекомпозициям
Сценарий с кнопкой «Наверх» в длинном списке:
@Composable
fun ScrollableList(items: List<Item>) {
val listState = rememberLazyListState()
val showScrollToTop = listState.firstVisibleItemIndex > 5
Box {
LazyColumn(state = listState) {
items(items) { item -> ItemRow(item) }
}
if (showScrollToTop) {
ScrollToTopButton(onClick = { /* ... */ })
}
}
}firstVisibleItemIndex обновляется на каждый кадр прокрутки, и значение меняется десятки раз в секунду. Compose видит, что showScrollToTop зависит от меняющегося state, и рекомпозит окружающий composable на каждое изменение, даже если булево значение остаётся true.
derivedStateOf решает эту проблему: он отслеживает зависимости и рекомпозит только когда финальное значение изменилось.
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}Теперь рекомпозиция произойдёт только в моменты пересечения порога (с 5 на 6 и обратно), а не на каждом изменении индекса. Для счётчика прокрутки в большом списке это разница между десятками рекомпозиций в секунду и парой за всю сессию.
derivedStateOf уместен, когда промежуточный state меняется чаще, чем финальный, и читателей у финального значения больше, чем у промежуточного. Если зависимость один к одному, derivedStateOf даёт только накладные расходы без выгоды.
Modifier создаётся внутри composable вместо хранения снаружи
Часто пишут так:
@Composable
fun ProductCard(product: Product) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(Color.White)
.clip(RoundedCornerShape(8.dp))
) {
Text(product.name)
Text(product.price)
}
}Цепочка Modifier создаётся при каждой рекомпозиции заново. Сами модификаторы стабильны (Compose Compiler знает про них), но создание длинной цепочки в каждом кадре даёт измеримый оверхед, особенно в LazyColumn с большим количеством элементов.
Оптимизация для случаев, когда modifier не зависит от состояния:
private val CardModifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.background(Color.White)
.clip(RoundedCornerShape(8.dp))
@Composable
fun ProductCard(product: Product) {
Column(modifier = CardModifier) {
Text(product.name)
Text(product.price)
}
}Modifier теперь создаётся один раз при загрузке класса, а не на каждую рекомпозицию. Для модификаторов, которые зависят от темы или composition-local значений, помогает remember:
@Composable
fun ProductCard(product: Product) {
val cardModifier = remember {
Modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(8.dp))
}
val themedModifier = cardModifier.background(MaterialTheme.colorScheme.surface)
Column(modifier = themedModifier) {
Text(product.name)
Text(product.price)
}
}Базовая часть кэшируется через remember, тема-зависимая часть навешивается поверх. На малых списках разница неощутима, но в реальных приложениях с десятками сложных композаблов на экран суммарно набегает заметная экономия.
Как искать эти проблемы у себя
Compose Compiler Reports включается флагом в build.gradle.kts:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}После сборки в указанной директории появляются файлы с разбором каждого composable: какие параметры stable, какие нет, какие composables skippable, какие нет, где лямбды не запоминаются. Это первый и самый информативный источник информации.
Layout Inspector в Android Studio показывает recomposition counts: над каждым composable выводится счётчик пересборок, и видно, какие из них перерисовываются чаще, чем должны. На экране со списком счётчики у элементов вне видимости должны оставаться нулевыми, у видимых, не зависящих от прокрутки, тоже.
Macrobenchmark с включённым BaselineProfile даёт измеримые цифры по фреймрейту реальных сценариев. Это последний рубеж проверки, ведь если профилирование показывает плавную прокрутку, остальное вторично.
Пять перечисленных ошибок встречаются практически в любом Compose-проекте: лямбды без remember в параметрах, нестабильные коллекции вместо immutable, чтение state наверху дерева, отсутствие derivedStateOf для производных значений, создание модификаторов в каждой рекомпозиции. По отдельности каждая даёт небольшой оверхед, в сумме на экране со списком в пару сотен элементов получается заметное падение фреймрейта.
Фикс всегда один: включить Compose Compiler Reports, посмотреть, где Compose помечает параметры как unstable, исправить причины, прогнать macrobenchmark до и после. Обычно после первой итерации очистки кадры на критичных экранах перестают проваливаться, и дальше остаётся точечная работа над сложными сценариями анимаций и тяжёлых вычислений в composables.

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

























