Урок по треку Android 8 мин

Compose: состояние и side-effects за 8 минут

remember, rememberSaveable, state hoisting, LaunchedEffect, DisposableEffect, derivedStateOf.

Состояние и эффекты это вторая половина вопросов про Compose. Нужно чётко различать remember и rememberSaveable, понимать state hoisting и знать, какой эффект брать для какой задачи.

State и remember

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Нажато $count раз") }
}
  • mutableStateOf создаёт наблюдаемый State: Compose знает, кто его прочитал, и рекомпозирует их при изменении.
  • remember сохраняет значение между рекомпозициями. Без него mutableStateOf(0) создавался бы заново при каждом вызове, и счётчик всегда был бы 0.
  • remember(key) { } пересчитывает значение при смене ключа: remember(userId) { formatter.format(userId) }.

remember живёт, пока composable находится в композиции. Ушла с экрана, вернулась, значение потеряно. При повороте тоже потеряно: Activity пересоздаётся.

rememberSaveable сохраняет значение в Bundle и переживает поворот и смерть процесса. Поддерживает типы Bundle, для своих классов нужен Saver или @Parcelize.

Для коллекций: mutableStateListOf(), mutableStateMapOf(). mutableStateOf(mutableListOf()) с изменением списка через add не вызовет рекомпозицию: ссылка та же, State не узнает об изменении.

State hoisting

Состояние поднимают к общему владельцу, а компонент делают stateless: он получает значение и колбэк.

@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
    val query by vm.query.collectAsStateWithLifecycle()
    SearchField(query, vm::onQueryChange)
}

Плюсы: компонент переиспользуем, тестируем, единый источник правды. Правило: состояние поднимают до ближайшего общего предка всех, кто его читает или меняет, и не выше. Бизнес-состояние экрана живёт во ViewModel, чисто UI-состояние (раскрыт ли элемент, состояние скролла) может жить в composable или в state holder-классе (rememberLazyListState()).

Побочные эффекты

Тело composable должно быть чистым. Для действий во внешнем мире есть эффекты:

APIКогда использовать
LaunchedEffect(key) { }запустить корутину при входе в композицию и перезапустить при смене ключа: загрузка, показ снекбара, анимация
DisposableEffect(key) { onDispose { } }подписка с обязательной отпиской: листенеры, LifecycleObserver, BroadcastReceiver
SideEffect { }после каждой успешной рекомпозиции синхронизировать состояние Compose с не-Compose объектом
rememberCoroutineScope()запустить корутину из колбэка (onClick), скоуп отменится при выходе из композиции
rememberUpdatedState(value)в долгоживущем эффекте всегда видеть актуальное значение, не перезапуская эффект
produceStateпревратить внешний источник в State
snapshotFlow { }превратить чтение State в Flow
LaunchedEffect(userId) {           // перезапуск при новом userId
    viewModel.load(userId)
}

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, e -> if (e == Lifecycle.Event.ON_RESUME) refresh() }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

Ключи эффекта определяют, когда он перезапускается. LaunchedEffect(Unit) или LaunchedEffect(true) выполнится один раз за время нахождения в композиции (но снова после поворота, ведь композиция новая). Если в ключи не включить значение, которое эффект использует, он будет работать со старым значением.

derivedStateOf

Когда состояние вычисляется из другого состояния, которое меняется чаще, чем результат:

val listState = rememberLazyListState()
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

firstVisibleItemIndex меняется при каждой прокрутке, а showButton только дважды. Без derivedStateOf рекомпозиция шла бы на каждый шаг. Если результат меняется так же часто, как источник, derivedStateOf не нужен, хватит remember(key).

Состояние из потоков

collectAsStateWithLifecycle() собирает StateFlow/Flow в State и останавливает сбор, когда приложение уходит в фон (ниже STARTED). collectAsState() продолжает собирать и в фоне.

Частые ошибки

  • mutableStateOf без remember.
  • remember там, где нужен rememberSaveable (ввод пользователя).
  • Изменение MutableList внутри mutableStateOf без замены ссылки.
  • Запуск корутины в теле composable вместо эффекта.
  • Неверные ключи эффектов: лишние перезапуски или устаревшие значения.
  • Отписка от листенера, забытая без DisposableEffect.

Что спросят на собесе

  • Чем remember отличается от rememberSaveable?
  • Что такое state hoisting и зачем он нужен?
  • LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope: когда что?
  • Как работают ключи эффектов?
  • Когда нужен derivedStateOf?
  • Чем collectAsStateWithLifecycle лучше collectAsState?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.