Привет, Хабр! 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-разработке отдельное внимание уделяется архитектуре приложений, работе с состоянием, производительности интерфейсов и инструментам, которые помогают находить такие проблемы до релиза. Именно этот уровень понимания обычно и отделяет работающий экран от действительно качественного мобильного приложения.
Комментарии (3)

ChPr
22.06.2026 14:38val onClick = remember(user) { { onUserClick(user) } }
Не надо так больше делать со strong skipping mode (уже давно включен по-умолчанию). Даже есть отдельная аннотация @DontMemoize для opt-out.
JetsBackend
а вы уверены в том что Jetpack Compose в 2026 году стал стандартом разработки UI на Android?
evstep
https://developer.android.com/develop/ui/compose/first#android-views
We now consider the View toolkit (for example, classes in
android.widgetsuch asTextViewandListView) to be in maintenance mode — this means that it will only receive highly critical fixes.