Как работает PagingSource и как его правильно реализовать

PagingSource — это основной компонент Paging 3, который отвечает за ленивую постраничную загрузку: реализуйте load(params) и getRefreshKey(state), возвращайте корректные prevKey/nextKey и используйте Pager в ViewModel для получения Flow.

Как устроен PagingSource

PagingSource<TK, TV> централизует логику получения страниц. Главное — два метода:

  • suspend fun load(params: LoadParams): LoadResult<Key, Value> — вызывается при REFRESH, PREPEND, APPEND. params.key — текущий ключ, params.loadSize — подсказка по объёму.
  • fun getRefreshKey(state: PagingState<Key, Value>): Key? — возвращает ключ для восстановления после обновления UI.

LoadResult поддерживает:

  • LoadResult.Page(data, prevKey, nextKey) — успешная страница;
  • LoadResult.Error(exception) — ошибка.

Принципы:

  • prevKey/nextKey null — границы списка;
  • не храните состояние внутри PagingSource: он может создаваться заново;
  • PagingConfig.pageSize влияет на подсказки loadSize и кэширование.

Для позиционирования после обновления используйте state.anchorPosition и state.closestItemToPosition(anchor) в getRefreshKey.

Правильная реализация для API и Room

Пример API (пагинация по странице):

class ApiPagingSource(
  private val api: ApiService
) : PagingSource<Int, Item>() {
  override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
    return try {
      val page = params.key ?: 1
      val response = api.getItems(page, params.loadSize)
      LoadResult.Page(
        data = response.items,
        prevKey = if (page == 1) null else page - 1,
        nextKey = if (response.items.isEmpty()) null else page + 1
      )
    } catch (e: Exception) {
      LoadResult.Error(e)
    }
  }

  override fun getRefreshKey(state: PagingState<Int, Item>): Int? =
    state.anchorPosition?.let { pos ->
      state.closestItemToPosition(pos)?.id?.let { id -> calculatePageFromId(id, state.config.pageSize) }
    }
}

Пример Room (LIMIT / OFFSET):

class RoomPagingSource(
  private val dao: ItemDao
) : PagingSource<Int, Item>() {
  override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
    val offset = params.key ?: 0
    val data = dao.getItems(offset, params.loadSize)
    return LoadResult.Page(
      data = data,
      prevKey = if (offset == 0) null else maxOf(0, offset - params.loadSize),
      nextKey = if (data.isEmpty()) null else offset + params.loadSize
    )
  }
}

Советы:

  • Оборачивайте сетевые/бД операции в withContext(Dispatchers.IO) при необходимости.
  • Параметр loadSize может быть больше pageSize (например, при предварительной загрузке).
  • Для гибридного сценария (API + Room) лучше использовать RemoteMediator.

Не пытайтесь вызывать load вручную — это нарушит управление потоками и кэшом.

Интеграция с ViewModel и RemoteMediator

В ViewModel используйте Pager и cachedIn для корректного кэширования в scope:

val flow = Pager(
  config = PagingConfig(pageSize = 20, enablePlaceholders = false),
  pagingSourceFactory = { ApiPagingSource(api) }
).flow.cachedIn(viewModelScope)

Если требуется оффлайн-кэш с Room + синхронизация с сетью — используйте RemoteMediator. RemoteMediator управляет загрузкой в базу, а PagingSource (из Room DAO) предоставляет данные в UI. Это даёт устойчивую к пересозданиям и оффлайн-готовую логику.

Частые ошибки

  • Неправильные prevKey/nextKey — приводит к зацикливанию или бесконечной подгрузке.
  • Смешение offset и page без корректного расчёта ключей.
  • Выставление enablePlaceholders = true при нерегулярных данных — мерцание и неправильное позиционирование.
  • Хранение состояния в экземпляре PagingSource (он пересоздаётся).
  • Выполнение сетевых вызовов в Main без сдвига в IO.

FAQ

  • Нужно ли реализовывать getRefreshKey?
    Лучше да — для корректного восстановления позиции после конфигурационных изменений и swipe-to-refresh.

  • Что выбрать: RemoteMediator или чистый PagingSource?
    Если нужен оффлайн-кэш и синхронизация — RemoteMediator + Room. Для простых API-only списков — PagingSource достаточно.

  • Как подобрать pageSize?
    Тестируйте: 20–30 подходит для большинства списков; уменьшайте для тяжёлых элементов или медленного соединения.