8、Paging3:Paging3基础概念、PagingSource与RemoteMediator、PagingData与UI层

各位同学,今天我们来聊聊Paging3。说实话,这个库刚出来的时候,我内心是拒绝的——又学新东西?但真正用上之后,嗯,真香。Paging3解决了一个很实际的问题:当你的数据多到一屏装不下时,怎么优雅地加载?

说白了,分页加载就是「用多少,拿多少」。但Android里实现方式五花八门,有人用LinearLayout手动加Footer,有人用RecyclerView的OnScrollListener自己算。Paging3把这些脏活累活都封装好了,你只需要告诉它「数据从哪来」和「怎么展示」就行。

8.1 核心概念:Paging3到底在做什么?

先看一张图,帮你快速建立整体认知:

Paging3 核心架构 数据源层 PagingSource RemoteMediator (网络/数据库/文件) 数据流层 Pager PagingData (响应式数据流) UI层 PagingDataAdapter LoadState (RecyclerView) 工作流程: 1. PagingSource 从网络/数据库加载数据块 2. Pager 将数据转换为 PagingData 流 3. PagingDataAdapter 监听数据变化,自动更新 RecyclerView 4. 用户滑动时,Paging3 自动触发下一页加载

这张图我画了好几次才满意。你看,Paging3把整个流程分成了三层:数据源、数据流、UI层。每一层各司其职,互不干扰。我在项目中经常看到有人把网络请求直接写在Adapter里——千万别这么干,Paging3就是来帮你解耦的。

8.2 PagingSource:数据从哪来?

PagingSource是Paging3的数据源接口。你需要实现它,告诉Paging3「怎么加载每一页的数据」。我习惯把它理解成一个「数据水龙头」——拧一下(调用一次load方法),就流出一批数据。

来看一个最简单的例子,从网络加载用户列表:

class UserPagingSource(
    private val api: UserApi,
    private val query: String
) : PagingSource<Int, User>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        return try {
            // params.key 就是页码,第一次为null时用1
            val page = params.key ?: 1
            val pageSize = params.loadSize  // Paging3自动计算的最佳大小
            
            val response = api.searchUsers(query, page, pageSize)
            
            LoadResult.Page(
                data = response.users,
                prevKey = if (page > 1) page - 1 else null,
                nextKey = if (response.hasMore) 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)
        }
    }
}

关键点:

  • LoadParams 包含页码和加载大小,Paging3会自动管理
  • LoadResult.Page 返回数据和前后页码
  • getRefreshKey 用于刷新后定位到之前的位置

我曾经犯过一个低级错误:在load方法里直接返回了LoadResult.Page(prevKey = page - 1, nextKey = page + 1),没有判断边界。结果用户翻到最后一页时,nextKey还是非null,Paging3继续请求,返回空数据,然后崩溃。嗯,边界判断一定要做。

8.3 RemoteMediator:网络+数据库的黄金搭档

如果你的App只用网络分页,PagingSource就够了。但大多数实际项目都是「网络+本地缓存」的模式——先展示缓存数据,后台再刷新。这时候就需要RemoteMediator了。

RemoteMediator的作用是:当本地数据不够时,自动去网络拉取,然后存到数据库。我把它比作「数据搬运工」——从网络搬到本地,再让PagingSource从本地读取。

@OptIn(ExperimentalPagingApi::class)
class UserRemoteMediator(
    private val api: UserApi,
    private val db: UserDatabase
) : RemoteMediator<Int, User>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, User>
    ): MediatorResult {
        return try {
            // 1. 确定要加载的页码
            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.page + 1)
                }
            }
            
            // 2. 请求网络
            val response = api.getUsers(page)
            
            // 3. 存入数据库
            db.userDao().insertAll(response.users.map { it.toEntity(page) })
            
            MediatorResult.Success(
                endOfPaginationReached = response.users.isEmpty()
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

注意:RemoteMediator需要配合@OptIn(ExperimentalPagingApi::class)注解,因为它在Paging3.0还是实验性API。另外,数据库表里最好加一个page字段,方便定位。

我记得第一次用RemoteMediator时,遇到了一个诡异的问题:下拉刷新后,列表数据重复了。排查了半天,发现是数据库的insertAll用了REPLACE策略,但主键没设对。后来我把userId设为主键,问题就解决了。细节决定成败啊。

8.4 PagingData:连接数据与UI的桥梁

PagingData是一个不可变的数据容器,它持有分页数据的快照。你不需要直接操作它,而是通过Pager来生成:

// 在ViewModel中
val userPagingData: Flow<PagingData<User>> = Pager(
    config = PagingConfig(
        pageSize = 20,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { UserPagingSource(api, query) }
).flow
    .cachedIn(viewModelScope)  // 缓存数据,避免重复加载

我的建议:

  • pageSize 设20-30比较合适,太小会导致频繁请求,太大则失去分页意义
  • enablePlaceholders 设为false,避免出现空白占位符,用户体验更好
  • cachedIn() 一定要调用,否则旋转屏幕时会重新加载

8.5 UI层:PagingDataAdapter与LoadState

UI层就简单多了。PagingDataAdapter继承自RecyclerView.Adapter,但它是专门为PagingData设计的。你只需要实现onBindViewHolderonCreateViewHolder,然后调用submitData()即可。

class UserAdapter : PagingDataAdapter<User, UserViewHolder>(UserComparator) {

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
        return UserViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.item_user, parent, false)
        )
    }

    companion object {
        // DiffUtil比较器,用于高效更新列表
        private val UserComparator = object : DiffUtil.ItemCallback<User>() {
            override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
                return oldItem.id == newItem.id
            }
            override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
                return oldItem == newItem
            }
        }
    }
}

在Fragment或Activity中,这样使用:

val adapter = UserAdapter()
binding.recyclerView.adapter = adapter

// 收集PagingData流
lifecycleScope.launch {
    viewModel.userPagingData.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

// 监听加载状态
adapter.addLoadStateListener { loadState ->
    when (loadState.refresh) {
        is LoadState.Loading -> showLoading()
        is LoadState.Error -> showError((loadState.refresh as LoadState.Error).error)
        is LoadState.NotLoading -> hideLoading()
    }
}

避坑指南:我曾经在collectLatest里直接调用了adapter.submitData(),但忘了用lifecycleScope。结果Activity销毁后,协程还在跑,导致内存泄漏。记住:collectLatest一定要绑定生命周期。

8.6 总结一下

Paging3的核心就三件事:

  • PagingSource:定义数据怎么从源头加载
  • RemoteMediator:处理网络+数据库的混合分页
  • PagingData + PagingDataAdapter:把数据优雅地展示到UI上

说实话,Paging3的学习曲线不算陡,但坑确实不少。我建议你先从最简单的PagingSource开始,跑通一个Demo,再逐步引入RemoteMediator。别一上来就搞「网络+数据库+分页」的大全套,容易把自己绕晕。

好了,这一章就到这里。记住:分页不是功能,是体验。用户滑动时卡顿一下,可能就流失了。Paging3帮你把体验做好了,你只需要把业务逻辑写对就行。


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