В прошлой статье я сравнивал Paginator с Paging 3 на кошачьем уровне: «вот простой фид, смотрите — три строки вместо тридцати». Это полезно для первого знакомства, но не отвечает на главный вопрос: а как оно себя поведёт, когда продукт начнёт требовать то, ради чего люди обычно и пишут свой велосипед поверх Paging 3?
В этой статье я беру мессенджер — потому что мессенджер это честный полигон. Там есть:
-
лента сообщений с подгрузкой вверх и вниз,
-
автоматическая подгрузка на скролле (prefetch) без кнопок «Загрузить ещё»,
-
новые сообщения из WebSocket в реальном времени,
-
optimistic send с откатом при ошибке,
-
редактирование и удаление,
-
deeplink на конкретное сообщение и прыжки на закреплённые,
-
date-разделители и плашка «Новые сообщения»,
-
транзакционные правки (несколько изменений атомарно, с откатом на сервере),
-
работа оффлайн с переживанием process death.
Девять боевых задач. Одна ViewModel. Никаких костылей.
Дисклеймер про курсорную пагинацию
Прежде чем начнём: если ваш бэкенд отдаёт сообщения не по номеру страницы, а по nextCursor / prevCursor (GraphQL connections, Slack API, Instagram, Reddit и прочие ленты с «подвижным краем»), — вам нужен не Paginator, а его курсорный брат CursorPaginator.
Это отдельный тип, потому что курсоры и Int-индексы живут по разным правилам: у курсора нет «страницы 42», нет random-access прыжков на произвольный номер, нет resize(capacity). Зато есть CursorBookmark(prev, self, next) и LinkedList-модель, где страница знает только своих соседей.
API при этом — зеркальное:
val paginator = mutableCursorPaginator<Message>(capacity = 50) { load { cursor -> val page = api.getMessages(cursor?.self as? String) CursorLoadResult( data = page.items, bookmark = CursorBookmark( prev = page.prevCursor, self = page.selfCursor, next = page.nextCursor, ), ) }}
Те же uiState, jump, goNextPage, interweave, transaction, L2-кэш — всё на месте. Паттерны из этой статьи переносятся один-в-один, меняется только ключ (Int → self: Any). Детали — в отдельной документации.
Дальше в статье — всё на обычном Paginator. Будем считать, что бэкенд отдаёт GET /chats/:id/messages?page=N.
Задача 0: сетап
class ChatViewModel( private val api: ChatApi, private val chatId: String,) : ViewModel() { private val paginator = mutablePaginator<Message>(capacity = 50) { load { page -> val response = api.getMessages(chatId, page) this.finalPage = response.totalPages // узнаём границу ленты сразу при загрузке LoadResult(response.items) } } val uiState = paginator.uiState .stateIn(viewModelScope, SharingStarted.Eagerly, PaginatorUiState.Idle) init { viewModelScope.launch { paginator.restart() } } override fun onCleared() { paginator.release() super.onCleared() }}
Три строки — и у нас уже есть стейт-машина с Idle / Loading / Empty / Error / Content(items, prependState, appendState). В UI это превращается в пятистрочный when и LazyColumn. Первая задача закрыта до того, как мы успели её поставить.
Обратите внимание на this.finalPage = response.totalPages внутри load: ресивер лямбды — сам пагинатор, поэтому мы присваиваем finalPage прямо на месте, без наблюдения uiState и ручной синхронизации. Когда goNextPage попытается прыгнуть за границу, он бросит FinalPageExceededException, и UI покажет плашку «Начало переписки».
Задача 1: история и подгрузка вверх
Юзер открыл чат. Нужно показать последние 50 сообщений, а при скролле вверх — подгрузить более старые.
Вопрос к Paginator: а где тут верх и где низ? У мессенджера перевёрнутая ось: «страница 1» — это самые свежие сообщения, «страница 2» — старее. То есть goNextPage в нашем случае означает «грузи более старую историю».
fun onScrolledToTop() { viewModelScope.launch { paginator.goNextPage() }}fun onSwipeToRefresh() { viewModelScope.launch { paginator.restart() }}
goNextPage знает, что такое «filled» страница (пришло capacity элементов) и «незаполненная» (пришло меньше). Если сервер вернул незаполненную страницу, на следующий вызов goNextPage он не перескочит вперёд, а повторно запросит ту же страницу через isFilledSuccessState — на случай, если бэк дослал. Поверх этого в UI уже есть ProgressPage с ранее закэшированными данными, так что пользователь увидит старый контент и индикатор загрузки одновременно. Это из коробки, писать руками нечего.
Задача 2: prefetch — подгрузка без кнопок «Ещё»
Ручной onScrolledToTop в 2026 году — анахронизм. Современный UX: пагинатор должен начать качать следующую страницу за несколько экранов до того, как пользователь доскроллит до края.
Для этого есть PaginatorPrefetchController — платформо-независимый контроллер, принимающий информацию о видимых элементах и сам вызывающий goNextPage / goPreviousPage:
private val prefetch = paginator.prefetchController( scope = viewModelScope, prefetchDistance = 10, // начинаем грузить за 10 элементов до края enableBackwardPrefetch = true, // и вверх тоже (история), и вниз (если бэк отдаёт))fun onScroll(firstVisible: Int, lastVisible: Int, total: Int) { prefetch.onScroll(firstVisible, lastVisible, total)}
В UI — минимальное:
val listState = rememberLazyListState()LaunchedEffect(listState) { snapshotFlow { Triple( listState.firstVisibleItemIndex, listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0, listState.layoutInfo.totalItemsCount, ) }.collect { (first, last, total) -> viewModel.onScroll(first, last, total) }}
Важные детали, которые делает контроллер:
-
Первый
onScroll— калибровочный. Пагинатор запомнит стартовую позицию и ничего не начнёт грузить — чтобы не было ложной подгрузки при первом появлении экрана. -
Тихая подгрузка. По умолчанию
silentlyLoading = true— это значит, чтоProgressPageне эмитится. UI не мигает «Loading» при каждом подлёте к краю. -
Уважает
finalPage. Если дошли до конца ленты — prefetch останавливается, лишних запросов в пустоту не будет. -
Уважает dirty pages. Если какая-то страница в контекст-окне помечена как устаревшая (например, после оффлайн-редактирования), следующий prefetch запустит фоновой refresh этих страниц параллельно.
-
Легко выключается. Модальный диалог?
prefetch.enabled = false, и контроллер молчит, пока вы его не включите обратно.
После jump или restart состояние списка меняется полностью — нужно сбросить калибровку:
fun openDeeplink(messageId: String) { viewModelScope.launch { val location = api.locate(chatId, messageId) paginator.jump(BookmarkInt(location.page)) prefetch.reset() // следующий onScroll станет калибровочным }}
Одна строка сетапа на ViewModel, одна строка интеграции в LazyColumn — и бесконечный скролл работает «сам». Попробуйте воспроизвести это поведение на Paging 3 без загрузочных лоадеров в середине списка. Посмотрим, сколько займёт.
Задача 3: новое сообщение из WebSocket
Приходит пуш: {"type": "message.new", "message": {...}}. Нужно вставить на самый верх (в нашей оси — на страницу 1, индекс 0), не перезапрашивая ленту.
fun onWebSocketMessage(msg: Message) { paginator.addAllElements( elements = listOf(msg), targetPage = 1, index = 0, )}
Что тут происходит внутри:
-
Сообщение вставляется в page=1 на позицию 0.
-
Page=1 уже содержит
capacity=50элементов — значит, после вставки их стало 51. Переполнение каскадирует вперёд: последний элемент page=1 уезжает в начало page=2, последний page=2 — в начало page=3, и так далее по цепочке закэшированных страниц. Инвариант «на странице не большеcapacityэлементов» держится автоматически.
Всё. Одна строка на событие WebSocket, library сама разбирается с capacity invariant. В Paging 3 такое делалось через RemoteMediator + ручная работа с Room + invalidate() + мерцание — и всё равно получалось криво.
Задача 4: optimistic send
Юзер нажал «Отправить». Сообщение должно мгновенно появиться в ленте с плашкой «отправляется», а когда придёт ответ сервера — заменить его на настоящее с серверным id. Если сервер вернул ошибку — показать плашку «не отправлено» с кнопкой ретрая.
Тут пригодится штука, про которую в первой статье я упоминал мельком: PageState — open-иерархия. Мы можем завести свои типы страниц и элементов.
Для элемента достаточно поля статуса:
data class Message( val id: String, // локальный UUID до подтверждения, серверный после val text: String, val createdAt: Instant, val status: MessageStatus = MessageStatus.Sent,)enum class MessageStatus { Sending, Sent, Failed }
Сам поток отправки:
fun sendMessage(text: String) { val localId = Uuid.random().toString() val pending = Message( id = localId, text = text, createdAt = Clock.System.now(), status = MessageStatus.Sending, ) // 1. Optimistic insert paginator.addAllElements(listOf(pending), targetPage = 1, index = 0, isDirty = true) viewModelScope.launch { runCatching { api.send(chatId, text) } .onSuccess { serverMsg -> // 2. Заменяем pending на серверное сообщение paginator.updateWhere( predicate = { it.id == localId }, transform = { serverMsg.copy(status = MessageStatus.Sent) }, ) } .onFailure { // 3. Помечаем как failed paginator.updateWhere( predicate = { it.id == localId }, transform = { it.copy(status = MessageStatus.Failed) }, ) } }}
updateWhere — extension на MutablePaginator, обходит все страницы в кэше и заменяет элементы по предикату. Возвращает количество затронутых. Для нашего случая O(1) по страницам (pending только что вставили в page=1, поиск найдёт его сразу), но даже если бы искали по всему чату — это несколько страниц по 50 элементов, не проблема.
Можно пойти дальше и сделать кастомный PageState, который UI будет отличать от обычного Success:
class PendingSendPage<T>( page: Int, data: List<T>, val pendingIds: Set<String>,) : PageState.SuccessPage<T>(page, data)
Но для 90% случаев достаточно статуса на элементе.
Задача 5: редактирование и удаление
Юзер открыл меню сообщения, нажал «Изменить». Отправили на сервер, получили обновлённое — патчим:
fun editMessage(messageId: String, newText: String) { viewModelScope.launch { val updated = api.edit(messageId, newText) paginator.updateWhere( predicate = { it.id == messageId }, transform = { updated }, ) }}
Удаление:
fun deleteMessage(messageId: String) { viewModelScope.launch { api.delete(messageId) paginator.removeAll { it.id == messageId } }}
Тут есть красивая деталь, о которой стоит сказать. Когда мы удаляем элемент из середины страницы, на странице остаётся capacity - 1 элемент. Дальше при goNextPage библиотека посмотрит на эту ситуацию через isFilledSuccessState и — если страница стала незаполненной — дозаберёт недостающий элемент из следующей закэшированной страницы. Инвариант «на странице либо capacity элементов, либо мы на хвосте» держится автоматически.
В Paging 3 для того же сценария пришлось бы писать свой RemoteMediator, триггерить invalidate(), надеяться на корректное восстановление скролла. Здесь — две строки.
Задача 6: transaction — несколько правок атомарно
Бывает сценарий, когда мы меняем несколько вещей сразу и хотим, чтобы либо все они применились, либо ни одна. Классика — «Отметить чат как прочитанный»: в списке сообщений все непрочитанные должны стать прочитанными, счётчик в шапке должен обнулиться, плашка «N новых» должна исчезнуть, и всё это должно быть подтверждено сервером. Если сервер упадёт — откатываемся на предыдущее состояние полностью, без полумер.
У Paginator для этого есть transaction { } — атомарный блок с deep-copy savepoint под капотом. Если внутри бросится любое исключение (включая CancellationException), всё состояние откатится: кэш, контекст-окно, dirty flags, capacity, finalPage, bookmarks, lock-флаги. Всё.
fun markChatRead() { viewModelScope.launch { try { paginator.transaction { // 1. Optimistic: помечаем все загруженные сообщения как прочитанные (this as MutablePaginator).updateAll { msg -> if (msg.isRead) msg else msg.copy(isRead = true) } // 2. Шлём на сервер. Если упадёт — transaction откатит updateAll api.markChatRead(chatId) } // 3. Успех — счётчик уже реагировал на updateAll через uiState } catch (e: IOException) { showError("Не удалось отметить как прочитанные") // Ручной откат не нужен — transaction уже всё вернул } }}
Что было бы без transaction:
-
Пишем
updateAll { ... }на L1 — UI обновился. -
Ловим ошибку сервера.
-
Вручную возвращаем все элементы обратно. Но мы уже не знаем, какие из них были
isRead = false, а какиеisRead = trueдо вызова — их состояние затёрлось. -
Дёргаем
refreshвсего видимого окна, ждём сеть, UI мигает, пользователь видит «моргнувшие» метки прочтения.
С transaction ничего этого нет: оптимистичное изменение применяется мгновенно, и если что-то ломается — состояние возвращается бит-в-бит к тому, каким оно было до блока.
Более злой сценарий — пересылка нескольких сообщений в другой чат с одновременным удалением из текущего:
fun forwardAndDelete(messageIds: List<String>, targetChatId: String) { viewModelScope.launch { try { paginator.transaction { val mp = this as MutablePaginator<Message> // 1. Оптимистично удаляем из текущего чата val removed = mp.removeAll { it.id in messageIds } check(removed == messageIds.size) { "не все сообщения найдены в кэше" } // 2. Навигация внутри транзакции разрешена (!) // jump/goNext/refresh работают без дедлока — mutex // 3. Шлём на сервер api.forward(messageIds, targetChatId) // Если forward упал — removeAll откатится, сообщения вернутся в ленту } } catch (e: Exception) { showError("Не удалось переслать") } }}
Ещё одна приятная деталь: transaction внутри вызывает flush() автоматически на успехе. То есть если у вас подключён L2 — все изменения, которые произошли внутри блока, атомарно запишутся в БД после успеха. Если блок упал — L2 вообще не трогался. «Eventual consistency» уровня Room из одной строки.
Задача 7: deeplink и прыжок на закреп
Пользователь тапнул на уведомление: «Ответ в чате X на сообщение msg_42». Приложение открылось, надо не просто открыть чат, а проскроллить к нужному сообщению — и чтобы вокруг него был контекст.
Бэкенд умеет отдавать «на какой странице лежит это сообщение»: GET /chats/:id/locate/:messageId → {page: 7}.
fun openDeeplink(messageId: String) { viewModelScope.launch { val location = api.locate(chatId, messageId) paginator.jump(BookmarkInt(location.page)) prefetch.reset() // список меняется целиком — калибруемся заново }}
После jump происходит следующее: контекст-окно (startContextPage..endContextPage) перестраивается вокруг страницы 7. Снимок, который получит UI, будет содержать страницы 6, 7, 8 — то есть сообщение с контекстом «до» и «после». Если юзер после прыжка начинает скроллить вверх, goPreviousPage будет догружать 5, 4, 3 — и когда дойдёт до уже закэшированной (если ранее был скролл оттуда) — окна сомкнутся без дубликатов, потому что кэш ключится по page: Int и страница 3 — это всегда та же самая страница 3.
Для закреплённых сообщений механика та же, но с bookmarks. Бэк отдаёт список закреплённых вместе с их страницами:
viewModelScope.launch { val pinned = api.getPinned(chatId) // List<{messageId, page}> paginator.bookmarks.clear() paginator.bookmarks.addAll(pinned.map { BookmarkInt(it.page) }) paginator.recyclingBookmark = true}
И в UI — две кнопки «следующий закреп» / «предыдущий закреп»:
fun nextPinned() = viewModelScope.launch { paginator.jumpForward() }fun prevPinned() = viewModelScope.launch { paginator.jumpBack() }
jumpForward / jumpBack сами следят, чтобы не прыгать на закреп, который уже виден на экране. Юзер листает между закрепами, контекст вокруг каждого догружается сам, окна смыкаются.
Небольшая сноска: если ваш бэкенд отдаёт не
{page: 7}, а курсорmsg_abc123, — это тот самый случай дляCursorPaginator. Там этоjump(CursorBookmark(prev = null, self = "msg_abc123", next = null)), и сервер в ответе дорисует настоящиеprev/next.
Задача 8: date-разделители и плашка «Новые сообщения»
Классический UX чата: сообщения, сгруппированные по дням, с разделителем «Сегодня», «Вчера», «17 апреля». Плюс жирная плашка «N новых сообщений» на границе непрочитанного.
Это не задача пагинатора. Пагинатор оперирует страницами и элементами; разделители — это UI-концепт, который должен вставляться между элементами финального потока. Но библиотека предлагает для этого чистый инструмент — Interweaver.
sealed interface ChatRow { data class Msg(val m: Message) : ChatRow data class DateSeparator(val day: LocalDate) : ChatRow data class UnreadBanner(val count: Int) : ChatRow}val chatRows: Flow<List<ChatRow>> = paginator.uiState .interweave { prev, curr, index -> buildList { // Плашка «Новые» — между прочитанными и непрочитанными if (prev != null && prev.isRead && !curr.isRead) { add(WovenEntry.Inserted(ChatRow.UnreadBanner(unreadCount))) } // Разделитель дня val prevDay = prev?.createdAt?.toLocalDate() val currDay = curr.createdAt.toLocalDate() if (prevDay != currDay) { add(WovenEntry.Inserted(ChatRow.DateSeparator(currDay))) } add(WovenEntry.Original(ChatRow.Msg(curr))) } }
Weaver — это чистая функция «предыдущий элемент, текущий элемент → что вставить». Пагинатор про разделители ничего не знает, UI получает готовый поток List<ChatRow>. Когда страница догружается — поток пересчитывается автоматически, разделители встают туда, куда надо.
Важное: эта же механика дословно работает для CursorPaginator — interweave реализован на уровне PaginatorUiState, которому всё равно, как адресуются страницы.
Задача 9: оффлайн-first
Это финал. Юзер открывает чат в метро — должно что-то показаться. Убил приложение, открыл через полчаса — должно открыться на том же месте, с тем же скроллом. В оффлайне отредактировал сообщение — изменение должно синхронизироваться, когда вернётся сеть. И всё это — без мерцания UI.
Это самая большая задача в статье, потому что она на стыке нескольких механизмов: L2-кэш, dirty-tracking, process death, warm-up, refresh. Разложим по слоям.
9.1. L2-кэш поверх Room
Библиотека сама ничего в БД не пишет — она предоставляет интерфейс PersistentPagingCache<T> с пятью методами: save, load, loadAll, remove, clear. Реализация — на вашей стороне. Шаблонный Room-бэкенд выглядит так:
@Entity(tableName = "messages_pages")data class PageEntity( @PrimaryKey val page: Int, val chatId: String, val dataJson: String, val isEmpty: Boolean, val updatedAt: Long,)@Daointerface PageDao { @Upsert suspend fun upsert(entity: PageEntity) @Query("SELECT * FROM messages_pages WHERE chatId = :chatId AND page = :page") suspend fun get(chatId: String, page: Int): PageEntity? @Query("SELECT * FROM messages_pages WHERE chatId = :chatId ORDER BY page") suspend fun getAll(chatId: String): List<PageEntity> @Query("DELETE FROM messages_pages WHERE chatId = :chatId AND page = :page") suspend fun delete(chatId: String, page: Int) @Query("DELETE FROM messages_pages WHERE chatId = :chatId") suspend fun clear(chatId: String)}class RoomMessagesCache( private val dao: PageDao, private val chatId: String,) : PersistentPagingCache<Message> { private val serializer = ListSerializer(Message.serializer()) override suspend fun save(state: PageState<Message>) { dao.upsert( PageEntity( page = state.page, chatId = chatId, dataJson = Json.encodeToString(serializer, state.data), isEmpty = state.isEmptyState(), updatedAt = Clock.System.now().toEpochMilliseconds(), ) ) } override suspend fun load(page: Int): PageState<Message>? { val entity = dao.get(chatId, page) ?: return null val data = Json.decodeFromString(serializer, entity.dataJson) return if (entity.isEmpty) EmptyPage(page, data) else SuccessPage(page, data.toMutableList()) } override suspend fun loadAll(): List<PageState<Message>> = dao.getAll(chatId).mapNotNull { load(it.page) } override suspend fun remove(page: Int) = dao.delete(chatId, page) override suspend fun clear() = dao.clear(chatId)}
Подключаем в DSL:
private val paginator = mutablePaginator<Message>(capacity = 50) { load { page -> val response = api.getMessages(chatId, page) this.finalPage = response.totalPages LoadResult(response.items) } cache = LruPagingCache(maxSize = 20) // L1: держим в памяти 20 страниц persistentCache = RoomMessagesCache(dao, chatId) // L2: всё}
И всё. Дальше цепочка работает автоматически:
-
Read-path: L1 → L2 → network. На cache-miss в памяти — пагинатор заглядывает в Room, и если страница там есть, она промотируется в L1 и возвращается мгновенно. Сеть не дёргается, лоадер не показывается.
-
Write-path: после каждого успешного
loadстраница автоматически пишется в L2. То есть всё, что юзер видел хотя бы раз, — сохранено.
9.2. Warm-up на холодном старте
По умолчанию L2 читается лениво — только когда пагинатору нужна конкретная страница. Но для чата это не то, что мы хотим. Мы хотим, чтобы при открытии приложения в оффлайне вся последняя сохранённая лента была сразу доступна, без «Loading…».
Для этого есть warmUpFromPersistent():
init { viewModelScope.launch { val inserted = paginator.warmUpFromPersistent() if (inserted == 0) { // Кэш пуст — это первый заход в чат. Тянем с сервера. paginator.restart() } else { // Есть закэшированное. Показываем немедленно, в фоне обновляем. paginator.refresh(pages = paginator.core.affectedPages.toList()) } }}
warmUpFromPersistent вернёт количество вставленных страниц и тихо (без эмита snapshot) разложит их по L1. Следующий jump/goNextPage попадёт сразу в L1, без сетевого запроса.
Нюанс: если у нас LruPagingCache(maxSize = 20), а в Room лежит 100 страниц — в L1 попадут только 20 (последние, потому что прогрев идёт через обычный setState). Остальные 80 останутся в L2 и подтянутся по мере скролла.
9.3. Process death: SavedStateHandle
Android может прибить процесс в любой момент. L2 это, конечно, переживёт — но позиция скролла, контекст-окно, bookmarks, lock-флаги живут в памяти пагинатора. Нужно сохранить его состояние целиком.
class ChatViewModel( private val api: ChatApi, private val chatId: String, private val savedState: SavedStateHandle,) : ViewModel() { private val paginator = mutablePaginator<Message>(capacity = 50) { load { page -> val response = api.getMessages(chatId, page) this.finalPage = response.totalPages LoadResult(response.items) } persistentCache = RoomMessagesCache(dao, chatId) } init { viewModelScope.launch { // 1. Пробуем восстановить снимок из SavedStateHandle (process death) val snapshot: String? = savedState[SNAPSHOT_KEY] if (snapshot != null) { paginator.restoreStateFromJson(snapshot, Message.serializer()) } else { // 2. Пробуем прогреть из Room (cold start) val inserted = paginator.warmUpFromPersistent() if (inserted == 0) paginator.restart() } } // Сохраняем снимок каждый раз, когда что-то меняется paginator.uiState .debounce(500) .onEach { savedState[SNAPSHOT_KEY] = paginator.saveStateToJson( elementSerializer = Message.serializer(), contextOnly = true, // только видимые страницы ) } .launchIn(viewModelScope) } companion object { private const val SNAPSHOT_KEY = "chat_paginator_snapshot" }}
contextOnly = true — ключевая деталь. Без неё мы бы серилизовали весь кэш (потенциально сотни страниц), и Bundle мог бы превысить лимит TransactionTooLargeException (1MB). С contextOnly = true сохраняются только страницы текущего окна — обычно 3-5 штук, сотня килобайт JSON, влезает без проблем.
При восстановлении:
-
ErrorPageиProgressPageконвертируются вSuccessPage/EmptyPageи помечаются dirty — чтобы при первом же подходе к ним пагинатор их обновил. -
Контекст-окно, bookmarks, lock-флаги,
finalPage— восстанавливаются как есть.
После restoreStateFromJson пагинатор выглядит так, как будто process death не было — тот же скролл, тот же контекст.
9.4. Dirty-tracking и отложенная синхронизация
А теперь самое интересное. Юзер в оффлайне:
-
Отредактировал сообщение —
updateWhereсisDirty = trueна соответствующей странице. -
Удалил сообщение —
removeAllсisDirty = true. -
Отправил новое сообщение —
addAllElements(... isDirty = true).
Все эти изменения лежат в L1. Их нужно:
-
Сохранить в L2, чтобы при убийстве приложения они не потерялись.
-
Отправить на сервер, когда вернётся сеть.
Для L2 — flush():
// После пачки изменений — явный flushpaginator.flush()
Либо автоматически — внутри transaction { } flush вызывается сам на успехе.
MutablePaginator сам трекает изменения: affectedPages: Set<Int> показывает, какие страницы были тронуты, hasPendingFlush: Boolean — есть ли вообще что-то незасейвленное. Это полезно для UI-индикатора «несохранённые изменения» или для тестов.
Для сервера — отдельный механизм на уровне репозитория (мы не можем автоматически знать, какой API вызвать для «отредактированного сообщения»), но у нас есть всё, чтобы его построить:
fun onNetworkAvailable() { viewModelScope.launch { // 1. Синхронизируем очередь исходящих изменений со своим REST-клиентом outboxSyncer.syncAll() // ваш кастомный код // 2. Обновляем видимый контекст — вдруг с сервера прилетело что-то новое val visiblePages = paginator.core.run { startContextPage..endContextPage }.toList() paginator.refresh(visiblePages) // 3. На всякий случай — flush L1 в L2 paginator.flush() }}
9.5. Что в итоге работает
Соберём в одну картину:
-
Юзер едет в метро → открывает чат. Тут же видит последние 20 страниц — prefetch подтягивает ещё из L2 по мере скролла.
-
Написал что-то → сообщение вставлено в L1 с
isDirty = true, лежит в памяти. -
Убил приложение →
SavedStateHandleсохранил снимок текущего окна. -
Открыл через час →
restoreStateFromJsonподнял окно с тем же скроллом. Всё остальное — из L2. -
Появилась сеть →
outboxSyncer.syncAll()отправил отложенные изменения,refreshобновил видимое окно,flushзаписал итог в L2.
Ни одного invalidate(). Ни одного Flow<PagingData>. Ни одного мерцания.
Что у нас получилось
Один ViewModel. Девять боевых задач. Давайте соберём:
|
Задача |
Вызов |
Строк |
|---|---|---|
|
История и подгрузка вверх |
|
2 |
|
Prefetch |
|
~10 |
|
Новое сообщение из WebSocket |
|
1 |
|
Optimistic send |
|
~15 |
|
Редактирование / удаление |
|
2 |
|
Transaction |
|
~10 |
|
Deeplink + закрепы |
|
~8 |
|
Date-разделители + «Новые сообщения» |
|
~15 |
|
Оффлайн-first + process death |
|
~40 |
Ни одного RemoteMediator. Ни одного PagingSource. Ни одного invalidate().
И самое приятное — это полноценный Kotlin Multiplatform код. Тот же ViewModel компилируется под iOS, и там uiState так же подцепится к SwiftUI через тонкий адаптер. Paging 3 на этом моменте просто выходит из чата, потому что его нет вне Android.
А если ваш бэкенд отдаёт курсоры вместо номеров страниц — всё ровно то же самое, только Paginator меняется на CursorPaginator, BookmarkInt(N) — на CursorBookmark(prev, self, next), targetPage = 1 — на targetSelf = headCursor. Остальные паттерны работают дословно.
В следующей статье — разберём, как это устроено изнутри: три слоя (PagingCore / Paginator / MutablePaginator), mutex вместо гонок, транзакции с savepoint для отката, и почему PageState — sealed, но все его наследники open. Это для тех, кто любит читать не только API, но и внутренности.
Если понравилось — звезда на GitHub сильно помогает, спасибо.
ссылка на оригинал статьи https://habr.com/ru/articles/1027686/