分页加载:Paging 3库集成、从网络获取分页数据、结合RemoteMediator实现网络+本地分页

分页加载这件事,说白了就是解决「数据太多,一次拉不完」的问题。我早期做项目时,遇到过列表加载到第50页直接OOM的情况——嗯,从那以后我再也不敢小看分页了。

Android官方给出的Paging 3库,是目前最成熟的方案。它不仅能帮你处理网络分页,还能无缝对接本地数据库。今天我们就来聊聊怎么把它用起来。

为什么选Paging 3?

你想想看,如果自己手写分页逻辑,要处理哪些事?

  • 维护当前页码、加载状态
  • 处理加载更多时的UI刷新
  • 避免重复请求
  • 支持列表局部刷新
  • 内存管理——防止数据堆积

这些Paging 3全帮你搞定了。它基于Kotlin协程和Flow构建,跟咱们课程里讲的Retrofit + Coroutine + Flow天然契合。

核心组件一览:

  • PagingSource:定义数据从哪里来(网络或数据库)
  • RemoteMediator:协调网络和本地数据源,实现「网络+缓存」
  • PagingData:分页数据的容器,配合Flow使用
  • PagingDataAdapter:RecyclerView的适配器,自动处理DiffUtil

从网络获取分页数据:最简单的场景

我们先从纯网络分页开始。假设后端API长这样:

interface ApiService {
    @GET("users")
    suspend fun getUsers(
        @Query("page") page: Int,
        @Query("size") size: Int = 20
    ): Response<UserResponse>
}

data class UserResponse(
    val data: List<User>,
    val totalPages: Int,
    val currentPage: Int
)

接下来写一个PagingSource:

class UserPagingSource(
    private val api: ApiService
) : PagingSource<Int, User>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        return try {
            val page = params.key ?: 1
            val response = api.getUsers(page, params.loadSize)
            val users = response.body()?.data ?: emptyList()
            val totalPages = response.body()?.totalPages ?: 1

            LoadResult.Page(
                data = users,
                prevKey = if (page > 1) page - 1 else null,
                nextKey = if (page < totalPages) page + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, User>): Int? {
        return state.anchorPosition?.let { anchorPos ->
            state.closestPageToPosition(anchorPos)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPos)?.nextKey?.minus(1)
        }
    }
}

这里有个坑,我踩过好几次。getRefreshKey 如果不实现,刷新时会回到第一页,用户体验很差。它的作用是告诉Paging:刷新后应该定位到哪一页。

在ViewModel里使用:

class UserViewModel(private val api: ApiService) : ViewModel() {

    val users: Flow<PagingData<User>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            enablePlaceholders = false,
            initialLoadSize = 40
        ),
        pagingSourceFactory = { UserPagingSource(api) }
    ).flow.cachedIn(viewModelScope)
}

注意 cachedIn(viewModelScope)——这个调用很关键。它会把分页数据缓存在ViewModel的作用域里,避免屏幕旋转时重新加载。

结合RemoteMediator:网络+本地分页

纯网络分页有个问题:每次下拉刷新都要重新请求第一页,而且离线时完全不能用。我个人习惯的做法是:用Room做本地缓存,RemoteMediator做网络同步。

先定义Room实体和DAO:

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: Int,
    val name: String,
    val email: String
)

@Dao
interface UserDao {
    @Query("SELECT * FROM users ORDER BY id ASC")
    fun getUsers(): PagingSource<Int, UserEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(users: List<UserEntity>)

    @Query("DELETE FROM users")
    suspend fun clearAll()
}

然后写RemoteMediator:

class UserRemoteMediator(
    private val api: ApiService,
    private val dao: UserDao
) : RemoteMediator<Int, UserEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, UserEntity>
    ): MediatorResult {
        return try {
            val page = when (loadType) {
                LoadType.REFRESH -> 1
                LoadType.PREPEND -> return MediatorResult.Success(
                    endOfPaginationReached = true
                )
                LoadType.APPEND -> {
                    val lastItem = state.lastItemOrNull()
                    if (lastItem == null) 1 else (lastItem.id / state.config.pageSize) + 1
                }
            }

            val response = api.getUsers(page, state.config.pageSize)
            val users = response.body()?.data ?: emptyList()

            if (loadType == LoadType.REFRESH) {
                dao.clearAll()
            }

            dao.insertAll(users.map { it.toEntity() })

            MediatorResult.Success(
                endOfPaginationReached = users.isEmpty()
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

在ViewModel里组合使用:

class UserViewModel(
    private val api: ApiService,
    private val dao: UserDao
) : ViewModel() {

    val users: Flow<PagingData<UserEntity>> = Pager(
        config = PagingConfig(pageSize = 20),
        remoteMediator = UserRemoteMediator(api, dao),
        pagingSourceFactory = { dao.getUsers() }
    ).flow.cachedIn(viewModelScope)
}

我的经验:RemoteMediator的loadType判断一定要仔细。PREPEND通常直接返回成功,因为我们是单向分页(往下翻)。REFRESH时清空数据库再重新插入,保证数据一致性。

核心流程可视化

下面这张图展示了整个数据流向:

Paging 3 + RemoteMediator 数据流 网络API Retrofit 请求 RemoteMediator 协调网络+本地 Room 数据库 本地缓存 PagingSource 从数据库读取分页数据 Pager 生成 PagingData Flow 请求数据 写入缓存 查询数据 数据从网络 → RemoteMediator → Room → PagingSource → Pager → UI

避坑指南

我曾经在RemoteMediator里犯过一个低级错误:没有处理LoadType.PREPEND,导致向上滑动时无限触发网络请求。后来加了判断才解决。

注意事项:

  • RemoteMediator的load方法运行在后台线程,不要直接更新UI
  • 数据库写入操作要放在事务里,避免部分写入导致数据不一致
  • PagingConfigpageSize要根据后端接口调整,不是越大越好
  • 记得在DAO里返回PagingSource类型,而不是FlowLiveData

总结一下

Paging 3 + RemoteMediator这套组合拳,说白了就是「网络请求数据,本地缓存兜底」。用户在线时从网络拉取最新数据并写入Room,离线时直接从数据库读取。既保证了数据新鲜度,又实现了离线可用。

我个人建议:如果你的App需要展示长列表,并且对离线体验有要求,直接上RemoteMediator。别犹豫,它值得你花时间学习。

核心要点回顾:

  • PagingSource负责定义数据来源,RemoteMediator负责协调网络和本地
  • Room作为本地缓存,配合PagingSource实现离线分页
  • cachedIn()避免屏幕旋转时重复加载
  • getRefreshKey()保证刷新后定位正确

公众号:蓝海资料掘金营,微信deep3321