返回

ViewPager2双向动态加载:适配器实现与问题详解

Android

ViewPager2 实现双向动态加载的适配器

在使用 ViewPager2 的时候,咱们经常会碰到需要动态加载上一页、下一页数据的需求,比如看帖子、刷新闻之类的。向下滚动加载更早的帖子通常比较容易实现,但是要支持向上滚动加载更新的帖子,就可能会遇到一些奇怪的问题。 这篇博客就来捋一捋这个问题,一起看看怎么解决。

一、问题

原代码的思路是这样的:用一个 ArrayList<PostModal> 来保存要显示的帖子数据,通过重写 FragmentStateAdaptergetItemCountcreateFragment 实现基本翻页,同时在 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 的更新机制理解不够透彻,再加上对数据列表的操作不当,导致了各种奇怪的现象。

  1. notifyDataSetChanged() 的滥用: notifyDataSetChanged() 会让 ViewPager2 重新加载 所有 的 Fragment,开销很大,而且容易导致状态混乱。在只需要更新部分数据的情况下,应该尽量使用 notifyItemInserted()notifyItemRemoved()notifyItemChanged() 等更精细的更新方法。
  2. 数据列表操作不当: 直接对 fragmentList 进行 add(0, ...) 操作,虽然在列表中加入了新数据,但在更新adapter上,使用多种不同函数可能带来不同的问题.
  3. getItemCount() 的问题: 通过给List大小增加1, 增加新的待加载页面. 该方式在双向加载情景可能存在逻辑判断问题.
  4. 重建adapter :在firstItemLoaded()中,将viewPageradapter设为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

根据 PostModalisLoading 状态,决定是显示加载中的占位符,还是显示实际的帖子内容。

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和数据的一致性.