概述
RecyclerView 作为替代 ListView 的组件,得益于 RecyclerView 的灵活性和可定制程度高的特性,除此之外 RecyclerView 的预取机制以及缓存机制也是一大亮点。相关类:
- LayoutManager: 布局管理器,用来决定 View 如何填充 RecyclerView, 接管 RecyclerView 的 Measure, Layout, Draw 过程。
- RecyclerView.Adapter: 负责提供 View 所需要的数据集以及管理 View 的创建和数据绑定。
- ViewHolder: View 持有者,负责 View 的初始化。
- Recycler: 缓存逻辑。
首先看一下 RecyclerView 的基础使用:
1. 依赖: androidx.recyclerview:recyclerview:1.1.0
2. 设置LayoutManager
1 | mRecyclerView = findViewById(R.id.recycle_view); |
3. 自定义Adapter
1 | mRecyclerView.setAdapter(adapter); |
4. ItemDecoration
RecyclerView 可以通过 addItemDecoration() 设置分割线,可以通过继承 RecyclerView.ItemDecoration 类自定义分割线。
5. ItemAnimator
RecyclerView 可以通过 setItemAnimator(ItemAnimator animator)
来设置添加和移除时的动画效果,需要注意更新数据集时要用 notifyItemInserted 与 notifyItemRemoved, 不能使用 notifyDataSetChanged, 否则没有动画效果。
缓存工作流程
- 列表首次填充时,它会创建并在列表的任意一侧绑定一些视图持有者。例如,如果视图显示的是列表位置 0 到 9,则 RecyclerView 会创建并绑定这些视图持有者,还可能创建并绑定位置 10 的视图持有者。这样,如果用户滚动列表,则下一个元素可随时显示出来。
- 当用户滚动列表时,RecyclerView 会根据需要创建新的视图持有者。它还会保存已在屏幕外滚动的视图持有者,以便重复使用。如果用户切换滚动方向,则之前在屏幕外滚动的视图持有者可以立即恢复。另一方面,如果用户继续沿同一方向滚动,屏幕外停留时间最长的视图持有者可以重新绑定到新数据。无需创建视图持有者,也不需要扩充其视图;应用只更新视图的内容以匹配其绑定到的新项。
缓存原理
数据结构
在 RecyclerView.Recycler 类中定义了缓存相关的数据结构:
1 | public final class Recycler { |
这里大概可以分为 4 种类别:
- Scrap 缓存:对应当前已加载的视图,也就是屏幕中的视图;
- Cache 缓存:刚刚移出屏幕的Item,默认大小是2个,缓存中的Item匹配后可以直接展示,主要用于解决RecyclerView滑动抖动时的情况,还有用于保存Prefetch的ViewHoder。根据FIFO原则先把老的数据放入下一级缓存中(如RecycledViewPool),然后添加新数据;
- ViewCacheExtension 缓存:用来给开发者自定义的缓存,通过type和position来查找缓存,开发者可通过实现 getViewForPositionAndType 方法来自定义;
- RecycledViewPool 缓存:默认的缓存数量是5个,优先级没有Cache缓存高,同时缓存中的Item需要再次调用onBindViewHolder()方法;
入缓存
缓存机制是从滑动开始的,在滑动的时候会触发 View 的绘制流程。RecyclerView 的绘制是委托给 LayoutManager 的,在 LinearLayoutManager 中,可以找到相关的代码:
1 |
|
在 scrapOrRecycleView() 方法中有两个逻辑分支,分别处理在屏幕中的 Item 以及需要回收的 Item, 先看如何处理屏幕中的Item:
1 | // Mark an attached view as scrap. |
这里主要处理的就是将ViewHolder加入Scrap缓存中。上面canReuseUpdatedViewHolder方法中,如果在局部更新方法里传入的 payload 不为空,则返回true接下来分析需要回收的情况:
1 | void recycleViewHolderInternal(ViewHolder holder) { |
recycleViewHolderInternal() 方法中主要是对 Cache 缓存和 RecycledViewPool 缓存进行处理。判断依据主要是根据 ViewHolde 的标志位进行判断:
- 对于 Cache 缓存:缓存池满了以后就移出第1个然后加入新的缓存数据。
- 对于 RecycledViewPool 缓存:主要根据ViewHolder的标志位进行判断(比如ViewHolder有FLAG_REMOVED标志)就会加入到RecycledViewPool缓存。
使用缓存
上面主要分析了 RecyclerView 中缓存数据的来源,接下来继续分析如何使用缓存。按照之前的思路,在 LayoutManager 执行 onLayoutChildren() 方法时会获取新的 Item, 此时就会触发 RecyclerView 的缓存机制:
1 |
|
可以看到 LayoutManager 是通过 fill() 方法来填充布局的,在这个过程中最终会通过 Recycler 的 getViewForPosition() 方法获取到需要添加的View。接下来就从 Recycler 中进行分析,而 Recycler.getViewForPosition() 最终会调用到 Recycler.tryGetViewHolderForPositionByDeadline() 方法:
1 | ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { |
从上面代码中可以看到获取 ViewHolder 的过程总共有5步:
- 一级缓存:从 Scrap 或者 Cache 中返回布局和内容都有效的ViewHolder,按照position或者id进行匹配,命中一级缓存无需onCreateViewHolder和onBindViewHolder;
- 二级缓存:从 ViewCacheExtension 缓存中取,也就是用户自定义的那部分缓存;
- 三级缓存:返回布局有效,内容无效的ViewHolder。在 RecycledViewPool 中获取,这里是通过 ViewType 来查找 ViewHolder 的,如果 ViewType 匹配就会返回 ViewHolder,这个时候会再次执行 onBindViewHolder() 方法;
- 如果上面的缓存中都没有则新建 ViewHolder, 会执行 createViewHolder 方法。
View复用错乱
RecyclerView 复用错乱的原因是取了之前的 View 来放到新 item 上,之前 View 的状态一直保留着,所以就出现错乱了。根据数据的来源可分为两种情况:
同步数据源
保证在 onBindViewHolder 方法调用的时候,根据给定位置的 item 的状态给 View 赋值。
异步数据源
当数据来源于网络时,举个例子:假设 item 中有一个 ImageView, 每次调用 onBindViewHolder 的时候都给 ImageView 一个 url, 然后等待服务器的结果,加载图片。此时若滑动屏幕,使得这个 item 不可见了,新的可见的 item 复用了它,这样当网络请求结束后,展示的图片就是之前的了。
解决方法:可以在 onBindViewHolder 中对每个 item 设置一个 tag, 其值为 url, 当网络请求返回的时候,比较一下 url 是不是跟 tag 对应,对应上了才显示图片。或者 item 不可见了就取消服务器的请求,新的 item 再发起新的请求。
实例解析
出屏幕时候的情况-mCacheViews未满
- 当ViewHolder(position=0,type=1)出屏幕的时候,由于mCacheViews是空的,那么就直接放在mCacheViews里面(从0-N是由老到新)。此时ViewHolder在mCacheViews里面布局和内容都是有效的,因此可以直接复用。
- ViewHolder(position=1,type=2)同步骤1。
出屏幕时候的情况-mCacheViews已经满
- 当ViewHolder(position=2,type=1)出屏幕的时候由于一级缓存mCacheViews已经满了,因此然后移除mCacheViews里面最老的ViewHolder(position=0,type=1)到RecyclePool中,然后将ViewHolder(position=2,type=1)存入mCacheViews。此时被移除到RecyclePool的ViewHolder的内容会被标记为无效,当其复用的时候需要再次通过Adapter.bindViewHolder来绑定内容。
- ViewHolder(position=3,type=3)同步骤3。
进屏幕时候的情况
- 当ViewHolder(position=3,type=3)进入屏幕绘制的时候,由于Recycler的mCacheViews里面找不到position匹配的View,同时RecyclerPool里面找不到type匹配的View,因此,其只能通过adapter.createViewHolder来创建ViewHolder,然后通过adapter.bindViewHolder来绑定内容。
- 当ViewHolder(position=11,type=1)进入屏幕的时候,发现RecyclerPool里面能找到type=1的缓存,因此直接从RecyclerPool里面取来使用。由于内容是无效的,因此还需要调用bindViewHolder来绑定布局。同时ViewHolder(position=4,type=3)需要出屏幕,会经历步骤3回收的过程。
- ViewHolder(position=12,type=3)同步骤6。
屏幕往下拉ViewHoder(position=1)进入屏幕的情况
- 由于mCacheView里面的有position=1的ViewHolder与之匹配,直接返回。由于内容是有效的,因此无需再次绑定内容。
- ViewHolder(position=0)同步骤8。
局部更新
在 RecyclerView 中有一系列局部更新的方法,这些方法带有默认的动画效果,具体效果是 DefaultItemAnimator 来控制和处理,就是因为动画效果,让开发时会出现一些意料之外的状况。
假设一个场景:上传照片,每个 ViewHolder 带有一张图片,然后上面有一个进度条,进度根据上传进度实时返回。如果使用 notifyItemChange() 来更新进度的话,会有两个问题:第一,发现每刷新一次,整个布局都会闪动一下。第二,进度的数值怎么传递才好呢?在 ViewHolder 的 Bean 对象中添加一个 progress 临时字段?另外,如果我们多次调用 notifyItemChange() 方法,条目会刷新多次吗?以及 notifyItemChange() 和 真正的局部刷新 同一个位置,ViewHolder 是同一个对象吗?
Adapter.notifyItemRangeChanged
方法会调用到 requestLayout() 方法:
1 | public void notifyItemRangeChanged(int positionStart, int itemCount, Object payload) { |
这里当 mAdapterHelper.onItemRangeChanged 返回 true 才会接着往下走:
1 | boolean onItemRangeChanged(int positionStart, int itemCount, Object payload) { |
payload 参数在这里被包装为对象,放入 mPendingUpdates 这个集合中。因此可以证明即使调用 notify 多次,其实只有会触发一次requestLayout。
接着看 RecyclerView.onLayout 方法,它直接调用了 dispatchLayout 方法:
1 | void dispatchLayout() { |
这里涉及到三个方法,直接说结论:
- dispatchLayoutStep1: 在这里会执行上面介绍过的缓存机制。
- dispatchLayoutStep2: 此时将 mInPreLayout 置为 false, 然后执行 LayoutManager 的 onLayoutChildren() 方法,所以不会执行 tryGetViewHolderForPositionByDeadline 中的第 0 步,不会从 mChangedScrap 中查找 ViewHolder。由于如果不需要更新或在局部更新方法里传入的 payload 不为空则 ViewHolder 会加到 mAttachedScrap 中,需要更新的且未传入 payload 则会放到 mChangedScrap 中,所以如果没传入 payload 则旧的 ViewHolder 无法被复用,它会从下面步骤中获取 ViewHolder 返回,刷新时它使用的不是同一个 ViewHolder;如果传入了 payload 则会复用。
- dispatchLayoutStep3: 保存 View 信息,执行动画。如果 position 处的 oldHolder 和 newHolder 不同,则会执行相关动画效果(闪烁现象)。
总结
总结来说 RecyclerView 的缓存都是发生在 Layout 过程中,在这里对缓存的数据进行管理,包括缓存的增加、删除、查找等行为。
RecyclerView性能优化方向总结:
一个 RecyclerView 的 item 往往由许多控件组成,当需要更新 item 中的某个状态时,如果调用 notifyItemChanged(int position) 则 item 中的每个控件都需要重新布局显示,无形中加大了内存和性能的损耗。其实就是使用了一个与之前不一样的 ViewHolder 对象。最常见的坑就是点击 item 的一个按钮,却引起同 item 的 ImageView 图片闪烁一下,可以使用 payload 参数(用来传递状态等)解决这个问题,此时更新时会复用之前的 ViewHolder 对象。
而如果需要更新整个 item 则可以不传 payload 参数。另外,多次调用 notifyItemChange() 方法,不会刷新多次,而是会合并。
如果调用了 notifyDataSetChanged 方法,即使数据源没有变化,默认只会有两个 item 重新走 onBindViewHolder 流程(mCachedViews),其余 item 会走 onCreateViewHolder 和 onBindViewHolder 流程。