Почему я написал Paginator вместо Paging 3

от автора

Это статья от автора библиотеки, поэтому нейтральным разбор не будет. Но это и не рассказ про
конкретный проект — а разбор задач, на которых, на мой взгляд, Paging 3 начинает буксовать, и
того, как Paginator устроен, чтобы эти задачи
закрывать. KMP-библиотека пагинации для Android, iOS, JVM и Desktop. Ниже — почему она появилась
именно как отдельная библиотека, а не как fork или обёртка над Paging 3.

Сцена, в которой Paging 3 перестаёт быть удобным

Возьмите список требований, которые рано или поздно прилетают в любую ленту, чат или поисковую
выдачу — обычно не на старте, а через несколько месяцев работы продукта:

— открыть переписку (или ленту, или результаты поиска) на конкретном элементе по deeplink из
push;
— дать поставить лайк или отметить «прочитано» так, чтобы изменился ровно один элемент, без
рефреша всего списка и без визуального мерцания;
— после убийства процесса вернуть пользователя ровно туда, где он был, со всем видимым контекстом, а не на первую страницу;
— добавить стрелку «к самому новому» или «к закладке» с прыжком в произвольное место ленты;
— то же самое, пожалуйста, на iOS.

В каждом из этих пунктов по отдельности Paging 3 не сдаётся — есть RemoteMediator, есть
cachedIn, есть способы выкрутиться. Но если они приходят пакетом — а в современном продукте
они приходят пакетом, — выкручивание занимает больше времени, чем сама фича. И в какой-то
момент команда замечает, что больше думает о том, как заставить PagingSource адресовать
страницу, а не ключ, чем о том, как должен вести себя экран.

С этого момента Paging 3 уже не инструмент, а ограничение, к которому подгоняется задача. Это
плохой знак — и именно ту точку, в которой он наступает, я и пытался отодвинуть, проектируя
Paginator.

Где Paging 3 начинает проседать

Я не считаю Paging 3 плохой библиотекой — у неё хорошее ядро для конкретного класса задач. Но
это ядро устроено вокруг определённых решений, и эти решения упираются в потолок раньше, чем
хотелось бы. Конкретно — в четырёх местах.

PagingSource адресует ключами, а не страницами. Это разумно, когда лента листается
курсорами вперёд-назад и неважно, какой по счёту элемент. Но как только в задаче появляется
«открой страницу с сообщением msg_817» — оказывается, что прыгнуть можно только в страницу,
ключ которой уже знаком. Канонический ответ — Room как источник правды и RemoteMediator,
который синхронизирует БД с сетью. Для проектов, где БД и так есть, это нормально. Если её
нет, появление БД приходится обосновывать только ради того, чтобы пагинация работала. Это
перевернутая зависимость: продуктовый сценарий начинает диктовать архитектуру слоя данных.

PagingData иммутабельна и заточена под UI. Когда задача требует поменять один элемент в
ленте — поставить лайк, обновить статус «прочитано», локально удалить — прямого пути нет.
PagingData — это поток событий для адаптера, а не структура, которой можно сказать «замени
элемент с id = 42». Стандартное решение опять упирается в Room: меняем строку, инвалидация
дёрнет адаптер. Если бэкенд эфемерный и БД нет — её нужно завести. Если хочется оптимистично
применить мутацию до ответа сервера — нужно расставить флаги в строках БД. Каждое следующее
требование добавляет к решению ещё один слой.

Pagination живёт в UI-слое. Если придерживаться клин-архитектуры в умеренном виде —
пагинация это поведение домена, а не UI. И если три экрана делят одну и ту же ленту, удобно
иметь один объект пагинации в use-case’е, на который подписаны три ViewModel. С
Flow<PagingData<T>> это превращается в задачу — поток одноразовый, привязан к scope, и
таскать его через слои значит тащить вместе с ним UI-абстракции в домен.

iOS просто нет. В апстрим-исходниках paging-common KMP-таргеты постепенно появляются, и это
хорошая новость на будущее. Но публикуемые артефакты, инструменты вокруг (Room, RecyclerView,
Compose-адаптеры) и реальная экосистема — Android-first. Когда нужно дать iOS-команде то же
самое поведение пагинации, что и Android, ответа из коробки нет.

Каждый из этих четырёх пунктов решаем по отдельности. Все четыре одновременно — нет.

Почему не fork и не обёртка

Прежде чем браться за отдельную библиотеку, естественный вопрос — нельзя ли обойтись патчем или
надстройкой. Короткий ответ — нет, и вот почему.

Ядро Paging 3 устроено вокруг pull-модели потока событий для UI. Чтобы превратить его в
обычный объект состояния, который живёт в домене и которым можно управлять снаружи (jump,
replace, serialize), нужно перепридумать почти все ключевые типы — PagingSource,
PagingData, LoadStates. После такой переделки от исходной библиотеки остаётся, по сути,
только название.

Wrapper тем более не работает: обёртка вокруг Flow<PagingData<T>> всё равно остаётся обёрткой
вокруг иммутабельного потока. Через неё нельзя дать ни мутацию элемента, ни сериализацию кэша,
ни прыжок на произвольную страницу — потому что под обёрткой этих операций нет.

Поэтому Paginator — не альтернатива в смысле «тот же подход, но иначе», а другая модель:
страница это адресуемая ячейка в кэше, кэш это обычная структура данных, навигация это обычные
методы.

Принцип 1. Страница — адресуемая

В Paging 3 движение по ленте — это движение по ключам: «дай страницу с этим cursor’ом», «дай
следующую». В Paginator страница адресуется напрямую — номером, курсором или закладкой:

paginator.goNextPage()paginator.goPreviousPage()paginator.jump(BookmarkInt(page = 42))paginator.jump(CursorBookmark(self = "msg_817"))

Это маленькое решение, но оно меняет очень многое. Открыть переписку на сообщении из push —
одна строка. Запомнить позицию пользователя и вернуться через сутки — одна строка. Поставить
именованную закладку «начало непрочитанных» и циклически прыгать между закладками с
jumpForward / jumpBack — встроенный механизм, а не самописный поверх состояния адаптера.

У адресуемости есть ещё одно следствие: страницы можно складывать в обычную сортированную
коллекцию и работать с ней как с коллекцией. Снапшот — это упорядоченный список загруженных
страниц, и его можно отдать в UI напрямую, без посредников вроде LazyPagingItems. Если
хочется — можно подписаться на готовый paginator.uiState: Flow<PaginatorUiState<T>>, в
котором уже свернуты состояния Idle / Loading / Empty / Error / Content(items, prependState, appendState) для типовых экранов.

Принцип 2. Мутабельность по запросу

В Paginator два интерфейса, и разделение между ними сделано осознанно.

Paginator — read-only навигация. Его можно отдать в UI или в любой компонент, который не
должен менять данные. Никаких set, replace, remove в публичном API — компилятор
гарантирует, что место чтения не поломает страницу.

MutablePaginator — расширение, в котором появляются операции CRUD над элементами:

mutablePaginator.replace(predicate = { it.id == 42 }, transform = { it.copy(liked = true) })mutablePaginator.removeWhere { it.deleted }mutablePaginator.insertAfter(target = anchor, element = newMessage)

После каждой операции страницы автоматически перебалансируются по capacity, кэш остаётся
консистентным, снапшот переиспускается. Никакой инвалидации, никакого «перезагрузим страницу».
Один лайк меняет один элемент.

Этот же механизм закрывает оптимистичные обновления: применили мутацию локально, отправили
запрос, при ошибке откатили через transaction { } — атомарный блок, в котором все изменения
откатываются целиком при любом исключении, включая отмену корутины. Это снимает целый класс
багов, связанных с расхождением UI и сервера в момент сетевой ошибки.

Принцип 3. Состояние, которое сохраняется

Process death — старая беда Android. cachedIn(viewModelScope) в Paging 3 живёт ровно столько,
сколько живёт scope, а scope умирает вместе с процессом. После возврата scroll position может
восстановиться, но данные подгружаются заново — и хорошо, если бэкенд возвращает их в том же
порядке.

В Paginator кэш — обычная структура данных, и она с самого начала проектировалась так, чтобы её
можно было сериализовать:

val saved: String = paginator.serializeToJson()// ... process death ...paginator.restoreFromJson(saved)

Сериализация работает через kotlinx.serialization, поэтому едет на любой KMP-таргет —
Android, iOS, Desktop. На выходе получается не «верни на ту же позицию», а «верни в то же
состояние, со всеми видимыми страницами и текущей точкой». Пользователь, открывший экран по
deeplink, после убийства процесса возвращается к тому же сообщению с теми же соседями вокруг —
и без сетевого запроса.

Это не магия — это следствие того, что состояние пагинации хранится отдельно от Flow и не
зависит от жизненного цикла UI.

Принцип 4. Библиотека, а не фреймворк

Paginator написан на чистом Kotlin без платформенных зависимостей. Это значит несколько
практически важных вещей.

Во-первых, он живёт в commonMain. Один и тот же код пагинации работает на Android, iOS,
Desktop и сервере. Не «когда-нибудь будет», а сейчас, в публикуемых артефактах. Логика
пагинации становится частью общего доменного слоя KMP-проекта — без копирования между
платформами и без двух отдельных реализаций «как у нас принято».

Во-вторых, его не обязательно держать в ViewModel. Pagination — это поведение, а не
UI-состояние, и оно нормально живёт в use-case’е или репозитории. Один Paginator в
data-слое — три ViewModel, подписанные на него. Тот же Paginator можно прокинуть в worker
для фоновой подгрузки. Юнит-тестировать его можно без runTest-обвязки и без mock’ов
AndroidX.

В-третьих, его легко вырезать. Если завтра окажется, что вам подходит другое решение —
Paginator локализован в одном месте архитектуры и не размазан по UI и БД. Это редко
обсуждаемое, но важное свойство любой библиотеки: не только удобно подключаться, но и удобно
отключаться.

Принцип 5. Cursor — равноправный, а не workaround

В Paging 3 курсорная пагинация делается как частный случай PagingSource с ключом-строкой.
Работает, но требует руками собрать модель prev / self / next для GraphQL-style
connections и аккуратно ловить EndOfPaginationReached.

В Paginator курсорная пагинация сделана равной по статусу: CursorPaginator рядом с
Paginator, одинаковая модель состояний, одинаковый uiState, одинаковый transaction { },
одинаковая сериализация. Различается только адресация — вместо номера страницы используется
тройка prev / self / next:

val messages = mutableCursorPaginator<Message> {    load { cursor ->        val page = api.getMessages(cursor?.self as? String)        CursorLoadResult(            data = page.items,            bookmark = CursorBookmark(prev = page.prev, self = page.self, next = page.next),        )    }}

Для GraphQL connections, чатов и activity-стримов это правильная модель из коробки — без
переизобретения колеса поверх LoadParams.

Что есть сейчас

На момент публикации статьи Paginator опубликован на Maven Central под версией 8.5.0.
Поддерживаемые таргеты — Android, JVM (Desktop / Server), iosX64, iosArm64, iosSimulatorArm64.
Есть отдельный артефакт paginator-compose с биндингами для Jetpack Compose / Compose
Multiplatform — он добавляет одну строку scroll-driven prefetch для LazyColumn / LazyRow /
LazyVerticalGrid и их вариантов, без ручного LaunchedEffect / snapshotFlow.

Документация лежит в docs/
репозитория, разбита по темам — от core concepts до cursor pagination и interweaving. Если
хочется быстрого ощущения —
есть демо APK.

Когда Paginator вам, скорее всего, не нужен

Если у вас простая лента, источник правды — Room, и продуктовые требования стабильны:
подгрузка вниз, pull-to-refresh, и больше ничего сверху не предвидится — Paging 3 у вас, скорее
всего, уже работает, и менять его ради смены смысла нет. Это другой класс задач, и в этом
классе Paginator не даст ощутимого выигрыша.

Ссылки

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