Лекции

Middle+

Ментальная модель Jetpack Compose

Декларативный UI, Composition, recomposition, наблюдение за state, позиционная идентичность и remember в Jetpack Compose.

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

Изучите ментальную модель Compose до перехода к state, effects, производительности, stability и архитектуре. Все примеры из исходного материала остаются фрагментами Android/Compose только для чтения: они не компилировались и не запускались во время подготовки.

Теория

Imperative UI vs Declarative UI

В классическом Android View UI обычно строится императивно.

Допустим, у нас есть состояние:

data class ScreenState(
    val isLoading: Boolean,
    val username: String?
)

Во View-системе мы могли бы писать:

fun render(state: ScreenState) {
    if (state.isLoading) {
        progressBar.visibility = View.VISIBLE
        content.visibility = View.GONE
    } else {
        progressBar.visibility = View.GONE
        content.visibility = View.VISIBLE
        usernameText.text = state.username
    }
}

То есть мы буквально говорим UI, что поменять:

  • показать ProgressBar;
  • скрыть content;
  • изменить текст в TextView.

Модель выглядит так:

old UI
   ↓
набор команд изменения
   ↓
new UI

В Compose подход другой:

@Composable
fun Screen(state: ScreenState) {
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        Text(state.username.orEmpty())
    }
}

Мы не говорим:

Удали ProgressBar и покажи TextView.

Мы говорим:

При таком состоянии UI должен выглядеть вот так.

Условно:

UI = f(state)

То есть:

State A
   ↓
@Composable
   ↓
UI A

State B
   ↓
@Composable
   ↓
UI B

Compose сам занимается переходом между этими состояниями UI.

Это и есть declarative UI.


Что такое @Composable

Наивная модель:

@Composable — функция, которая создаёт View.

Это неверно.

Например:

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

Composable-функция выполняется внутри инфраструктуры Compose Runtime.

Compose Compiler трансформирует её таким образом, чтобы Runtime мог:

  • отслеживать положение вызова в Composition;
  • запоминать значения;
  • понимать зависимости от state;
  • переисполнять необходимые участки;
  • пропускать ненужные участки;
  • управлять жизненным циклом Composition.

Концептуально:

@Composable
fun Greeting(name: String)

можно представить примерно как:

fun Greeting(
    name: String,
    composer: Composer,
    changed: Int
)

Это не реальная сигнатура исходного кода, а полезная ментальная модель.

Главное

@Composable — это функция, которую Compose Compiler связывает с Compose Runtime.

Поэтому composable нельзя рассматривать как обычную функцию вида:

вызвали → создали UI → забыли

Что такое Composition

Возьмём:

@Composable
fun UserScreen(user: User) {
    Column {
        Text(user.name)

        Button(onClick = {}) {
            Text("Follow")
        }
    }
}

При первом выполнении Compose создаёт Composition.

Условно её можно представить так:

UserScreen
│
└── Column
    │
    ├── Text
    │   └── "Konsti"
    │
    └── Button
        └── Text
            └── "Follow"

Но важно:

Composition — это не Android View hierarchy.

Composition — внутренняя структура Compose Runtime, которая хранит информацию о выполнении composable-функций и связанном с ними состоянии.

Первое построение называется:

Initial Composition

Что происходит при изменении State

Рассмотрим:

@Composable
fun Counter() {
    var count by remember {
        mutableStateOf(0)
    }

    Button(
        onClick = { count++ }
    ) {
        Text("Count: $count")
    }
}

При первом выполнении:

count = 0

Counter()
   ↓
Button
   ↓
Text("Count: 0")

Пользователь нажимает кнопку:

count++

Получаем:

MutableState
0 → 1

Compose видит изменение observable state.

Дальше:

state changed
     ↓
affected scope invalidated
     ↓
recomposition scheduled
     ↓
affected composable code executed again

Это называется Recomposition.


Recomposition не означает перестройку всего экрана

Распространённая неправильная модель:

state changed
↓
весь экран пересоздан

Compose старается переисполнять только необходимые части.

Например:

@Composable
fun Screen() {
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        Header()

        Counter(count)

        Footer()
    }
}

При изменении count концептуально возможно:

Header()   → SKIP
Counter()  → RECOMPOSE
Footer()   → SKIP

То есть Compose старается пропускать участки, которые не требуется выполнять повторно.


Recomposition != Redraw

Это важная тема для Middle+/Senior интервью.

У Compose есть три основные фазы:

1. Composition
      ↓
2. Layout
      ↓
3. Draw

Composition

Определяет:

Что должно находиться в UI?

Например:

if (loggedIn) {
    HomeScreen()
} else {
    LoginScreen()
}

Layout

Определяет:

Какого размера элементы и где они находятся?

Упрощённо:

parent constraints
      ↓
measure children
      ↓
choose own size
      ↓
place children

Draw

Определяет:

Какие пиксели нужно нарисовать?

Например:

drawRect(...)
drawCircle(...)

Изменение state не всегда требует запуска всех трёх фаз.

Иногда можно избежать Composition и затронуть только Layout или Draw.


Почему место чтения State имеет значение

Например:

val offset by animateDpAsState(...)

Box(
    Modifier.offset(x = offset)
)

Здесь offset читается при построении Modifier.

Условно:

state changed
↓
Composition
↓
Layout
↓
Draw

Но есть overload:

Box(
    Modifier.offset {
        IntOffset(offset.roundToPx(), 0)
    }
)

Теперь значение читается позже — во время Layout.

Получаем:

state changed
↓
Layout
↓
Draw

Composition может быть пропущена.

Полезный принцип

Чем позже по UI pipeline читается часто меняющийся state, тем меньше работы потенциально нужно выполнить Compose.


Почему обычная Kotlin-переменная не обновляет UI

Например:

@Composable
fun Counter() {
    var count = 0

    Button(
        onClick = { count++ }
    ) {
        Text("$count")
    }
}

Здесь две проблемы.

Первая:

count++

не сообщает Compose, что что-то изменилось.

count — обычная Kotlin-переменная.

Compose её изменение не наблюдает.

Вторая проблема: если Counter() всё же выполнится снова:

var count = 0

будет выполнено заново.

Значение снова станет 0.

Значит, нам нужны две независимые возможности:

1. сохранить значение между recompositions
2. уведомлять Compose об изменениях

И это делают разные механизмы.


remember

val something = remember {
    SomeObject()
}

Можно читать как:

При первом проходе этой позиции Composition создай значение и запомни его. При следующих recomposition верни сохранённое значение.

Пример:

@Composable
fun Example() {
    val object1 = SomeObject()

    val object2 = remember {
        SomeObject()
    }
}

После recomposition:

object1 → новый объект
object2 → тот же remembered object

Условно:

Composition
│
├── position #1
│
├── position #2
│   └── remembered SomeObject
│
└── position #3

remember не делает объект observable

Например:

val list = remember {
    mutableListOf<String>()
}

Теперь список переживёт recomposition.

Но:

list.add("A")

само по себе не вызовет recomposition.

Причина:

remember

отвечает за сохранение значения.

А:

MutableList

сам по себе не является observable state для Compose.

Поэтому важно разделять:

remember
        ≠
observable state

mutableStateOf

Теперь:

val count = remember {
    mutableStateOf(0)
}

Здесь участвуют два механизма.

remember

Сохраняет объект:

MutableState<Int>

между recompositions.

mutableStateOf

Создаёт state, изменения которого Compose умеет наблюдать.

Например:

count.value = 1

Compose видит изменение.

Поэтому:

var count by remember {
    mutableStateOf(0)
}

полезно мысленно раскладывать так:

remember
   ↓
где хранить значение

mutableStateOf
   ↓
как отслеживать изменение

Откуда Compose знает, кто читал State

Возьмём:

@Composable
fun Screen() {
    var count by remember {
        mutableStateOf(0)
    }

    Header()

    Text("$count")

    Footer()
}

Compose должен понять, что count связан именно с той частью Composition, где он читается.

Очень упрощённо:

Composition executes
        ↓
MutableState.value is read
        ↓
Compose records dependency

Можно представить:

MutableState(count)
       │
       ▼
composition scope

Когда происходит:

count++

Compose может найти зависимые участки:

MutableState changed
       ↓
find readers
       ↓
invalidate scopes
       ↓
schedule recomposition

За этим стоит Snapshot State system.

Позже мы разберём его отдельно.


Почему Composable должен быть близок к pure function

Плохой пример:

@Composable
fun Screen() {
    analytics.send("Screen rendered")

    Text("Hello")
}

Проблема: тело composable может выполняться много раз.

Нельзя исходить из модели:

Composable вызывается один раз при открытии экрана

Он может выполниться снова из-за recomposition.

Поэтому side effects нельзя бесконтрольно размещать внутри тела composable.

Например:

analytics.send(...)
database.write(...)
repository.request(...)

Для этого существуют специальные API:

LaunchedEffect
DisposableEffect
SideEffect
rememberCoroutineScope
rememberUpdatedState
produceState

Они будут разобраны отдельно.


Позиционная идентичность

Рассмотрим:

@Composable
fun Screen(showCounter: Boolean) {
    if (showCounter) {
        Counter()
    }

    Footer()
}

Compose должен понимать:

  • какой remember относится к Counter;
  • какой remember относится к Footer;
  • какие вызовы соответствуют прошлой Composition.

Во многом состояние связывается с позицией вызова в Composition.

Это можно представить как:

Screen
│
├── Counter
│   └── remember(count)
│
└── Footer

Это приводит нас к темам:

  • identity;
  • key;
  • keys в LazyColumn;
  • positional memoization.

Они будут разобраны в лекции про recomposition.


remember привязан к позиции в Composition, а не к функции

Допустим:

@Composable
fun Counter() {
    var count by remember {
        mutableStateOf(0)
    }
}

И вызываем:

Column {
    Counter()
    Counter()
}

У каждого вызова будет своё состояние:

Column
├── Counter A
│   └── remember(count A)
│
└── Counter B
    └── remember(count B)

Например:

Counter A → 3
Counter B → 12

Хотя Kotlin-функция одна и та же.

Вывод

remember принадлежит конкретной позиции в Composition.


Recomposition Scope

Compose не обязан выполнять заново весь экран или всё дерево.

Упрощённый пример:

@Composable
fun Screen() {
    Header()
    Counter()
    Footer()
}

Runtime может переисполнить только необходимый restartable участок:

Screen
│
├── Header   → skip
├── Counter  → recompose
└── Footer   → skip

Поэтому утверждение:

Если родитель recomposed, то все дети обязательно recomposed.

не является корректной моделью Compose.

Тема scopes, restartability и skipping будет разобрана глубже позже.


Recomposition сама по себе не является проблемой

Не нужно пытаться устранить каждую recomposition.

Compose спроектирован так, чтобы recomposition была сравнительно дешёвой.

Проблема появляется, когда сочетаются:

частые recompositions
×
дорогая работа
×
большой участок UI

Например:

@Composable
fun Screen(items: List<Item>) {
    val processed = items
        .filter { complicatedCheck(it) }
        .sortedBy { expensiveCalculation(it) }

    // ...
}

Если этот код выполняется очень часто, вычисление может стать узким местом.

Тогда может понадобиться:

val processed = remember(items) {
    items
        .filter { complicatedCheck(it) }
        .sortedBy { expensiveCalculation(it) }
}

Но оптимизировать recomposition стоит после понимания причины и измерений, а не просто потому, что recomposition произошла.


Полный пример жизненного цикла State

Возьмём:

@Composable
fun CounterScreen() {
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        Text("Counter")

        Text("$count")

        Button(
            onClick = {
                count++
            }
        ) {
            Text("Increment")
        }
    }
}

Шаг 1. Initial Composition

Compose выполняет:

CounterScreen()
       ↓
Column
├── Text("Counter")
├── Text("0")
└── Button
    └── Text("Increment")

Шаг 2. remember

Создаётся:

Composition
└── remembered MutableState(0)

Шаг 3. State read

В выражении:

Text("$count")

читается значение MutableState.

Compose регистрирует зависимость.

Упрощённо:

MutableState(count)
        ↓
dependent composition scope

Шаг 4. Click

Пользователь нажимает кнопку:

count++

Происходит:

0 → 1

Шаг 5. Invalidation

Compose видит изменение observable state:

count changed
↓
dependent scope invalidated

Шаг 6. Recomposition

Compose повторно выполняет необходимый участок.

Результат:

Text("0")
   ↓
Text("1")

Другие части UI могут быть пропущены.


Главная ментальная схема

               STATE
                 │
                 │ read
                 ▼
        ┌─────────────────┐
        │   COMPOSITION   │
        │                 │
        │ @Composable     │
        │ functions       │
        └────────┬────────┘
                 │
                 ▼
              LAYOUT
                 │
                 ▼
               DRAW
                 │
                 ▼
                UI


Mutable State changes
        │
        ▼
Snapshot system
        │
        ▼
invalidate readers
        │
        ▼
recomposition
        │
        └──────────► Composition

Если эта схема понятна, многие API Compose перестают выглядеть как магия.


Что нужно вынести из лекции

Compose — declarative UI

UI = f(state)

@Composable — не фабрика View

Composable работает внутри механизма Compose Compiler + Runtime.

Composition

Хранит структуру composable-вызовов и связанную с ними информацию.

Recomposition

Это повторное выполнение необходимых composable-участков.

Она не обязательно означает перестройку всего экрана.

Основные фазы

Composition → Layout → Draw

remember

сохраняет значение между recompositions

Но не делает его observable.

mutableStateOf

создаёт observable state

Compose может заметить его изменение.

Snapshot State

Compose отслеживает места чтения observable state и может инвалидировать зависящие от него участки.


Практика

Контрольные вопросы

После первой лекции стоит уметь ответить без подсказки:

  1. Чем declarative UI отличается от imperative UI?
  2. Что в действительности означает @Composable?
  3. Что такое Composition?
  4. Что такое recomposition?
  5. Чем recomposition отличается от redraw?
  6. Какие основные фазы есть у Compose?
  7. Почему обычный var count = 0 не обновляет UI?
  8. За что отвечает remember?
  9. За что отвечает mutableStateOf?
  10. Почему remember { mutableListOf() } недостаточно для автоматического обновления UI?
  11. Почему side effects опасно выполнять прямо в теле composable?
  12. Почему два вызова одной composable-функции могут иметь независимые remember-значения?
  13. Что означает invalidation?
  14. Почему не каждая recomposition является проблемой производительности?
  15. Почему место чтения state может влиять на производительность?

Дополнительные материалы

Курс Compose для Middle+/Senior

Дальнейший порядок:

  1. ✅ Ментальная модель Compose
  2. State в Compose
  3. Recomposition: scopes, skipping, identity и keys
  4. Snapshot system
  5. Stability, @Stable, @Immutable, strong skipping
  6. Side Effects
  7. Layout и Modifier
  8. Lazy layouts
  9. Compose + ViewModel + Flow
  10. Lifecycle и сохранение состояния
  11. Performance
  12. Проектирование Compose API
  13. Navigation, CompositionLocal, interoperability и testing
  14. Middle+/Senior interview drill

Следующая лекция: State в Compose — State<T>, MutableState<T>, remember, rememberSaveable, state hoisting, derivedStateOf и выбор места хранения состояния.

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