Android 从 ListView 到 RecyclerView 的进化
如果是近期才开始学习 Android 开发,恐怕连 ListView
都不知道是啥。遥想高中写第一个 App,Android 开发还在用 Eclipse 的年代,ListView
用的不亦乐乎,转眼间就成了众矢之的了。
ListView
与 LinearLayout
里插入一大堆 View 相比,ListView
也称得上是高性能。之所以这么讲,主要是它实现了两个关键功能:懒加载与复用。所谓懒加载就是只有某一项需要显示时才尝试解析 View、绑定数据。而复用则是随着列表的滚动,新项目直接使用那些不再可见项目的 View,只是重新绑定一下数据而已。
当然,
ListView
的复用不是天然的,需要开发者配合实现。同时为了节约多次调用findViewById()
的开销,开发者还要记得套一层 ViewHolder 记录各个子 View。一个常见的写法是把创建的 ViewHolder 以 tag 的形式附着在 View 上。
二层缓存
官方说 ListView
是二级缓存。具体负责缓存的类是 AbsListView.RecycleBin
,每一个 ListView
内部都有它的实例 mRecycler
,这里面可以看见两个缓存的定义:
/**
* Views that were on screen at the start of layout. This array is populated at the start of
* layout, and at the end of layout all view in mActiveViews are moved to mScrapViews.
* Views in mActiveViews represent a contiguous range of Views, with position of the first
* view store in mFirstActivePosition.
*/
private View[] mActiveViews = new View[0];
/**
* Unsorted views that can be used by the adapter as a convert view.
*/
private ArrayList<View>[] mScrapViews;
mActiveViews
缓存屏幕上正在显示的 View,它主要在「数据没有改变但触发了重新布局」时发挥作用。注意 Adapter.notifyDataSetChanged()
方法中 ListView 默认数据改变了,不会做额外的检查。因此这个缓存的适用场景缩窄到了 ListView.onLayout()
因故被多次调用时。
mScrapViews
则是一个列表数组,存储的是从屏幕上移出的 View。其外层数组下标是 viewType
,这也是 ListView 要求 viewType 必须是从 0 开始的连续整数的原因。有两种情况会填充/利用这层缓存:
- 当
notifyDataSetChanged()
被调用时,屏幕上的所有项目的 View 都会按照 viewType 保存到mScrapViews
里,然后取出来重新绑定数据再显示。 - 当列表滑动时,不再处于显示范围内的 View 按照类型保存起来。新显示的项目则提取对应类型已缓存的 View,重新绑定数据后显示。
由此可见,ListView 的离屏缓存(暂且这么叫吧)是不能缓存数据的,每次取出都得重新填充。而且这一步需要开发者的配合(在 Adapter.getView()
的实现里判断传入的 convertView
是否为空,采取不同的操作),要是代码写的不好,这层缓存就废了。
RecyclerView
运行机制
宏观原理
RecyclerView 三个最著名的组件是:RecyclerView
, LayoutManager
和 Adapter
,宏观的运行机制如下:
flowchart LR
RecyclerView-->LayoutManager
LayoutManager-.Request View.->Adapter
Adapter-.ViewHolder.->LayoutManager
这个宏观图有两个要点:
- 是
LayoutManager
主动请求View
,而不是Adapter
主动。只有LayoutManager
才知道需要加载几个项目的 View,因为每个项目的大小都不确定,Adapter
没有能力得知当前屏幕可以摆下多少个。 LayoutManager
与Adapter
不直接耦合,它们中间还藏着一个组件。
更加完整的图是这样:
flowchart LR
RecyclerView-->LayoutManager
LayoutManager--Request View-->Recycler
Recycler--"onCreateViewHolder()"-->Adapter
Recycler--"onBindViewHolder()"-->Adapter
Adapter--ViewHolder-->Recycler
Recycler--View-->LayoutManager
Recycler
接到 View 请求时首先检查自己的缓存,看是否有可用的,若有则直接返回。若没有则请求 Adapter
创建新的 View 或复用已存在的 View 但重新绑定数据。这里也蕴含这单一可信数据源的思想,Recycler
作为唯一可信的提供 View 的源,避免了 Adapter
同时负责创建和缓存,状态容易搞乱的问题。
两次布局
当数据改变,RecyclerView
重新布局时,实际上会向 LayoutManager
请求两次布局,分别叫做 pre-layout 与 post-layout。目的是实现动画效果。
假设目前屏幕上显示 ABC 三个项目,当删除 C 时,新项目 D 应该显示,并带有进入动画。提到动画,就少不了初始位置与结束位置。结束位置显而易见,初始位置就不好办了。根据 LayoutManager 的不同,D 应该从哪里出现是不确定的。因此需要两次布局,pre-layout 计算数据改变前的布局,但是要把即将要显示的项目计算出来(在这个例子中就是 D)。post-layout 自然是计算数据改变后的布局。有了前后两个布局,动画也就水到渠成。
更新某项目,尤其是只更新它的个别 View 甚至 UI 上没有更新时,闪烁问题的罪魁祸首也是动画。默认项目改变动画的效果是:旧的 View 淡出,新 View 淡入。解决方案有两个:
- 关闭预测性动画
- 使用真局部刷新
对于表项数据改变的情况,两次布局用到的 ViewHolder(以及背后的 View)必须是不同的对象。因为它们要用来做动画的,显然我们不能在同一个 View 上同时执行淡入和淡出效果。
缓存
首先最大的区别是 RecyclerView
吸取了开发者利用 ViewHolder 的经验,并改为强制使用。因此缓存的单位也从 View 变成了 ViewHolder。
ListView
中保存缓存的是 RecycleBin
,RecyclerView
中也有个类似的东西,是 RecyclerView.Recycler
,其内部核心成员是:
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
RecycledViewPool mRecyclerPool;
一级缓存
mCachedViews
中的 View 连重新填充都不用就可以直接显示,类似 ListView 中的 mActiveViews
,但作用场景更广泛了,主要在滑动/回滚情况下能大幅提高效率。在 ListView 中这种操作最多只能使用 mScrapViews
级别的缓存,需要重新填充数据。在默认情况下 mCachedViews
最多允许 2 个元素,新缓存的 ViewHolder 会替换旧的,这一限制可通过 RecyclerView.setItemViewCacheSize()
更改。
具体来说,假设列表向上滚动,最顶上的项目将被移出屏幕,此时他们的 ViewHolder 会被保存到 mCachedViews
中。若用户又往下滚动,刚刚消失的项目重新显示,此时可直接从 mCachedViews
取出,不需要重新填充数据,即不会触发 onBindViewHolder()
方法。
mCachedViews
中按照 position 查找,与 viewType 无关。
二级缓存
RecycledViewPool
则对应 ListView 中的 mScrapViews
,其内部缓存的 ViewHolder 按照 viewType 分类,取用时需要重新填充数据。mCachedViews
因容量不足被挤出的元素会进入 RecycledViewPool
。RecycledViewPool
中定义了 ScrapData
来包装 ViewHolder,所以本质上,存储 ViewHolder 的是一个 Map 结构,类似 SparseArray<ArrayList<ViewHolder>>
。由此可以推断出,RecyclerView 解除了 viewType 必须从 0 开始且连续的限制。
SparseArray
是针对 Android 优化的 Map 结构,相当于 Key 只能是 Int 的 Map。
默认情况下每个 viewType 最多留有 5 个缓存,当然,也可以通过 RecycledViewPool.setMaxRecycledViews()
来修改。如果单一 viewType 显示的比较密集,通常可以考虑改成更大的值。
不过为啥要把这层缓存单独封装成一个类呢?一个明显的优势就是可以复用啦!RecyclerView.setRecycledViewPool()
可以手动指定回收池。例如 Google Play Store 竖直摆放了多个横向的滑动列表,它们的 ItemView 都是一样的,那么就完全可以共享一个回收池。
在 mCachedViews
未命中的情况下将尝试从 RecycledViewPool
根据 viewType 取缓存,若取到则重新填充数据后显示。
局部刷新暂存
局部刷新有两层含义:
- 只刷新数据改变了的那一项。
- 在某一项中,只刷新个别改变了的 View。
这两个 RecyclerView 都支持,这里主要讨论第一个。
调用 notifyDataSetChanged()
等方法后视为数据有更新,与 ListView 一样,这种场景下将跳过一级缓存,现有的 ViewHolder 直接进入二级缓存(RecycledViewPool
)。这就有个小问题,RecycledViewPool
中每个 viewType 所允许的缓存个数是有限的,这个限制并不是针对这一场景而优化,因此很可能不够用,每一次数据全量刷新都要触发几次布局解析。其实就算命中缓存,也需要重新填充数据,这也是没必要的开销。
所以记得调用局部刷新专用的 API,不要偷懒一股脑全量刷新了。
局部刷新时,那些数据没有改变的项目的 ViewHolder 应该缓存以备后用。乍一听似乎应该由一级缓存 mCachedViews
负责,毕竟不需要填充数据就能用,但别忘了,它也有大小限制,并且不是为数据刷新这一场景优化的,所以额外引入了个无限制大小的缓存数组 mAttachedScrap
。那些数据没有改变的项目重新布局时优先从这里取。
一些「RecyclerView 有四级缓存」的说法把 mAttachedScrap
算作第一层缓存,这样并不准确。多层缓存的说法往往意味着,数据总是按层读取。但 mAttachedScrap
相对来说有独立的数据流与使用场景:
mAttachedScrap
的生命周期局限于单次布局内部。- 一次布局结束后,
mAttachedScrap
里剩余的元素均要被移动到RecycledViewPool
中,自身清空。
对应的,改变了的项目的 ViewHolder 也会被暂存起来,放入另一个数组 mChangedScrap
中。
为啥要分开暂存?前面两次布局那一节提到过,对于「项目数据改变」这一场景,需要两个 ViewHolder,一个显示旧数据,一个显示新数据。pre-layout 时可以从 mChangedScrap
与 mAttachedScrap
两个暂存区取用,post-layout 时只能从 mAttachedScrap
取用。这样可以保证新旧数据一定不是同一个 ViewHolder 对象。由此可见,mChangedScrap
仅仅是为了防止重复取用而被分离出来,所以若是不需要动画,这个暂存区也就不工作了,而是所有元素都进入 mAttachedScrap
,实践中有两个场景:
- 关闭预测性动画。
- 使用真局部刷新(调用双参数的
notifyItemChanged()
),也就是本节开头说的局部刷新的第二层含义。
小节
总结一下 RecyclerView 相比 ListView 在缓存上的改进:
- 优化了一级缓存,使其适用场景变广(扩展了回滚场景)。
- 封装了二级缓存,可以多个 RecyclerView 共享回收池。
- 二级缓存可以根据 ViewType 设置不同的容量上限。
- 减少了 ViewType 值的限制,现在只要是 int 就可以。
- 支持局部加载,并设有专用的暂存区,免受缓存上限的影响。
具体的缓存使用顺序写在 RecyclerView.tryGetViewHolderForPositionByDeadline()
里:
flowchart TB
subgraph "No Rebinding"
pre{{is pre-layout?}}
pre--YES-->changedScrap[ChangedScrap]
changedScrap-->AHC["AttachedScrap/Hidden/CachedViews (via pos)"]
pre--NO-->AHC
AHC-->stableId{{stable id?}}
stableId--YES-->ids["AttachedScrap/CachedViews (via id)"]
ids-->custom[custom cache]
stableId--NO-->custom
end
subgraph "Need Rebinding"
custom-->pool["RecycledViewPool (via type)"]
pool-->new[create new]
end