ViewPager2双向动态加载:适配器实现与问题详解
2025-03-23 14:08:28
ViewPager2 实现双向动态加载的适配器
在使用 ViewPager2 的时候,咱们经常会碰到需要动态加载上一页、下一页数据的需求,比如看帖子、刷新闻之类的。向下滚动加载更早的帖子通常比较容易实现,但是要支持向上滚动加载更新的帖子,就可能会遇到一些奇怪的问题。 这篇博客就来捋一捋这个问题,一起看看怎么解决。
一、问题
原代码的思路是这样的:用一个 ArrayList<PostModal>
来保存要显示的帖子数据,通过重写 FragmentStateAdapter
的 getItemCount
和 createFragment
实现基本翻页,同时在 firstItemLoaded
处理头部加载,实现向上翻页,使用ViewPager2 显示这些 Fragment。但是呢,这样做在向上滚动加载新帖子时会出问题,具体表现为:
- 当前帖子上面的帖子被同时添加到了列表的开头和结尾。
- 后续滚动出现重复的帖子。
- 某些情况下,滚动方向还会反转。
原代码在帖子末尾用了这个判断来增加item:
override fun getItemCount(): Int {
if (fragmentList[fragmentList.size-1].sentTime != null) {
return fragmentList.size + 1
}
return fragmentList.size
}
初始加载新数据用了类似下面的代码:
fun firstItemLoaded() {
if (fragmentList[0].sentTime != null) {
val newModal = PostModal(fragmentList[0].sentTime, false)
activity.getViewPager().adapter = null
fragmentList.add(0, newModal)
this.notifyItemInserted(0)
activity.getViewPager().adapter = this
activity.getViewPager().setCurrentItem(1, false)
}
}
二、问题原因分析
问题的根源在于对 ViewPager2 的工作机制以及 FragmentStateAdapter
的更新机制理解不够透彻,再加上对数据列表的操作不当,导致了各种奇怪的现象。
notifyDataSetChanged()
的滥用:notifyDataSetChanged()
会让 ViewPager2 重新加载 所有 的 Fragment,开销很大,而且容易导致状态混乱。在只需要更新部分数据的情况下,应该尽量使用notifyItemInserted()
、notifyItemRemoved()
、notifyItemChanged()
等更精细的更新方法。- 数据列表操作不当: 直接对
fragmentList
进行add(0, ...)
操作,虽然在列表中加入了新数据,但在更新adapter上,使用多种不同函数可能带来不同的问题. getItemCount()
的问题: 通过给List大小增加1, 增加新的待加载页面. 该方式在双向加载情景可能存在逻辑判断问题.- 重建adapter :在
firstItemLoaded()
中,将viewPager
的adapter
设为null
, 然后再重新设置. 这会造成viewPager
重新初始化,可能丢失状态.
三、解决方案
针对上面分析的原因,咱们可以从以下几个方面入手,优化适配器的实现:
1. 更精细地控制数据更新
不用 notifyDataSetChanged()
,改用 notifyItemInserted(0)
插入新数据,它仅仅是插入一个新的项目。避免整个列表的重绘。
fun addNewItemToTop(newModal: PostModal) {
fragmentList.add(0, newModal)
notifyItemInserted(0)
}
2. 避免直接修改 Adapter 的数据源
最佳实践是Adapter 的数据源 (这里是 fragmentList
) 应该由 Adapter 自身管理。提供方法给外部调用,而不是让外部直接修改数据源。
3. 明确加载状态
给 PostModal
增加一个状态标记,比如 isLoading
,用来表示这个位置的帖子正在加载中。getItemCount
直接返回 fragmentList
的实际大小,不用 +1。
data class PostModal(
val sentTime: Long? = null,
val isLoading: Boolean = false, // 用于标记是否正在加载
// ... 其他字段 ...
)
4. 重构 createFragment
根据 PostModal
的 isLoading
状态,决定是显示加载中的占位符,还是显示实际的帖子内容。
override fun createFragment(position: Int): Fragment {
val modal = fragmentList[position]
val args = Bundle()
args.putParcelable("modal", modal)
val frag = PostScreenFragment(this) // 假设你的 Fragment 接收 Adapter 作为参数
frag.arguments = args
return frag
}
5. 使用ConcatAdapter组合加载布局(可选进阶)
如果你的"加载更多"布局非常复杂,不希望把它放在 PostScreenFragment
里,那么可以用 ConcatAdapter
把两个 Adapter 组合起来。
// 一个简单的加载中 Adapter
class LoadingAdapter : RecyclerView.Adapter<LoadingAdapter.LoadingViewHolder>() {
class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LoadingViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.loading_item, parent, false) // 加载中的布局文件
return LoadingViewHolder(view)
}
override fun getItemCount(): Int = 1 // 只显示一个加载中的 Item
override fun onBindViewHolder(holder: LoadingViewHolder, position: Int) {}
}
// ...
//你的主要 Adapter
val postAdapter = PostScreenAdapter(fragmentList, activity)
// 加载更多的 Adapter
val loadingAdapter = LoadingAdapter()
// 用 ConcatAdapter 把它们组合起来
val concatAdapter = ConcatAdapter(postAdapter, loadingAdapter) //顶部可以加上 headerAdapter.
activity.getViewPager().adapter = concatAdapter
// ...
这种方式,把不同的布局分离,可以使代码更简洁。
6.完整整合和示例代码
class PostScreenAdapter(
private val fragmentList: ArrayList<PostModal>,
private val activity: PostScreenFragmented
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = fragmentList.size
override fun createFragment(position: Int): Fragment {
val modal = fragmentList[position]
val args = Bundle()
args.putParcelable("modal", modal)
val frag = PostScreenFragment(this)
frag.arguments = args
return frag
}
fun addNewItemToTop(newModal: PostModal) {
fragmentList.add(0, newModal)
notifyItemInserted(0)
activity.getViewPager().setCurrentItem(1, false) // 移动到新添加的 item 的下一个
}
fun addItemToBottom(newModal: PostModal){
fragmentList.add(newModal)
notifyItemInserted(fragmentList.size - 1)
}
//在fragment调用,用来实际获取新的顶部数据,替代原来的firstItemLoaded
fun loadNewerPosts(currentTopSentTime: Long) {
// 模拟网络请求,实际中这里应该去请求服务器
// 假设请求到了新的数据 newPosts
Thread {
val newPosts = fetchNewerPosts(currentTopSentTime) // 你自己的获取新帖子的函数
activity.runOnUiThread{
if (newPosts.isNotEmpty()) {
//因为要添加到顶部,倒序插入
for (i in newPosts.size -1 downTo 0){
addNewItemToTop(newPosts[i])
}
}
}
}.start()
}
//在fragment调用,用来实际获取新的底部数据
fun loadOlderPosts(currentBottomSentTime:Long){
Thread {
val oldPosts = fetchOlderPosts(currentBottomSentTime) // 你自己的获取旧帖子的函数
activity.runOnUiThread{
if (oldPosts.isNotEmpty()) {
//添加到列表尾部
oldPosts.forEach{
addItemToBottom(it)
}
}
}
}.start()
}
//你需要实现的从服务器拿取更新数据的函数.
fun fetchNewerPosts(currentTopSentTime:Long):List<PostModal>{
// ... 实现你的网络请求
TODO()
}
fun fetchOlderPosts(currentBottomSentTime:Long):List<PostModal>{
// ... 实现你的网络请求
TODO()
}
}
在你的PostScreenFragment
中,做如下修改:
- 在滑动到底部的时候,触发加载旧帖子. 通过监听
ViewPager2.OnPageChangeCallback()
判断. - 在滑到到顶部的时候, 触发加载新帖子,通过监听
ViewPager2.OnPageChangeCallback()
判断. - 使用
postModal.isLoading
属性,来决定是否要显示 loading 指示器。 - 在获得新数据的时候,调用
loadNewerPosts()
或loadOlderPosts()
. - 不要在Fragment里直接修改
fragmentList
.
7. 安全建议
- 网络请求放到后台线程,避免阻塞 UI 线程。
- 对服务器返回的数据进行校验,防止异常数据导致崩溃。
- 处理好加载失败的情况,给用户友好的提示。
通过以上方式优化,你的双向动态加载功能应该就可以正常工作了,并且代码也会更清晰、更易于维护。记住,核心是准确控制数据的增删,以及adapter的刷新,保持ViewPager2和数据的一致性.