Compose
Jetpack Compose: то, на чём спотыкаются в первые месяцы — правила рекомпозиции, состояние и побочные эффекты.
#Как работает рекомпозиция
Композабл — не метод отрисовки, а описание UI для текущего состояния. Compose вызывает его повторно при изменении прочитанного состояния, и делает это непредсказуемо часто.
@Composable
fun Greeting(name: String) {
Text("Привет, $name")
}
Функция без возвращаемого значения, описывающая часть UI.
// НЕЛЬЗЯ: композабл обязан быть без побочных эффектов
@Composable
fun Bad(user: User) {
analytics.track("screen_open") // выстрелит N раз за одну отрисовку
var count = 0 // сбросится на каждой рекомпозиции
Text(user.name)
}
Три главных правила: композабл должен быть идемпотентным, быстрым и без побочных эффектов. Всё, что «должно произойти один раз», выносится в эффект.
Порядок вызовов и число вызовов не гарантированы. Compose может выполнять композаблы параллельно и пропускать те, чьи входы не изменились.
Text(
text = "Заголовок",
modifier = Modifier
.padding(16.dp) // сначала отступ
.background(Color.Red) // потом фон — покрасится БЕЗ отступа
)
Modifier применяется по порядку слева направо. Переставь padding и background — получишь другой результат.
Правило: modifier — всегда первый опциональный параметр композабла, и он должен прокидываться наружу.
#Состояние
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
mutableStateOf делает значение наблюдаемым, remember сохраняет его между рекомпозициями. Без remember значение будет пересоздаваться.
var text by rememberSaveable { mutableStateOf("") }
Переживает смену конфигурации и убийство процесса. Работает для типов, которые умеет Bundle; для своих классов нужен Saver.
val items = remember(query) {
all.filter { it.contains(query) }
}
Ключи remember: пересчитается только при изменении query. Без ключа значение «застынет» с первого вызова — очень частый баг.
// плохо: пересчёт на каждой рекомпозиции
val sorted = items.sortedBy { it.name }
// хорошо: пересчёт только когда меняется items
val sorted by remember { derivedStateOf { items.sortedBy { it.name } } }
derivedStateOf для вычисляемого состояния, которое зависит от других состояний.
// состояние поднято наверх — композабл ниже становится
// переиспользуемым и тестируемым
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}
State hoisting: состояние живёт выше, композабл получает значение и колбэк. Это делает его stateless — проще тестировать и переиспользовать.
Базовое правило Compose: состояние поднимается до самого низкого общего родителя всех, кому оно нужно.
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn(state = listState) { /* ... */ }
Button(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
Text("Вверх")
}
Состояния-холдеры (rememberLazyListState, rememberScrollState, rememberPagerState) дают программный контроль над компонентом.
#Побочные эффекты
Всё, что выходит за пределы описания UI — сеть, аналитика, подписки, показ снекбара — живёт в эффектах, а не в теле композабла.
LaunchedEffect(userId) {
// корутина, привязанная к композиции
// перезапустится при смене userId, отменится при уходе с экрана
viewModel.load(userId)
}
Основной эффект. Ключ определяет, когда перезапускать. LaunchedEffect(Unit) — ровно один раз за жизнь композиции.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Для подписок, которые надо снимать. onDispose обязателен.
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { snackbarHostState.showSnackbar("Готово") }
}) { Text("Сохранить") }
rememberCoroutineScope — когда корутину запускает пользователь, а не композиция. В обработчике клика LaunchedEffect неприменим.
val current by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5_000)
current() // вызовется актуальный колбэк, а не захваченный при старте
}
rememberUpdatedState решает проблему устаревшего замыкания в долгоживущем эффекте, который не хочется перезапускать.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> viewModel.onScroll(index) }
}
snapshotFlow превращает состояние Compose в Flow. Так состояние UI отдаётся в мир корутин.
SideEffect {
// выполняется после КАЖДОЙ успешной рекомпозиции
analytics.setCurrentScreen(screenName)
}
Для синхронизации с не-Compose объектами. Не для запуска корутин и не для тяжёлой работы.
| Задача | Инструмент |
|---|---|
| Загрузить данные при входе на экран | LaunchedEffect(key) |
| Подписка, которую надо снять | DisposableEffect |
| Действие по клику пользователя | rememberCoroutineScope |
| Свежий колбэк в долгом эффекте | rememberUpdatedState |
| Состояние Compose → Flow | snapshotFlow |
| Синхронизация с внешним объектом | SideEffect |
#Layout и списки
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Первый")
Text("Второй")
}
Arrangement.spacedBy вместо padding у каждого ребёнка — меньше кода и корректные отступы между элементами.
Row {
Text("слева", modifier = Modifier.weight(1f))
Text("справа")
}
weight распределяет свободное место, аналог layout_weight.
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
key обязателен для изменяемых списков: без него при вставке или удалении Compose переиспользует неправильные элементы, ломая состояние и анимации.
Симптом отсутствия key: галочки или раскрытые карточки «прыгают» на другие строки после удаления элемента.
Scaffold(
topBar = { TopAppBar(title = { Text("Экран") }) },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Content(modifier = Modifier.padding(padding))
}
Отданный Scaffold'ом padding надо применить, иначе контент уедет под топбар.
BoxWithConstraints {
if (maxWidth < 600.dp) CompactLayout() else WideLayout()
}
Адаптивность по доступному размеру, а не по размеру экрана.
#Связка с ViewModel
data class UiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null,
)
class UsersViewModel(private val repo: UserRepository) : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun load() = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repo.users() }
.onSuccess { users -> _state.update { it.copy(isLoading = false, users = users) } }
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
}
}
Одно состояние экрана одним объектом. Так исключаются невозможные комбинации вроде «загрузка и ошибка одновременно».
val state by viewModel.state.collectAsStateWithLifecycle()
Именно collectAsStateWithLifecycle, а не collectAsState: подписка снимается, когда экран уходит в фон.
Из androidx.lifecycle:lifecycle-runtime-compose. collectAsState продолжает собирать поток в свёрнутом приложении и жжёт батарею.
// однократные события: навигация, снекбар, тост
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// на стороне UI
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.NavigateBack -> navController.popBackStack()
is UiEvent.ShowError -> snackbarHostState.showSnackbar(event.text)
}
}
}
Однократные события нельзя держать в StateFlow — при повороте экрана они повторятся. Отдельный канал решает это.
#Производительность
Compose пропускает рекомпозицию композабла, если все его параметры стабильны и не изменились. Нестабильный параметр отключает эту оптимизацию.
// нестабильно: List — интерфейс, компилятор не знает про изменяемость
@Composable fun Bad(users: List<User>)
// стабильно
@Composable fun Good(users: ImmutableList<User>) // kotlinx.collections.immutable
// или пометить свой тип
@Immutable data class UiState(val users: List<User>)
Классическая причина лишних рекомпозиций: List в параметрах. Компилятор считает его нестабильным, потому что за интерфейсом может стоять изменяемая реализация.
// плохо: новая лямбда на каждой рекомпозиции
UserRow(onClick = { viewModel.select(user.id) })
// лучше: ссылка на метод
UserRow(onClick = viewModel::selectFirst)
Лямбда, захватывающая изменяемые значения, пересоздаётся и ломает пропуск рекомпозиции. Compose-компилятор многое запоминает сам, но в горячих списках это заметно.
// плохо: чтение состояния наверху — рекомпозиция всего дерева
val offset = scrollState.value
Box(Modifier.offset(y = offset.dp)) { HugeContent() }
// лучше: отложенное чтение внутри лямбды модификатора
Box(Modifier.offset { IntOffset(0, scrollState.value) }) { HugeContent() }
Deferred reads: лямбда-версии модификаторов читают состояние на фазе layout, а не композиции. Рекомпозиции не происходит вовсе.
./gradlew assembleRelease -Pandroidx.enableComposeCompilerReports=true
Отчёт компилятора о стабильности классов и пропускаемости композаблов. Показывает, какие параметры признаны нестабильными и почему.
adb shell dumpsys gfxinfo <pkg> framestats
Замер реальных janky-кадров. Начинать оптимизацию стоит отсюда, а не с догадок.
#Тестирование
Text("Итого", modifier = Modifier.testTag("total"))
testTag — стабильный якорь для теста. Надёжнее, чем поиск по тексту, который меняется вместе с локализацией.
@get:Rule val rule = createComposeRule()
@Test
fun showsError() {
rule.setContent { UsersScreen(UiState(error = "нет сети")) }
rule.onNodeWithText("нет сети").assertIsDisplayed()
}
createComposeRule для чистых композаблов, createAndroidComposeRule<MainActivity>() когда нужна настоящая Activity.
rule.onNodeWithTag("total").assertTextEquals("1 200 ₽")
rule.onNodeWithTag("submit").performClick()
rule.onAllNodesWithTag("row").assertCountEquals(3)
rule.onNodeWithContentDescription("Закрыть").performClick()
Базовый набор: найти, проверить, потыкать.
rule.mainClock.autoAdvance = false
rule.mainClock.advanceTimeBy(500)
rule.waitUntil { rule.onAllNodesWithTag("row").fetchSemanticsNodes().isNotEmpty() }
Ручной контроль времени для анимаций и waitUntil вместо слепых Thread.sleep.
rule.onRoot().printToLog("TREE")
Дамп семантического дерева в logcat — аналог uiautomator dump. Первое, что делать, когда узел не находится.
| Симптом | Причина |
|---|---|
| Значение сбрасывается при вводе | нет remember |
| Не переживает поворот | remember вместо rememberSaveable |
| Состояние «застыло» на старом | нет ключа у remember/LaunchedEffect |
| Эффект стреляет много раз | нестабильный ключ у LaunchedEffect |
| Снекбар повторяется после поворота | событие лежит в StateFlow, нужен Channel |
| Галочки прыгают на другие строки | нет key в items() |
| Контент под топбаром | не применён padding из Scaffold |
| Список тормозит при скролле | нестабильные параметры, чтение состояния наверху |