Как и большинство других наборов инструментов для создания интерфейсов, Compose отрисовывает кадр в несколько отдельных этапов. Например, система Android View состоит из трех основных этапов: измерения, макета и отрисовки. Compose очень похож на него, но в начале у него есть важный дополнительный этап – композиция.
В документации по Compose композиция описана в разделах Принципы работы с Compose и Состояние и Jetpack Compose.
Три этапа фрейма
Compose состоит из трех основных этапов:
- Композиция. Какой интерфейс показывать. Compose выполняет composable-функции и создает описание вашего интерфейса.
- Макет. Где разместить интерфейс. Эта фаза состоит из двух этапов: сбора данных и размещения. Элементы макета измеряют и размещают себя и все дочерние элементы в двумерных координатах для каждого узла в дереве макета.
- Рисунок – как он отрисовывается. Элементы интерфейса отрисовываются на холсте, обычно на экране устройства.
Обычно эти этапы выполняются в одном и том же порядке, что позволяет данным передаваться в одном направлении: от композиции к макету, а затем к отрисовке кадра (этот процесс также называется однонаправленной передачей данных). BoxWithConstraints, LazyColumn и LazyRow – заметные исключения, поскольку их дочерние элементы зависят от этапа макета родительского элемента.
Концептуально каждый из этих этапов происходит для каждого кадра, но для оптимизации производительности Compose избегает повторения работы, которая вычисляет одни и те же результаты из одних и тех же входных данных на всех этих этапах. Compose пропускает выполнение composable-функции, если может использовать предыдущий результат, а Compose UI не перекомпоновывает и не перерисовывает все дерево, если в этом нет необходимости. Compose выполняет только минимальный объем работы, необходимый для обновления интерфейса. Оптимизация возможна, поскольку Compose отслеживает состояние чтения в разных фазах.
Этапы
В этом разделе более подробно описано, как выполняются три этапа Compose для функций, создающих интерфейс.
Композиция
На этапе композиции среда выполнения Compose выполняет composable-функции и выводит древовидную структуру, представляющую ваш интерфейс. Дерево интерфейса состоит из узлов макета, которые содержат всю информацию, необходимую для следующих этапов, как показано на видео ниже.
Рисунок 2. Дерево, представляющее ваш интерфейс, созданный на этапе композиции.
Подраздел дерева кода и интерфейса выглядит следующим образом:
В этих примерах каждая composable-функция в коде соответствует одному узлу макета в дереве интерфейса. В более сложных примерах компонуемые функции могут содержать логику и поток управления, а также создавать разные деревья в зависимости от состояния.
Макет
На этапе макета Compose использует дерево интерфейса, созданное на этапе композиции. Коллекция узлов макета содержит всю информацию, необходимую для определения размера и местоположения каждого узла в двумерном пространстве.
Рисунок 4. Измерение и размещение каждого узла макета в дереве интерфейса во время этапа макета.
На этапе макета дерево обходится с помощью следующего трехэтапного алгоритма:
- Измерение дочерних элементов. Узел измеряет свои дочерние элементы, если они есть.
- Определение собственного размера. На основе этих измерений узел определяет свой размер.
- Разместить дочерние элементы. Каждый дочерний элемент размещается относительно позиции родительского элемента.
В конце этого этапа у каждого узла макета есть:
- Назначенные значения ширины и высоты.
- Координаты X и Y, где нужно нарисовать фигуру.
Вспомните дерево интерфейса из предыдущего раздела:
Для этого дерева алгоритм работает следующим образом:
- Элемент
Rowизмеряет дочерние элементыImageиColumn. - Измеряется
Image. У него нет дочерних элементов, поэтому он определяет свой размер и сообщает его элементуRow. - Затем измеряется
Column. Сначала он измеряет свои дочерние элементы (два композируемых элементаText). - Измеряется первое значение
Text. У него нет дочерних элементов, поэтому он определяет свой размер и сообщает его элементуColumn.- Измеряется второй день:
Text. У него нет дочерних элементов, поэтому он сам определяет свой размер и сообщает егоColumn.
- Измеряется второй день:
Columnиспользует размеры дочерних элементов, чтобы определить собственный размер. Он использует максимальную ширину дочернего элемента и сумму высот дочерних элементов.- Элемент
Columnразмещает дочерние элементы относительно себя, располагая их друг под другом по вертикали. - Размер
Rowопределяется на основе размеров дочерних элементов. Он использует максимальную высоту дочернего элемента и сумму ширин дочерних элементов. Затем он размещает дочерние элементы.
Обратите внимание, что каждый узел был посещен только один раз. Во время выполнения Compose требуется только один проход по дереву интерфейса, чтобы измерить и разместить все узлы, что повышает производительность. При увеличении количества узлов в дереве время, затрачиваемое на его обход, увеличивается линейно. Если же каждый узел посещается несколько раз, время обхода увеличивается экспоненциально.
Рисунок
На этапе отрисовки дерево снова обходится сверху вниз, и каждый узел по очереди отрисовывается на экране.
Рисунок 5. На этапе отрисовки пиксели отображаются на экране.
В примере выше дерево контента будет построено следующим образом:
- Тег
Rowотрисовывает любой контент, который может содержать, например цвет фона. Imageрисует себя.Columnрисует себя.- Первый и второй значки
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.
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Состояние и Jetpack Compose
- Списки и сетки
- Kotlin для Jetpack Compose