【动画图解】这个值取对了,ViewPager2才能纵享丝滑
前言
在前两篇文章中,我们通过一张张清晰明了的「示意图」,详细地复盘了RecyclerView「缓存复用机制」与「预拉取机制」的工作流程,这种「图解」创作形式也得到了来自不同平台读者们的一致认可。
而从本文开始,我们将正式进入ViewPager2的篇章,并将辅以更加生动易懂的「动态示意图」来进行讲解。
ViewPager2可讲的内容有很多,今天我们主要介绍是ViewPager2的「离屏加载机制」,你可能是第一次听说这个术语,但在实际开发中,你肯定使用过它,因为它对应的配置入口,就是ViewPager2的OffscreenPageLimit属性。
OffscreenPageLimit是什么?
OffscreenPageLimit,直译过来是离屏页面限制值的意思,该值代表的是在滑动视图中应保留在当前可见页面之外的任一方向上的页面数。
比如,当我们采用水平分页时,该值代表的便是在左右两侧应保留的页面数。
水平分页.gif而当我们采用垂直分页时,该值代表的则是在上下两侧应保留的页面数。
垂直分页.gif保留页面的方式是通过扩展额外的布局空间实现的,以LinearLayoutManager为例,其最关键的步骤在于对calculateExtraLayoutSpace
方法的重写:
/**
* 计算额外的布局空间
*/
@Override
protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
@NonNull int[] extraLayoutSpace) {
int pageLimit = getOffscreenPageLimit();
if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
// 仅在需要时才对屏幕外页面进行自定义预取
super.calculateExtraLayoutSpace(state, extraLayoutSpace);
return;
}
// 计算多pageLimit*2个页面大小的空间
final int offscreenSpace = getPageSize() * pageLimit;
extraLayoutSpace[0] = offscreenSpace;
extraLayoutSpace[1] = offscreenSpace;
}
/**
* 获取单个页面大小
*/
int getPageSize() {
final RecyclerView rv = mRecyclerView;
// 水平分页时,取去除了左右内边距后的RecyclerView宽度
// 垂直分页时,取去除了上下内边距后的RecyclerView高度
return getOrientation() == ORIENTATION_HORIZONTAL
? rv.getWidth() - rv.getPaddingLeft() - rv.getPaddingRight()
: rv.getHeight() - rv.getPaddingTop() - rv.getPaddingBottom();
}
该方法会计算LinearLayoutManager应布置的额外空间量(以像素为单位)。已知默认布置的空间量为单个页面大小,则额外布置的空间量应为OffscreenPageLimit*2个单页面大小,计算出来的结果会存储在int数组类型的extraLayoutSpace
结构中,其中:
- extraLayoutSpace[0]应用于顶部或左侧的额外空间;
- extraLayoutSpace[1]应用于底部或右侧的额外空间。
虽然这部分额外创建的页面在当前屏幕上并不可见,但实际已经被添加至我们的视图层次结构中了。这么做可以减少切换分页时花费在视图创建与布局上的时间,从而提升ViewPager2滑动时的整体流畅度。
结合前面两篇文章我们可以看到,从缓存复用机制到预拉取机制再到现在的离屏加载机制,RecyclerView与ViewPager2在提升滑动流畅度方面真的是做了非常多的努力。
区别在于:
- 缓存复用机制是通过缓存已创建的页面,以提供给新进入屏幕的页面重用来实现的。
- 预拉取机制是通过利用UI线程空闲的时机,提前创建并缓存下一个待进入屏幕的页面来实现的。
- 离屏加载机制则是通过扩展额外的布局空间,以提前创建并保留屏幕两侧的页面来实现的。
从调用方法流程上讲,离屏加载机制除了常规的onCreateViewHolder、onBindViewHolder方法之外,还会执行一个多onViewAttachedToWindow
方法,以将页面提前添加至我们的视图层次结构中。
虽然我们一直强调的是“ViewPager2的离屏加载机制”,但其实,离屏加载机制并不是ViewPager2才引入的新特性,作为ViewPager的改进版本,ViewPager2也只是把早已存在于ViewPager中的这个特性照搬过来而已,二者的主要区别有以下几点:
- 对于OffscreenPageLimit默认值的设置
- 对于OffscreenPageLimit赋值条件的限制
OffscreenPageLimit的默认值设置与赋值条件限制
ViewPager一直为人所诟病的一个点就是,其设置的OffscreenPageLimit默认值为1,且不允许外部传入低于1的修改值,即会强制开启离屏加载机制。
// 默认的离屏加载限制值为1
private static final int DEFAULT_OFFSCREEN_PAGES = 1;
public void setOffscreenPageLimit(int limit) {
// 小于默认值的数会被强制设为默认值
if (limit < DEFAULT_OFFSCREEN_PAGES) {
Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to "
+ DEFAULT_OFFSCREEN_PAGES);
limit = DEFAULT_OFFSCREEN_PAGES;
}
if (limit != mOffscreenPageLimit) {
mOffscreenPageLimit = limit;
populate();
}
}
谁赞成谁反对.gif
这也就意味着,在使用ViewPager构建的滑动视图中,不管开发者需不需要,都至少会有1~2个页面会被离屏加载,而这会导致一系列依赖于Fragment生命周期的逻辑被异常执行,进而产生非预期的结果,需要开发者手动实现延迟加载机制。
相比较之下,ViewPager2设置的OffscreenPageLimit默认值则为-1,也即默认不开启离屏加载机制,且对于外部传入的修改值也只要求必须是大于0的正数或默认值。
// 默认的离屏加载限制值为-1
public static final int OFFSCREEN_PAGE_LIMIT_DEFAULT = -1;
public void setOffscreenPageLimit(@OffscreenPageLimit int limit) {
// 低于1且非默认值的传参会报异常
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;
// 触发重新布局操作,以便通过getExtraLayoutSize()方法进行离屏加载
mRecyclerView.requestLayout();
}
另外,我们在本系列的第一篇就讲了,ViewPager2是在RecyclerView的基础上构建而成的。因此,即使是默认不开启离屏加载机制,预拉取机制也会正常工作。
而我们前面又讲了,预拉取机制会提前创建并缓存下一个待进入屏幕的页面,但不会添加至我们的视图层次结构中,因此不会像ViewPager一样,导致一系列依赖于Fragment生命周期的逻辑被异常执行,相当于自动帮我们实现延迟加载机制了。
从以上2个默认数值我们可以看到,无论是ViewPager还是ViewPager2,其对于OffscreenPageLimit默认值的设置都是比较克制的。实际上,在setOffscreenPageLimit方法的注释中,Android也是建议我们将此限制值保持在较低水平,尤其是当我们的页面具有复杂的布局时。
但实际情况是,大部分的开发者为图方便,往往会将此值设为页面总数-1,也即默认会离屏加载所有的页面。
这种做法无疑是很不规范的,为什么说不规范呢?这就引申出我们下一个问题了,即OffscreenPageLimit的不同赋值,会对ViewPager2产生什么样的影响呢?
不同的OffscreenPageLimit值产生的影响
行为表现
OffscreenPageLimit值为-1
当OffscreenPageLimit值为-1时,也即保持默认不开启离屏加载机制,这种情况下只有RecyclerView的缓存复用机制和预拉取机制会工作。
limit-1第一次滑动.gif- 当滑动视图初始化完成时,只有position=0的页面项会被添加至当前视图层次结构中。
- 随着我们往左滑动屏幕,预拉取机制会开始工作,提前创建position=2的页面项并放入mCachedView中。
- 同时,position=0的页面项也将随着向左滑动的手势被移出屏幕,并放入mCachedView中。
- 再次向左滑动屏幕,滑动视图会取出预拉取的position=2的页面项进行使用,同时开启对position=3的页面项的预拉取。
- 此时,由于还未超过mCachedView大小的限制,下一个被移出屏幕的position=1的页面项也将放入mCachedView中。
- 第三次向左滑动屏幕,同样,会取出预拉取的position=3的页面项进行使用,同时开启对position=4的页面项的预拉取。
- 但是,由于超过了mCachedView大小的限制,在下一个被移出屏幕的position=2的页面项尝试进入时,会先按照先进先出的顺序,先从mCachedView中移出position=0的页面项,放入RecyclerPool中对应itemType的ArrayList容器中,然后position=2的页面项才顺利进入mCachedView。
- 之后的滑动同样遵循这个规律,不再赘述。
OffscreenPageLimit值为1
当OffscreenPageLimit值为1时,也即会在左右两侧各离屏加载1个页面。
limit1第一次滑动.gif- 当滑动视图初始化完成时,由于左侧无更多的页面项,因此只有position=0及position=1的页面项会被添加至当前视图层次结构中。
- 随着我们往左滑动屏幕,position=2的页面项会被添加至当前视图层次结构中,而position=0的页面项会继续保留在当前视图层次结构中,同时预拉取机制会开始工作,提前创建position=3的页面项并放入mCachedView中。
- 再次向左滑动屏幕,滑动视图会取出预拉取的position=3的页面项添加至当前视图层次结构中,而position=1的页面项会继续保留在当前视图层次结构中,并开启对position=4的页面项的预拉取。
- 同时,position=0的页面项也将随着向左滑动的手势被移出屏幕,并放入mCachedView中。
- 第三次向左滑动屏幕,同样,会取出预拉取的position=4的页面项添加至当前视图层次结构中,并保留position=2的页面项在当前视图层次结构中,同时开启对position=5的页面项的预拉取。
- 此时,由于还未超过mCachedView大小的限制,下一个被移出屏幕的position=1的页面项也将放入mCachedView中。
- 第四次向左滑动屏幕,同样,会取出预拉取的position=5的页面项添加至当前视图层次结构中,并保留position=3的页面项在当前视图层次结构中,同时开启对position=6的页面项的预拉取。
- 但是,由于超过了mCachedView大小的限制,在下一个被移出屏幕的position=2的页面项尝试进入时,会先按照先进先出的顺序,先从mCachedView中移出position=0的页面项,放入RecyclerPool中对应itemType的ArrayList容器中。
OffscreenPageLimit值为3
当OffscreenPageLimit值为3时,也即会在左右两侧各离屏加载3个页面。
limit3第一次滑动.gif- 当滑动视图初始化完成时,由于左侧无更多的页面项,因此只有position=0至position=3的页面项会被添加至当前视图层次结构中。
- 随着我们往左滑动屏幕,position=4的页面项会被添加至当前视图层次结构中,而position=0的页面项会继续保留在当前视图层次结构中,同时预拉取机制会开始工作,提前创建position=5的页面项并放入mCachedView中。
- 再次向左滑动屏幕,滑动视图会取出预拉取的position=5的页面项添加至当前视图层次结构中,而position=1的页面项会继续保留在当前视图层次结构中,并开启对position=6的页面项的预拉取。
- 第三次向左滑动屏幕,滑动视图会取出预拉取的position=6的页面项添加至当前视图层次结构中,而position=2的页面项会继续保留在当前视图层次结构中。也即这个时候,所有的页面项已经都被添加至当前视图层次结构中了。
- 第四次向左滑动屏幕,由于超出了OffscreenPageLimit值,position=0的页面项将随着向左滑动的手势被移出屏幕,并放入mCachedView中。
-
第五次向左滑动屏幕,此时,由于还未超过mCachedView大小的限制,下一个被移出屏幕的position=1的页面项也将放入mCachedView中。
-
第六次向左滑动屏幕,由于开启了预拉取机制,mCachedView大小的限制由默认的2项再加上预拉取的1项,变为3项,因此仍未超过mCachedView大小的限制,下一个被移出屏幕的position=2的页面项仍将放入mCachedView中,此处不再进行动画展示。
OffscreenPageLimit值为页面总数-1
limit_6.gif当OffscreenPageLimit值为页面总数-1时,也即在滑动视图初始化完成时就已经离屏加载所有的页面了,这种情况下RecyclerView的缓存复用机制和预拉取机制完全没有工作的机会。
虽然设置更高的OffscreenPageLimit值,可以更好地提升ViewPager2滑动时的流畅度,但由于需要在初始化阶段同时创建多个页面项,意味着将花费更久的创建时间,页面项内容也将更慢显示,同时,由于两侧有更多的页面项被保留而不走缓存复用流程,意味着应用会占用更多的内存,且这些问题将随着页面复杂度提升更加突出。
为了更直观地展示不同的OffscreenPageLimit值对应用的性能影响,我们将从白屏时间、流畅度、占用内存三个维度来进行横向对比:
性能影响
白屏时间
白屏时间.gif可以看到,随着OffscreenPageLimit值的增加,在滑动视图的初始化阶段,会有更多的页面项需要被创建并被添加至当前的视图层次结构中,白屏时间也随之延长。
流畅度
参考上一篇的做法,我们同样在FragmentStateAdapter中对Fragment的视图准备工作做了延迟,以在GPU渲染模式中展示更加清晰的柱状图:
流畅度.gifOffscreenPageLimit值为1时,虽然可以离屏加载下一个页面,但由于每次滑动还要执行预拉取的工作,因此对于流畅度的提升不是很明显。
OffscreenPageLimit值为3时,即每次都会保留当前屏幕两侧的各3个页面项,在滑动到中间位置时,对于流畅度的提升是最大的,此时无论是往前滑还是往后滑,都无需再执行页面项的创建工作,即使滑到边界也可以利用缓存复用机制来重用视图。
OffscreenPageLimit值为6时,也即在滑动视图初始化完成时就已经离屏加载所有的页面了,每次的滑动就相当于只是在当前的视图层次结构中进行位移,因此全程的流畅度都有极大的提升。
内存占用
可以看到,随着OffscreenPageLimit值的增加,在滑动视图的初始化阶段,会有更多的Fragment对象驻留在内存中。
内存占用.png同时,由于OffscreenPageLimit值会保留当前屏幕两侧的页面项,因此,滑动到中间位置时,OffscreenPageLimit值为1的情况最多会保留3个Fragment对象,而OffscreenPageLimit值为3的情况最多会保留7个Fragment对象。
但在其他位置时,它会将超出OffscreenPageLimit值限制的页面将从视图层次结构中移除,并交由RecyclerView的缓存复用机制处理,同往常一样回收ViewHolders对象以供重用。
OffscreenPageLimit值取多大比较合适?
现在我们知道了,当OffscreenPageLimit值设得过大,比如页面总数-1时,会给应用带来比较大的内存压力,特别是在部分低端机型上。
而OffscreenPageLimit值设得过小,比如1时,又无法发挥出离屏加载机制提高页面滑动流畅度的优势。
一般来讲,同时保持3-4个页面项处于活动状态是一个比较合适的值,一方面,可以提高用户来回翻页时的流畅度,另一方面又不会给应用带来太大的内存压力。当然,还需要我们自己维护好Fragment重建以及视图回收/复用时的处理逻辑。
最好的情况下,还是希望能够根据应用当前的内存使用情况,对该值进行动态调整,在行为表现与性能影响上取一个平衡点。
但如果多个页面项之间存在互斥关系,同时处于活动状态可能影响业务的判断时,保持OffscreenPageLimit为默认值,也即默认关闭离屏加载机制,只让预拉取机制与缓存复用机制工作,也许是个更好的选择。
后记
讲到这里,相信你对ViewPager2的离屏加载机制已经有了一定的认识,但不知道你发现没有,我们全文讲的都是ViewPager2顺序依次翻页的情况,但在实际运用中,我们常常会搭配TabLayout,提供点击标签页跳转到指定页面项的功能。
而当增加了这一种新的交互方式后,问题的维度再一次上升了,我们会发现离屏加载机制的行为逻辑又有所不同了,而这,就是下一篇的内容了。