Это перевод моей собственной статьи, опубликованной ранее в ProAndroidDev.
Сотня рекомпозиций в секунду при скролле — это приговор. Приговор батарее устройства, плавности анимаций и вашей репутации как инженера. Мы привыкли мыслить высокоуровневыми абстракциями: закинуть LazyColumn, добавить пару Modifier.padding и отправить в продакшен. Но что делать, когда стандартные компоненты начинают «захлебываться», а Layout Inspector горит красным от избыточных отрисовок?
Давайте снимем розовые очки абстракций. Если убрать всю «магию» компонентов Compose, останутся только Layout и Modifier. Даже Text под капотом — это BasicText, который опирается на фундамент Layout.
Сегодня мы разберем хардкорный кейс из моего проекта MicroMod: создание бесконечной сетки элементов, расположенных по спирали, с абсолютно нулевым показателем рекомпозиций при активном скролле. Никакой ерунды в духе «просто добавь @Stable». Только глубокая работа с фазами рендеринга, кастомный LazyLayout и умное управление состоянием.
Прежде чем перейти к практике, нужно закрепить внутреннюю механику фазы Layout. Процесс компоновки состоит из трех шагов: Constraints (Ограничения), Measurement (Измерение) и Placement (Размещение). Вечный спор о том, передается ли управление «сверху вниз» или «снизу вверх», не имеет однозначного ответа, так как эти этапы взаимосвязаны:
-
Constraints передаются от родителя к детям (сверху вниз).
-
Measurement (расчет размеров) идет в обратном порядке (снизу вверх).
-
Placement (позиционирование) финализируется снова сверху вниз.
Анатомия рендеринга: почему Compose лагает
Отрисовка каждого элемента в Compose проходит через три строго регламентированных этапа:
-
Composition: Что мы показываем? Compose строит дерево UI.
-
Layout: Где это находится? Включает шаги Measurement (снизу вверх) и Placement (сверху вниз).
-
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 и начинаем агрессивно скроллить спираль.
Результат:
Layout Inspector:
-
Recomposition Count: 0.
-
Skipped Count: Максимальный для всех нод.
Вынос логики вычислений за пределы фазы композиции и грамотное использование возможностей SubcomposeLayoutState (на котором базируется LazyLayout) позволяют добиться идеального фреймрейта даже на слабых устройствах.
Заключение
Мы собрали высокопроизводительный кастомный компонент. Да, в отличие от использования готового LazyColumn, нам пришлось вручную считать границы, обрабатывать жесты и дирижировать фазами Measure и Place. Но именно это отличает обычного пользователя фреймворка от инженера, который понимает его внутренности.
Баланс между чистым кодом и производительностью всегда хрупок. Если вам нужен стандартный список — используйте стандартные компоненты. Но если ваша задача, как и в проекте MicroMod (построенном на микромодульной архитектуре с Navigation3), требует бескомпромиссной скорости от нестандартного UI — спускайтесь на уровень LazyLayout. Контроль над фазами рендеринга — это ваша суперсила. Используйте её с умом.
Полную реализацию смотрите на Git
ссылка на оригинал статьи https://habr.com/ru/articles/1035388/