Как и большинство UI-фреймворков, Compose рендерит кадр в несколько различных этапов. В системе Android View есть 3 этапа: Measure, Layout и Drawing. Compose очень похож, но имеет важный дополнительный этап Composition в начале.
Этап Composition описан в документации Compose, включая Thinking in Compose и State and Jetpack Compose
Содержание статьи:
Три этапа отрисовки кадра
Compose имеет 3 основных этапа:
Composition: какой UI показывать. Compose запускает composable-функции и создает описание вашего UI
Layout: где размещать UI. Этот шаг состоит из двух: измерение и размещение (measurement и placement). Элементы верстки измеряют и помещают самих себя и все дочерние элементы в 2D-координатах.
Drawing: как рендерить. UI-элементы отрисовываются в Canvas, обычно на экране устройства.
Очередность этапов, как правило, одинакова, что позволяет данным идти в одном направлении: Composition → Layout → Drawing для создания кадра. Этот подход известен как однонаправленный поток данных. Но есть два исключения: BoxWithConstraints и LazyColumn and LazyRow, для которых Composition дочерних элементов зависит от этапа родительского Layout.
Вы можете рассчитывать, что эти три этапа будут происходить практически для каждого кадра. Но для производительности Compose избегает повторения работы, которая при одних и тех же входных данных выдает одинаковые результаты. Compose пропускает выполнение composable-функции, если можно переиспользовать предыдущий результат, и Compose UI не выполняет этапы Layout и Drawing для целого дерева элементов, если без этого можно обойтись. Compose выполняет минимальное количество работы, необходимой для обновления UI. Эта оптимизация возможна, потому что Compose отслеживает, на каких этапах происходит считывание состояния.
Считывание состояния
В Compose на основе состояния отрисовывается весь UI. Когда вы читаете значение состояния во время одного из этапов, Compose автоматически отслеживает, что он делал в момент, когда значение состояния было прочитано. Это отслеживание позволяет Compose повторно запустить считывание состояния при изменении его значения. Этот подход является основой наблюдения за состоянием в Compose.
Состояние обычно создается при помощи mutableStateOf()
. Затем доступ к состоянию можно получить двумя способами: либо через свойство value, либо с использованием Kotlin property delegate. Вы можете прочесть больше здесь: State in composables. В этой статье под «чтением состояния» подразумевается любой из этих двух способов:
// Считывание состояния без property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(paddingState.value)
)
// Считывание состояния с использованием property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(padding)
)
Под капотом property delegate getter- и setter-функции используются для считывания и обновления значения состояния. Эти функции вызываются только тогда, когда вы ссылаетесь на свойство как на значение, а не при создании свойства. Поэтому эти два варианта равнозначны.
Каждый блок кода, который можно повторно выполнить при изменении считанного состояния, является областью перезапуска (restart scope). Compose отслеживает изменение состояния и перезапускает области в различных фазах.
Поэтапное считывание состояния
Выше мы рассказали про 3 этапа в Compose, и про то, что Compose отслеживает, какое состояние считывается внутри каждого этапа. Это позволяет Compose при изменении состояния выполнить только некоторые этапы для затронутого UI-элемента.
Обратите внимание: для этапов не так важно, где инстанс состояния создан и сохранен. Важно лишь, когда и где значение состояния считывается.
Давайте пройдемся по каждому этапу и опишем, что происходит при считывании в них значения состояния.
Этап 1: Composition
Чтение состояния в @Composable-функции или лямбда-блоке влияет на Composition и потенциально на следующие этапы. Когда значение состояния меняется, recomposer планирует перезапуск всех composable-функций, которые его считывали. Обратите внимание, что среда выполнения может решить пропустить некоторые или все composable-функции, если входные данные не изменились. Для дополнительной информации посмотрите: «Пропуски при неизмененных входных данных».
В зависимости от результата Composition, Compose UI запускает этапы Layout и Drawing. Эти этапы могут быть пропущены, если контент не изменился, и, следовательно, общий размер элементов не изменится.
var padding by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
// Состояние padding считывается на этапе Composition
// во время создания модификатора.
// изменение padding повлечет за собой
// повторное выполнение этапа Composition (recomposition).
modifier = Modifier.padding(padding)
)
Этап 2: Layout
Этап Layout включает два шага: измерение и размещение. Шаг измерения запускает лямбда-функции измерения, переданные в Layout
-composable, метод MeasureScope.measure
интерфейса LayoutModifier
, и т.д. Размещение запускает блок функции layout, лямбду из Modifier.offset {…}
и т.д.
Считывание состояний во время каждого шага затрагивает этапы Layout и, потенциально, Drawing. Когда значение состояния меняется, Compose UI планирует выполнение этапа Layout. Это также запускает этап Drawing, если размер или расположение изменились.
Если быть более точным, шаги измерения и размещения имеют различные области перезапуска. То есть изменение прочитанного состояния на шаге размещения не вызывает повторно шаг измерения, который шел раньше. Однако эти два шага часто взаимосвязаны, так что чтение состояния на шаге размещения может повлиять на области перезапуска, которые относятся к шагу измерения.
var offsetX by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// Состояние offsetX считывается на шаге размещения
// этапа Layout, когда рассчитываются отступы.
// Изменение offsetX перезапустит этап Layout.
IntOffset(offsetX.roundToPx(), 0)
}
)
Этап 3: Drawing
Чтение состояния внутри кода отрисовки влияет на этап Drawing. Распространенные примеры включают: Canvas(), Modifier.drawBehind
и Modifier.drawWithContent
. Когда значение считанного на этом этапе состояния меняется, Compose UI запускает только этап Drawing.
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
// Состояние color считывается во время этапа Drawing
// во время отрисовки Canvas.
// Изменение color повлечет перезапуск этапа Drawing.
drawRect(color)
}
Оптимизация считывания состояния
Поскольку Compose выполняет отслеживание считывания состояний внутри этапов, мы можем минимизировать количество работы, выполняемой считыванием каждого состояния на этапах.
Посмотрим на пример ниже. У нас есть Image()
, который использует offset-модификатор для смещения своего положения. В результате во время скроллинга пользователь наблюдает эффект параллакса за счет добавления offset.
Box {
val listState = rememberLazyListState()
Image(
// Неоптимальная реализация!
Modifier.offset(
with(LocalDensity.current) {
// считывание firstVisibleItemScrollOffset
// на этапе Composition
(listState.firstVisibleItemScrollOffset / 2).toDp()
}
)
)
LazyColumn(state = listState)
}
Этот код работает, но дает неоптимальную производительность. Как и написано, он считывает значение состояния firstVisibleItemScrollOffset
и передает его в функцию Modifier.offset(offset: Dp). По мере прокрутки пользователем значение firstVisibleItemScrollOffset
будет меняться. Как мы знаем, Compose отслеживает любое чтение состояния, чтобы можно было повторно вызвать считывающий это состояние код, в нашем случае — содержимое Box
.
В этом примере состояние читается внутри этапа Composition. Это необязательно плохо. Фактически — это основа рекомпозиции, позволяющая при изменении данных создавать новый UI.
Причина неоптимальности кода в примере выше в том, что каждое событие скролла приводит к переоценке всего существующего composable-содержимого, и затем новому измерению, расположению и финальной отрисовке. Мы запускаем этап Composition на каждую прокрутку, даже если то, что мы показываем, не изменилось, а изменилось только где показываем. Мы можем оптимизировать считывание нашего состояния, чтобы повторно запускать этапы, начиная с Layout.
Существует другая версия offset-модификатора: Modifier.offset(offset: Density.() -> IntOffset).
Эта версия функции принимает лямбду, которая возвращает итоговый offset. Давайте используем это в коде:
Box {
val listState = rememberLazyListState()
Image(
Modifier.offset {
// Состояние firstVisibleItemScrollOffset
// считывается на этапе Layout
IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
}
)
LazyColumn(state = listState)
}
Почему этот способ более производительный? Лямбда, которую мы предоставляем модификатору, вызывается во время этапа Layout — если быть точнее, во время шага размещения — что означает, что наше состояние firstVisibleItemScrollOffset
больше не считывается во время этапа Composition. Compose отслеживает, когда состояние считано. Поэтому, если значение firstVisibleItemScrollOffset
меняется, Compose должен только перезапустить этапы Layout и Drawing.
Вы можете спросить себя, не может ли использование лямбды привести к дополнительным затратам по сравнению с использованием простого значения. Так и есть. Однако выигрыш от чтения состояния на этапе Layout перевешивает эти затраты. Значение firstVisibleItemScrollOffset
меняет каждый кадр в течение прокрутки, и, отложив чтение состояния до этапа Layout, мы совсем избегаем повторных этапов Composition.
Этот пример полагается на различные offset-модификаторы для получения возможности оптимизировать итоговый код. Но общая мысль такая: постарайтесь свести чтения состояний к минимально возможному этапу, чтобы Compose выполнял минимальное количество работы.
Конечно, часто необходимо считать состояние на этапе Composition. Но даже в этом варианте есть случаи, когда мы можем минимизировать количество рекомпозиций, фильтруя изменения состояния. Если вы хотите узнать об этом больше, посмотрите derivedStateOf: конвертация одного или нескольких объектов состояний в другое состояние.
Цикл рекомпозиции (циклическая зависимость этапов)
Чуть ранее мы упомянули, что этапы Compose всегда вызываются в одном и том же порядке, то есть нет возможности вернуться назад по этапам при отрисовке одного кадра. Однако это не запрещает приложениям попадать в циклы Composition при отрисовке разных кадров.
Рассмотрим пример:
Box {
var imageHeightPx by remember { mutableStateOf(0) }
Image(
painter = painterResource(R.drawable.rectangle),
contentDescription = "I'm above the text",
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { size ->
// Не делайте так
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 уже указывает на наличие некоторых проблем в коде.
Проблема этого примера в том, что мы не дошли до «конечного» расположения элементов за один кадр. Коду нужно отрисовать несколько кадров, что провоцирует дополнительную работу, и в результате для пользователя UI-элементы скачут по экрану.
Давайте пройдемся по шагам кадров, чтобы понять происходящее:
На этапе Composition на первом кадре imageHeightPx
имеет значение 0, и тексту передается модификатор Modifier.padding(top = 0)
. Затем следует этап Layout, и вызывается callback от модификатора onSizeChanged
. В этот момент imageHeightPx
обновляется до фактической высоты изображения. Compose планирует рекомпозицию для следующего кадра, тк значение состояния изменилось. На этапе Drawing текст отображается с отступом в 0, потому что изменение значения imageHeightPx
еще не учитывается.
Затем Compose начинает отрисовку второго кадра, назначенную измененным значением imageHeightPx
. Состояние считывается в блоке содержимого Box и вызывается на этапе Сomposition. В это время тексту передается padding со значением, соответствующим высоте изображения. На этапе Layout код устанавливает значение imageHeightPx
снова, но рекомпозиция не планируется повторно, потому что значение высоты остается прежним.
В конце мы получаем нужный отступ для текста, но это не оптимальный выбор. Мы потратили кадр для передачи значения offset обратно на другой этап и в результате отрисовали дополнительный кадр с измененным содержимым.
Этот пример может показаться надуманным, но будьте осторожны с этим общим паттерном:
Использование
Modifier.onSizeChanged()
,onGloballyPositioned()
, или некоторых других Layout-операцийИзменение внутри них состояния
Использование этого измененного состояния в качестве входа для Layout модификаторов:
padding()
,height()
или похожихЭто влечет потенциальное повторение этапов
Чтобы решить описанную проблему, нужно использовать правильные Layout-примитивы. Рассмотренный пример может быть реализован с помощью простого Column()
, но у вас может быть более сложный вариант, требующий индивидуального подхода и написания своего уникального Layout. Если вы хотите узнать больше, посмотрите руководство Custom layouts.
Общий принцип в том, чтобы иметь единый источник истины для нескольких UI-элементов, которые следует измерить и разместить относительно друг друга. При использовании правильного Layout-примитива или создании индивидуального Layout ближайший общий родительский элемент служит источником истины, который координирует отношения между несколькими дочерними элементами. Введение динамического состояния нарушает этот принцип.
Другие статьи по Android-разработке для начинающих:
Статья про OAuth, из которой узнаете, на какие моменты стоит обратить внимание, какие способы реализации выбрать
Но это (не)точно: чего ждать мобильным разработчикам в 2023-м году
Другие статьи по Android-разработке для продвинутых: