Этапы Jetpack Compose

Как и большинство других наборов инструментов для создания интерфейсов, Compose отрисовывает кадр в несколько отдельных этапов. Например, система Android View состоит из трех основных этапов: измерения, макета и отрисовки. Compose очень похож на него, но в начале у него есть важный дополнительный этап – композиция.

В документации по Compose композиция описана в разделах Принципы работы с Compose и Состояние и Jetpack Compose.

Три этапа фрейма

Compose состоит из трех основных этапов:

  1. Композиция. Какой интерфейс показывать. Compose выполняет composable-функции и создает описание вашего интерфейса.
  2. Макет. Где разместить интерфейс. Эта фаза состоит из двух этапов: сбора данных и размещения. Элементы макета измеряют и размещают себя и все дочерние элементы в двумерных координатах для каждого узла в дереве макета.
  3. Рисунок – как он отрисовывается. Элементы интерфейса отрисовываются на холсте, обычно на экране устройства.
Три этапа, на которых Compose преобразует данные в интерфейс (в порядке выполнения: данные, композиция, макет, отрисовка, интерфейс).
Рисунок 1. Три этапа, на которых Compose преобразует данные в интерфейс.

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

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

Этапы

В этом разделе более подробно описано, как выполняются три этапа Compose для функций, создающих интерфейс.

Композиция

На этапе композиции среда выполнения Compose выполняет composable-функции и выводит древовидную структуру, представляющую ваш интерфейс. Дерево интерфейса состоит из узлов макета, которые содержат всю информацию, необходимую для следующих этапов, как показано на видео ниже.

Рисунок 2. Дерево, представляющее ваш интерфейс, созданный на этапе композиции.

Подраздел дерева кода и интерфейса выглядит следующим образом:

Фрагмент кода с пятью функциями, которые можно комбинировать, и полученное дерево интерфейса с дочерними узлами, исходящими из родительских.
Рисунок 3. Подраздел дерева пользовательского интерфейса с соответствующим кодом.

В этих примерах каждая composable-функция в коде соответствует одному узлу макета в дереве интерфейса. В более сложных примерах компонуемые функции могут содержать логику и поток управления, а также создавать разные деревья в зависимости от состояния.

Макет

На этапе макета Compose использует дерево интерфейса, созданное на этапе композиции. Коллекция узлов макета содержит всю информацию, необходимую для определения размера и местоположения каждого узла в двумерном пространстве.

Рисунок 4. Измерение и размещение каждого узла макета в дереве интерфейса во время этапа макета.

На этапе макета дерево обходится с помощью следующего трехэтапного алгоритма:

  1. Измерение дочерних элементов. Узел измеряет свои дочерние элементы, если они есть.
  2. Определение собственного размера. На основе этих измерений узел определяет свой размер.
  3. Разместить дочерние элементы. Каждый дочерний элемент размещается относительно позиции родительского элемента.

В конце этого этапа у каждого узла макета есть:

  • Назначенные значения ширины и высоты.
  • Координаты X и Y, где нужно нарисовать фигуру.

Вспомните дерево интерфейса из предыдущего раздела:

Фрагмент кода с пятью функциями, которые можно использовать в интерфейсе, и полученное дерево интерфейса с дочерними узлами, исходящими из родительских узлов

Для этого дерева алгоритм работает следующим образом:

  1. Элемент Row измеряет дочерние элементы Image и Column.
  2. Измеряется Image. У него нет дочерних элементов, поэтому он определяет свой размер и сообщает его элементу Row.
  3. Затем измеряется Column. Сначала он измеряет свои дочерние элементы (два композируемых элемента Text).
  4. Измеряется первое значение Text. У него нет дочерних элементов, поэтому он определяет свой размер и сообщает его элементу Column.
    1. Измеряется второй день: Text. У него нет дочерних элементов, поэтому он сам определяет свой размер и сообщает его Column.
  5. Column использует размеры дочерних элементов, чтобы определить собственный размер. Он использует максимальную ширину дочернего элемента и сумму высот дочерних элементов.
  6. Элемент Column размещает дочерние элементы относительно себя, располагая их друг под другом по вертикали.
  7. Размер Row определяется на основе размеров дочерних элементов. Он использует максимальную высоту дочернего элемента и сумму ширин дочерних элементов. Затем он размещает дочерние элементы.

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

Рисунок

На этапе отрисовки дерево снова обходится сверху вниз, и каждый узел по очереди отрисовывается на экране.

Рисунок 5. На этапе отрисовки пиксели отображаются на экране.

В примере выше дерево контента будет построено следующим образом:

  1. Тег Row отрисовывает любой контент, который может содержать, например цвет фона.
  2. Image рисует себя.
  3. Column рисует себя.
  4. Первый и второй значки Text рисуются сами по себе.

Рисунок 6. Дерево интерфейса и его графическое представление.

Чтение штата

Когда вы читаете value snapshot state на одном из этапов, перечисленных выше, Compose автоматически отслеживает, что он делал, когда читал value. Благодаря этому отслеживанию Compose повторно выполняет функцию чтения, когда состояние value меняется. Это основа наблюдаемости состояния в Compose.

Обычно состояние создается с помощью mutableStateOf(), а затем к нему можно получить доступ двумя способами: напрямую через свойство value или с помощью делегата свойства Kotlin. Подробнее о состоянии в компонентах… В этом руководстве под термином "чтение состояния" подразумевается любой из этих способов доступа.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

В делегате свойства используются функции getter и setter для доступа к value объекта State и его обновления. Эти функции геттера и сеттера вызываются только при обращении к свойству как к значению, а не при его создании, поэтому два описанных выше способа эквивалентны.

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

Чтение данных о фазах

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

В следующих разделах описаны этапы и то, что происходит при чтении значения состояния на каждом из них.

Этап 1. Композиция

Чтение состояния в функции @Composable или блоке lambda влияет на композицию и, возможно, на последующие этапы. Когда значение value состояния меняется, компоновщик планирует повторный запуск всех функций, которые считывают это значение value. Обратите внимание, что среда выполнения может решить пропустить некоторые или все функции, если входные данные не изменились. Подробнее о том, как пропускать шаги, если входные данные не изменились…

В зависимости от результата композиции Compose UI выполняет этапы макета и отрисовки. Если контент не меняется, а размер и макет остаются прежними, эти этапы могут быть пропущены.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Этап 2. Макет

Этап макета состоит из двух шагов: измерения и размещения. На этапе измерения выполняется лямбда-функция измерения, переданная в composable-функцию Layout, метод MeasureScope.measure интерфейса LayoutModifier и т. д. На этапе размещения выполняется блок размещения функции layout, лямбда-блок функции Modifier.offset { … } и аналогичных функций.

Чтение состояния на каждом из этих этапов влияет на макет и, возможно, на этап отрисовки. Когда состояние value меняется, Compose UI планирует этап макета. Если размер или положение изменились, также выполняется фаза рисования.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Этап 3. Рисование

Чтение состояния во время отрисовки влияет на этап отрисовки. Примеры: Canvas(), Modifier.drawBehind и Modifier.drawWithContent. Когда состояние value меняется, Compose UI выполняет только этап отрисовки.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

На диаграмме показано, что состояние, прочитанное на этапе отрисовки, запускает повторное выполнение этого этапа.

Оптимизация чтения состояния

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

Рассмотрим следующий пример. В этом примере используется Image(), в котором с помощью модификатора offset смещается окончательное положение макета, что приводит к эффекту параллакса при прокрутке.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Этот код работает, но приводит к снижению эффективности. В приведенном ниже коде считывается value состояния firstVisibleItemScrollOffset и передается функции Modifier.offset(offset: Dp). По мере прокрутки страницы firstVisibleItemScrollOffset будет меняться.value Как вы уже знаете, Compose отслеживает все операции чтения состояния, чтобы при необходимости перезапустить (повторно вызвать) код чтения, который в этом примере представляет собой содержимое Box.

Это пример чтения состояния на этапе композиции. Это не обязательно плохо, и на самом деле это основа рекомпозиции, позволяющая изменениям данных создавать новый интерфейс.

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

Смещение с помощью лямбда-выражения

Также доступна другая версия модификатора смещения:Modifier.offset(offset: Density.() -> IntOffset).

В этой версии используется параметр lambda, где полученное смещение возвращается блоком lambda. Чтобы использовать код, обновите его:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Почему этот способ более эффективен? Лямбда-блок, который вы передаете модификатору, вызывается на этапе макета (а именно на этапе размещения макета), поэтому состояние firstVisibleItemScrollOffset больше не считывается во время композиции. Поскольку Compose отслеживает, когда считывается состояние, это изменение означает, что если firstVisibleItemScrollOffset value изменится, Compose нужно будет только перезапустить этапы макета и отрисовки.

Конечно, часто абсолютно необходимо считывать состояния на этапе композиции. Однако в некоторых случаях можно уменьшить количество перекомпоновок, отфильтровав изменения состояния. Подробнее о том, derivedStateOfкак преобразовать один или несколько объектов состояния в другой объект состояния…

Цикл рекомпозиции (зависимость фаз цикла)

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

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

В этом примере реализован вертикальный столбец, в котором изображение находится вверху, а текст – под ним. Он использует Modifier.onSizeChanged(), чтобы получить разрешение изображения, а затем Modifier.padding(), чтобы сместить текст вниз. Неестественный переход от Px к Dp уже указывает на то, что в коде есть проблема.

Проблема в том, что код не достигает "окончательной" разметки в пределах одного кадра. Код зависит от нескольких кадров, что приводит к ненужной работе и скачкам интерфейса на экране.

Композиция первого кадра

Во время композиции первого кадра значение imageHeightPx изначально равно 0. Таким образом, код предоставляет текст с Modifier.padding(top = 0). На этапе макета вызывается обратный вызов модификатора onSizeChanged, который обновляет imageHeightPx до фактической высоты изображения. Выполняется композиция, затем планируется рекомпозиция для следующего кадра. Однако на текущем этапе отрисовки текст будет отображаться с отступом 0, поскольку обновленное значение imageHeightPx ещё не применено.

Композиция второго кадра

Функция Compose запускает второй кадр, когда меняется значение imageHeightPx. На этапе композиции кадра состояние считывается в блоке контента Box. Теперь текст дополняется отступами, которые точно соответствуют высоте изображения. На этапе макета значение imageHeightPx задается снова, но рекомпозиция не планируется, поскольку значение остается неизменным.

Диаграмма, на которой показан цикл рекомпозиции, в котором изменение размера на этапе макета запускает рекомпозицию, которая затем снова вызывает макет.

Этот пример может показаться надуманным, но будьте внимательны к следующей общей схеме:

  • Modifier.onSizeChanged(), onGloballyPositioned() или другой макет операции
  • Обновить состояние
  • Используйте это состояние в качестве входных данных для модификатора макета (padding(), height() или аналогичного).
  • Потенциально повторяющийся

Чтобы исправить приведенный выше пример, используйте правильные примитивы макета. Приведенный выше пример можно реализовать с помощью Column(), но в более сложных случаях вам может понадобиться собственный макет. Подробнее о пользовательских макетах…

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

Подробнее о циклах повторной композиции и о том, как избежать записи в состояние на разных этапах, можно узнать в статье Обратная запись в Compose.