count объявлен как обычная локальная переменная: при каждой рекомпозиции она снова становится 0, а запись в неё ничего не сообщает Compose. Исправление требует обеих частей (remember, чтобы сохранить значение, и mutableStateOf, чтобы запись стала наблюдаемой).
**remember**: кладёт значение в композицию, и при следующем запуске функция получает его обратно, а не начинает с 0.
**mutableStateOf**: Compose отслеживает, какие объекты состояния прочитал composable; запись в один из них планирует рекомпозицию этого composable. Обычный var для него невидим.
По отдельности не работает ни одно: remember { 0 } хранит число, но экран не перерисовывается; mutableStateOf(0) без remember перерисовывает, но каждый раз создаёт новый стейт со значением 0.
Рабочая строка: var count by remember { mutableIntStateOf(0) }. Если лог в onClick печатается, а UI стоит на месте, проблема в наблюдаемости, а не в клике.
AndroidMiddle
Состояние в Compose
remember, mutableStateOf, derivedStateOf, поднятие состояния, ViewModel + StateFlow
Закрыто вопросов
0 / 20
- 0Повторить
- 7Часто спрашивают
Вопросы
mutableStateOf(value) создаёт наблюдаемый MutableState<T>: когда composable читает .value, Compose запоминает это чтение, а когда кто-то записывает новое значение, все composable, которые его читали, ставятся в очередь на рекомпозицию.
Чтение = подписка: явного слушателя нет. Именно чтение стейта во время композиции связывает его с этим composable.
Запись = инвалидация: перекомпонуются только читатели, а не весь экран. Запись значения, равного текущему (по equals), ничего не делает.
**Внутри composable всегда оборачивайте в remember, иначе каждая рекомпозиция создаст новый стейт.
Синтаксис**: by позволяет не писать .value; для примитивов лучше mutableIntStateOf и аналоги, они не упаковывают значение.
remember сохраняет значение только между рекомпозициями; rememberSaveable дополнительно записывает его в saved instance state, поэтому оно переживает смену конфигурации и смерть процесса.
**remember: теряется, когда composable уходит из композиции, когда Activity пересоздаётся (поворот, тема, ресайз окна) и когда процесс умирает.
rememberSaveable**: восстанавливается после поворота и смерти процесса (а также при возврате по back stack навигации или прокрутке ленивого списка). Если composable просто убран из композиции через if, значение тоже теряется. Значение должно помещаться в Bundle (примитивы, String, Parcelable) или идти вместе с Saver; listSaver и mapSaver покрывают большинство своих типов.
Ключи: remember(userId) { ... } выбрасывает старое значение и пересчитывает его, когда ключ меняется.
Что куда: remember для объектов, которые дёшево пересоздать (форматтер, стейт анимации); rememberSaveable для того, что пользователь ввёл или выбрал: черновик, выбранная вкладка, флаг раскрытия.
Ни один из них не заменяет ViewModel. Данные из репозитория живут там, а saved-state Bundle должен оставаться маленьким.
State hoisting (подъём состояния) выносит стейт из composable в вызывающий код: composable становится stateless и получает текущее value и колбэк onValueChange, а stateful-composable сам владеет стейтом через remember.
Stateless: переиспользуемый, превью с обычными значениями, тесты без подготовки, а откуда берётся стейт, решает вызывающий. Так устроены TextField, Switch и Checkbox из Material.
Stateful: удобно просто вставить, но вызывающий не может прочитать, сбросить или контролировать стейт.
Частый приём: дать оба варианта: stateless NameField(name, onNameChange) и, если нужно, тонкую stateful-обёртку над ним.
Решая, где живёт стейт, поднимайте его до ближайшего общего предка всех, кто его читает или пишет, и не выше:
Нужен одному composable (флаг раскрытия, позиция прокрутки, нужная только здесь): оставьте локально.
Нужен соседям: поднимите в их родителя.
Бизнес-данные или то, что должно пережить навигацию: во ViewModel экрана.
Слишком высокий подъём тоже не бесплатен: каждое изменение стейта тогда перекомпонует бо́льшую часть дерева.
remember выполняет свою лямбду только при первой композиции этого места вызова, а дальше возвращает сохранённое значение, поэтому параметр, изменившийся позже, никогда не попадёт в стейт, который он когда-то инициализировал.
rememberSaveable ведёт себя так же, так что смена функции не поможет. Варианты, от лучшего:
Поднять стейт: если значение есть у вызывающего, он и есть источник истины. Принимайте name и onNameChange и не держите локального стейта вовсе.
Добавить ключ в remember: remember(initialName) { mutableStateOf(initialName) } выбрасывает стейт и создаёт заново, когда ключ меняется по equals.
Синхронизировать в эффекте: LaunchedEffect(initialName) { name = initialName }, когда локальные правки нужно сохранить, а перезаписывать их должно только реальное изменение сверху.
Зеркальный баг: ключ по объекту, который при каждой эмиссии новый, например remember(user), стирает то, что человек печатает, при каждом обновлении. Ключом делайте id, а не весь объект.
ViewModel отдаёт StateFlow<UiState>, а экран читает его через collectAsStateWithLifecycle(), который собирает значения, только пока жизненный цикл не ниже STARTED.
**collectAsStateWithLifecycle()**: из lifecycle-runtime-compose. Прекращает сбор, когда приложение уходит в фон, поэтому останавливается и источник (геолокация, база, сокет). Вариант по умолчанию на Android.
**collectAsState(): собирает, пока composable в композиции, а это включает и остановленную Activity. Подходит для потока, который дёшево держать активным, или для кода без владельца жизненного цикла.
produceState(initial, key) { value = ... }**: превращает любой suspend-вызов или колбэк в State внутри композиции. Для разового suspend-вызова без ViewModel.
Обычная схема:
ViewModel строит стейт через stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial), так что он переживает поворот, но останавливается, когда экрана больше нет.
Route-composable собирает стейт и передаёт обычные значения и лямбды в stateless-composable экрана; его вы и показываете в превью и тестируете.
Snapshot-стейт отслеживает ссылку, которую хранит, а не то, что происходит внутри объекта: list.add(x) оставляет тот же экземпляр, поэтому записи в стейт не происходит и ни один читатель не инвалидируется.
С StateFlow то же самое: _state.value.items.add(item) правит список за значением, ничего не эмитя.
Присваивайте новый список: держите тип read-only List и пишите items = items + item. Во ViewModel: _state.update { it.copy(items = it.items + item) }. update работает как цикл compare-and-set, поэтому две корутины не затрут изменения друг друга.
**mutableStateListOf() / mutableStateMapOf()**: коллекции на snapshot-стейте, у которых add, remove и set действительно уведомляют читателей. Хороши внутри composable или простого state holder.
Не смешивайте: SnapshotStateList внутри значения StateFlow меняется на месте, поэтому поток ничего не эмитит и подписчики потока не видят изменения.
Ловушка равенства: copy(items = items) после правки на месте даёт объект, равный старому, и StateFlow всё равно его отбросит.
PersistentList из kotlinx.collections.immutable делает намерение явным, и компилятор Compose считает его стабильным.
derivedStateOf { ... } создаёт стейт, вычисляемый из другого стейта, который уведомляет читателей только при изменении результата, а не при каждом изменении входных данных.
Когда помогает: вход меняется намного чаще выхода. Классика: listState.firstVisibleItemIndex меняется почти на каждом кадре скролла, а showScrollToTop переключается редко. Читатели Boolean перекомпонуются только при переключении.
**Всегда внутри remember**: remember { derivedStateOf { ... } }. Без remember новый derived-стейт создаётся при каждой рекомпозиции.
remember без ключей создаёт derivedStateOf один раз, и его лямбда хранит products и maxPrice из первой композиции. State здесь только query, поэтому ввод текста фильтрует список заново, а новый лимит цены или свежие данные с сервера игнорируются.
Почему: derivedStateOf пересчитывается, только когда меняется snapshot-State, прочитанный внутри него. Обычные параметры передаются как захваченные значения, а не как стейт: родитель передаёт maxPrice = 500, ProductList перекомпонуется, а remember возвращает старый derived state со старым замыканием.
Исправление: передайте в remember ключами все обычные значения, от которых зависит вычисление: минимум remember(products, maxPrice). Лучше убрать обёртку: результат здесь меняется при любом изменении входов, поэтому хватает remember(products, maxPrice, query) { ... }. derivedStateOf окупается, только когда результат меняется реже входов (например, Boolean из позиции прокрутки).
Правило: State, прочитанный внутри блока, отслеживается автоматически; параметры и другие значения не из State идут в ключи remember; это то же правило, что и для ключей LaunchedEffect.
Как поймать: на ревью отмечайте любой remember { derivedStateOf { } }, который читает параметр функции. UI-тест, который меняет параметр через mutableStateOf в setContent и проверяет, что появилась новая строка, на версии с багом падает.
Под side effect (побочным эффектом) понимают всё, что меняет мир за пределами composable; так как тело composable может перезапуститься в любой момент, эффекты идут через effect-API, которые запускаются, перезапускаются и убираются синхронно с композицией.
Effect-API
**LaunchedEffect(key) { }: запускает корутину при входе в композицию, отменяет и перезапускает её при смене ключа, отменяет при выходе. Для загрузки данных по аргументу, таймера, сбора потока одноразовых событий.
DisposableEffect(key) { onDispose { } }**: для всего, что нужно отписать: слушатели, сенсоры, LifecycleObserver. Для колбэков жизненного цикла есть готовые LifecycleStartEffect и LifecycleResumeEffect.
**SideEffect { }: выполняется после каждой успешной рекомпозиции. Чтобы передать стейт Compose в объект вне Compose, например текущий экран в клиент аналитики.
produceState(initial, key) { }**: LaunchedEffect и State в одном вызове. Для работы, запущенной кликом, используйте rememberCoroutineScope().
Всё решают ключи
LaunchedEffect(Unit) запускается один раз на каждый вход в композицию. Если блок использует параметр, либо сделайте его ключом, либо читайте через rememberUpdatedState.
Ключ по объекту, который пересоздаётся при каждой рекомпозиции, перезапускает эффект каждый раз.
Никогда не запускайте корутину и не вызывайте репозиторий прямо из тела composable: это выполнится при каждой рекомпозиции.
LaunchedEffect сам является composable, а onClick остаётся обычной лямбдой, поэтому компилятор выдаёт @Composable invocations can only happen from the context of a @Composable function. Работе, которую запускает действие пользователя, нужен rememberCoroutineScope() и scope.launch { }.
**LaunchedEffect: для работы, которую запускает сама композиция: экран появился, ключ изменился. Объявляется в теле composable.
rememberCoroutineScope()**: возвращает scope, привязанный к композиции. Используйте его внутри колбэков вроде onClick; его корутины отменяются, когда composable уходит.
Подходит: UI-работа, которая должна умереть вместе с экраном: snackbarHostState.showSnackbar, listState.animateScrollToItem, sheetState.hide().
Не подходит: сохранение данных или сетевой запрос. Этот scope умирает с экраном, и запрос отменится, если пользователь уйдёт. Вызовите ViewModel, и пусть работой владеет viewModelScope или репозиторий.
Частый неуклюжий обход строится на флаге var trigger, за которым следит LaunchedEffect(trigger): это работает, но нужен сброс флага и лишняя рекомпозиция, а scope запускает код напрямую.
LaunchedEffect(Unit) запускается один раз, и его корутина захватывает onTimeout из первой композиции; следующие рекомпозиции передают новую лямбду, но запущенная корутина держит старую.
Ключ по лямбде здесь не подходит: LaunchedEffect(onTimeout) перезапустил бы эффект, и трёхсекундный таймер начинался бы заново при каждом изменении.
**rememberUpdatedState(onTimeout)**: возвращает State, который обновляется последним значением при каждой рекомпозиции. Эффект продолжает работать и читает currentOnTimeout только в момент, когда он нужен, поэтому всегда вызывает самую новую лямбду.
Когда использовать: долгоживущий эффект (таймер, слушатель в DisposableEffect, блок pointerInput), который не должен перезапускаться, но должен видеть актуальные значения.
Когда не нужно: если изменённое значение должно перезапустить работу, например новый userId для загрузки, сделайте его ключом.
Тот же устаревший захват случается с любым значением, прочитанным внутри эффекта, а не только с лямбдами.
snapshotFlow { } превращает чтение стейта Compose в холодный Flow: выполняет блок, запоминает, какой стейт был прочитан, и эмитит новый результат, когда один из этих стейтов меняется.
Когда нужен: вам нужны операторы Flow над стейтом Compose: debounce, filter, flatMapLatest, collectLatest. Типичные случаи: логирование позиции скролла, сохранение черновика после паузы в наборе, поиск по TextFieldState.
Где запускается: внутри LaunchedEffect или во ViewModel через stateIn(viewModelScope, ...).
Равные результаты пропускаются: подряд идущие равные значения не эмитятся, поэтому distinctUntilChanged() сразу после него лишний.
Отслеживается только snapshot-стейт: чтение обычного свойства в блоке никогда не вызовет новой эмиссии.
Обратное направление, из Flow в стейт Compose, обеспечивает collectAsStateWithLifecycle().
CompositionLocal неявно передаёт значение всему, что ниже провайдера; это правильно для окружающего контекста вроде темы или плотности экрана и неправильно для данных, о которых сам экран.
Как это работает
compositionLocalOf { default }: чтения отслеживаются, поэтому новое значение перекомпонует только те composable, которые его читают. Для значений, меняющихся, пока экран показан.
staticCompositionLocalOf { default }: чтения не отслеживаются; изменение перекомпонует всё поддерево провайдера. Читать дешевле, подходит для значений, которые почти не меняются.
CompositionLocalProvider(LocalX provides value) { } задаёт значение для поддерева, а LocalX.current читает ближайшее.
Правильное и неправильное применение
Правильно: сквозные значения, нужные большей части дерева: LocalContext, LocalDensity, LocalContentColor, свои токены отступов или загрузчик картинок.
Неправильно: пользователь, корзина или ViewModel. Входы composable становятся невидимыми, каждому превью и тесту нужен провайдер, а забытый провайдер молча даёт значение по умолчанию вместо ошибки компиляции.
Это не DI-контейнер: зависимость должна приходить в конструктор класса, который её использует.
Если разумного значения по умолчанию нет, пусть default бросает исключение через error(...), чтобы забытый провайдер сразу падал с понятной ошибкой.
Обычный state holder нужен для UI-логики, то есть стейта, который важен только UI и может умереть вместе с composable; ViewModel служит для бизнес-логики и данных экрана, которые должны пережить смену конфигурации и обращаются к слою данных.
Обычный state holder: простой класс с полями mutableStateOf и логикой над ними, создаваемый функцией rememberCheckoutFormState(). Живёт столько же, сколько composable, и может держать rememberCoroutineScope() или LazyListState. Образцом служат собственные holder'ы фреймворка: LazyListState, SnackbarHostState, DrawerState.
ViewModel: загруженные данные, то, что пользователь отправляет, всё, что требует viewModelScope, репозитория или SavedStateHandle.
Как отличить: скрывается ли FAB при скролле и похож ли ввод на email, решает UI-логика. Можно ли оформить заказ и какая скидка, решает бизнес-логика.
Признаки, что пора выносить: несколько значений remember, которые всегда меняются вместе, или тело composable, занятое работой, не связанной с выводом UI.
Обычный holder теряется при повороте, если фабрика не использует rememberSaveable с Saver.
У строки два источника истины: локальная копия, которая переключается при нажатии, и значение из ViewModel, которое снова применяется со старым false до завершения записи, поэтому звезда откатывается, пока не придёт ответ сервера.
IconToggleButton, Switch и Checkbox рисуют ровно то checked, которое вы передали, так что мерцание означает, что два источника истины расходятся:
Нажатие пишет локальную копию, и звезда заполняется.
ViewModel эмитит новый item (изменилось другое поле), в котором всё ещё isFavorite = false; ключ remember(item) меняется и сбрасывает копию, и звезда пустеет.
Приходит ответ, ViewModel переключается, и звезда снова заполнена.
Один владелец, оптимистично
ViewModel сразу обновляет свой стейт, потом отправляет запрос. UI ничего не запоминает: checked = item.isFavorite.
Сохраните предыдущее значение и откатите его, если вызов упал, показав видимую ошибку, например снекбар.
Отслеживайте id в процессе отправки, чтобы второе нажатие не отправило второй запрос.
С offline-first репозиторием (запись в Room, потом синхронизация) база служит единственным источником истины, и её поток сам эмитит заново.
Синхронизация локальной копии через LaunchedEffect(item.isFavorite) сохраняет гонку и добавляет кадр задержки.
StateFlow оставляет ViewModel свободной от Compose и даёт операторы Flow; mutableStateOf проще читать в Compose и он перекомпонует по каждому объекту стейта, но привязывает ViewModel к рантайму Compose.
Что даёт стейт Compose
Экран читает vm.uiState напрямую: без коллектора, без начального значения, без вопроса о жизненном цикле.
Отслеживание чтений по каждому объекту стейта. При отдельных стейтах query и results composable, читающий только query, не перекомпонуется, когда приходят результаты. Один StateFlow<UiState> уведомляет каждого читателя всего объекта.
Что даёт StateFlow
Операторы: combine, flatMapLatest, debounce, stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial), чтобы строить стейт из потоков репозитория. Со стейтом Compose нужен snapshotFlow, чтобы вернуться во Flow.
Нет зависимости ViewModel от рантайма Compose, поэтому её можно использовать с экранами на View и в чистых Kotlin-модулях.
Обычные тесты корутин через runTest и Turbine. Стейт Compose тоже тестируется, но всё, что наблюдает за ним через snapshotFlow, реагирует только после Snapshot.sendApplyNotifications() (тестовое правило Compose делает это само, а обычные юнит-тесты нет).
Что одинаково
Оба пропускают равные значения: MutableStateFlow по equals, mutableStateOf по умолчанию по structuralEqualityPolicy().
Обоим нужны неизменяемые значения; правка на месте ломает любой из них.
Выберите один подход на кодовую базу. StateFlow чаще выбирают по умолчанию; стейт Compose подходит приложению только на Compose с большими экранами, где точечные чтения важны.
Цикл value/onValueChange должен обновляться синхронно; любой асинхронный шаг позволяет полю показать текст старше набранного. TextFieldState убирает цикл, отдавая буфер редактирования самому полю.
Где ломается
Любая асинхронность между двумя половинами (debounce, flowOn, LaunchedEffect, suspend-валидация) позволяет следующему нажатию прийти раньше, чем вернулось предыдущее значение, и поле откатывается назад.
Фильтрация в onValueChange (удаление нецифр, перевод в верхний регистр) отдаёт клавиатуре значение, которого она не ждала, и курсор прыгает в конец.
Возврат String, отличающейся от только что выданной клавиатурой или пришедшей с опозданием, сбрасывает выделение и composing-регион.
Что исправляет
С API на основе значения держите цикл синхронным: пишите обычный snapshot-стейт прямо в onValueChange, никогда не через поток с операторами.
Лучше взять поле на основе стейта: BasicTextField(state = ...) или перегрузка TextField(state = ...) в Material 3. Поле правит свой TextFieldState напрямую, кругового пути нет.
InputTransformation фильтрует нажатия в момент применения, OutputTransformation форматирует отображаемое (пробелы в номере карты), не меняя хранимый текст.
ViewModel может владеть TextFieldState и наблюдать за ним через snapshotFlow { state.text }, чтобы делать debounce поиска или валидацию.
Сохраняйте то, что пользователь ввёл или выбрал: поля только для UI кладите в rememberSaveable, то, что нужно ViewModel, кладите в SavedStateHandle; всё, что загружено из слоя данных, перезагружается по id.
Что сохранять
Неотправленный ввод пользователя и указатели: черновик, запрос, выбранный id, вкладку. Никогда не сохраняйте загруженные данные; перезагрузите их по id.
handle.getStateFlow(key, default) даёт наблюдаемую запись; аргументы навигации приходят в тот же handle.
Bundle проходит через Binder-транзакцию с лимитом около 1 МБ, поэтому большой бандл заканчивается TransactionTooLargeException.
Как тестировать
В юнит-тестах создавайте ViewModel с SavedStateHandle(mapOf(...)) и проверяйте, что она восстанавливается из этой map.
Для composable StateRestorationTester имитирует пересоздание и проверяет rememberSaveable.
Стейт Compose живёт в снапшотах (изолированных версионированных представлениях всех объектов стейта), которые дают каждому читателю согласованную картину и делают группу записей похожей на транзакцию.
Механика
MutableState хранит цепочку записей (state records), каждая помечена снапшотом, который её сделал. Чтение возвращает самую новую запись, видимую снапшоту читателя.
Чтения наблюдаются. Композиция, layout и отрисовка выполняются с подключённым наблюдателем чтений, и так Compose знает, какие scope инвалидировать. Рекомпозицией управляет этот набор чтений, а не сравнение деревьев.
Записи изолированы. Изменения мутабельного снапшота не видны снаружи до apply(); если другой снапшот уже изменил тот же стейт, конфликт обнаруживается (и сливается, если политика это позволяет), а не теряет обновление молча.
Snapshot.withMutableSnapshot { } применяет несколько записей разом, и ни один читатель не видит наполовину обновлённый стейт. Записи в глобальном снапшоте доставляются в Recomposer через Snapshot.sendApplyNotifications(), которое Compose планирует сам.
Что гарантирует
Snapshot-стейт можно читать и писать из любого потока без блокировок.
Один кадр никогда не смешивает старые и новые значения связанного стейта.
Следствия
Участвует только snapshot-стейт: MutableList внутри mutableStateOf или обычное свойство невидимы.
snapshotFlow и derivedStateOf построены на том же отслеживании чтений.
Запись в стейт во время композиции, который уже был прочитан в этой же композиции, вызывает ещё один проход и может зациклиться.