Реализация пагинации данных в мобильном приложении
Запрос списка из 10 000 товаров одним вызовом — классическая ошибка, которую видишь в каждом третьем проекте. API возвращает 40 МБ JSON, приложение зависает на парсинге, пользователь видит белый экран на 8 секунд и уходит. Пагинация решает это не на уровне UI, а на уровне контракта между клиентом и сервером.
Offset vs Cursor: где что применять
Большинство разработчиков берут offset-пагинацию по умолчанию — ?page=2&limit=20. Это работает, пока данные статичны. Стоит добавить живую ленту, где записи вставляются в начало, и пользователь на странице 3 пропустит записи или увидит дубли: INSERT в начало сдвигает все offset'ы.
Cursor-пагинация решает проблему: сервер возвращает next_cursor, клиент передаёт его в следующем запросе. Курсор — это опакный токен (обычно base64 от id + timestamp), который фиксирует позицию в наборе данных. PostgreSQL-бэкенды реализуют это через WHERE id < :cursor ORDER BY id DESC LIMIT 20. Никаких дублей, никаких пропусков.
На Android с Paging 3 курсорная пагинация реализуется через RemoteMediator + PagingSource:
class ItemPagingSource(
private val api: ItemsApi,
private val query: String
) : PagingSource<String, Item>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, Item> {
return try {
val response = api.getItems(
cursor = params.key,
limit = params.loadSize,
query = query
)
LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = response.nextCursor
)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<String, Item>): String? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.nextKey
}
}
}
LazyColumn в Compose подключается через collectAsLazyPagingItems() — это готовый binding, который обрабатывает состояния Loading, Error, NotLoading без лишнего кода.
На iOS аналог — compositional layout с UICollectionViewDiffableDataSource и NSDiffableDataSourceSnapshot. Prefetch реализуется через UICollectionViewDataSourcePrefetching: метод prefetchItemsAt вызывается за N ячеек до края, что позволяет запустить сетевой запрос заранее. Без prefetch при скорости скролла больше 500 px/s видно пустые ячейки — пользователь ждёт подгрузки.
Бесконечный скролл и pull-to-refresh
Бесконечный скролл без состояния ошибки — типичная проблема. Сеть пропала на 5-й странице, запрос завис, пользователь скролит вниз и ничего не происходит. Нужен явный LoadStateFooter с кнопкой retry.
В Paging 3:
adapter.addLoadStateListener { loadState ->
binding.retryButton.isVisible = loadState.source.append is LoadState.Error
binding.progressBar.isVisible = loadState.source.append is LoadState.Loading
}
Pull-to-refresh сбрасывает пагинацию к первой странице через adapter.refresh() — Paging 3 инвалидирует PagingSource и начинает заново. На SwiftUI это refreshable модификатор, который вызывает invalidateQueries() в TCA или обновляет @StateObject вьюмодели.
Кэш и offline
Paging 3 + Room — стандартная связка для offline-first. RemoteMediator записывает данные в локальную БД, PagingSource читает из Room, а не из сети. Пользователь видит данные даже без интернета, свежие данные подгружаются фоном.
Ключевой момент: стратегия инвалидации кэша. Если сервер обновил запись, локальная копия устареет. Решение — ETag или Last-Modified в заголовках ответа: клиент отправляет If-None-Match, сервер возвращает 304 без тела при отсутствии изменений. Room обновляет только изменённые записи через @Insert(onConflict = OnConflictStrategy.REPLACE).
Сроки
Простая offset-пагинация без кэша — 1-2 дня. Cursor-пагинация с RemoteMediator, offline-кэшем и retry-логикой — 3-5 дней. Стоимость рассчитывается индивидуально после анализа требований и существующего API.







