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

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

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

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


Что значит "записать в обратном порядке"?

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

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

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

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

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

Прямой поток через фазы

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

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

Комбинации фаз: назад и вперед

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

Обратная совместимость: чтение в Composition, запись в Layout

  • Что происходит. Композиция считывает componentHeight, чтобы определить, какой интерфейс нужно создать. Позже на этапе макета измеряются или размещаются представления и записывается новое значение в componentHeight (например, с помощью onSizeChanged, onGloballyPositioned или пользовательского LayoutModifier).
  • Результат. Изменение componentHeight в Layout аннулирует только что завершенный этап Composition. Обратите внимание, что 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)
        }
    }
}

Обратный порядок: чтение в Composition, запись в Drawing

  • Что происходит. Состояние считывается в теле 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
        }
    )
}

Назад: чтение в Composition, запись в Composition (та же фаза)

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