Mission Impossible: как добиться 0 рекомпозиций в сложном кастомном UI

от автора

Image by author

Image by author

Это перевод моей собственной статьи, опубликованной ранее в ProAndroidDev.

Сотня рекомпозиций в секунду при скролле — это приговор. Приговор батарее устройства, плавности анимаций и вашей репутации как инженера. Мы привыкли мыслить высокоуровневыми абстракциями: закинуть LazyColumn, добавить пару Modifier.padding и отправить в продакшен. Но что делать, когда стандартные компоненты начинают «захлебываться», а Layout Inspector горит красным от избыточных отрисовок?

Давайте снимем розовые очки абстракций. Если убрать всю «магию» компонентов Compose, останутся только Layout и Modifier. Даже Text под капотом — это BasicText, который опирается на фундамент Layout.

Сегодня мы разберем хардкорный кейс из моего проекта MicroMod: создание бесконечной сетки элементов, расположенных по спирали, с абсолютно нулевым показателем рекомпозиций при активном скролле. Никакой ерунды в духе «просто добавь @Stable». Только глубокая работа с фазами рендеринга, кастомный LazyLayout и умное управление состоянием.

Прежде чем перейти к практике, нужно закрепить внутреннюю механику фазы Layout. Процесс компоновки состоит из трех шагов: Constraints (Ограничения), Measurement (Измерение) и Placement (Размещение). Вечный спор о том, передается ли управление «сверху вниз» или «снизу вверх», не имеет однозначного ответа, так как эти этапы взаимосвязаны:

  1. Constraints передаются от родителя к детям (сверху вниз).

  2. Measurement (расчет размеров) идет в обратном порядке (снизу вверх).

  3. Placement (позиционирование) финализируется снова сверху вниз.


Анатомия рендеринга: почему Compose лагает

Отрисовка каждого элемента в Compose проходит через три строго регламентированных этапа:

  1. Composition: Что мы показываем? Compose строит дерево UI.

  2. Layout: Где это находится? Включает шаги Measurement (снизу вверх) и Placement (сверху вниз).

  3. Drawing: Как это выглядит? Отрисовка пикселей на экране.

Большинство проблем с производительностью (те самые лаги при скролле) возникают, когда мы заставляем Compose возвращаться на фазу Composition при изменении каждого пикселя. Даже механизмы вроде Donut-hole skipping (пропуск рекомпозиции внутри функции, если параметры не изменились) или новый Strong Skipping Mode не спасут, если вы читаете состояние скролла напрямую в теле @Composable функции.


Лямбда-модификаторы и спуск до Canvas

Классический пример делегирования вычислений: вместо Modifier.offset(x = scrollState.value), грамотный инженер напишет Modifier.offset { IntOffset(x = scrollState.value, 0) }. Лямбда откладывает чтение состояния до фазы Layout (а именно — Placement). Фаза Composition при этом остается нетронутой.

А когда стоит опускаться до Canvas? Если ваш UI — это график или система частиц без сложного внутреннего стейта и интерактивных дочерних элементов. В Canvas мы работаем напрямую в фазе Drawing. Но если нам нужны полноценные ноды Compose (карточки, кнопки, изображения), наше оружие — низкоуровневый LazyLayout.


Практика: Бесконечная спираль на LazyLayout

Задача: Расположить элементы не списком и не сеткой, а по бесконечной спирали.

Математика размещения (расчет координат X и Y по индексу n) выглядит так:

private fun getSpiralCoordinates(n: Int): Pair<Int, Int> {    if (n == 0) return Pair(0, 0)    val k = ceil((sqrt(n.toDouble() + 1) - 1) / 2).toInt()    val t = 2 * k    val m = (2 * k + 1) * (2 * k + 1)    return when {        n >= m - t -> Pair(k - (m - n), -k)        n >= m - 2 * t -> Pair(-k, -k + (m - t - n))        n >= m - 3 * t -> Pair(-k + (m - 2 * t - n), k)        else -> Pair(k, k - (m - 3 * t - n))    }}

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

Шаг 1: Подготовка State и Provider

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

typealias ComposableItemContent = @Composable (ListItem) -> Unitdata class LazyLayoutItemContent(    val item: ListItem,    val itemContent: ComposableItemContent)class ItemProvider(    private val itemsState: State<List<LazyLayoutItemContent>>) : LazyLayoutItemProvider {    override val itemCount        get() = itemsState.value.size    @Composable    override fun Item(index: Int, key: Any) {        val item = itemsState.value.getOrNull(index)        item?.itemContent?.invoke(item.item)    }    fun getItemIndexesInRange(boundaries: ViewBoundaries, stepX: Int, stepY: Int): List<Int> {        val result = mutableListOf<Int>()        itemsState.value.forEachIndexed { index, itemContent ->            val item = itemContent.item            val realX = item.coordinates.x * stepX            val realY = item.coordinates.y * stepY            if (realX in boundaries.fromX..boundaries.toX &&                realY in boundaries.fromY..boundaries.toY            ) result.add(index)        }        return result    }        fun getItem(index: Int): ListItem? = itemsState.value.getOrNull(index)?.item}

Шаг 2: Оптимизация через derivedStateOf и rememberUpdatedState

Чтобы наш ItemProvider реагировал на изменения DSL, но не вызывал лишних рекомпозиций всей обертки, используем хирургически точное управление состоянием:

@Composablefun rememberItemProvider(customLazyListScope: CustomLazyListScope.() -> Unit): ItemProvider {    val customLazyListScopeState = rememberUpdatedState(customLazyListScope)    return remember {        ItemProvider(            itemsState = derivedStateOf {                val layoutScope = CustomLazyListScopeImpl().apply(customLazyListScopeState.value)                layoutScope.items            }        )    }}

Здесь derivedStateOf — наш лучший друг. Он кэширует результат вычислений DSL. Если входные данные не изменились, Compose даже не посмотрит в эту сторону на следующем цикле.

Шаг 3: Управление скроллом без рекомпозиции

В кастомном Layout мы сами обрабатываем жесты. Создадим LazyLayoutState, который хранит оффсет:

@Stableclass LazyLayoutState(initialOffset: IntOffset = IntOffset.Zero) {    private val _offsetState = mutableStateOf(initialOffset)    val offsetState: State<IntOffset> get() = _offsetState        fun onDrag(offset: IntOffset) {        val x = _offsetState.value.x - offset.x        val y = _offsetState.value.y - offset.y        _offsetState.value = IntOffset(x, y)    }    // ... расчет границ видимости}

Шаг 4: Placement — там, где происходит магия «нуля»

Самая ответственная часть: мы вызываем measure только для видимых индексов, а расчет размещения (placement) делаем «на лету».

fun Placeable.PlacementScope.placeItem(    state: LazyLayoutState,    listItem: ListItem,    placeables: List<Placeable>,    gridStepX: Int,    gridStepY: Int) {    val xPosition = (listItem.coordinates.x * gridStepX) - state.offsetState.value.x    val yPosition = (listItem.coordinates.y * gridStepY) - state.offsetState.value.y    placeables.forEach { placeable ->        placeable.placeRelative(xPosition, yPosition)    }}

Почему это дает 0 рекомпозиций? Мы читаем state.offsetState.value внутри лямбды размещения функции layout(). Когда оффсет меняется (пользователь скроллит), Compose инвалидирует только фазу Placement. Ему не нужно заново пересчитывать размеры дочерних элементов (Measurement), и уж тем более перестраивать дерево UI (Composition).


Бенчмарк: Layout Inspector не врет

Запускаем Layout Inspector и начинаем агрессивно скроллить спираль.

Результат:

MicroMod preview

MicroMod preview

Layout Inspector:

Smooth scrolling without unnecessary calculations

Smooth scrolling without unnecessary calculations
  • Recomposition Count: 0.

  • Skipped Count: Максимальный для всех нод.

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

Заключение

Мы собрали высокопроизводительный кастомный компонент. Да, в отличие от использования готового LazyColumn, нам пришлось вручную считать границы, обрабатывать жесты и дирижировать фазами Measure и Place. Но именно это отличает обычного пользователя фреймворка от инженера, который понимает его внутренности.

Баланс между чистым кодом и производительностью всегда хрупок. Если вам нужен стандартный список — используйте стандартные компоненты. Но если ваша задача, как и в проекте MicroMod (построенном на микромодульной архитектуре с Navigation3), требует бескомпромиссной скорости от нестандартного UI — спускайтесь на уровень LazyLayout. Контроль над фазами рендеринга — это ваша суперсила. Используйте её с умом.

Полную реализацию смотрите на Git

ссылка на оригинал статьи https://habr.com/ru/articles/1035388/