Комплексный гайд по декларативной модели Compose, recomposition, runtime/compiler internals, state, effects, UI phases, производительности, архитектуре и практическим Android-сценариям.
Compose активно развивается. Материал описывает актуальную модель на 2026 год и намеренно не привязан к конкретной версии большинства артефактов. Experimental API нужно проверять по документации версии, используемой в проекте.
- Отвечайте на вопрос вслух, затем сверяйтесь с объяснением.
- На Senior-уровне раскрывайте механизм, lifetime, ограничения и способ измерения.
- Не называйте recomposition проблемой без доказательства влияния на frame time.
- Различайте API Compose Runtime, Compose UI, Foundation, Material и Android integration.
Compose — декларативный UI toolkit. Приложение не говорит «создай View, затем измени её текст», а описывает, какой UI соответствует текущему state. Runtime сохраняет предыдущую Composition, отслеживает зависимости и применяет минимально необходимые изменения к UI-дереву.
Главная модель:
State + Composable functions
↓
Composition
↓
UI node tree
↓
Layout and Draw
При изменении наблюдаемого state Compose инвалидирует связанные участки, повторно исполняет нужные composable-функции и обновляет только изменившуюся структуру. Это не полная перерисовка экрана и не обязательное пересоздание всех UI nodes.
Функция с @Composable может вызывать другие composable-функции и участвует в протоколе Composition. Обычно она возвращает Unit: её результатом является не View, а описание UI через вызовы, которые runtime сопоставляет с предыдущим выполнением.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello, $name",
modifier = modifier,
)
}Composable должна быть:
- быстрой;
- идемпотентной относительно одинаковых входов;
- свободной от неконтролируемых side effects;
- готовой к повторному, пропущенному или отменённому выполнению.
Composition — runtime-представление результата выполнения composable-кода:
- структура групп вызовов;
- сохранённые
remember-значения; - identity участков;
- restart scopes;
- зависимости от snapshot state;
- связь с UI nodes и effects.
Composition не равна Android View hierarchy. Один composable может не создать ни одного UI node, а другой — создать несколько.
- Initial composition — первое выполнение и создание структуры.
- Recomposition — повторное выполнение инвалидированных участков после изменения state или параметров.
- Skipping — пропуск тела composable, если runtime/compiler доказали, что повторное выполнение не требуется.
Recomposition может завершиться без изменений UI-дерева. После неё layout или draw запускаются только при необходимости.
В imperative UI состояние часто распределено между model и полями View: один callback обновил текст, другой забыл visibility, третий изменил enabled. Compose заставляет получить весь видимый результат из state.
Это не устраняет архитектурные ошибки автоматически. Mutable shared state, потерянные events, гонки Flow и неверный lifecycle остаются возможны. Compose лишь делает однонаправленную модель естественнее.
Нельзя полагаться на точный порядок выполнения sibling composables, частоту вызова или обязательное завершение начатой recomposition. Runtime и compiler имеют право:
- пропустить функцию;
- повторно выполнить только часть дерева;
- отменить незавершённую recomposition;
- выполнять независимую работу оптимизированным способом.
Поэтому side effect в теле composable некорректен.
@Composable меняет соглашение вызова. Compose Compiler plugin преобразует исходную функцию, добавляя служебные параметры и runtime-протокол. Это объясняет, почему composable можно вызывать только из composable-контекста и почему обычная function reference не всегда эквивалентна composable lambda.
Compiler обычно добавляет:
- скрытый
Composer; - changed/dirty bit masks;
- маски default arguments;
- группы для сопоставления вызовов;
- restart scope;
- проверки skipping;
- лямбду повторного вызова;
- source information для tooling.
Упрощённый псевдокод:
fun Greeting(
name: String,
composer: Composer,
changed: Int,
) {
composer.startRestartGroup(/* key */)
val dirty = calculateDirtyFlags(name, changed)
if (canSkip(dirty) && composer.skipping) {
composer.skipToEndGroup()
} else {
Text(name, composer, /* flags */)
}
composer.endRestartGroup()?.updateScope { next, force ->
Greeting(name, next, changed or force)
}
}Это концептуальная модель. Конкретные имена, флаги и формат generated code являются implementation detail.
Маски передают информацию об аргументах:
- значение известно как неизменившееся;
- значение изменилось;
- значение статическое;
- runtime должен сравнить с предыдущим.
Если родитель уже знает, что параметр не изменился, ребёнок не обязан выполнять повторное сравнение. На основе объединённых dirty flags generated code принимает решение о skip.
Dirty mask относится к конкретному вызову и не означает, что объект глобально «грязный». Snapshot invalidation и сравнение параметров — связанные, но разные механизмы.
Нет.
- Restartable composable создаёт точку, с которой runtime может начать recomposition.
- Skippable composable можно целиком пропустить при неизменившихся входах.
- Non-restartable функция выполняется вместе с ближайшим restartable parent.
- Non-skippable функция выполняется, если её parent дошёл до вызова.
Большинство обычных composables компилятор делает restartable. Аннотации вроде @NonRestartableComposable и @NonSkippableComposable — инструменты узкой оптимизации, а не стандарт для прикладного кода.
Composer:
- сопоставляет текущее выполнение с предыдущей Composition;
- работает с группами и SlotTable;
- реализует positional memoization;
- регистрирует scopes и state reads;
- создаёт change list.
Recomposer:
- получает invalidations;
- планирует recomposition;
- координируется с frame clock;
- запускает recomposition и применение изменений;
- управляет effect lifecycle.
Applier применяет рассчитанные операции к целевому дереву. Compose Runtime не привязан только к Android UI; конкретный UI backend предоставляет свой способ применения nodes.
SlotTable — компактное side storage Composition:
- иерархия групп;
- ключи, parent/size metadata;
- число UI nodes;
- remembered values;
- anchors;
- данные restart scopes и CompositionLocal;
- служебная информация runtime.
Один composable не обязан соответствовать одной группе. Группы также создаются для условий, циклов, key, remembered values и emitted nodes.
remember привязан не к имени локальной переменной, а к логической позиции group/slot.
Это потенциальная точка входа для повторного выполнения restart group.
Когда scope читает snapshot state:
- runtime регистрирует зависимость state → scope;
- запись в state инвалидирует scope;
- Recomposer планирует работу;
- generated restart lambda повторно вызывает composable;
- зависимости записываются заново.
Зависимости динамические: если новая ветка больше не читает state, соответствующая связь должна исчезнуть.
Compose должен сопоставить вызовы текущего выполнения с предыдущими экземплярами. По умолчанию используется positional identity: call site плюс положение среди соседних вызовов и структура групп.
Identity определяет lifetime:
remember-значений;- effects;
- coroutine из
LaunchedEffect; - локального state;
- сохранения lazy item;
- анимаций и UI nodes.
Типичные причины:
- изменение
MutableState, прочитанного scope; - новый параметр composable;
- изменение
CompositionLocal; - invalidation от внутренних Compose API;
- изменение observable state в layout/draw может перезапустить соответствующую фазу без composition.
Обычное изменение поля объекта, не являющегося snapshot-aware state, Compose не наблюдает.
if (show) {
val controller = remember { Controller() }
}Значение связывается с позицией вызова remember. Когда ветка исчезает, группа покидает Composition и значение забывается. Когда ветка появляется снова, создаётся новый controller.
В цикле несколько вызовов из одного call site различаются порядком. Вставка элемента в начало может сместить identity всех последующих элементов.
for (message in messages) {
key(message.id) {
MessageRow(message)
}
}key добавляет data identity к позиции. При reorder runtime может переместить группу вместе с:
- remembered state;
- effects;
- restart scope;
- UI nodes.
Ключ должен быть стабильным и уникальным среди siblings этого call site. Индекс — плохой ключ для изменяемого порядка, random UUID при каждой recomposition ещё хуже.
Несмотря на одинаковое слово, задачи разные:
key(id) {}задаёт identity участка Composition;remember(id) {}сбрасывает кешированное значение при измененииid;LaunchedEffect(id)отменяет coroutine и запускает новую;DisposableEffect(id)выполняет cleanup и регистрацию заново;LazyColumn.items(key = ...)задаёт item identity.
Неверный key означает либо stale state, либо лишние сбросы и перезапуски.
Нет. Snapshot state invalidates ближайший подходящий restart scope, и Recomposer может начать с него. Если state прочитан слишком высоко, область invalidation становится шире.
@Composable
fun Screen(state: ScreenState) {
Header()
Counter(value = state.counter)
Footer()
}При грамотных границах Header и Footer могут быть skipped. Важно не «дробить всё на функции ради performance», а создавать осмысленные APIs и читать часто меняющийся state близко к потребителю.
Без стабильного key identity item зависит от позиции. После вставки runtime считает, что существующий composable теперь представляет другой item. Его LaunchedEffect может отмениться или продолжить работу с другой сущностью.
Стабильный business ID — часть correctness, а не только оптимизация.
Он создаёт content, identity которого можно перемещать между местами Composition, сохраняя remembered state и nodes. Нужен для специальных случаев, когда один и тот же stateful subtree физически переезжает между ветками adaptive UI.
Это не замена обычному state hoisting и key. API добавляет runtime complexity и применяется только когда действительно нужно переместить саму Composition.
В новых версиях Compose Runtime retain дополняет remember и rememberSaveable: значение может пережить временный выход из hierarchy без сериализации. По lifetime оно ближе к короткоживущему retained state, но не является durable storage и не переживает process death как сохранённые данные.
Нужно проверять доступность и контракт в версии Runtime проекта. Для screen business state стандартными владельцами по-прежнему являются ViewModel, SavedStateHandle и data layer.
Compose state — не просто callback/listener. Snapshot system хранит версии state records, отслеживает чтения и атомарно применяет изменения. Концептуально это похоже на versioned state/MVCC, но не следует отождествлять snapshots с конкретной СУБД.
var count by remember { mutableIntStateOf(0) }
Text("$count")Во время чтения runtime регистрирует зависимость текущей фазы от state object. При записи:
- mutation policy решает, отличается ли значение;
- запись публикуется через snapshot;
- наблюдатели получают invalidation;
- Recomposer планирует затронутый scope;
- UI повторно читает актуальное значение.
Если state не был прочитан участвующей фазой, сама запись не обязана изменить UI.
Snapshot задаёт согласованное представление snapshot-aware state. Mutable snapshot изолирует изменения до apply. Параллельные snapshots могут читать разные версии, а несовместимые записи способны вызвать apply conflict.
Runtime использует read/write observers:
- read observer строит зависимости;
- write/apply notifications инвалидируют потребителей;
- state records обеспечивают versioned view.
Snapshots не делают произвольный object graph immutable и не устраняют все data races внешнего кода.
Для mutableStateOf можно выбрать:
structuralEqualityPolicy()— эквивалентность по==;referentialEqualityPolicy()— по===;neverEqualPolicy()— каждая запись считается новой;- custom
SnapshotMutationPolicy.
Если policy считает значения эквивалентными, наблюдаемого изменения не происходит.
val state = mutableStateOf(
value = initial,
policy = referentialEqualityPolicy(),
)Custom policy должна соответствовать UI-семантике. Игнорирование значимого поля создаст stale UI, а neverEqualPolicy может вызвать лишние invalidations.
val users = mutableStateOf(mutableListOf<User>())
users.value.add(newUser)Изменилось содержимое списка, но setter value не вызван. Snapshot system не наблюдает внутреннюю мутацию обычного MutableList.
Варианты:
users.value = users.value + newUserили snapshot-aware collection:
val users = mutableStateListOf<User>()
users += newUserПервый вариант лучше выражает immutable snapshot на архитектурной границе. Второй удобен для локального fine-grained UI state.
Для часто обновляемых примитивов доступны:
mutableIntStateOf;mutableLongStateOf;mutableFloatStateOf;mutableDoubleStateOf.
Они позволяют избежать boxing, возникающего у generic MutableState<Int>. Использовать их особенно полезно в animation/layout hot path, но только после оценки реальной частоты.
Compose Runtime предоставляет:
mutableStateListOf/SnapshotStateList;mutableStateMapOf/SnapshotStateMap;- в актуальных версиях — state set API /
SnapshotStateSet; toMutableStateListдля создания observable list.
Операции коллекции являются наблюдаемыми. Но сама коллекция не делает элементы immutable: изменение обычного поля элемента не уведомит Compose.
data class Item(
val id: Long,
val selected: Boolean,
)
val items = mutableStateListOf<Item>()
items[index] = items[index].copy(selected = true)На domain/ViewModel boundary обычно проще экспортировать immutable List, а snapshot collections оставить внутри UI state holder.
Он позволяет выполнить группу snapshot writes и применить их вместе. Это полезно для согласованного обновления нескольких snapshot states вне обычной main-thread Composition.
Snapshot.withMutableSnapshot {
first.value = newFirst
second.value = newSecond
}Это не database transaction и не заменяет синхронизацию внешних ресурсов. При сложной межпоточной модели лучше централизовать state transitions.
Text("Count: $count") // read
count++ // write после readScope прочитал старое значение и тут же инвалидировал себя новой записью. Это может создать бесконечный цикл recomposition.
Записи выполняют в:
- event callback;
LaunchedEffect;- ViewModel/state holder;
SideEffect, если публикация должна быть после успешной composition.
Snapshot read регистрируется не только в composition:
- composition read → recomposition;
- measure read → remeasure;
- placement read → replace;
- draw read → redraw.
Это даёт один из важнейших способов оптимизации: читать часто меняющийся state в самой поздней фазе, которая действительно должна измениться.
remember — positional cache внутри текущей Composition. Он не является общим cache, lifecycle component или persistent storage.
val formatter = remember(locale) {
DateFormatter(locale)
}Calculation выполняется при первом входе и повторно, когда изменился key. Значение забывается, когда соответствующая identity покидает Composition.
Типичные ошибки:
- забытый key создаёт объект со старой dependency;
- лишний key постоянно сбрасывает state;
- side effect внутри calculation;
- ожидание сохранения после Activity recreation.
@Composable
fun Editor(initialText: String) {
var text by remember { mutableStateOf(initialText) }
}initialText используется только при первом входе. Последующее новое значение параметра не синхронизирует локальный state.
Возможные контракты:
- параметр действительно только initial value — это нужно явно назвать;
- state полностью контролирует parent;
remember(entityId), если при смене сущности редактор должен сброситься;- отдельная синхронизация через effect, если она осмыслена и не уничтожает пользовательский ввод.
Сохраняет небольшое UI state через saved-state registry, переживая configuration recreation и system-driven process recreation при наличии сохранённого Bundle.
var query by rememberSaveable { mutableStateOf("") }Это не durable storage:
- Bundle ограничен размером;
- сохранение зависит от Android lifecycle;
- большие DTO, bitmap и списки хранить нельзя;
- business data восстанавливается из repository/database.
Custom state сохраняют в компактное saveable-представление:
val UserDraftSaver = Saver<UserDraft, Bundle>(
save = { draft ->
bundleOf(
"name" to draft.name,
"age" to draft.age,
)
},
restore = { bundle ->
UserDraft(
name = bundle.getString("name").orEmpty(),
age = bundle.getInt("age"),
)
},
)Хороший Saver сохраняет минимум для восстановления, а не сериализует весь объектный граф.
Позволяет сохранять rememberSaveable state для динамически появляющегося content по ключу. Применяется в навигации, tab/pager и custom containers, где subtree временно удаляется из Composition.
Owner обязан удалять state для identity, которая больше никогда не вернётся, иначе registry будет удерживать ненужные данные.
Создаёт производный snapshot state:
val showScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}Полезен, когда dependency меняется часто, а итоговое значение — редко. Scroll position изменяется почти каждый кадр, boolean пересекает порог значительно реже.
Не нужен для каждого вычисления:
val fullName = "$firstName $lastName"Если результат меняется с каждым входом и вычисление дешёвое, derivedStateOf только добавляет tracking overhead.
remember(key)пересчитывает значение во время recomposition, когда key изменился.derivedStateOfсамостоятельно отслеживает snapshot reads и предоставляетState.- Обычное вычисление выполняется при каждом достижении строки.
derivedStateOf не переносит тяжёлую работу в background. Большую фильтрацию/сортировку лучше выполнять в ViewModel/Flow или заранее подготовленном state.
Преобразует snapshot reads в cold Flow:
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { it > 0 }
.distinctUntilChanged()
.filter { it }
.collect { analytics.reportScrolled() }
}Block:
- исполняется в read-only snapshot;
- автоматически отслеживает прочитанный state;
- повторяется при изменении dependency;
- emits только неравный предыдущему результат;
- должен быть чистым и идемпотентным.
snapshotFlow полезен для event-processing/analytics, но не заменяет прямое чтение state для UI.
Stability — compile-time контракт, помогающий compiler/runtime решить, можно ли безопасно skip composable при неизменившихся аргументах.
Упрощённый контракт:
equalsодной пары instances не меняет смысл произвольно;- изменение публичного observable state уведомляет Compose;
- публичные свойства также имеют подходящую стабильность.
MutableState<T> — stable, хотя mutable: изменение value наблюдаемо.
Публично наблюдаемое состояние объекта после создания не меняется, а методы не скрывают изменение значимого state.
@Immutable
data class UserUi(
val id: Long,
val name: String,
)Аннотация — обещание разработчика, а не runtime-проверка. Если внутри @Immutable хранится изменяемый список, Compose может skip нужное обновление.
Интерфейс List read-only, но underlying implementation может быть mutable. Compiler не может доказать глубокую неизменяемость стандартной коллекции.
Решения по ситуации:
- strong skipping и тот же list instance;
- immutable/persistent collections;
- wrapper с честным контрактом;
- stability configuration для типов, в которых команда уверена;
- не оптимизировать, если нет измеренной проблемы.
В актуальной модели compiler strong skipping включён по умолчанию начиная с Kotlin 2.0.20.
Он:
- делает restartable composables потенциально skippable даже с unstable parameters;
- автоматически memoizes lambdas внутри composable по captured values.
Сравнение:
- stable parameters — обычно
equals; - unstable parameters — identity (
===).
Новый равный по содержимому unstable List считается изменившимся, а тот же instance может быть skipped.
Нет. Он меняет generated skipping strategy, но не исправляет mutable model и не делает внутренние мутации observable.
val list = mutableListOf("A")
Content(list)
list += "B"Если ссылка та же и нет observable state write, UI может не получить invalidation вообще.
Нет. Проверка аргументов тоже имеет стоимость. Skipping мало полезен для:
- функции, которая редко recomposes;
- дешёвой обёртки над skippable children;
- функции с большим количеством дорогих
equals; - участка вне performance bottleneck.
Сначала измеряют frame time и источник работы, затем смотрят compiler metrics/stability reports.
Compiler memoizes lambdas внутри composable с keys по captured values. Это уменьшает случаи, когда новый callback ломает skipping child.
Но автоматическая мемоизация:
- не исправляет stale capture в неправильно спроектированном effect;
- не заменяет
rememberUpdatedState; - не делает event handler pure;
- может быть отключена для конкретной лямбды через специализированную аннотацию, если это действительно нужно.
Инструменты:
- Layout Inspector recomposition/skip counts;
- Compose compiler reports;
- stability configuration/report;
- system trace и composition tracing;
- Macrobenchmark.
Не начинайте с @Stable на всех моделях. Сначала найдите пользовательскую проблему и докажите, что её причина — лишняя composition work.
State hoisting переносит state к минимальному общему владельцу. Основной контракт stateless component:
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
)State идёт вниз, события — вверх. Parent остаётся единственным источником истины и может принять, преобразовать или отклонить событие.
У владельца с нужным lifetime и ответственностью:
- ripple/animation — внутри компонента;
- раскрытие dropdown — локальный UI state;
- draft поля —
rememberSaveableили UI state holder; - query, влияющий на repository —
ViewModel; - настройки пользователя — persistent data layer.
«Всё в ViewModel» так же плохо, как «всё в remember».
Reusable компонент может иметь:
- stateless core для контроля и тестирования;
- stateful convenience overload для простого случая.
Нужно избегать двух независимых sources of truth. Stateful overload должен делегировать stateless и ясно определять initial/default behavior.
@Composable
fun ProfileRoute(
viewModel: ProfileViewModel,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
onRetry = viewModel::retry,
onBack = onBack,
)
}
@Composable
fun ProfileScreen(
state: ProfileUiState,
onRetry: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
// Pure UI
}Route знает о ViewModel, lifecycle и navigation. Screen получает данные и intents, легко preview/test и переиспользуется.
В Android это рекомендованный способ преобразовать Flow в Compose State с учётом Lifecycle. Collection активна только в заданном состоянии, обычно STARTED, поэтому невидимый UI не поддерживает ненужную подписку.
collectAsState() подходит platform-independent Compose или источнику, lifetime которого уже ограничен иным способом.
val uiState: StateFlow<UiState> =
combine(repository.items, filter) { items, currentFilter ->
UiState.Content(items.filterBy(currentFilter))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)Выбор SharingStarted — часть контракта:
Eagerly— upstream нужен сразу;Lazily— запускается первым subscriber и продолжает жить;WhileSubscribed— работает только при subscribers, возможно с timeout.
Timeout часто переживает короткое пересоздание collector при configuration change.
Единый state полезен для атомарных переходов и невозможных комбинаций:
sealed interface ContentState {
data object Loading : ContentState
data class Data(val items: List<Item>) : ContentState
data class Error(val message: UiText) : ContentState
}Но god object смешивает независимые состояния и усложняет обновления. Dialog visibility, local animation и screen data не обязаны находиться в одном data class.
Граница определяется общими инвариантами и owner, а не правилом «один экран — один класс».
UDF — направление state down/events up. MVI — конкретная архитектурная форма с intents, reducer и effects.
Для простого экрана универсальный onEvent(UiEvent) может ухудшить discoverability. Типизированные callbacks onRetry(), onItemClick(id) дают более ясный API. Reducer оправдан, если атомарные переходы и история действий действительно важны.
Сначала определите гарантию.
- State должен позволять восстановить текущий UI.
- Transient command может быть доставлен только активному consumer.
- Важный бизнес-результат нельзя терять при configuration change.
SharedFlow(replay = 0) может потерять navigation/snackbar event без subscriber. Критичное действие лучше представить как pending state с idempotent consumption или durable domain result.
ViewModel переживает configuration change, но не process death. Для восстановления:
rememberSaveable— локальный небольшой UI state;SavedStateHandle— минимальные screen keys/filter/draft;- Room/DataStore — durable business data;
- repository заново строит state по ID.
Не сохраняйте полный DTO или большой список в Bundle.
Composable body должен описывать UI. Network request, registration listener, analytics и изменение внешнего объекта выполняются через effect API с явным lifetime.
Запускает coroutine при входе в Composition. При смене key старая coroutine отменяется и запускается новая; при выходе — отменяется.
LaunchedEffect(userId) {
controller.load(userId)
}Бизнес-загрузка обычно принадлежит ViewModel, чтобы переживать UI recreation. LaunchedEffect лучше подходит для composition-bound задач: focus, scroll, snackbar, animation.
Key описывает identity операции. Если операция зависит от userId, он должен быть key.
Слишком широкий key вызывает лишние отмены. Слишком узкий оставляет effect со stale dependency. LaunchedEffect(Unit) означает «на lifetime этого call site», а не «один раз на приложение».
Позволяет long-lived effect видеть свежий callback без restart:
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3_000)
currentOnTimeout()
}Он обновляет ссылку, но не изменяет identity effect.
Для регистрации ресурса с обязательным cleanup:
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
analytics.onLifecycleEvent(event)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}Все изменяемые dependencies должны быть key либо доступны через rememberUpdatedState.
Выполняется после каждой успешно применённой Composition. Подходит для публикации Compose state во внешний объект:
SideEffect {
analytics.currentUserType = userType
}Не предназначен для дорогого I/O и не имеет cleanup.
Возвращает scope, отменяемый при выходе call site из Composition. Используется в callbacks:
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
snackbarHostState.showSnackbar("Saved")
}
}) {
Text("Save")
}Не запускайте coroutine прямо во время composition.
Адаптирует suspend/callback источник к Compose State. Внутри использует remembered state и effect lifecycle.
val image by produceState<Result<Image>?>(null, url) {
value = repository.load(url)
}Для callback API применяют awaitDispose для unregister. Если источник уже Flow, обычно проще collectAsStateWithLifecycle.
Используйте его только если operation lifetime не должен меняться. Если смена userId означает другую подписку, прятать userId в rememberUpdatedState неверно: старую операцию нужно отменить и создать новую.
- Coroutine на lifetime composable/key —
LaunchedEffect. - Coroutine из event callback —
rememberCoroutineScope. - Listener/resource с cleanup —
DisposableEffect. - Публикация после успешной composition —
SideEffect. - Свежая dependency без restart —
rememberUpdatedState. - Внешний async источник → State —
produceState. - Snapshot state → Flow —
snapshotFlow.
Compose UI обрабатывает кадр несколькими фазами:
- Composition — определение структуры и параметров.
- Layout:
- measurement;
- placement.
- Draw.
Runtime отслеживает state reads отдельно по фазам и может инвалидировать только нужную работу.
Нет.
- изменение структуры обычно требует composition и далее необходимых фаз;
- изменение размера — layout и draw;
- изменение позиции через placement lambda — placement и draw;
- изменение цвета в draw lambda — draw.
Некоторые layouts, например LazyColumn, BoxWithConstraints, SubcomposeLayout, выполняют subcomposition во время layout.
Modifier.offset {
IntOffset(
x = 0,
y = scrollOffset.value,
)
}Lambda-версия читает state в placement phase. Если передать готовый Dp через обычный offset(y = ...), state будет прочитан в composition.
Для draw:
Modifier.drawBehind {
drawRect(animatedColor.value)
}Изменение цвета может вызвать только redraw.
Нет. Это корректно, только если изменение действительно не влияет на структуру или размер. Draw translation не меняет layout bounds, соседей, scroll range и иногда hit testing. Если геометрия должна участвовать в layout, нужна layout phase.
Запись в state после его чтения в той же или более ранней фазе может создать цикл: composition → write → composition либо layout → write → layout.
Частая ошибка — безусловно писать координаты из onGloballyPositioned в state, который влияет на тот же layout. Сначала ищут решение через constraints, alignment lines или layout API.
Recomposition — повторное выполнение Kotlin-кода, а redraw — повторная отрисовка. Они не являются синонимами. Счётчик recomposition без frame trace не показывает источник jank.
Modifier — упорядоченная immutable chain элементов, которые участвуют в layout, draw, input, focus, semantics и parent data. Порядок — часть поведения.
Modifier
.padding(16.dp)
.background(Color.Red)Фон рисуется внутри padding.
Modifier
.background(Color.Red)
.padding(16.dp)Фон включает padding area.
То же относится к clickable, clip, size, offset, graphicsLayer и semantics.
Публичный composable обычно:
- принимает
modifier: Modifier = Modifier; - применяет его к корневому UI element;
- не заменяет modifier своим;
- не добавляет неожиданную внешнюю padding/size семантику;
- сохраняет порядок caller modifier относительно внутренней реализации.
@Composable
fun UserCard(
user: UserUi,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier) {
// ...
}
}Да. Modifier immutable, поэтому длинную неизменную цепочку можно hoist и передавать повторно. Это уменьшает allocation/comparison work, особенно в animation или большом lazy list.
Не следует hoist-ить modifier, зависящий от element-specific state, density или scope-specific parent data.
Современный низкоуровневый API custom modifiers:
ModifierNodeElementописывает конфигурацию;Modifier.Nodeхранит долгоживущее состояние;- node реализует capability-интерфейсы.
Примеры:
DrawModifierNode;LayoutModifierNode;PointerInputModifierNode;SemanticsModifierNode;ParentDataModifierNode;CompositionLocalConsumerModifierNode;DelegatingNode.
Node:
- не добавляет composable-группу для каждого modifier;
- переиспользуется через
update; - имеет явный lifecycle
onAttach/onDetach/onReset; - может инвалидировать конкретную фазу;
- обычно создаёт меньше аллокаций.
composed остаётся legacy/специальным инструментом, но не должен быть default для нового custom modifier.
fun Modifier.fastBorder(
color: Color,
width: Dp,
): Modifier = this then BorderElement(color, width)
private data class BorderElement(
val color: Color,
val width: Dp,
) : ModifierNodeElement<BorderNode>() {
override fun create() = BorderNode(color, width)
override fun update(node: BorderNode) {
node.color = color
node.width = width
}
}
private class BorderNode(
var color: Color,
var width: Dp,
) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
drawContent()
drawRect(
color = color,
style = Stroke(width.toPx()),
)
}
}Корректные equals/hashCode element помогают определить обновление. Node нельзя переиспользовать одновременно в нескольких chains.
weight, align и похожие scoped modifiers не измеряют ребёнка сами. Они передают parent-specific metadata layout-родителю. Поэтому Modifier.weight доступен только в подходящем scope и бессмысленен вне соответствующего parent.
Основное правило измерения:
Parent передаёт constraints, child выбирает допустимый размер, parent размещает child.
Constraints задают:
minWidth ≤ width ≤ maxWidth
minHeight ≤ height ≤ maxHeight
Размер в Compose не запрашивается ребёнком у родителя. Родитель контролирует диапазон, а ребёнок возвращает Placeable.
Во время measurement:
- parent получает constraints;
- измеряет children с выбранными constraints;
- выбирает собственный размер;
- возвращает placement block.
Во время placement parent назначает координаты children.
State read в placement block может перезапустить placement без полного measurement.
sizeпытается установить размер, но уважает входящие constraints.requiredSizeзаставляет child измериться указанным размером, даже если он выходит за constraints.
При requiredSize parent всё равно может видеть coerced size в допустимых границах, а выходящий content будет размещён по правилам modifier. Это не означает, что parent действительно выделил дополнительное пространство.
В обычном layout pass — нет. Compose следует single-pass measurement и защищает контракт runtime-проверкой.
Если layout действительно требует другой стратегии, применяют:
- intrinsic measurements;
SubcomposeLayout;- специализированный lazy/container API;
- lookahead для планирования будущей геометрии.
Нельзя просто вызвать measure() повторно.
@Composable
fun SimpleColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Layout(
modifier = modifier,
content = content,
) { measurables, constraints ->
val childConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
)
val placeables = measurables.map {
it.measure(childConstraints)
}
val width = constraints.constrainWidth(
placeables.maxOfOrNull { it.width } ?: 0,
)
val height = constraints.constrainHeight(
placeables.sumOf { it.height },
)
layout(width, height) {
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(0, y)
y += placeable.height
}
}
}
}placeRelative учитывает RTL. Arithmetic должен учитывать Constraints.Infinity, иначе возможен overflow.
Intrinsics позволяют запросить предполагаемые размеры до фактического measure:
minIntrinsicWidth;maxIntrinsicWidth;minIntrinsicHeight;maxIntrinsicHeight.
Row(Modifier.height(IntrinsicSize.Min)) {
Text("Left", Modifier.weight(1f))
VerticalDivider(Modifier.fillMaxHeight())
Text("Right", Modifier.weight(1f))
}Intrinsic pass добавляет работу. Для custom layout default intrinsic implementation может быть приблизительной. Subcomposition-based layouts вроде lazy containers обычно не могут заранее знать полный набор children.
Предоставляет constraints в composable content и выполняет subcomposition во время layout. Полезен, когда структура UI действительно зависит от доступного места.
Не применяйте его в каждом item только для чтения ширины: subcomposition имеет overhead. Для adaptive screen-level решений чаще подходят window/adaptive APIs.
Позволяет сначала измерить один content, а затем скомпоновать другой на основе результата. Используется внутри сложных containers, lazy layouts и компонентов с зависимой структурой.
Это мощный, но дорогой и сложный API. Для обычного custom layout достаточно Layout.
Child может передать parent логическую линию, например first/last text baseline. Parent использует её для выравнивания элементов с различной внутренней геометрией.
Это надёжнее ручного измерения текста через глобальные координаты.
Lookahead APIs позволяют узнать будущие layout bounds и анимировать переход к ним. Они полезны для shared/structural layout transitions, но требуют понимания обычной и lookahead measurement/placement.
Если достаточно простой draw transform, сложный lookahead layout не нужен.
Lazy layouts композируют и размещают только нужные элементы плюс служебное окно prefetch/reuse. Они не являются RecyclerView API, но решают похожую задачу через subcomposition и сохранение item identity.
Основные containers:
LazyColumn/LazyRow;LazyVerticalGrid/LazyHorizontalGrid;- staggered grid;
HorizontalPager/VerticalPager;- custom lazy layout APIs для специальных случаев.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}Предоставляет:
- текущий индекс и offset;
layoutInfo;scrollToItem;animateScrollToItem;- состояние прокрутки.
Поля scroll state меняются часто. Не читайте их высоко в composition без необходимости; используйте derivedStateOf, snapshotFlow или layout/draw lambda.
LazyColumn {
items(
items = users,
key = { user -> user.id },
) { user ->
UserRow(user)
}
}Key связывает identity с business entity и помогает:
- сохранить remembered/saveable state;
- корректно обработать reorder;
- переиспользовать composition;
- анимировать перемещение;
- не перенести state на другой item.
Для rememberSaveable key должен поддерживаться механизмом saved state.
LazyColumn {
items(
items = feed,
key = { it.id },
contentType = {
when (it) {
is FeedItem.Article -> "article"
is FeedItem.Ad -> "ad"
is FeedItem.Header -> "header"
}
},
) { item ->
FeedRow(item)
}
}Runtime переиспользует item composition между совместимыми структурами. contentType особенно полезен для гетерогенной ленты.
Item modifier может анимировать появление, исчезновение и placement при изменении набора. Для корректного сопоставления нужны стабильные keys.
Анимация не исправит неверную identity. При key по индексу runtime не понимает, какая бизнес-сущность реально переместилась.
LazyColumn {
items(users.sortedBy { it.name }) { /* ... */ }
}Сортировка выполняется при достижении composable и создаёт новый список. Подготовьте данные:
- в ViewModel/Flow;
- через
remember(users)для небольшого локального случая; - в domain/data layer, если это часть запроса.
В hot UI path не должно быть тяжёлой фильтрации, сортировки или mapping.
- key по индексу;
- duplicate или случайный key;
- элементы с нулевой высотой — runtime может скомпоновать слишком много;
Column.verticalScrollдля большой коллекции;- вложенные scrollables одного направления без ограниченного размера;
- ViewModel передаётся каждому item;
- image request зависит от позиции, а не ID;
- placeholder сильно отличается по размеру от content;
- чтение полного
layoutInfoв широком composition scope; - отсутствие
contentTypeв сложной ленте.
Paging Compose предоставляет lazy integration с LazyPagingItems.
Senior должен учитывать:
itemKeyиitemContentType;- load states;
- refresh против append/prepend error;
- placeholder size;
- retry;
- cached data lifetime;
- scroll restoration;
- недопустимость бизнес side effect из item composition.
rememberPagerState хранит current/settled/target page и scroll progress. Для analytics лучше snapshotFlow { pagerState.settledPage }, а не side effect в каждом page composable.
Количество страниц передаётся как lambda, чтобы state видел актуальное значение без ненужного пересоздания.
Compose drawing построен вокруг DrawScope и Canvas. Draw phase может обновляться независимо от composition, что важно для частых визуальных изменений.
drawBehind— рисует перед content.drawWithContent— позволяет управлять порядком и вызовомdrawContent().drawWithCache— кешируетPath,Brushи другие объекты по size/state dependencies.graphicsLayer— transform, alpha, clipping и compositing на уровне layer.
Modifier.drawWithCache {
val path = Path().apply {
moveTo(0f, size.height)
lineTo(size.width / 2f, 0f)
lineTo(size.width, size.height)
close()
}
onDrawBehind {
drawPath(path, Color.Blue)
}
}Cache сбрасывается при изменении размера или snapshot state, прочитанного в cache block.
Когда создаются дорогие объекты:
Path;Brush;Shader;- text measurement;
- сложная geometry.
Для простого drawRect(color) cache добавит сложность без выгоды.
Modifier.graphicsLayer {
translationY = offset
scaleX = scale
scaleY = scale
alpha = alphaValue
}Transform влияет на draw, но layout продолжает считать element находящимся в старых bounds. Siblings и scroll range не меняются.
Layer может потребовать offscreen buffer. Избыточные layers, clipping, blend modes и alpha увеличивают память/compositing work.
- одно target value —
animate*AsState; - несколько связанных значений —
updateTransition; - imperative control, velocity, cancellation —
Animatable; - enter/exit —
AnimatedVisibility; - смена content —
AnimatedContent; - бесконечная —
rememberInfiniteTransition; - изменение размера —
animateContentSize; - lazy item —
animateItem.
Выбор определяется ownership и interrupt semantics, а не краткостью API.
Предоставляет:
- suspend animation;
- взаимное исключение — новая анимация отменяет старую;
- velocity continuity;
- bounds;
snapTo,animateTo,animateDecay.
Запускается из effect или event coroutine, не из composable body.
Draw transform обычно дешевле, потому что не требует remeasure siblings. Но он неверен, если:
- соседи должны сдвинуться;
- scroll range должен измениться;
- hit bounds должны соответствовать новому размеру;
- accessibility geometry должна измениться.
Performance не должен ломать semantics.
Анимация должна учитывать системный animation scale и accessibility expectations. Долгая бесконечная анимация вне видимого экрана тратит CPU/GPU и battery.
alpha = 0f не удаляет element: он остаётся в composition, layout, input и semantics. Для настоящего исчезновения используйте conditional composition или AnimatedVisibility.
Input в Compose проходит через modifier nodes и несколько pointer-event passes. Высокоуровневые компоненты предпочтительнее ручной обработки: они уже реализуют semantics, focus, keyboard и accessibility.
clickable, toggleable, selectable, draggable предоставляют готовые:
- gesture recognition;
- interaction source;
- indication;
- semantics;
- keyboard/accessibility behavior.
pointerInput нужен для custom gesture.
Modifier.pointerInput(itemId) {
detectTapGestures(
onLongPress = onLongPress,
)
}Keys управляют перезапуском handler coroutine. Для свежего callback без restart может потребоваться rememberUpdatedState.
Несколько handlers могут претендовать на события. Важно понимать consumption, nested scroll и приоритет высокого/низкого уровня.
Не комбинируйте несколько top-level detector calls последовательно в одном pointerInput block, если первый никогда не завершается. Используйте отдельные modifiers или низкоуровневый event loop.
Основные инструменты:
FocusRequester;focusRequester;focusable;focusProperties;onFocusChanged;LocalFocusManager.
requestFocus() является side effect и выполняется из event/effect, а не безусловно в composable body.
BasicText— foundation primitive.- Material
Textинтегрирован с typography/colors/defaults.
Text layout включает font resolving, shaping, bidi, line breaking и glyph layout. Нельзя считать, что один Unicode code unit равен одному символу или glyph.
Современный API использует TextFieldState, содержащий:
- text;
- selection;
- IME composition.
InputTransformation преобразует ввод до сохранения, OutputTransformation меняет только отображение. Это надёжнее асинхронного фильтрования старого value-based callback, которое может конфликтовать с IME.
val textFieldState = rememberTextFieldState()
TextField(
state = textFieldState,
inputTransformation = InputTransformation.maxLength(32),
)Проверяйте доступность конкретных transformations в версии Compose Foundation.
- не задавайте жёсткую высоту без проверки большого font scale;
- учитывайте RTL, locale и fallback fonts;
- кешируйте сложный
AnnotatedString, если его построение дорого; - для custom draw используйте
rememberTextMeasurer; - не записывайте state безусловно из
onTextLayout; - не режьте строки по индексам без понимания grapheme clusters.
Compose предоставляет WindowInsets API и modifiers:
- status/navigation bars padding;
imePadding;- consume/exclude insets;
- safe drawing/content/gestures.
Edge-to-edge screen должен определить, кто consume-ит каждый inset. Слепое добавление нескольких padding modifiers приводит к двойным отступам.
CompositionLocal передаёт tree-scoped значение без явного параметра через каждый уровень. Это ambient context, а не универсальный dependency injection container.
compositionLocalOfотслеживает конкретные readers; изменение инвалидирует их.staticCompositionLocalOfне отслеживает чтения; изменение provider invalidates весь subtree.
Static вариант подходит для практически неизменяемого значения, например набора design tokens.
Для cross-cutting tree-scoped данных:
- theme/colors/typography;
- density/layout direction;
- content alpha;
- локальная policy компонента;
- analytics context, если контракт действительно tree-scoped.
Repository, use case и ViewModel лучше передавать через явный owner/DI. Иначе зависимости становятся скрытыми и тесты труднее.
Используйте semantic tokens:
surfaceCritical, а неred500;textSecondary, а неgray600;- semantic spacing/shape/typography;
- light/dark/high-contrast mapping.
Это позволяет менять visual implementation без изменения component API.
Собственные AppButton, AppCard, AppTextField помогают:
- централизовать tokens;
- поддерживать accessibility;
- ограничить варианты;
- обновлять design system.
Не делайте wrapper с десятками boolean flags. Лучше sealed/enum variant и slot APIs.
Dynamic color является источником схемы, но design system должен:
- иметь fallback;
- проверять contrast;
- поддерживать brand-critical colors;
- учитывать светлую/тёмную тему;
- не кодировать смысл только цветом.
Compose Navigation управляет back stack и destination lifecycle. UI-компоненты не должны знать NavController, если им достаточно выразить intent callback.
Передавайте минимальные устойчивые identifiers:
userId;orderId;- filter enum;
- небольшие primitive route arguments.
Не передавайте целый DTO:
- он устаревает;
- Bundle ограничен;
- deep link не имеет объекта;
- process recreation требует восстановления;
- destination должен загрузить актуальные данные.
Современные Navigation Compose APIs поддерживают типизированные route-модели. Это уменьшает строковые ошибки и централизует сериализацию аргументов.
Типобезопасность route не отменяет:
- валидацию внешнего deep link;
- проверку авторизации;
- обработку отсутствующего ID;
- идемпотентность действий.
- destination scope — один экран;
- nested graph scope — flow из нескольких экранов;
- activity scope — действительно общая координация.
Слишком широкий scope удерживает state и зависимости дольше нужного. После удаления back stack entry owner должен очищаться.
ProductScreen(
onProductClick = { id ->
navController.navigate(ProductRoute(id))
},
)Screen выражает пользовательское намерение, route решает механизм навигации. Это упрощает previews, UI tests и переиспользование.
Back — progress-aware gesture, а не только callback. Custom screen transitions должны:
- корректно реагировать на progress/cancel/commit;
- не ломать системную навигацию;
- сохранять state;
- учитывать nested navigation.
Конкретные APIs зависят от используемой версии navigation/adaptive библиотек.
Responsive UI меняет размеры и arrangement. Adaptive UI может менять способ взаимодействия:
- bottom navigation → rail/drawer;
- one pane → list-detail;
- modal → supporting pane.
Решение принимается по текущему window size/posture, а не по ярлыку «tablet». Планшет может находиться в compact split-screen.
Полезные направления API:
- current window adaptive info;
- navigation suite scaffold;
- list-detail pane scaffold;
- supporting pane scaffold;
- posture/hinge-aware layout.
Нужно тестировать runtime resize, fold/unfold, multi-window, keyboard/mouse и большие font scales.
Если stateful child переезжает между условными ветками, его positional identity может измениться.
Решения:
- hoist state выше adaptive structure;
- держать state holder в стабильном месте;
- применять scaffold, сохраняющий pane identity;
- использовать stable keys;
movableContentOfтолько для специального переноса самой Composition.
Semantics tree — логическое представление UI для accessibility, testing, autofill и других сервисов. Оно не обязано совпадать с layout tree.
Несколько визуальных children могут образовывать одну логическую control:
Modifier.semantics(mergeDescendants = true) {}Button, clickable, ListItem и другие компоненты часто уже merge descendants. Вложенная интерактивная control обычно остаётся отдельной.
Compose tests по умолчанию часто работают с merged tree; при диагностике нужен unmerged tree.
contentDescription;stateDescription;role;selected,disabled;heading;liveRegion;progressBarRangeInfo;- collection/item info;
- traversal group/index;
- custom actions.
Не дублируйте видимый текст через contentDescription: screen reader может озвучить его дважды.
toggleable даёт input, keyboard, role и state semantics. Ручной semantics { onClick } не создаёт полноценное pointer/keyboard behavior.
Row(
Modifier
.toggleable(
value = checked,
role = Role.Switch,
onValueChange = onCheckedChange,
)
.padding(16.dp),
) {
Text("Notifications")
}- hiding API убирает декоративный/redundant node из accessibility;
clearAndSetSemantics {}очищает semantics узла и descendants для потребителей;clearAndSetSemantics { ... }заменяет их новым контрактом.
Агрессивная очистка может сломать tests, autofill и будущие accessibility services.
- TalkBack и Switch Access;
- keyboard/D-pad;
- touch target;
- font scale;
- RTL;
- contrast/high contrast;
- state, выраженный не только цветом;
- traversal order;
- custom gesture alternative.
Миграция редко происходит одним шагом. Compose поддерживает View внутри Composition и ComposeView внутри legacy hierarchy.
AndroidView(
factory = { context ->
LegacyChartView(context)
},
update = { view ->
view.setData(data)
view.isEnabled = enabled
},
onRelease = { view ->
view.releaseResources()
},
)factoryсоздаёт View;updateсинхронизирует её с Compose state;onReleaseосвобождает ресурсы.
Не создавайте View через remember вне factory.
Для переиспользования нужен overload с onReset. Reset должен очистить item-specific state/listeners перед binding новой сущности. onRelease освобождает окончательные ресурсы.
Особенно важно для WebView, player, map и сложных custom Views.
composeView.apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
)
setContent {
AppTheme {
Screen()
}
}
}Composition должна быть связана с lifecycle View Fragment, а не самого Fragment. Иначе уничтоженная View hierarchy может удерживаться.
- два независимых owner одного state;
- listener хранит stale callback;
- View пересоздаётся при recomposition;
- отсутствует cleanup adapter/WebView/player;
- неверная composition disposal strategy;
- несовместимые nested scroll/focus/insets;
- дублирующиеся semantics;
- View передаётся дольше Activity lifecycle.
Compose test взаимодействует не с composable-функциями, а с semantics nodes. Поэтому accessibility-friendly API обычно одновременно лучше тестируется.
- Unit:
- reducer;
- state holder/ViewModel;
- Flow/coroutines.
- Component Compose tests:
- state → UI;
- action → callback;
- semantics.
- Integration:
- navigation;
- fake repository;
- restoration/deep links.
- Screenshot:
- themes/locales/window sizes/font scales.
- Macrobenchmark:
- startup;
- scroll;
- frame timing.
Предпочтительный порядок:
- видимый text;
- role/content description;
- semantic property;
testTag, когда устойчивого пользовательского признака нет.
Тест, привязанный к внутренней hierarchy, ломается при harmless refactoring.
Compose test rule ждёт idleness runtime, но не знает о любой внешней фоновой системе. Для coroutines/Flow используют контролируемый dispatcher и fake data source.
mainClock позволяет управлять animation/delay, но нужно понимать, какая работа привязана к frame clock, а какая — к coroutine scheduler.
StateRestorationTester проверяет rememberSaveable:
- content создаётся;
- state изменяется;
- эмулируется save/restore;
- проверяется восстановленное UI.
Критический process-death flow дополнительно тестируют на уровне Activity/navigation/data restoration.
Preview — development tool, не тест. Полезная матрица:
- loading/content/empty/error;
- light/dark;
- compact/expanded;
- длинные строки;
- RTL;
- большой font scale;
- различные данные через
PreviewParameterProvider.
Previewable Screen не должен требовать настоящий ViewModel, network или production DI.
Хороши для visual regression design system, но чувствительны к:
- fonts;
- rendering backend;
- OS/device;
- animation;
- locale.
Они дополняют semantics/behavior tests, а не заменяют их.
Главная ошибка — оптимизировать количество recompositions вместо пользовательского результата. Кадр может тормозить из-за layout, draw, image decoding, GC, shader compilation, I/O или слишком большой main-thread работы.
- Воспроизвести в release/profileable build.
- Зафиксировать конкретный сценарий и metric.
- Измерить Macrobenchmark/frame timing.
- Изучить Perfetto/system trace.
- Определить фазу и thread bottleneck.
- Проверить recomposition/skip counts, если подозрение на composition.
- Проверить compiler stability reports.
- Сформулировать одну гипотезу.
- Внести локальное изменение.
- Повторить измерение.
Debug:
- содержит tooling/check overhead;
- иначе оптимизирован;
- может не использовать R8;
- имеет другую compilation/JIT картину;
- не отражает Baseline Profiles.
Layout Inspector полезен для причины recomposition, но не заменяет release benchmark.
- state read слишком высоко;
- тяжёлая фильтрация/сортировка в composition;
- новые unstable collections;
- неверные lazy keys/content types;
- аллокации в draw/animation loop;
- intrinsics/subcomposition без необходимости;
- избыточные graphics layers;
- image decoding на main;
- layout feedback loop;
- бесконечная animation вне экрана.
Для часто меняющихся значений:
offset {}вместо готового offset;graphicsLayer {}для draw transforms;drawBehind/drawWithContentдля visual state;derivedStateOfдля threshold;snapshotFlowдля async observation.
Переносите read только в фазу, соответствующую реальной семантике.
Baseline Profile помогает ART заранее компилировать критические paths:
- startup;
- navigation;
- scroll;
- common UI interactions.
Он уменьшает warmup/JIT cost, но не исправляет плохой алгоритм, blocking I/O или огромный layout.
Количество вызовов не равно стоимости. Десять дешёвых recompositions могут быть незаметны, одна сортировка большого списка — сорвать кадр.
Смотрите:
- duration;
- skipped groups;
- layout/draw;
- allocations/GC;
- frame deadline;
- CPU/GPU trace.
При доказанной проблеме:
- включить/использовать strong skipping;
- перестать создавать новый unstable object;
- перейти на immutable/persistent model;
- стабилизировать API;
- применить честную stability annotation/config;
- разделить composable boundaries.
Нельзя ложно поставить @Stable: stale UI хуже лишней recomposition.
Хороший Compose API выражает ownership и intent, минимизирует невозможные комбинации и остаётся тестируемым.
Типично:
- обязательные данные;
- обязательные callbacks;
modifier: Modifier = Modifier;- optional параметры;
- trailing content slot.
Конкретные conventions команды важнее механического правила, но modifier должен быть легко заметен и применяться к root.
@Composable
fun AppCard(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
leading: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
// ...
}Slots позволяют caller управлять content без передачи десятков styling flags. Scope receiver можно использовать для parent data и ограниченного DSL.
Передавайте минимальные данные и callbacks. Иначе:
- dependency скрыта;
- preview требует DI;
- компонент нельзя применить с другим owner;
- child получает слишком широкие права;
- testing усложняется.
ViewModel получают на route/screen boundary.
AppButton(
primary = true,
destructive = true,
compact = false,
loading = true,
)Создаёт противоречивые комбинации. Используйте:
ButtonVariant;ButtonSize;- явный
loading; - slots для icon/content.
Callback должен описывать намерение:
onRetry;onItemClick(id);onQueryChange.
Не передавайте callback onNavigate(navController) или onMutableStateChanged, раскрывающий детали реализации.
Plain state holder полезен, когда компонент имеет:
- несколько взаимосвязанных state;
- suspend commands;
- сложные UI transitions;
- отдельный unit-testable контракт.
@Stable
class SearchBarState internal constructor(
initialQuery: String,
) {
var query by mutableStateOf(initialQuery)
private set
fun updateQuery(value: String) {
query = value
}
}
@Composable
fun rememberSearchBarState(
initialQuery: String = "",
): SearchBarState = remember {
SearchBarState(initialQuery)
}Нужно продумать keys, Saver и честность @Stable.
data class UiState(
val items: MutableList<Item>,
)
var state by mutableStateOf(UiState(mutableListOf()))
state.items += newItemИзменилась внутренняя обычная коллекция, setter state не вызван. Compose не получил snapshot write.
Решение начинается не с copy, а со смены типа поля: MutableList в модели состояния — сама ошибка. Пока поле объявлено как MutableList<Item>, вариант с copy даже не скомпилируется, потому что MutableList + Item возвращает List:
data class UiState(
val items: List<Item>, // было MutableList<Item>
)
state = state.copy(items = state.items + newItem)или локальная SnapshotStateList (mutableStateListOf()) с понятным owner — тогда мутация сама является snapshot write и copy не нужен. Выбор между двумя вариантами и есть содержательная часть ответа: immutable-модель проще для skipping и diff'а, SnapshotStateList дешевле при частых точечных вставках в длинный список.
LaunchedEffect(RequestParams(query)) {
repository.search(query)
}Если key object создаётся заново и не имеет подходящего equality, effect получает новую identity при каждой recomposition. Используйте стабильные primitive/data keys, реально определяющие операцию:
LaunchedEffect(query) {
repository.search(query)
}Бизнес search обычно лучше реализовать в ViewModel через Flow/debounce/flatMapLatest.
LazyColumn {
items(users) { user ->
var expanded by remember { mutableStateOf(false) }
UserRow(user, expanded)
}
}После reorder positional identity может сопоставиться с другой сущностью. Нужен:
items(
items = users,
key = { it.id },
) { user ->
// ...
}Если expanded — business state, его лучше hoist по ID.
val fullName by remember {
derivedStateOf { "$firstName $lastName" }
}Здесь не «избыточность», а бага корректности, и это важно проговорить именно так. Если firstName/lastName — обычные параметры, а не snapshot state, то remember без ключей вычислит блок один раз, а derivedStateOf не сможет отследить чтение не-snapshot значений. В результате fullName навсегда останется первой версией: классический stale UI, который не воспроизводится в превью и вылезает на втором экране.
Правильно так:
val fullName = "$firstName $lastName" // параметры: просто выражение
val fullName by remember(firstName, lastName) { ... } // если нужно кэшировать дорогое вычислениеderivedStateOf уместен только тогда, когда входы — snapshot state и производное значение меняется реже входов (канонический пример — listState.firstVisibleItemIndex > 0): он гасит лишние инвалидации. Если результат меняется синхронно с входами, выигрыша нет, а лишняя косвенность есть.
val presenter = remember {
Presenter(repository, userId)
}При смене repository или userId calculation не повторяется. Нужны keys:
val presenter = remember(repository, userId) {
Presenter(repository, userId)
}Либо owner presenter находится вне Composition.
@Composable
fun Screen(state: State) {
analytics.screenShown()
Content(state)
}Side effect находится в composable body и выполняется при каждой recomposition. Используйте effect с корректной identity или перенесите screen analytics в navigation/lifecycle layer.
Alpha меняет drawing, но node остаётся в semantics и input. Для удаления используйте условную Composition/AnimatedVisibility, либо отдельно задайте корректную semantics policy, если невидимый node действительно должен оставаться.
Scale/translation не меняют layout bounds. Parent разместил siblings по старой геометрии. Если они должны раздвигаться, анимируйте layout size/placement.
val state by viewModel.state.collectAsState()В Android screen collection не учитывает Lifecycle и может поддерживать upstream в фоне. Обычно нужен:
val state by viewModel.state.collectAsStateWithLifecycle()Для multiplatform UI collectAsState может быть правильным.
Его lifetime связан с call site в Composition. При уходе UI scope отменяется. Business operation, которая должна пережить configuration change, принадлежит viewModelScope/repository.
@Immutable
data class FeedState(
val items: MutableList<Item>,
)Аннотация обещает неизменность, которую тип нарушает. Compiler может принять skipping decision и не показать внутреннюю мутацию. Аннотации stability — unsafe contract при неправильном применении.
Не отправлять событие из item composition и не читать offset высоко:
LaunchedEffect(listState) {
snapshotFlow {
listState.firstVisibleItemIndex
}
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it }
.collect {
analytics.onListScrolled()
}
}Block snapshotFlow чистый, side effect находится в collector.
Senior-ответ:
- воспроизвести release/profileable;
- Macrobenchmark scroll scenario;
- Perfetto frame trace;
- определить CPU/GPU/composition/layout/draw/image bottleneck;
- проверить keys/content types/item size;
- проверить allocations и image decoding;
- проверить state read/stability;
- изменить одну причину;
- повторить benchmark.
Ответ «добавлю remember» недостаточен.
Нет. Это штатный механизм поддержания UI. Плохо:
- выполнять дорогую работу внутри;
- читать часто меняющийся state слишком высоко;
- создавать feedback loop;
- ломать identity;
- вызывать ненужные layout/draw;
- пропускать frame deadline.
Оптимизируют измеренный пользовательский bottleneck, а не сам факт повторного вызова.
Compose владеет ephemeral UI mechanics и composition-bound resources. ViewModel владеет screen state и бизнес-операциями, которые должны переживать configuration change. Data layer владеет durable source of truth.
Граница определяется lifetime и ответственностью, а не тем, можно ли технически вызвать API.
Если пользователь должен увидеть результат после recreation, это state. Если значение имеет смысл только активному consumer и допустима потеря, это event. Если потеря недопустима, нужен acknowledgement/durable representation.
Название SharedFlow не решает семантику доставки.
Хорошая abstraction:
- сохраняет modifier contract;
- имеет ясный state owner;
- выражает intent callbacks;
- поддерживает semantics;
- допускает slots;
- не скрывает дорогой side effect;
- не требует ViewModel внутри reusable UI;
- измеримо не ухудшает performance.
Когда стандартные primitives не выражают необходимое поведение, а задача естественно относится к layout/draw/input node.
Не следует писать custom low-level API только ради «меньшего числа composables». Высокоуровневые primitives обычно лучше протестированы, доступны и оптимизированы.
Достаточная цепочка:
Compose Compiler transforms @Composable
→ Composer records groups/slots
→ snapshot reads bind State to scopes/phases
→ writes create invalidations
→ Recomposer schedules work
→ affected scopes execute
→ unchanged groups may be skipped
→ Applier updates UI nodes
→ layout and draw run only as needed
Senior должен дополнить её identity/keys, stability и phase-specific reads.
- declarative model, Composition и recomposition;
- compiler transformation, Composer/Recomposer/SlotTable;
- restartable, skippable, strong skipping;
- positional identity,
key, lazy keys; - snapshot system и mutation policies;
remember,rememberSaveable, Saver;derivedStateOf,snapshotFlow;- state lists/maps/primitive state;
- state hoisting, UDF, Route/Screen;
- lifecycle-aware Flow collection;
- effect APIs и key semantics;
- composition/layout/draw phases;
- constraints и custom layout;
- modifier order и
Modifier.Node; - lazy
contentType, Paging, pager; - drawing, graphics layers и animation choice;
- text/input/focus/gestures;
- CompositionLocal и design system;
- navigation/adaptive UI;
- semantics/accessibility;
- View interop;
- testing и performance diagnostics.
- Почему mutable list внутри state не обновляет UI.
- Как state read определяет invalidation scope.
- Почему key — correctness, а не только performance.
- Когда effect restart нужен, а когда
rememberUpdatedState. - Чем
rememberотличается отrememberSaveableи ViewModel. - Почему strong skipping не делает модель immutable.
- Как перенести read из composition в layout/draw.
- Почему modifier order меняет hit area и drawing.
- Как устроить reusable stateless component.
- Как восстановить screen после process death.
- Как расследовать lazy-list jank.
- Как протестировать state restoration и semantics.
Для любой Compose-конструкции раскройте:
- какую проблему она решает;
- кто владеет её state/resource;
- какой lifetime;
- что является key/identity;
- какая фаза инвалидируется;
- что происходит при cancellation/recreation;
- какие есть performance и correctness traps;
- как проверить поведение тестом или trace.
- Thinking in Compose
- Lifecycle of composables
- State and Jetpack Compose
- State hoisting
- Side effects
- Compose phases
- Stability
- Strong skipping
- Performance best practices
- Custom layouts
- Custom modifiers
- Lazy layouts
- Accessibility and semantics
- Navigation Compose
- Compose testing
- View interoperability
- Compose Runtime design: How Composition Works
- Compose Runtime release notes