Paging3:从分页焦虑到优雅加载

做Android开发的朋友,谁没被列表分页折磨过?

我记得刚入行那会儿,自己写分页逻辑,ScrollListener、加载状态、数据拼接、网络重试……一套下来代码又臭又长。后来用了Paging2,好了一些,但依然有不少坑。直到Paging3出来,我才真正觉得——嗯,分页这件事,终于可以优雅地解决了。

今天我们就来聊聊Paging3。我会从架构讲起,带你一步步理解它的核心组件,最后用一个实战案例收尾。你想想看,掌握了这套东西,以后做列表加载是不是就轻松多了?

Paging3的整体架构

Paging3的架构其实不复杂,说白了就是三层:数据层、中间层、UI层。

核心思想:数据从哪里来(PagingSource),怎么传递(PagingData),怎么展示(PagingDataAdapter)。如果需要从网络和本地数据库混合加载,再加一个RemoteMediator。

我个人习惯把Paging3的架构画成下面这张图,你看一眼就明白了:

Paging3 架构图 数据层 PagingSource RemoteMediator 中间层 Pager PagingData UI层 PagingDataAdapter LoadState 提供数据 传递数据 数据流向: 1. PagingSource 从网络或数据库加载数据 2. Pager 将 PagingSource 转换为 PagingData 流 3. PagingDataAdapter 接收 PagingData 并展示到 RecyclerView 4. RemoteMediator 在本地数据库不足时,自动从网络加载更多 5. LoadState 反馈加载状态(Loading/Error/Success)

你看,数据从PagingSource出来,经过Pager包装成PagingData,最后交给PagingDataAdapter展示。整个过程是响应式的,你只需要关心数据源和UI展示,中间的分页逻辑Paging3全帮你搞定了。

PagingSource:数据从哪里来

PagingSource是Paging3的数据源接口。说白了,它就是告诉Paging3:你要的数据在哪儿,怎么按页取。

我项目中遇到过最典型的场景,就是从网络API分页加载用户列表。来看代码:

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 {
            val page = params.key ?: 1
            val response = api.searchUsers(query, page, params.loadSize)
            
            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.loadSize是Paging3根据屏幕尺寸和RecyclerView的item数量自动计算出来的。你不需要手动算每页多少条,Paging3会帮你优化。

这里有个关键点——getRefreshKey。为什么需要它?

嗯,当数据刷新时(比如下拉刷新),Paging3需要知道刷新后应该定位到哪一页。如果不实现这个方法,刷新后会回到第一页,用户体验很差。我曾经踩过这个坑,用户刷着刷着突然跳回顶部,被吐槽了好几次。

PagingData:数据怎么传递

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

Pager是Paging3的入口,它把PagingSource和配置信息组合起来,生成一个Flow<PagingData>:

val pager = Pager(
    config = PagingConfig(
        pageSize = 20,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { UserPagingSource(api, query) }
).flow.cachedIn(viewModelScope)

注意:cachedIn()非常重要!如果不调用这个方法,每次收集Flow都会重新创建PagingData,导致重复加载。我见过有人忘了加,结果列表每次旋转屏幕都重新请求网络。

PagingConfig里几个参数我解释一下:

  • pageSize:每页加载多少条。这个根据你的业务来,我一般设20。
  • enablePlaceholders:是否显示占位符。如果设为true,列表会预留空位,加载完成后填充。设为false则不会显示占位,体验更流畅。
  • initialLoadSize:首次加载多少条。通常是pageSize的两倍,让用户第一眼看到更多内容。

PagingDataAdapter:数据怎么展示

PagingDataAdapter是RecyclerView.Adapter的子类,专门用来消费PagingData。它的用法和普通Adapter很像,但多了几个回调:

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

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        val item = getItem(position) ?: return
        holder.bind(item)
    }

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

class UserDiffCallback : 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
    }
}

在Activity或Fragment中,这样使用:

val adapter = UserAdapter()

viewLifecycleOwner.lifecycleScope.launch {
    viewModel.pagingData.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

binding.recyclerView.adapter = adapter

你看,代码是不是很简洁?不需要手动管理加载更多、不需要监听滚动位置,PagingDataAdapter会自动处理这些。

RemoteMediator:网络+数据库的完美配合

如果你的App同时使用网络和本地数据库(比如Room),RemoteMediator就派上用场了。它的作用是:当本地数据不够时,自动从网络加载更多,并保存到数据库。

我做过一个新闻App,就是用RemoteMediator实现的离线优先策略:

@OptIn(ExperimentalPagingApi::class)
class NewsRemoteMediator(
    private val api: NewsApi,
    private val db: NewsDatabase
) : RemoteMediator<Int, NewsEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, NewsEntity>
    ): 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.page + 1)
                }
            }

            val response = api.getNews(page, state.config.pageSize)
            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.newsDao().clearAll()
                }
                db.newsDao().insertAll(response.news.map { it.toEntity(page) })
            }

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

关键点:RemoteMediator的load方法会根据LoadType决定加载行为。REFRESH是刷新,PREPEND是向前加载(一般用不到),APPEND是向后加载更多。我建议你在APPEND时从数据库最后一条记录获取页码,而不是从网络状态判断,这样更可靠。

实战:列表分页加载

好了,理论讲完了,我们来做一个完整的实战。假设我们要做一个GitHub用户搜索列表,支持分页加载。

第一步,定义数据层:

// API接口
interface GitHubApi {
    @GET("search/users")
    suspend fun searchUsers(
        @Query("q") query: String,
        @Query("page") page: Int,
        @Query("per_page") perPage: Int
    ): GitHubResponse
}

// PagingSource
class GitHubPagingSource(
    private val api: GitHubApi,
    private val query: String
) : PagingSource<Int, GitHubUser>() {
    // ... 实现同上
}

第二步,ViewModel中创建Pager:

class SearchViewModel : ViewModel() {
    private val api = Retrofit.Builder()
        .baseUrl("https://api.github.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(GitHubApi::class.java)

    fun searchUsers(query: String): Flow<PagingData<GitHubUser>> {
        return Pager(
            config = PagingConfig(pageSize = 20),
            pagingSourceFactory = { GitHubPagingSource(api, query) }
        ).flow.cachedIn(viewModelScope)
    }
}

第三步,UI层展示:

class SearchFragment : Fragment() {
    private val viewModel: SearchViewModel by viewModels()
    private val adapter = GitHubUserAdapter()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        binding.recyclerView.adapter = adapter
        
        // 搜索按钮点击
        binding.searchButton.setOnClickListener {
            val query = binding.searchInput.text.toString()
            if (query.isNotBlank()) {
                lifecycleScope.launch {
                    viewModel.searchUsers(query).collectLatest { pagingData ->
                        adapter.submitData(pagingData)
                    }
                }
            }
        }
        
        // 监听加载状态,显示加载动画或错误提示
        lifecycleScope.launch {
            adapter.loadStateFlow.collect { loadState ->
                when (loadState.refresh) {
                    is LoadState.Loading -> showLoading()
                    is LoadState.Error -> showError((loadState.refresh as LoadState.Error).error)
                    is LoadState.NotLoading -> hideLoading()
                }
            }
        }
    }
}

避坑指南:我曾经在collectLatest中忘记处理生命周期,导致Fragment销毁后还在收集数据流。建议使用lifecycleScope和collectLatest,或者用repeatOnLifecycle来确保安全。

到这里,一个完整的Paging3分页加载就完成了。你想想看,是不是比你自己写ScrollListener+AsyncTaskLoader要清爽得多?

Paging3把分页的复杂性封装得很好,你只需要关注数据源和UI展示。中间的分页逻辑、加载状态管理、数据拼接,它全帮你处理了。我个人觉得,这是Android官方在列表加载方面做得最漂亮的一个组件。

好了,今天的分享就到这里。希望你在实际项目中能用上Paging3,让列表分页不再成为你的烦恼。


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