Привет, Хабр! 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)


  1. JetsBackend
    22.06.2026 14:38

    а вы уверены в том что Jetpack Compose в 2026 году стал стандартом разработки UI на Android?


    1. evstep
      22.06.2026 14:38

      https://developer.android.com/develop/ui/compose/first#android-views
      We now consider the View toolkit (for example, classes in android.widget such as TextView and ListView) to be in maintenance mode — this means that it will only receive highly critical fixes.


  1. ChPr
    22.06.2026 14:38

    val onClick = remember(user) { { onUserClick(user) } }

    Не надо так больше делать со strong skipping mode (уже давно включен по-умолчанию). Даже есть отдельная аннотация @DontMemoize для opt-out.