Реалізація пагинації даних в мобільному додатку
Запрос списку з 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 вьюмоделі.
Кеш та офлайн
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.







