Постраничная загрузка данных в 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 и корректную пагинацию.