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) -> Unit

data 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, но не вызывал лишних рекомпозиций всей обертки, используем хирургически точное управление состоянием:

@Composable
fun 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, который хранит оффсет:

@Stable
class 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