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

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

Композиция описана в наших документах Compose, включая Thinking in Compose и State и Jetpack Compose .

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

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

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

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

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

Понимание фаз

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

Состав

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

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

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

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

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

Макет

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

Рисунок 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. Дерево пользовательского интерфейса и его нарисованное представление.

Государство читает

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

Состояние обычно создается с помощью mutableStateOf() , а затем доступно одним из двух способов: путем прямого доступа к свойству value или, альтернативно, с использованием делегата свойства Kotlin. Подробнее о них можно прочитать в разделе State in composables . Для целей данного руководства «чтение состояния» относится к любому из этих эквивалентных методов доступа.

// 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 состояния. Эти функции получения и установки вызываются только тогда, когда вы ссылаетесь на свойство как на значение, а не при его создании, поэтому два вышеописанных способа эквивалентны.

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

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

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

Давайте пройдемся по каждому этапу и опишем, что происходит, когда внутри него считывается значение состояния.

Этап 1: Состав

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

В зависимости от результата композиции 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 { … } и т. д.

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

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

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 . При изменении значения состояния 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() , который использует модификатор смещения для смещения конечного положения макета, что приводит к эффекту параллакса при прокрутке пользователем.

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) {
        // ...
    }
}

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

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

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

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

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

Box {
    var imageHeightPx by remember { mutableStateOf(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, поскольку изменение значения еще не отражается.

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

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

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

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

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

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

{% дословно %} {% дословно %} {% дословно %} {% дословно %}