Обратная запись

Compose выполняет кадр в три строго упорядоченных, последовательных этапа:

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

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


Что заставляет операцию записи выполняться «в обратном порядке»?

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

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

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

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

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

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

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

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

Комбинации фаз: Обратный и прямой ход

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

Обратный путь: чтение в процессе сочинения, написание текста в процессе верстки.

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

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