DimaSOS

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 → FlowsnapshotFlow
Синхронизация с внешним объектом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
Список тормозит при скролленестабильные параметры, чтение состояния наверху