Каковы лучшие практики производительности в Jetpack Compose (стабильность, пропускаемость)?
Compose пропускает recomposition только для стабильных параметров. Ключевые практики: @Immutable/ImmutableList для типов, derivedStateOf для derived state, key() в LazyColumn, передача лямбды вместо значения в offset/padding для defer-чтения state в layout-фазу.
Как Compose решает, пересобирать ли composable
Compose Runtime перед каждым recomposition проверяет, изменились ли параметры composable. Если все параметры стабильны и их значения не изменились с прошлого вызова — composable пропускается (skipped). Это называется skippability. Нестабильный тип вынуждает Compose пересобирать функцию при каждом изменении родительского state, даже если данные для этого composable не изменились.
Тип считается стабильным, если компилятор Compose может доказать, что все его публичные поля неизменны или помечены @Stable/@Immutable. Типы из внешних библиотек (включая List, Map из стандартной библиотеки Kotlin) по умолчанию нестабильны, потому что компилятор не видит их исходники.
Практика: стабильность параметров
// ПЛОХО: List<Item> нестабилен → composable не пропускается
@Composable
fun ItemList(items: List<Item>) { /* ... */ }
// ХОРОШО: kotlinx.collections.immutable дает стабильный тип
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ItemList(items: ImmutableList<Item>) { /* ... */ }
// Или обернуть в @Immutable data class
@Immutable
data class ItemListUiState(val items: List<Item>)
@Composable
fun ItemList(state: ItemListUiState) { /* ... */ }
derivedStateOf: читать state без лишних recomposition
Если вычисление зависит от state, который меняется часто, но результат — редко, используйте derivedStateOf. Иначе каждое изменение исходного state вызовет recomposition потребителя.
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// showButton меняется только когда пересекает порог 0/1
// без derivedStateOf — recomposition на каждый scroll
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.KeyboardArrowUp, contentDescription = null)
}
}
}
key() в LazyColumn: правильная идентификация элементов
LazyColumn {
// Без key Compose использует позицию → при вставке/удалении
// переиспользует неправильные composable
items(messages, key = { it.id }) { message ->
MessageItem(message)
}
}
Отложенное чтение state: передавать лямбду вместо значения
Если state передается в modifier (например, для анимации позиции), чтение внутри layout/draw lambda пропускает composition фазу полностью.
// ПЛОХО: читаем state в composition → recomposition при каждом изменении
@Composable
fun BadOffset(scrollState: ScrollState) {
Box(Modifier.offset(y = scrollState.value.dp)) { /* ... */ }
}
// ХОРОШО: читаем в layout lambda → только layout фаза
@Composable
fun GoodOffset(scrollState: ScrollState) {
Box(Modifier.offset { IntOffset(0, scrollState.value) }) { /* ... */ }
}
Профилирование: Compose Layout Inspector и Recomposition Counts
В Android Studio Layout Inspector включите Recomposition Counts — он показывает, сколько раз каждый composable был пересобран и сколько раз пропущен. Это главный инструмент до любой оптимизации. Также полезен Trace.beginSection / Trace.endSection для custom tracing в Systrace/Perfetto.
Подводные камни
- Оборачивать всё в
rememberвслепую —rememberкеширует объект между recomposition, но не делает тип стабильным. Если внутриremember { mutableListOf() }, список всё равно нестабилен. - Нестабильный ViewModel state — если UiState содержит
Listбез@Immutable, весь экран не пропускается. Один нестабильный тип в data class «заражает» весь граф. - Лямбды-захватчики нестабильного state —
onClick = { viewModel.doSomething(item) }создает новую лямбду на каждый recomposition. Используйтеremember { { viewModel.doSomething(item) } }или передавайте стабильную ссылку. - Игнорировать фазу draw — тяжелые
drawBehind/Canvasоперации могут быть bottleneck независимо от recomposition; их нужно профилировать отдельно в Perfetto. - Слишком крупные composable — один большой composable пересобирается целиком. Декомпозиция на маленькие функции с точечными state reads снижает область invalidation.
- Чтение state в composition вместо layout/draw — особенно критично для scroll-driven анимаций: чтение
scrollState.valueпрямо в теле composable вызывает recomposition на каждый пиксель прокрутки. - Отсутствие baseline profile — без скомпилированного кода первый запуск экрана медленный из-за интерпретации байткода. Baseline Profiles решают это для production.
- Неверная работа с Flow в composition —
collectAsState()безlifecycleOwner.repeatOnLifecycleможет держать подписки активными в background; это утечка ресурсов, а не проблема recomposition, но обнаруживается в тех же трейсах.
Common mistakes
- Объяснять «производительность Compose» только как синтаксис и не описывать поведение runtime/compiler.
- Игнорировать важный риск: Автоматическое оборачивание всего в remember не решает проблему и может сохранить устаревшие данные.
- Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.
What the interviewer is testing
- Формулирует суть темы «производительность Compose» своими словами и связывает ее с кодом.
- Называет механизм: Сильный ответ различает recomposition, layout и drawing, а оптимизацию начинает с measurement, tracing и устранения лишних invalidations.
- Видит production-последствие: Автоматическое оборачивание всего в remember не решает проблему и может сохранить устаревшие данные.