Middle+
Ментальная модель Jetpack Compose
Декларативный UI, Composition, recomposition, наблюдение за state, позиционная идентичность и remember в Jetpack Compose.
На этой странице
Изучите ментальную модель 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 и может инвалидировать зависящие от него участки.
Практика
Контрольные вопросы
После первой лекции стоит уметь ответить без подсказки:
- Чем declarative UI отличается от imperative UI?
- Что в действительности означает
@Composable? - Что такое Composition?
- Что такое recomposition?
- Чем recomposition отличается от redraw?
- Какие основные фазы есть у Compose?
- Почему обычный
var count = 0не обновляет UI? - За что отвечает
remember? - За что отвечает
mutableStateOf? - Почему
remember { mutableListOf() }недостаточно для автоматического обновления UI? - Почему side effects опасно выполнять прямо в теле composable?
- Почему два вызова одной composable-функции могут иметь независимые
remember-значения? - Что означает invalidation?
- Почему не каждая recomposition является проблемой производительности?
- Почему место чтения state может влиять на производительность?
Дополнительные материалы
Курс Compose для Middle+/Senior
Дальнейший порядок:
- ✅ Ментальная модель Compose
- State в Compose
- Recomposition: scopes, skipping, identity и keys
- Snapshot system
- Stability,
@Stable,@Immutable, strong skipping - Side Effects
- Layout и Modifier
- Lazy layouts
- Compose + ViewModel + Flow
- Lifecycle и сохранение состояния
- Performance
- Проектирование Compose API
- Navigation, CompositionLocal, interoperability и testing
- Middle+/Senior interview drill
Следующая лекция: State в Compose — State<T>, MutableState<T>, remember, rememberSaveable, state hoisting, derivedStateOf и выбор места хранения состояния.