Постраничная загрузка данных в Android: практический гайд по Paging 3
Короткий ответ: для сетевых списков используйте PagingSource + Pager + PagingDataAdapter; для кэша и офлайна добавляйте RemoteMediator и Room — внизу рабочие примеры и конкретные рекомендации для RecyclerView, обработки LoadState и тестирования.
Быстрый старт: PagingSource, Pager и PagingDataAdapter
Простой поток для сетевого API:
class MoviesPagingSource(private val api: MoviesApi) : PagingSource<Int, Movie>() {
override suspend fun load(params: LoadParams<Int>) = try {
val page = params.key ?: 1
val resp = api.getMovies(page = page, size = params.loadSize)
val items = resp.items
LoadResult.Page(
data = items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (items.isEmpty()) null else page + 1
)
} catch (e: Exception) { LoadResult.Error(e) }
override fun getRefreshKey(state: PagingState<Int, Movie>) =
state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1)
?: state.closestPageToPosition(it)?.nextKey?.minus(1) }
}
Создание Pager в репозитории:
fun getMovies(): Flow<PagingData<Movie>> =
Pager(PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false)) {
MoviesPagingSource(api)
}.flow
В ViewModel: cachedIn(viewModelScope). В Activity/Fragment: collectLatest и adapter.submitData(pagingData). Подключите adapter.withLoadStateHeaderAndFooter для отображения прогресса/ошибок.
Используйте collectLatest в lifecycleScope и cachedIn в ViewModel, чтобы избежать повторных загрузок при повороте экрана.
Room + RemoteMediator: кэш и офлайн
Когда нужен single source of truth — читайте UI из Room, а RemoteMediator подгружает и записывает страницы в БД.
DAO:
@Dao
interface MoviesDao {
@Query("SELECT * FROM movies ORDER BY releaseDate DESC")
fun pagingSource(): PagingSource<Int, MovieEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<MovieEntity>)
@Query("DELETE FROM movies")
suspend fun clearAll()
}
RemoteMediator (упрощённо):
class MoviesRemoteMediator(
private val api: MoviesApi, private val db: AppDatabase, private val dao: MoviesDao
) : RemoteMediator<Int, MovieEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, MovieEntity>): MediatorResult {
try {
val page = when(loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(true)
LoadType.APPEND -> (state.lastItemOrNull()?.page ?: 0) + 1
}
val resp = api.getMovies(page = page, size = state.config.pageSize)
val entities = resp.items.map { it.toEntity(page) }
db.withTransaction {
if (loadType == LoadType.REFRESH) dao.clearAll()
dao.insertAll(entities)
}
return MediatorResult.Success(endOfPaginationReached = resp.items.isEmpty())
} catch (e: Exception) { return MediatorResult.Error(e) }
}
}
Pager с RemoteMediator: передайте remoteMediator и pagingSourceFactory (dao.pagingSource()).
Неправильная логика расчёта следующей страницы в RemoteMediator — частая причина дублей или пропусков. Тестируйте REFRESH/APPEND/ PREPEND отдельно.
LoadState, UX и тестирование
- Обработка LoadState: слушайте adapter.addLoadStateListener, показывайте initial progress при source.refresh, footer/header для append/prepend, и кнопку retry при ошибке (adapter.retry()).
- Pull-to-refresh: вызывайте adapter.refresh() или запускайте new PagingData с refresh key.
- PagingConfig: pageSize 20–50; initialLoadSize = pageSize * 2 часто даёт хороший UX. enablePlaceholders = false по умолчанию удобнее.
- Тестирование: мокируйте PagingSource/RemoteMediator; проверяйте переходы LoadState (Loading → NotLoading/Error) и сценарии офлайн.
Если API отдаёт курсоры (cursor-based), реализуйте ключи страниц в PagingSource/RemoteMediator через cursor, а не Int page.
Частые ошибки
- Неправильный getRefreshKey → потеря позиции при refresh или rotation.
- Использование launch вместо collectLatest → конкурирующие потоки submitData.
- Запись в БД вне транзакции в RemoteMediator → частичные обновления и неконсистентность.
- Игнорирование loadSize vs pageSize → несоответствие ожиданий API.
FAQ
- Нужно ли использовать RemoteMediator всегда?
- Нет. Для простых сетевых списков достаточно PagingSource + Pager. RemoteMediator нужен для кэша/офлайна и single-source-of-truth.
- Как восстановить позицию списка после refresh?
- Корректно реализуйте getRefreshKey, используйте snapshot/anchorPosition из PagingState.
- Как показать пустой экран, если данных нет?
- Отслеживайте LoadState: source.refresh is NotLoading и adapter.itemCount == 0 → показать empty view.
Заключение: начните с PagingSource + PagingDataAdapter, добавьте RemoteMediator и Room, когда понадобится кэш и офлайн. Правильная конфигурация PagingConfig, обработка LoadState и тесты обеспечат плавный UX и корректную пагинацию.