Как и большинство других наборов инструментов пользовательского интерфейса, Compose отображает кадр в несколько отдельных этапов. Например, в системе Android View есть три основных этапа: измерение, макет и рисование. В Compose все очень похоже, но в начале есть важный дополнительный этап, который называется компоновкой.
О композиции рассказывается в статьях Thinking in Compose и State and Jetpack Compose.
Три фазы фрейма
Compose имеет три основных этапа:
- Композиция. Какие элементы интерфейса показывать. Compose запускает composable-функции и создает описание пользовательского интерфейса.
- Макет – где разместить интерфейс. Этот этап состоит из двух шагов: измерение и размещение. Элементы макета измеряют и размещают себя и любые дочерние элементы в двумерных координатах для каждого узла в дереве макета.
- Отрисовка. Как отображается объявление. Элементы интерфейса отрисовываются на холсте, обычно на экране устройства.
Порядок этих этапов обычно одинаков, что позволяет данным течь в одном направлении от композиции к макету и рисованию для создания кадра (также известного как однонаправленный поток данных). BoxWithConstraints, LazyColumn и LazyRow – заметные исключения, в которых композиция дочерних элементов зависит от фазы макета родительского элемента.
Концептуально каждый из этих этапов происходит для каждого кадра; однако для оптимизации производительности Compose избегает повторения работы, которая привела бы к вычислению одних и тех же результатов на основе одних и тех же входных данных на всех этих этапах. Compose пропускает выполнение composable-функции, если может повторно использовать предыдущий результат, а Compose UI не перерисовывает и не перестраивает все дерево, если в этом нет необходимости. Compose выполняет только минимальный объем работы, необходимый для обновления пользовательского интерфейса. Эта оптимизация возможна, поскольку Compose отслеживает считывание состояния на разных этапах.
Понимание этапов
В этом разделе подробно описывается, как выполняются три этапа Compose для composable-функций.
Композиция
На этапе компоновки среда выполнения Compose выполняет composable-функции и выводит древовидную структуру, которая представляет ваш пользовательский интерфейс. Это дерево пользовательского интерфейса состоит из узлов макета, которые содержат всю информацию, необходимую для следующих этапов, как показано в следующем видео:
Рисунок 2. Дерево, представляющее ваш пользовательский интерфейс, созданный на этапе композиции.
Подраздел кода и дерева пользовательского интерфейса выглядит следующим образом:
В этих примерах каждая composable-функция в коде сопоставляется с одним узлом макета в дереве пользовательского интерфейса. В более сложных примерах composable-функции могут содержать логику и управление потоком, а также создавать разные деревья в зависимости от состояния.
Макет
На этапе макета Compose использует дерево пользовательского интерфейса, созданное на этапе композиции, в качестве входных данных. Набор узлов макета содержит всю информацию, необходимую для определения размера и местоположения каждого узла в двумерном пространстве.
Рисунок 4. Измерение и размещение каждого узла макета в дереве пользовательского интерфейса на этапе макета.
На этапе макета дерево обходится с использованием следующего трехэтапного алгоритма:
- Измерение дочерних элементов. Узел измеряет свои дочерние элементы, если они есть.
- Определение размера. На основе полученных измерений узел определяет свой размер.
- Размещение дочерних элементов. Каждый дочерний узел размещается относительно положения узла.
В конце этой фазы у каждого узла макета есть:
- Заданные ширина и высота.
- координаты x, y, где он должен быть нарисован;
Вспомните дерево пользовательского интерфейса из предыдущего раздела:
Для этого дерева алгоритм работает следующим образом:
Rowизмеряет дочерние элементыImageиColumn.Imageизмеряется. У него нет дочерних элементов, поэтому он сам определяет свой размер и сообщает его элементуRow.- Затем измеряется
Column. Сначала он измеряет свои дочерние элементы (две composable-функции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. Подробнее о них рассказывается в статье Состояние в composable-функциях. В этом руководстве под "считыванием состояния" понимается любой из этих эквивалентных методов доступа.
// 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 отслеживает изменения состояния value и перезапускает области видимости на разных этапах.
Поэтапное считывание состояния
Как упоминалось ранее, в Compose есть три основных этапа, и Compose отслеживает, какое состояние считывается на каждом из них. Это позволяет Compose уведомлять только те фазы, которые должны выполнить работу для каждого затронутого элемента вашего пользовательского интерфейса.
В следующих разделах описывается каждый этап и то, что происходит, когда в нем считывается значение состояния.
Этап 1. Композиция
Чтение состояния в функции @Composable или лямбда-блоке влияет на композицию и, возможно, на последующие фазы. Когда value состояния меняется, рекомпоновщик планирует повторный запуск всех composable-функций, которые считывают value этого состояния. Обратите внимание, что среда выполнения может пропустить некоторые или все 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. Макет
Этап макета состоит из двух шагов: измерения и размещения. На этапе измерения запускается лямбда-выражение, переданное в 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(), который использует модификатор смещения, чтобы сместить конечную позицию макета, в результате чего при прокрутке пользователем возникает эффект параллакса.
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.
Это пример чтения состояния на этапе композиции. Это не обязательно плохо и фактически является основой рекомпозиции, позволяющей изменениям данных создавать новый пользовательский интерфейс.
Ключевой момент: этот пример неоптимален, поскольку каждое событие прокрутки приводит к повторной оценке, измерению, компоновке и, наконец, отрисовке всего содержимого composable-функции. Вы запускаете фазу Compose при каждой прокрутке, даже если показанный контент не изменился, а изменилась только его позиция. Вы можете оптимизировать чтение состояния, чтобы повторно запускать только фазу макета.
Смещение с лямбда-функцией
Доступна другая версия модификатора смещения: 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(), но у вас может быть более сложный пример, требующий чего-то нестандартного, что потребует написания собственного макета. Подробную информацию можно найти в руководстве Специальные макеты.
Общий принцип здесь заключается в том, чтобы иметь единый источник истины для нескольких элементов пользовательского интерфейса, которые следует измерять и размещать по отношению друг к другу. Использование правильного примитива макета или создание пользовательского макета означает, что минимальный общий родительский элемент служит источником истины, который может координировать отношения между несколькими элементами. Динамическое состояние нарушает этот принцип.
Дополнительную информацию о циклах рекомпозиции и о том, как избежать записи в состояние на разных этапах, см. в разделе Обратная запись в Compose.
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Состояние и Jetpack Compose
- Списки и сетки
- Kotlin для Jetpack Compose