14、DataBinding与Paging3:PagingData与DataBinding、PagingDataAdapter的绑定、加载状态显示、分页刷新处理

说实话,Paging3 刚出来那会儿,我第一反应是「又来个新东西,学不动了」。但真正用上之后,我发现它跟 DataBinding 简直是天生一对。你想想看,一个负责数据分页加载,一个负责自动更新 UI,配合起来代码量能少一半。

这一章,我们就来聊聊怎么把 Paging3 和 DataBinding 揉在一起用。我会把我在项目中踩过的坑、总结的经验都抖出来。

14.1 PagingData 与 DataBinding 的绑定思路

先理清一个核心问题:PagingData 是异步流,DataBinding 是同步绑定。怎么让它们和谐共处?

我的做法是——在 ViewModel 里把 PagingData 转成 LiveData,然后让 DataBinding 去观察这个 LiveData。这样既保留了 Paging3 的分页能力,又兼容了 DataBinding 的自动更新机制。

来看代码:

class UserViewModel : ViewModel() {
    // 1. 定义 PagingData 的 LiveData
    private val _users = MutableLiveData<PagingData<User>>()
    val users: LiveData<PagingData<User>> get() = _users

    // 2. 创建 PagingSource
    class UserPagingSource : PagingSource<Int, User>() {
        override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
            return try {
                val page = params.key ?: 1
                val response = ApiService.getUsers(page, params.loadSize)
                LoadResult.Page(
                    data = response.data,
                    prevKey = if (page > 1) page - 1 else null,
                    nextKey = if (response.data.isNotEmpty()) page + 1 else null
                )
            } catch (e: Exception) {
                LoadResult.Error(e)
            }
        }

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

    // 3. 启动分页加载
    fun loadUsers() {
        viewModelScope.launch {
            Pager(PagingConfig(pageSize = 20)) {
                UserPagingSource()
            }.flow.cachedIn(viewModelScope).collect {
                _users.value = it
            }
        }
    }
}

这里有个细节:cachedIn(viewModelScope) 一定要加。我之前没加,结果每次屏幕旋转都重新请求网络,用户数据刷得飞起,体验极差。

14.2 PagingDataAdapter 与 DataBinding 的绑定

PagingDataAdapter 是 Paging3 提供的专用 Adapter,它知道怎么处理 PagingData 的增量更新。但问题来了——它默认不支持 DataBinding。

嗯,这里需要我们自己动手封装一下。

我的做法是写一个基类,让 Adapter 的 ViewHolder 支持 DataBinding:

abstract class BindingPagingDataAdapter<T, V : ViewDataBinding>(
    private val diffCallback: DiffUtil.ItemCallback<T>
) : PagingDataAdapter<T, BindingPagingDataAdapter<T, V>.BindingViewHolder>(diffCallback) {

    inner class BindingViewHolder(val binding: V) : RecyclerView.ViewHolder(binding.root)

    override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
        val item = getItem(position)
        item?.let { bind(holder.binding, it, position) }
        holder.binding.executePendingBindings()
    }

    protected abstract fun bind(binding: V, item: T, position: Int)
}

然后具体的 Adapter 就清爽多了:

class UserAdapter : BindingPagingDataAdapter<User, ItemUserBinding>(UserDiffCallback()) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {
        val binding = ItemUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return BindingViewHolder(binding)
    }

    override fun bind(binding: ItemUserBinding, item: User, position: Int) {
        binding.user = item
        binding.executePendingBindings()
    }
}

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
    }
}

你看,Adapter 里只需要关心怎么绑定数据,分页逻辑全交给 PagingDataAdapter 处理了。

14.3 加载状态显示

分页加载时,用户需要知道当前状态:是加载中、加载成功还是加载失败。Paging3 提供了 CombinedLoadStates,我们可以用它来驱动 UI 状态。

我在项目中是这样做的——在 Fragment 里监听加载状态,然后通过 DataBinding 更新 UI:

class UserFragment : Fragment() {
    private lateinit var binding: FragmentUserBinding
    private val viewModel: UserViewModel by viewModels()
    private lateinit var adapter: UserAdapter

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        binding = FragmentUserBinding.inflate(inflater, container, false)
        binding.lifecycleOwner = viewLifecycleOwner
        binding.viewModel = viewModel
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

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

        // 监听加载状态
        viewModel.users.observe(viewLifecycleOwner) { pagingData ->
            adapter.submitData(lifecycle, pagingData)
        }

        // 监听加载状态变化
        adapter.addLoadStateListener { loadState ->
            when (loadState.refresh) {
                is LoadState.Loading -> {
                    binding.isLoading = true
                    binding.errorMessage = null
                }
                is LoadState.Error -> {
                    binding.isLoading = false
                    binding.errorMessage = (loadState.refresh as LoadState.Error).error.message
                }
                is LoadState.NotLoading -> {
                    binding.isLoading = false
                    binding.errorMessage = null
                }
            }
        }
    }
}

布局文件里,我用 DataBinding 来控制加载和错误视图的显示:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable name="isLoading" type="Boolean" />
        <variable name="errorMessage" type="String" />
    </data>

    <LinearLayout android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <ProgressBar
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:visibility="@{isLoading ? View.VISIBLE : View.GONE}" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="@{errorMessage}"
            android:visibility="@{errorMessage != null ? View.VISIBLE : View.GONE}" />
    </LinearLayout>
</layout>
小技巧: 加载状态监听里,记得区分 refreshappend。refresh 是首次加载或刷新,append 是加载更多。我一般只监听 refresh 来显示全屏加载状态,append 用底部加载更多指示器。

14.4 分页刷新处理

下拉刷新是分页列表的标配。Paging3 配合 SwipeRefreshLayout 用起来很顺手。

我的做法是在 ViewModel 里暴露一个刷新方法:

class UserViewModel : ViewModel() {
    private val refreshTrigger = MutableStateFlow(0)

    val users: StateFlow<PagingData<User>> = refreshTrigger
        .flatMapLatest {
            Pager(PagingConfig(pageSize = 20)) {
                UserPagingSource()
            }.flow.cachedIn(viewModelScope)
        }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())

    fun refresh() {
        refreshTrigger.value++
    }
}

Fragment 里绑定 SwipeRefreshLayout:

binding.swipeRefreshLayout.setOnRefreshListener {
    viewModel.refresh()
}

// 监听刷新完成
adapter.addLoadStateListener { loadState ->
    binding.swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading
}

这里有个坑:refreshTrigger.value++ 会触发新的 PagingData 流,但旧的流还在。如果不加 cachedIn(viewModelScope),旧的流会泄漏。我之前就因为这个吃过亏,内存占用一直涨。

14.5 知识体系总览

为了让你更直观地理解这一章的内容,我画了一张图:

DataBinding + Paging3 分页架构 ViewModel PagingData 流 cachedIn 缓存 refresh 刷新 PagingDataAdapter BindingViewHolder DiffUtil 增量更新 submitData RecyclerView 列表展示 滑动加载 加载状态监听 addLoadStateListener refresh / append / prepend DataBinding 绑定 LiveData / StateFlow 自动更新 UI 核心流程:ViewModel → PagingDataAdapter → RecyclerView 加载状态和 DataBinding 贯穿整个链路

14.6 避坑指南

最后,分享几个我踩过的坑:

  • PagingData 不能重复使用:每个 PagingData 只能被 submitData 一次。如果需要刷新,必须创建新的 PagingData 流。
  • Lifecycle 绑定adapter.submitData(lifecycle, pagingData) 一定要传 lifecycle,否则页面不可见时还在加载数据,浪费流量。
  • DiffUtil 的 equals 方法:如果数据类用了 data class,DiffUtil 的 areContentsTheSame 默认比较所有字段。如果字段太多,建议只比较关键字段,提升性能。
  • 空列表处理:PagingData 为空时,loadState.refresh 是 NotLoading,但列表是空的。需要单独判断 adapter.itemCount == 0 来显示空视图。
特别注意: 我曾经在项目里把 PagingData 存在 ViewModel 的 LiveData 里,然后每次刷新都重新赋值。结果发现旧的分页请求还在跑,导致数据错乱。后来改用 flatMapLatest 才解决——它会自动取消旧的流。

好了,这一章的内容就到这里。DataBinding 和 Paging3 的组合拳打好了,分页列表的开发效率能提升一大截。下一章我们聊聊更高级的用法——DataBinding 与 Room 数据库的配合,敬请期待。


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