Senior
State в Jetpack Compose
Snapshot state, State и MutableState, remember, scope наблюдения, коллекции и владелец state в Compose.
На этой странице
State связывает изменяющиеся данные приложения с декларативным UI. В лекции разделены наблюдение Snapshot State, lifetime в Composition, зависимости чтения, mutation policy, коллекции и архитектурное владение state. Примеры Android/Compose остаются только для чтения и не запускались во время подготовки.
Теория
Зачем Compose вообще нужен State
В классическом View-подходе мы привыкли императивно менять UI:
textView.text = "Hello"
progressBar.isVisible = true
button.isEnabled = false
Мы говорим: «найди конкретный View и измени его».
Compose устроен иначе. Мы описываем UI как функцию состояния:
UI = f(state)
Например:
@Composable
fun Counter(count: Int) {
Text("Count: $count")
}
Мы не говорим Text: «измени свой текст». Мы говорим Compose: «при count = 5 UI должен выглядеть вот так».
Изменилось состояние → Compose снова выполняет необходимую часть описания UI → UI приводится к новому состоянию.
Это и есть declarative UI.
Почему обычная var не работает
@Composable
fun Counter() {
var count = 0
Button(
onClick = {
count++
}
) {
Text("Count: $count")
}
}
Кажется логичным:
count = 0
↓
count++
↓
count = 1
Но откуда Compose должен узнать, что count изменился?
Int — обычное Kotlin-значение:
var count: Int
Compose не перехватывает произвольные присваивания Kotlin-переменным.
Кроме того, при следующем выполнении Counter() мы опять сделаем:
var count = 0
Здесь сразу две разные проблемы:
- Compose не наблюдает изменение
count. - Локальное значение не сохраняется между recomposition.
Эти проблемы решают разные механизмы.
State<T> и MutableState<T>
Compose предоставляет observable-контейнер:
interface State<out T> {
val value: T
}
Упрощённо:
State<T>
┌───────────────┐
│ value: T │
└───────────────┘
Значение можно прочитать:
val state: State<Int>
val count = state.value
Но через State<T> изменить его нельзя.
Для изменяемого состояния существует MutableState<T>:
interface MutableState<T> : State<T> {
override var value: T
}
Создаётся оно обычно через:
val count: MutableState<Int> = mutableStateOf(0)
Теперь:
count.value++
— это уже не просто изменение обычного Int. Compose умеет наблюдать за таким состоянием.
Что делает mutableStateOf
Важно не сформировать неправильную ментальную модель:
MutableState— специальная переменная, которая просто вызывает перерисовку экрана.
Это слишком грубо.
Правильнее:
MutableState интегрирован в Snapshot State System Compose.
Когда происходит:
state.value
Compose может зарегистрировать read.
Когда происходит:
state.value = newValue
регистрируется write.
Упрощённо:
Composition
↓ READ
MutableState<Int>
value = 10
↓ WRITE
value = 11
↓
Compose видит:
"Изменилось состояние,
которое было прочитано UI"
↓
invalidate
↓
recomposition
Почему одного mutableStateOf недостаточно
@Composable
fun Counter() {
val count = mutableStateOf(0)
Button(
onClick = {
count.value++
}
) {
Text("Count: ${count.value}")
}
}
Observable state появился, но код всё ещё неправильный.
При recomposition Counter() выполняется снова:
val count = mutableStateOf(0)
Создаётся новый объект:
Composition #1
MutableState
value = 0
click
value = 1
↓ recomposition
Composition #2
НОВЫЙ MutableState
value = 0
Мы решили проблему «как уведомить Compose об изменении?», но не решили проблему «как сохранить объект между recomposition?».
Здесь появляется remember.
remember
Правильный вариант:
@Composable
fun Counter() {
val count = remember {
mutableStateOf(0)
}
Button(
onClick = {
count.value++
}
) {
Text("Count: ${count.value}")
}
}
remember сохраняет значение между recompositions, пока соответствующая позиция остаётся в Composition.
При первой composition блок выполняется:
remember {
mutableStateOf(0)
}
Получаем:
MutableState(value = 0)
После клика:
0 → 1
При recomposition код снова доходит до remember, но блок не выполняется заново. Compose возвращает сохранённый объект:
MutableState(value = 1)
Два независимых механизма
mutableStateOf
↓
делает значение observable
remember
↓
сохраняет объект между recomposition
Вопрос на собеседовании
Что делает remember { mutableStateOf(...) }?
Хороший ответ:
Здесь работают два независимых механизма.
mutableStateOfсоздаёт observable snapshot state, изменение которого может инвалидировать scope, где state был прочитан.rememberсохраняет созданныйMutableStateмежду recompositions, пока соответствующая позиция остаётся в Composition.
Где remember хранит значение
Свяжем это с Composition и Slot Table.
@Composable
fun Screen() {
val count = remember { mutableStateOf(0) }
Text("Hello")
Button(...) {
...
}
}
Упрощённо Composition можно представить так:
Slot Table
Screen
│
├── remember
│ └── MutableState(0)
│
├── Text
│
└── Button
При recomposition Compose возвращается к соответствующей позиции и находит ранее сохранённое значение.
remember можно мысленно читать как:
Compose, запомни результат этого вычисления для этой позиции Composition.
remember — не глобальный cache
remember {
ExpensiveObject()
}
не означает «закешируй объект навсегда».
Lifetime remember привязан к Composition.
Пока соответствующий composable остаётся в Composition:
recomposition
recomposition
recomposition
↓
объект сохраняется
Но если composable покинул Composition:
Composition
Screen
└── UserCard
└── remember → Object
↓
UserCard удалён
↓
remember тоже удалён
Если UserCard позже появится снова, значение будет создано заново.
Composition vs Recomposition
Composition
Первоначальное построение UI:
Composable functions
↓
Composition
↓
UI structure
Recomposition
Повторное выполнение необходимой части существующей Composition из-за изменения наблюдаемого состояния:
State changed
↓
scope invalidated
↓
Recomposition
Поэтому remember переживает:
recomposition ✅
Но само по себе не обязано переживать:
leaving Composition ❌
Как Compose понимает, что именно нужно recomposed
@Composable
fun Screen() {
val count = remember {
mutableStateOf(0)
}
Header()
Counter(count.value)
Footer()
}
Во время Composition происходит чтение:
count.value
Compose отслеживает, что этот state был прочитан в соответствующем restart scope.
Упрощённо:
MutableState(count)
│
│ read
▼
Counter scope
Затем:
count.value++
Snapshot system видит write.
Compose знает:
count изменился
Кто его читал?
Counter scope
↓
invalidate Counter scope
Поэтому декларативный подход не означает, что любое изменение state вызывает полный rerender приложения.
Recomposition scope
@Composable
fun ProfileScreen(
user: User,
notifications: Int,
) {
Header(user)
NotificationBadge(notifications)
Footer()
}
При изменении соответствующего state Compose потенциально может переисполнить только необходимую область.
Концептуально:
ProfileScreen
│
├── Header
│
├── NotificationBadge ← INVALIDATED
│
└── Footer
Важно:
recomposition ≠ redraw всего экрана
И даже:
recomposition ≠ обязательное выполнение
всех дочерних composables
Compose умеет skip-ать части дерева.
Где происходит регистрация зависимости
Не сам факт существования:
val state: State<Int>
подписывает UI.
Важно чтение .value в наблюдаемом Compose-контексте.
@Composable
fun Screen(state: State<Int>) {
Text("${state.value}")
}
Здесь state читается во время Composition.
А здесь:
@Composable
fun Screen(state: State<Int>) {
Button(
onClick = {
println(state.value)
}
) {
Text("Click")
}
}
state.value читается позже, внутри callback.
Само это чтение не означает, что Screen должен recomposed при каждом изменении state.
Snapshot System — зачем она нужна
Можно спросить: почему нельзя было использовать обычный Observer?
Потому что Compose должен работать с согласованными версиями состояния.
Очень грубая модель:
State:
A = 10
B = 20
Snapshot #1
A = 10
B = 20
изменения:
A = 11
B = 21
Snapshot #2
A = 11
B = 21
Snapshot State System занимается большим, чем:
value changed → callback()
Она обеспечивает механизмы чтения, записи, отслеживания изменений и применения изменений между snapshots.
Поэтому mutableStateOf(...) — часть Snapshot State System, а не просто аналог ObservableField.
Почему state.value = state.value может не вызвать recomposition
val count = mutableStateOf(10)
count.value = 10
По умолчанию mutableStateOf использует mutation policy, учитывающую эквивалентность значений.
Если новое значение считается эквивалентным старому:
old = 10
new = 10
equivalent?
YES
↓
значимого изменения нет
Поэтому утверждение:
любое присваивание
.valueвызывает recomposition
неправильно.
Точнее:
изменение observable snapshot state может инвалидировать scopes, наблюдавшие соответствующее значение, если изменение согласно mutation policy считается значимым.
Распространённая ошибка с коллекциями
Плохо:
val users = remember {
mutableListOf<User>()
}
Затем:
users.add(user)
Обычный MutableList сам по себе не является observable snapshot state.
Compose не знает, что внутри него что-то изменилось.
Вариант 1: snapshot-aware коллекция
val users = remember {
mutableStateListOf<User>()
}
Теперь:
users.add(user)
наблюдается Compose.
Вариант 2: immutable list внутри state
var users by remember {
mutableStateOf(emptyList<User>())
}
Затем:
users = users + user
Меняется само значение MutableState:
old List
↓
new List
Compose видит изменение.
Почему это особенно важно с data class
data class UiState(
val users: MutableList<User>
)
И:
var state by mutableStateOf(
UiState(mutableListOf())
)
Теперь:
state.users.add(user)
Мы не присвоили новое значение state.
Для MutableState<UiState> всё ещё хранится тот же объект:
state.value
│
└── тот же UiState
Это типичный источник бага:
данные изменились, но Compose почему-то не обновился.
Для UI state обычно безопаснее:
data class UiState(
val users: List<User>
)
и:
state = state.copy(
users = state.users + user
)
Получаем:
old UiState
↓
event
↓
new UiState
↓
UI
Это естественно приводит к UDF — Unidirectional Data Flow.
State<T> vs MutableState<T> — архитектурный смысл
Если передать:
fun SomeComponent(
state: MutableState<Int>
)
компонент получает одновременно:
READ + WRITE
и может самостоятельно сделать:
state.value = 500
Часто лучше разделить данные и события:
@Composable
fun Counter(
count: Int,
onIncrement: () -> Unit,
)
Получаем поток:
State
│
▼
UI
│
▼
Event
│
▼
State owner
│
└──────→ new State
Это основа Unidirectional Data Flow и state hoisting.
.value и by
Можно писать:
val count = remember {
mutableStateOf(0)
}
count.value++
Но обычно Compose-код выглядит так:
var count by remember {
mutableStateOf(0)
}
Затем:
count++
Магии Compose здесь нет. Это обычный Kotlin property delegation.
Для Compose существуют operator functions вроде:
operator fun <T> State<T>.getValue(...)
и для MutableState — setValue.
Поэтому:
var count by state
позволяет писать:
count
вместо:
state.value
и:
count = 10
вместо:
state.value = 10
Ментально:
by
↓
Kotlin delegation
↓
getValue / setValue
↓
State.value
↓
Snapshot read/write
Вопрос на собеседовании
Есть ли принципиальная разница для recomposition между .value и by?
Нет. Delegation — синтаксический сахар Kotlin. В конечном итоге происходит чтение/запись State.value.
Полный жизненный цикл простого Counter
@Composable
fun Counter() {
var count by remember {
mutableStateOf(0)
}
Button(
onClick = {
count++
}
) {
Text("Count: $count")
}
}
Initial Composition
Выполняется Counter().
Затем:
remember { mutableStateOf(0) }
создаёт:
MutableState(0)
Compose сохраняет его в Composition.
При:
Text("Count: $count")
происходит чтение snapshot state, и Compose фиксирует зависимость.
Пользователь нажимает Button
count++
Фактически:
read value
0
↓
write value
1
Snapshot state изменился.
Invalidation
Compose знает, что state читался соответствующим composition scope.
Scope становится:
INVALID
Recomposition
Compose переисполняет необходимый scope.
Снова встречает:
remember {
mutableStateOf(0)
}
но получает старый объект:
MutableState(1)
После этого:
Text("Count: $count")
получает:
Count: 1
Главная ментальная модель
Не запоминай:
mutableStateOf → перерисовывает экран
Запоминай:
┌─────────────┐
│ MutableState│
└──────┬──────┘
│
READ during
composition
│
▼
┌─────────────┐
│ restart │
│ scope │
└─────────────┘
▲
│
WRITE
│
state changes
↓
invalidate
↓
recomposition
А рядом существует другой механизм:
remember
│
▼
Slot Table / Composition
│
▼
сохранить значение
между recompositions
То есть две ортогональные задачи:
mutableStateOf → observation
remember → lifetime
Это главная мысль первой части лекции.
Практика
Вопросы для собеседования
Почему этот Counter неправильный?
@Composable
fun Counter() {
var count = 0
Button(onClick = { count++ }) {
Text("$count")
}
}
Нужно назвать две отдельные проблемы:
- Compose не наблюдает обычную
var; - локальное значение не сохраняется между recompositions.
Что не так здесь?
@Composable
fun Counter() {
val count = mutableStateOf(0)
Button(onClick = { count.value++ }) {
Text("${count.value}")
}
}
MutableState observable, но при recomposition создаётся новый объект со значением 0.
Нужен remember.
Что делают независимо друг от друга?
var count by remember {
mutableStateOf(0)
}
mutableStateOf— observation через Snapshot State System;remember— lifetime внутри Composition;by— Kotlin property delegation.
Вызовет ли изменение MutableState recomposition composable, который получил объект, но не прочитал .value во время Composition?
Сам факт передачи State не создаёт зависимость. Важен наблюдаемый read.
Почему это потенциально не обновит UI?
var state by mutableStateOf(
UiState(users = mutableListOf())
)
state.users += newUser
Изменился внутренний MutableList, но новое значение в MutableState<UiState> не было записано.
Какая формулировка точнее?
Грубая:
MutableStateвызывает recomposition.
Точнее:
Изменение snapshot state инвалидирует наблюдавшие его composition scopes; затем Compose может выполнить recomposition соответствующих scope.
Дополнительные материалы
Что дальше
Следующая часть лекции посвящена lifetime состояния:
remember
remember(key)
↓
rememberSaveable
↓
retain
↓
ViewModel
Разберём, что переживает:
- recomposition;
- уход composable из Composition;
- изменение
key; - navigation;
- configuration change;
- process death.
После этого перейдём к:
- state hoisting;
- stateful vs stateless composables;
derivedStateOf;- выбору между composable, state holder и ViewModel;
- хорошей и плохой архитектуре state в реальном приложении.