Как работает 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 подходит для большинства списков; уменьшайте для тяжёлых элементов или медленного соединения.