Запись в обратном порядке

Compose выполняет кадр в три строго упорядоченные, прямоточные фазы:

Три последовательных этапа: 1. Композиция, 2. макет; г) Рисунок
Рисунок 1. Три этапа фрейма Compose
  1. Композиция: Выполняет @Composable функции для создания и обновления дерева пользовательского интерфейса.
  2. Макет. Измеряет дочерние элементы и размещает их.
  3. Рисование. Отправляет команды рисования на холсте для отрисовки пикселей на экране.

Каждый раз, когда во время любого этапа считывается состояние Compose State, Compose автоматически регистрирует зависимость между этим состоянием и соответствующим этапом.


Что значит "запись назад"?

Обратная запись происходит, когда состояние изменяется на более позднем этапе (или в подчиненной области видимости – то есть в компонуемой области видимости, которая выполняется позже в том же проходе компоновки), чем оно было прочитано. В результате Compose приходится выполнять повторную компоновку, планируя повторный запуск более раннего этапа или компонуемого элемента. Обратная запись – это неоптимизированный цикл повторной композиции.

Пример обратной записи через фазы цикла перекомпоновки
Рисунок 2. Пример обратной записи через фазы цикла перекомпоновки

Последствия обратной записи

Обратная запись не всегда приводит к сбоям, но она неэффективна и может негативно повлиять на производительность приложения:

  • Дополнительная отрисовка кадров и пропущенные кадры. Обратная запись заставляет Compose выполнять избыточные проходы композиции по последовательным кадрам, расходуя ресурсы ЦП и графического процессора и потенциально вызывая рывки.
  • Проблемы с первым кадром. Если для определения окончательных размеров или состояния компонента требуется обратная запись, первый кадр будет отрисован с недействительными, стандартными или неопределенными данными (например, с нулевым размером или неправильным смещением). Это приводит к заметным визуальным скачкам или миганию макета при отрисовке второго кадра.
  • Бесконечные циклы перекомпоновки. Если изменение состояния влияет на размер макета, а размер макета постоянно записывает новое значение в состояние, может возникнуть бесконечный цикл кадров, в котором экран постоянно перекомпоновывается в каждом кадре, не стабилизируясь.

Переход к следующей фазе

Изменение статуса всегда должно происходить по порядку:

Этап чтения Контекст для написания Допустимо? Почему
Макет (Modifier.offset { }) Композиция Да Композиция обновляет состояние → макет считывает его позже в том же кадре без повторной композиции.
Ничья (graphicsLayer { }, drawBehind { }) Композиция Да Композиция обновляет состояние → отрисовка считывает его на последнем этапе. Композиция и макет пропускаются.
Рисование Вид страницы "Обзор" Да Макет обновляет состояние, а отрисовка считывает его.
Композиция Обратный вызов события (onClick, onValueChange) приводит к изменению состояния. Примечание. Обратные вызовы макета не считаются событиями. Да Событие изменяет состояние, которое используется для создания композиции. Если событие происходит за пределами кадра (не в композиции, макете или рисунке), это допустимо.
Композиция Копрограмма (LaunchedEffect) Да, но с осторожностью Асинхронно обновляет состояние в ответ на события жизненного цикла. Записи из эффектов могут быть действительными, но могут указывать на неэффективное наложение состояний. По возможности их следует избегать.
Место размещения (в макете) Измерение (в макете) Да В макете допустимо сначала обновить состояние, а затем прочитать его в месте размещения.
Измерение (в макете) Место размещения (в макете) Нет – запись назад Запись в состояние размещения, которое затем считывается, приводит к циклу повторного измерения.
Композиция Макет (onSizeChanged, LayoutModifier) Нет – обратная запись. Макет делает композицию недействительной → цикл повторной композиции.
Композиция Ничья (drawWithContent, Canvas) Нет – обратная запись При отрисовке композиция становится недействительной → цикл повторной композиции.

Сочетания фаз: назад или вперед

Ниже приведены примеры обратной записи в Compose и способы ее устранения.

Справа налево: чтение в режиме композиции, запись в режиме макета

  • Что происходит. Композиция считывает componentHeight, чтобы определить, какой интерфейс нужно создать. На более позднем этапе фрейма фаза макета измеряет или размещает представления и записывает новое значение в componentHeight (например, с помощью onSizeChanged, onGloballyPositioned или специального LayoutModifier).
  • Результат. Изменение componentHeight в макете делает недействительным этап композиции, который только что был завершен. Обратите внимание, что onSizeChanged сообщает о размере после завершения прохода для измерения макета. Если обновленное значение состояния стабилизируется на следующем проходе, перекомпоновка может остановиться после одного дополнительного кадра. Однако если новое значение продолжает изменять размер, это приводит к бесконечному циклу кадров. Кроме того, onGloballyPositioned выполняется после макета и размещения, поэтому записи состояния внутри него ещё более подвержены непрерывной перекомпоновке и циклам перекомпоновки макета в последовательных кадрах.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Назад: чтение в режиме "Композиция", запись в режиме "Рисунок"

  • Что происходит. Состояние считывается в теле Composable-функции (фаза композиции), но изменяется внутри Modifier.drawWithContent, Modifier.drawBehind или Canvas (фаза отрисовки).
  • Результат. На этапе отрисовки состояние изменяется → композиция становится недействительной → бесконечный цикл.

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

Назад: чтение в фазе композиции, запись в фазе композиции (одна и та же фаза)

  • Что происходит. Чтение count в composable-функции и изменение count непосредственно в другом слоте контента composable-функции после чтения.
  • Результат. Система моментальных снимков записывает чтение и последующую запись в рамках одного прохода композиции, сразу же делая текущую область недействительной.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


Основные правила, позволяющие предотвратить запись в более ранние версии

  1. Не указывайте в onGloballyPositioned, onSizeChanged или LayoutModifier состояние, которое считывается в композиции, так как это приводит к проблеме корректности первого кадра.
    • Если координаты или размеры макета нужны только для отрисовки, считывайте их непосредственно на этапе отрисовки или макета (например, с помощью Modifier.drawWithCache или Modifier.layout).
    • Для изменения размера на уровне окна (WindowWidthSizeClass): поднимите размер подъемника на уровень окна. Композиция ветвей по классам размера окна до выполнения локального измерения.
    • Сохраняйте единообразие композиции. Используйте один макет или компоненты, например FlowRow или LazyVerticalGrid, которые регулируют размеры и размещение на втором этапе, не изменяя композицию и ее состояние.
    • Использовать вложенные композиции. Используйте BoxWithConstraints или SubcomposeLayout, если дочерние композиции должны ветвиться в зависимости от локальной ширины или высоты. Внимание! Вложенные композиции снижают производительность и обычно не нужны.
    • В крайнем случае: разрешите первому фрейму быть неправильным, сохранив размер в onSizeChanged, чтобы запустить повторную композицию второго фрейма. Это приводит к заметным скачкам макета, рывкам и риску бесконечных циклов.
  2. Не изменяйте состояние после первого чтения в композиции:
    • Хотя вы можете безопасно записывать данные в объекты MutableState во время создания вне SideEffect, будьте осторожны и не записывайте данные в состояние, которое вы могли ранее прочитать в композиции. В таких случаях рекомендуется использовать rememberUpdatedState. Запись в состояние во время композиции другим способом обычно указывает на отсутствие эффекта или на то, что состояние или composable-функция разработаны неправильно. Помните, что композиция оптимистична и всегда выполняется с последним значением состояния, поэтому вы можете не увидеть все изменения состояния в повторных композициях. Обновления интерфейса не следует использовать для обработки однократных событий, поэтому значение состояния редко приходится обновлять в результате повторной композиции.
    • Не изменяйте состояния, которые наблюдаются за пределами композиции (например, поля ViewModel или флаги isVisible). В документации по Compose указано, что функции, изменяющие состояние, должны быть в лямбда-выражениях событий (onClick), сопрограммах (LaunchedEffect) или побочных эффектах (SideEffect). Исключением является rememberUpdatedState, поскольку она предназначена для изменения состояния, которое используется только в теле @Composable.
  3. Отложите чтение состояния до последнего возможного этапа.
    • Состояния чтения в Draw (Modifier.graphicsLayer { alpha = ... }) или Layout (Modifier.offset { IntOffset(...) }) гарантируют, что изменения будут аннулировать только фазу 2 или 3, полностью пропуская фазу 1 (композиция).