Рекомпозиция и её оптимизация
Вопрос уровня senior: тут проверяют, умеете ли вы объяснить, почему интерфейс на Compose может тормозить, и что с этим делать конкретно.
Рекомпозиция — повторный вызов composable-функций, состояние которых изменилось. Compose старается перевызвать минимум: функции, чьи входные данные не изменились, пропускаются (skipping).
Вопрос 1: «Что запускает рекомпозицию и как Compose решает, что пропустить?»
Что проверяет интервьюер
Знаете ли вы про чтение состояния как триггер и про стабильность типов как условие пропуска. Это две ключевые идеи всей темы производительности Compose.
Развёрнутый ответ
Рекомпозицию запускает изменение значения State, которое было прочитано внутри composable. Compose во время композиции запоминает, какая функция читала какой State, и при изменении перевызывает только эти функции — в этом смысл фразы «Compose перерисовывает не весь экран».
Пропустить вызов Compose может, только если все параметры функции стабильны и не изменились. Тип считается стабильным, если:
- это примитив,
String, функциональный тип или неизменяемый тип; - все его публичные свойства —
valстабильных типов; - либо он помечен
@Immutableили@Stable.
Классическая ловушка — List. Хотя в Kotlin это read-only интерфейс, компилятор Compose не может доказать неизменяемость: под ним может лежать MutableList. Поэтому параметр типа List<Item> делает функцию непропускаемой, и она рекомпозируется при каждом проходе родителя.
// items нестабилен -> функция не пропускается
@Composable
fun ItemsList(items: List<Item>) { ... }
// вариант 1: персистентная коллекция из kotlinx.collections.immutable
@Composable
fun ItemsList(items: ImmutableList<Item>) { ... }
// вариант 2: обещание неизменяемости на уровне модели
@Immutable
data class FeedState(val items: List<Item>, val title: String)
Здесь важно предупредить: @Immutable — это обещание компилятору, а не проверка. Если пометить так класс, который на самом деле меняется, Compose пропустит рекомпозицию и UI покажет устаревшие данные. Отдельно стоит упомянуть strong skipping mode, включённый по умолчанию в свежих версиях компилятора: он позволяет пропускать функции и с нестабильными параметрами, сравнивая их по ссылке — но это не отменяет пользы от честно стабильных моделей.
Вопрос 2: «Как оптимизировать частые рекомпозиции?»
Главный приём — откладывать чтение состояния как можно ниже по дереву. Читать State нужно там, где значение реально используется, а не в родителе:
// ПЛОХО: читаем offset в теле -> рекомпозиция всей функции на каждый кадр скролла
@Composable
fun Header(scroll: ScrollState) {
Box(modifier = Modifier.offset(y = scroll.value.dp)) { ... }
}
// ХОРОШО: лямбда-версия модификатора читает значение на фазе layout,
// рекомпозиция не нужна вовсе
@Composable
fun Header(scroll: ScrollState) {
Box(modifier = Modifier.offset { IntOffset(0, scroll.value) }) { ... }
}
Второй приём — derivedStateOf, когда из часто меняющегося состояния вычисляется редко меняющееся:
// firstVisibleItemIndex меняется постоянно, showButton — редко
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Без derivedStateOf рекомпозиция шла бы на каждый пиксель скролла; с ним — только когда булево значение реально переключилось.
Третий приём — ключи в ленивых списках. Без key Compose сопоставляет элементы по позиции, и вставка в начало списка приводит к пересозданию состояния всех последующих элементов:
LazyColumn {
items(
items = articles,
key = { it.id }, // стабильная идентичность элемента
contentType = { it.type } // переиспользование по типу разметки
) { article ->
ArticleRow(article)
}
}
Четвёртый — не выполнять тяжёлые вычисления в теле composable. Сортировку и фильтрацию оборачивают в remember(key) либо, что честнее, выносят во ViewModel.
Вопрос 3: «Как найти лишние рекомпозиции?»
- Layout Inspector в Android Studio показывает счётчики Recomposition count и Skipped count по каждому узлу — прямой ответ на вопрос «кто перерисовывается на каждый кадр».
- Отчёты компилятора Compose (compiler metrics) выдают, какие функции restartable/skippable и какие параметры признаны нестабильными.
- Macrobenchmark измеряет реальные метрики: frame timing, jank, время старта.
- Baseline profiles ускоряют первый запуск и первый скролл, снимая часть JIT-разогрева.
Типичные ошибки кандидатов
- «Compose перерисовывает весь экран при каждом изменении» — неверно, перевызываются только читающие функции.
- Считают, что лямбда, созданная в теле composable, обязательно ломает пропуск. Компилятор запоминает лямбды, если они не захватывают нестабильные значения.
- Ставят
@Immutableна изменяемые модели «чтобы ускорить» — получают устаревший UI. - Оптимизируют вслепую, без Layout Inspector и без замеров.
- Не знают про
keyвLazyColumnи удивляются, почему при вставке элемента «прыгает» состояние строк.
Как ответить кратко (20–30 секунд)
«Рекомпозицию запускает изменение
State, который был прочитан внутри composable, — перевызываются только читающие функции. Пропустить вызов Compose может, если все параметры стабильны и не изменились; классическая проблема —List, который компилятор считает нестабильным, поэтому использую персистентные коллекции или помечаю модель@Immutable. Оптимизирую тремя приёмами: откладываю чтение состояния вниз, например через лямбда-версии модификаторов; используюderivedStateOf, когда из часто меняющегося значения выводится редко меняющееся; задаюkeyвLazyColumn. Тяжёлые вычисления в теле composable не делаю — либоremember, либо ViewModel. Ищу лишние рекомпозиции через Layout Inspector и отчёты компилятора, а измеряю Macrobenchmark.»