Лекции

Senior

State в Jetpack Compose

Snapshot state, State и MutableState, remember, scope наблюдения, коллекции и владелец state в Compose.

jetpack-composestaterecompositionkotlin
На этой странице

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

Здесь сразу две разные проблемы:

  1. Compose не наблюдает изменение count.
  2. Локальное значение не сохраняется между 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 в реальном приложении.

Официальная документация