Этапы создания реактивного ранца

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

В документации Compose описывается процесс композиции в рамках концепции Thinking in Compose and State и Jetpack Compose .

Три фазы каркаса

Процесс создания композиции состоит из трех основных этапов:

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

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

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

Разберитесь в этапах.

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

Композиция

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

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

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

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

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

Макет

На этапе компоновки 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)
)

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

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

Поэтапное состояние чтения

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

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

Этап 1: Составление

Чтение состояния внутри функции или лямбда-блока с аннотацией @Composable влияет на композицию и потенциально на последующие этапы. Когда 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: Планировка

Этап компоновки состоит из двух шагов: измерения и размещения . На этапе измерения выполняется лямбда-функция измерения, переданная в составной объект 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) . По мере прокрутки пользователем value firstVisibleItemScrollOffset будет изменяться. Как вы уже узнали, Compose отслеживает каждое считывание состояния, чтобы перезапускать (повторно вызывать) код чтения, который в этом примере представляет собой содержимое Box .

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

Ключевой момент: этот пример неоптимален, поскольку каждое событие прокрутки приводит к повторной оценке, измерению, компоновке и, наконец, отрисовке всего компонуемого содержимого. Фаза компоновки запускается при каждой прокрутке, даже если отображаемое содержимое не изменилось, а изменилось только его положение . Можно оптимизировать чтение состояния, чтобы повторно запускать только фазу компоновки.

Смещение с помощью лямбда

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

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

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 отслеживает момент считывания состояния, это изменение означает, что если value firstVisibleItemScrollOffset изменяется, 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 до фактической высоты изображения. Затем Compose планирует перекомпоновку для следующего кадра. Однако на текущем этапе отрисовки текст отображается с отступом 0 , поскольку обновленное значение imageHeightPx еще не отображено.

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

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

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

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

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

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

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

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}