深入理解ViewPager2原理及其实践(上篇)

2,832 阅读8分钟

一 ViewPager2介绍

ViewPager2(以下简称VP2)ViewPager(以下简称VP) 库的改进版本,内部使用RecyclerView实现,可以把VP2理解为每个ItemView都充满全屏的RecyclerViewVP2可提供增强型功能并解决使用 VP 时遇到的一些常见问题。

1.1 ViewPager2特性

  • 水平、垂直方向布局支持 默认是水平方向,设置VP2布局的 android:orientation="vertical" 即可轻松完成垂直方向滑动。
  • RTL(right-to-left)从右到左布局支持 设置VP2布局的 android:layoutDirection="rtl" 即可。
  • 一键禁止用户滑动支持 通过setUserInputEnabled()设置是否禁止用户滑动。
  • 可修改的Fragment集合 VP2 支持对可修改的 Fragment 集合进行分页浏览,在底层集合发生更改时调用 notifyDatasetChanged() 来更新界面。这意味着,您的应用可以在运行时动态修改 Fragment 集合,而 VP2 会正确显示修改后的集合。
  • 支持DiffUtil VP2RecyclerView 的基础上构建而成,这意味着它可以访问 DiffUtil 实用程序类。所以VP2支持当数据变化时进行局部更新,而不用通过notifyDatasetChanged()全量更新。
  • 支持模拟拖拽fakeDragBy

二 ViewPager2使用

2.1 基于ViewPager2实现的Banner库效果图

功能示例
基本使用
仿淘宝搜索栏上下轮播

上述示例效果源码参见:lib_viewpager2,这里只列出了实现效果图,会在下篇中进行详细介绍。

2.2 ViewPager2基本使用

  • VP2不同于VP,需要单独引入:
dependencies {
    implementation "androidx.viewpager2:viewpager2:1.0.0"
}

声明XML布局:

<androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager2"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintDimensionRatio="2:3"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
  • 设置Adapter:

因为VP2内部是RecyclerView实现的,所以简单的界面直接继承RecyclerView.Adapter

class VpAdapter : RecyclerView.Adapter<VpAdapter.VpViewHolder>() {
 
    // adapter的数据源
    private var data: MutableList<HouseItem> = mutableListOf()
 
    fun setData(list: MutableList<HouseItem>) {
        data.clear()
        data.addAll(list)
        notifyDataSetChanged()
    }
 
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VpViewHolder {
       //......
    }
 
    override fun onBindViewHolder(holder: VpViewHolder, position: Int) {
    }
 
    override fun getItemCount() = data.size
 
    class VpViewHolder(_itemView: View) : RecyclerView.ViewHolder(_itemView) {
      //......
    }
}

如果用到了Fragment,那么需要使用FragmentStateAdapter

const val PAGES_NUM = 4
 
class ViewPager2Adapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
 
    private val mItems: ArrayList<VP2Model> = arrayListOf()
 
    override fun getItemCount(): Int = PAGES_NUM
 
    override fun createFragment(position: Int): Fragment {
        log("pos:$position: createFragment()")
        return VP2Fragment(position)
    }
 
    override fun onBindViewHolder(
        holder: FragmentViewHolder,
        position: Int,
        payloads: MutableList<Any>
    ) {
        super.onBindViewHolder(holder, position, payloads)
        log("pos:$position: onBindViewHolder()")
    }
 
    override fun getItemId(position: Int): Long {
        return super.getItemId(position)
    }
 
    override fun containsItem(itemId: Long): Boolean {
        return super.containsItem(itemId)
    }
 
    fun setModels(newItems: List<VP2Model>) {
        //不借助DiffUtil更新数据
        //mItems.clear()
        //mItems.addAll(newItems)
        //notifyDataSetChanged()
 
        //借助DiffUtil更新数据
        val callback = PageDiffUtil(mItems, newItems)
        val difResult = DiffUtil.calculateDiff(callback)
        mItems.clear()
        mItems.addAll(newItems)
        difResult.dispatchUpdatesTo(this)
    }
}
  • 在Activity/Fragment中调用:
//mVP2Adapter = VpAdapter() //RecyclerView.Adapter
mVP2Adapter = ViewPager2Adapter(this) //FragmentStateAdapter
VP2.adapter = mVP2Adapter

使用起来很简单,效果图不再贴出~

2.3 进阶使用

2.3.1 Fragment懒加载

VP2使用FragmentStateAdapter加载Fragment时,是通过setOffscreenPageLimit(int limit)设置离屏缓存数量,当limit<1时,不会进行预加载,即不会回调Fragment相应的生命周期;反之会进行预加载,并回调预加载Fragment相应的生命周期,limit的默认值OFFSCREEN_PAGE_LIMIT_DEFAULT为-1,即默认就是懒加载;这一点跟VP不同,VP中默认值为1,即默认就会加载左右两侧的Fragment

如果在VP2中既想缓存Fragment(设置setOffscreenPageLimit()的参数>=1),同时又想对数据进行懒加载(Fragment可见时才去请求数据),可以像下面这样:

/**
 * 懒加载Fragment
 */
abstract class BaseLazyFragment : Fragment() {

    private var mIsFirstLoad = true //是否是首次加载

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        if (container != null) {
            val rootView = inflater.inflate(getLayoutId(), container, false)
            initViews(rootView)
            return rootView
        }
        return super.onCreateView(inflater, container, savedInstanceState)
    }

    override fun onResume() {
        super.onResume()
        if (mIsFirstLoad) {
            initData()
            mIsFirstLoad = false
        }
    }

    @LayoutRes
    protected abstract fun getLayoutId(): Int

    protected fun initViews(view: View) {}

    protected fun initData() {}
}

其中onResume()只会在当前Fragment可见时执行,所以用一个Boolean字段来控制只执行一次数据请求。

PS:offscreenPageLimit对mCachedViews的影响

  • 当没有设置offscreenPageLimit离屏缓存时,VP2中的RecyclerView默认会在mCachedViews中缓存前面的2个Item以及后面预抓取的1个Item
  • 如果设置了offscreenPageLimit为1,则左右离屏各新增一个缓存的Item,可以认为是把画布宽度增加到3倍(左右这两个默认不可见),加上RecyclerView默认缓存的3个,除了当前显示的Item,还会缓存总共5个Item

2.3.2 一屏多页

设置一屏多页的关键代码如下:

VP2.apply {
    //下面是关键代码
    val recyclerView = getChildAt(0) as RecyclerView
    recyclerView.apply {
        val padding = 50
        // setting padding on inner RecyclerView puts overscroll effect in the right place
        setPadding(padding, 0, padding, 0)
        clipToPadding = false
    }
    adapter = Adapter()
}

VP2源码内部第254行,RecyclerView固定索引为0:

attachViewToParent(mRecyclerView, 0, mRecyclerView.getLayoutParams());

所以可以通过VP2.getChildAt(0)直接获取VP2内部的RecyclerView,进而通过设置padding来实现一屏多页,运行效果如下: 一屏多页

2.3.3 ViewPager2嵌套滑动冲突

因为VP2内部是通过RecyclerView实现的,所以滑动相关处理主要在RecyclerView中进行,其内部实现:

private class RecyclerViewImpl extends RecyclerView {
    RecyclerViewImpl(@NonNull Context context) {
        super(context);
    }
 
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return isUserInputEnabled() && super.onInterceptTouchEvent(ev);
    }
}

onInterceptTouchEvent中会进行事件拦截,可以看到源码中的onInterceptTouchEvent只是多了isUserInputEnabled的判断,其他的都没有处理, 所以官方并没有对VP2的嵌套滑动进行处理,需要开发者进行自行处理,这里可以通过事件传递中的内部拦截法(requestDisallowInterceptTouchEvent()) 进行处理,如果嵌套滑动中的内部控件需要滑动时,就控制外部父控件不拦截事件,设置为requestDisallowInterceptTouchEvent(true);反之则让外部父控件拦截事件,设置为requestDisallowInterceptTouchEvent(false)。官方Demo中也给出了对应例子:NestedScrollableHost:

class NestedScrollableHost : FrameLayout {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    private var touchSlop = 0
    private var initialX = 0f
    private var initialY = 0f
    private val parentViewPager: ViewPager2?
        get() {
            var v: View? = parent as? View
            while (v != null && v !is ViewPager2) {
                v = v.parent as? View
            }
            return v as? ViewPager2
        }

    private val child: View? get() = if (childCount > 0) getChildAt(0) else null

    init {
        touchSlop = ViewConfiguration.get(context).scaledTouchSlop
    }

    private fun canChildScroll(orientation: Int, delta: Float): Boolean {
        val direction = -delta.sign.toInt()
        return when (orientation) {
            0 -> child?.canScrollHorizontally(direction) ?: false
            1 -> child?.canScrollVertically(direction) ?: false
            else -> throw IllegalArgumentException()
        }
    }

    override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
        handleInterceptTouchEvent(e)
        return super.onInterceptTouchEvent(e)
    }

    private fun handleInterceptTouchEvent(e: MotionEvent) {
        val orientation = parentViewPager?.orientation ?: return

        // Early return if child can't scroll in same direction as parent
        if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
            return
        }

        if (e.action == MotionEvent.ACTION_DOWN) {
            initialX = e.x
            initialY = e.y
            parent.requestDisallowInterceptTouchEvent(true)
        } else if (e.action == MotionEvent.ACTION_MOVE) {
            val dx = e.x - initialX
            val dy = e.y - initialY
            val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL

            // assuming ViewPager2 touch-slop is 2x touch-slop of child
            val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
            val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f

            if (scaledDx > touchSlop || scaledDy > touchSlop) {
                if (isVpHorizontal == (scaledDy > scaledDx)) {
                    // Gesture is perpendicular, allow all parents to intercept
                    parent.requestDisallowInterceptTouchEvent(false)
                } else {
                    // Gesture is parallel, query child if movement in that direction is possible
                    if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
                        // Child can scroll, disallow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(true)
                    } else {
                        // Child cannot scroll, allow all parents to intercept
                        parent.requestDisallowInterceptTouchEvent(false)
                    }
                }
            }
        }
    }
}

2.3.4 支持DiffUtil增量更新

VP2内部由RecyclerView实现,所以支持DiffUtil进行增量更新,从而提高性能;尽量避免使用notifyDatasetChanged()全量更新。DiffUtil使用方式如下:

class PageDiffUtil(private val oldModels: List<Any>, private val newModels: List<Any>) :
    DiffUtil.Callback() {

    /**
     * 旧数据
     */
    override fun getOldListSize(): Int = oldModels.size

    /**
     * 新数据
     */
    override fun getNewListSize(): Int = newModels.size

    /**
     * DiffUtil调用来决定两个对象是否代表相同的Item。true表示两个Item相同(表示View可以复用),false表示不相同(View不可以复用)
     * 例如,如果你的项目有唯一的id,这个方法应该检查它们的id是否相等。
     */
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldModels[oldItemPosition]::class.java == newModels[newItemPosition]::class.java
    }

    /**
     * 比较两个Item是否有相同的内容(用于判断Item的内容是否发生了改变),
     * 该方法只有当areItemsTheSame (int, int)返回true时才会被调用。
     */
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldModels[oldItemPosition] == newModels[newItemPosition]
    }

    /**
     * 该方法执行时机:areItemsTheSame(int, int)返回true 并且 areContentsTheSame(int, int)返回false
     * 该方法返回Item中的变化数据,用于只更新Item中变化数据对应的UI
     */
    override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
        return super.getChangePayload(oldItemPosition, newItemPosition)
    }
}

调用方式:

//使用DiffUtil更新数据
val callback = PageDiffUtil(mItems, newItems)
val difResult = DiffUtil.calculateDiff(callback)
mItems.clear()
mItems.addAll(newItems)
difResult.dispatchUpdatesTo(adapter)

注意:如果想异步进行数据比较,可以使用AsyncListDiffer 或者RecyclerView#ListAdapter

2.3.5 支持转场动画Transformer

调用方式:ViewPager2.setPageTransformer(transformer),如果同时想执行多个Transformer,可以像下面这样写:

val multiTransformer = CompositePageTransformer()
multiTransformer.addTransformer(ScaleInTransformer())
multiTransformer.addTransformer(MarginPageTransformer(10))
ViewPager2.setPageTransformer(multiTransformer)

三 源码浅析

3.1 RecyclerView缓存机制

因为VP2内部基于RecyclerView,所以VP2的缓存也是基于RecyclerView缓存机制实现的,直接来看RecyclerView的缓存机制:

缓存涉及对象作用重新创建视图View(onCreateViewHolder)重新绑定数据(onBindViewHolder)
一级缓存mAttachedScrap缓存屏幕中可见范围的ViewHolderfalsefalse
二级缓存mCachedViews缓存滑动时即将与RecyclerView分离的ViewHolder,按子View的position或id缓存,默认最多存放2个falsefalse
三级缓存mViewCacheExtension开发者自行实现的缓存--
四级缓存mRecyclerPoolViewHolder缓存池,本质上是一个SparseArray,其中key是ViewType(int类型),value存放的是 ArrayList< ViewHolder>,默认每个ArrayList中最多存放5个ViewHolderfalsetrue

RecyclerView缓存机制更详细解析参见:Android深入理解RecyclerView的缓存机制 。在VP2中主要使用的是mCachedViewsmRecyclerPool

  • mCachedViews:缓存滑动时即将与RecyclerView页面分离的ViewHolder,按子Viewpositionid缓存,默认存放2个,可以通过setItemViewCacheSize(int size)修改缓存个数。如果RecyclerView开启了预抓取功能(默认预抓取个数为1),则缓存池大小默认为3(mCachedViews缓存2 + 预抓取个数1 )。
  • mRecyclerPoolViewHolder缓存池,本质上是一个SparseArray,其中keyViewType(int类型)value存放的是 ArrayList< ViewHolder>,默认每个ArrayList中最多存放5个ViewHolder。回收到该缓存池的ViewHolder会将数据解绑,当复用该ViewHolder时,需要重新绑定数据(即重新走(onBindViewHolder)。

3.2 offscreenPageLimit离屏缓存

//ViewPager2.java
public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
    if (limit < 1 && limit != OFFSCREEN_PAGE_LIMIT_DEFAULT) {
        throw new IllegalArgumentException(
                "Offscreen page limit must be OFFSCREEN_PAGE_LIMIT_DEFAULT or a number > 0");
    }
    mOffscreenPageLimit = limit;
    // Trigger layout so prefetch happens through getExtraLayoutSize()
    mRecyclerView.requestLayout();
}

setOffscreenPageLimit设置的是VP2的离屏显示个数,默认是-1,因为RecyclerView中的布局是通过LayoutManager,所以真正进行离屏计算是在VP2.LinearLayoutManagerImpl#calculateExtraLayoutSpace()中,该方法计算的是LinearLayoutManager布局的额外空间,LinearLayoutManagerImpl继承自LinearLayoutManager

protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
        @NonNull int[] extraLayoutSpace) {
    int pageLimit = getOffscreenPageLimit();
    if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
        // Only do custom prefetching of offscreen pages if requested
        super.calculateExtraLayoutSpace(state, extraLayoutSpace);
        return;
    }
    final int offscreenSpace = getPageSize() * pageLimit;
    extraLayoutSpace[0] = offscreenSpace;
    extraLayoutSpace[1] = offscreenSpace;
}

getPageSize()表示ViewPager2的宽度,左右离屏大小都为getPageSize() * pageLimitextraLayoutSpace[0]表示左边,extraLayoutSpace[1]表示右边。比如设置offscreenPageLimit为1,可以认为是把屏幕扩大到3倍。左右两边各有一个离屏PageSize的宽度(左右不可见),如图所示: offscreenPageLimit

3.3 FragmentStateAdapter缓存原理

FragmentStateAdapter的使用前面已经介绍过了,因为FragmentStateAdapter继承自RecyclerView.Adapter,所以可以直接通过setAdapter设置给VP2。我们知道FragmentStateAdapter作为Adapter时,每个Item都是Fragment,那么Fragment又是怎么跟FragmentStateAdapter关联起来的呢?下面就尝试分析一下:

    //FragmentStateAdapter.java
    final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
    private final LongSparseArray<Integer> mItemIdToViewHolder = new LongSparseArray<>();

    public abstract @NonNull Fragment createFragment(int position);

    @NonNull
    @Override
    public final FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return FragmentViewHolder.create(parent);
    }

  //FragmentViewHolder.java
  public final class FragmentViewHolder extends ViewHolder {
      private FragmentViewHolder(@NonNull FrameLayout container) {
          super(container);
      }

      @NonNull static FragmentViewHolder create(@NonNull ViewGroup parent) {
          FrameLayout container = new FrameLayout(parent.getContext());
          container.setLayoutParams(
                  new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.MATCH_PARENT));
          //设置唯一ID
          container.setId(ViewCompat.generateViewId());
          container.setSaveEnabled(false);
          return new FragmentViewHolder(container);
      }

      @NonNull FrameLayout getContainer() {
          return (FrameLayout) itemView;
      }
  }

onCreateViewHolder中设置的是名为FragmentViewHolderViewHolder,内部的根布局是一个FrameLayout,为该FrameLayout设置一个唯一ID,后续复用ViewHolderFragment的布局时会使用。FragmentStateAdapter内部两个很有用的数据结构:

 final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
 private final LongSparseArray<Integer> mItemIdToViewHolder = new LongSparseArray<>();
  • mFragments:是positionFragment的映射表。随着position的增长,Fragment是会不断的新建出来的。Fragment可以被缓存起来,回收后不能重复使用,只能被重新创建。
  • mItemIdToViewHolder:是positionViewHolder#Id的映射表。由于ViewHolderRecyclerView缓存机制的载体,所以随着position的增长,ViewHolder会被重新利用。

VP2滑动时,当前屏幕正在显示的前面最近的2个Item会被缓存到mCachedViews中,超过2个时会从mCachedViews删除,并将其转移到RecyclerPool中,此时会调用onViewRecycled()如下:

@Override
public final void onViewRecycled(@NonNull FragmentViewHolder holder) {
    final int viewHolderId = holder.getContainer().getId();
    final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
    if (boundItemId != null) {
        removeFragment(boundItemId);
        mItemIdToViewHolder.remove(boundItemId);
    }
}

ViewHolder回收到RecyclerPool中时,将ViewHolder相关的信息删除。在前面的介绍中我们知道从mCachedViews中取ViewHolder时并不会执行onBindViewHolder,只有从RecyclerPoolViewHolder时才会执行到onBindViewHolder,接着看一下onBindViewHolder:

    @Override
    public final void onBindViewHolder(final @NonNull FragmentViewHolder holder, int position) {
        //如果mItemIdToViewHolder中跟当前ViewHolder的ID一样,那么需要将mItemIdToViewHolder中的ID进行删除,并在后面重新对该ViewHolder的ID进行赋值
        final long itemId = holder.getItemId();
        final int viewHolderId = holder.getContainer().getId();
        final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
        if (boundItemId != null && boundItemId != itemId) {
            removeFragment(boundItemId);
            mItemIdToViewHolder.remove(boundItemId);
        }
        //在这里将viewHolerId重新添加到mItemIdToViewHolder中
        mItemIdToViewHolder.put(itemId, viewHolderId); // this might overwrite an existing entry
        //创建Fragment并添加到mFragments中
        ensureFragment(position);

        final FrameLayout container = holder.getContainer();
        if (ViewCompat.isAttachedToWindow(container)) {
           //...其他...
            container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
                @Override
                public void onLayoutChange(View v, int left, int top, int right, int bottom,
                        int oldLeft, int oldTop, int oldRight, int oldBottom) {
                    if (container.getParent() != null) {
                        container.removeOnLayoutChangeListener(this);
                        //将新来的Fragment的布局依附到ViewHolder中
                        placeFragmentInViewHolder(holder);
                    }
                }
            });
        }

        gcFragments();
    }

    //onBindViewHolder()中调用该方法创建Fragment
    private void ensureFragment(int position) {
        long itemId = getItemId(position);
        if (!mFragments.containsKey(itemId)) {
            //在这里创建Fragment 
            Fragment newFragment = createFragment(position);
            newFragment.setInitialSavedState(mSavedStates.get(itemId));
            mFragments.put(itemId, newFragment);
        }
    }

可以看到在onBindViewHolder()中创建了Fragment并将其添加到了mFragments中,从而FragmentFragmentStateAdapter关联起来了。

默认当前Item的前面2个及后面的1个(RecyclerView默认会开启预抓取能力:isItemPrefetchEnabled默认为true)总共3个Fragment会缓存在mCachedViews中;超过2个的位置时创建的Fragment就会被销毁,有一种特殊情况需要注意:VP2滑动到最后时,当前Item前面的3个(这里不是默认的2个了)Fragment都会被缓存,因为滑动到最后了,后面预抓取的1个给到了前面。当第一次加载时,由于还没有触发VP2onTouch操作,所以此时还不会进行后面的预抓取。

四 ViewPager、ViewPager2差异对比

功能ViewPagerViewPager2
ListeneraddPageChangeListenerregisterOnPageChangeCallback(OnPageChangeCallback callback),其中OnPageChangeCallback是一个抽象类,不同于接口方式,抽象类里用到哪个覆写哪个即可
FragmentFragmentPagerAdapter、FragmentStatePagerAdapterFragmentStateAdapter
setOffscreenPageLimit(int num)离屏缓存,当设置小于1时,会强制设为1,即强制左右各缓存1个OFFSCREEN_PAGE_LIMIT_DEFAULT默认为-1,及默认不会离屏缓存
AdapterPagerAdapterRecyclerView.Adapter
其他操作/支持RTL从右到左排序、垂直滑动、停止用户操作

五 参考

【1】官方:使用 ViewPager2 在 Fragment 之间滑动
【2】官方:从 ViewPager 迁移到 ViewPager2
【3】ViewPager2中的Fragment懒加载实现方式
【4】聊聊ViewPager2中的缓存和复用机制