RecyclerView缓存机制

RecyclerView之所以能支撑起大量列表项显示的场景,离不开其优秀的缓存机制

多级缓存

RecyclerView采用了多级缓存机制,按照优先级排列如下:

缓存 保存位置
一级缓存 mChangedScrap、mAttachedScrap、mCachedViews
二级缓存 mViewCacheExtension
三级缓存 mRecyclerPool

一级缓存

mChangedScrapmAttachedScrapmCachedViews所缓存的ViewHolder的类型是不同的(此处不是指的viewType类型,而是指的不同状态下的ViewHolder)

  • mChangedScrap:数据有变化的ViewHolder将会缓存在此处,例如通过notifyItemChanged(position)方法通知RecyclerView某个索引位置对应的组件数据有变化,通过该字段缓存的View无需重建,但是需要重新绑定数据(调用onBindViewHolder方法)
  • mAttachedScrap:目前在屏幕上仍然可见的组件所对应的ViewHolder将会缓存在此处,在页面绘制时,无需重建View且无需重新绑定数据
  • mCachedViews:最近被移出(例如用户滑动)或即将被移出的View所对应的ViewHolder将会缓存在此处,重绘时无需重建ViewHolder且也无需重新绑定数据。主要场景是用户在短距离内快速滑出又快速划入,所以这个缓存列表并不是很大,默认长度为2,类型为SparseArray(Android系统针对整数类型的键的Map优化后的数据结构,适用于键为整数的场景)

二级缓存

由开发者自定义,通过继承RecyclerView.ViewCacheExtension类并重写getViewForPositionAndType方法来实现

三级缓存

mRecyclerPool实际就是一个SparseArray,其键为viewType(Int类型),值为一个ArrayList,缓存的ViewHolder就存储在这里。

如何理解viewTypeviewType的含义实际上是开发者决定的,例如有一个列表,列表的第一个组件始终是一个广告,除此之外,下面的组件都是视频封面组件(参考B站手机端主页)。那么我们就可以定义两种viewType

1
2
val ad_type = 1
val video_type = 2

然后我们就可以重写RecyclerView.AdaptergetItemViewType方法:

1
2
3
override fun getItemViewType(position: Int): Int = if (position == 0) {
ad_type
} else { video_type }

这样一来,就会通过重写的getItemViewType方法来获取viewType的值并传入到onCreateViewHolderviewType参数中。实际上,在调用onCreateViewHolder之前,会先根据getItemViewType返回的值去缓存中寻找是否存在现成的ViewHolder对象(此处是在三级缓存中进行查找)避免重复创建。所以getItemViewType的返回值也会作为缓存查找的依据,而不仅仅是position

缓存失效

如果在三级缓存中都没有找到符合条件的可重用ViewHolder,那么就会执行创建新ViewHolder的流程(onCreateViewHolderonBindViewHolder

请思考一个问题:这个新创建出来的ViewHolder会被立刻缓存吗?如果是,将会换存到我们上面说的哪一级缓存中?

答案是并不会被立刻缓存起来。你可能会有疑惑,既然我都创建它了,说明其需要显示在屏幕上,那不应该被换存到mAttachedScrap中吗?对于这个问题,我们需要深入RecyclerView的布局流程以及缓存时机。

众所周知,RecyclerView的布局是代理给LayoutManager进行的,缓存实际上是LayoutManager在重新布局前,通过调用Recycler的相关方法执行的。在如下情况下会导致重新布局:第一次显示、用户滑动列表、各种可能导致RecyclerView尺寸发生变化的情况(例如旋转屏幕、键盘弹出)。

在这些场景下,LayoutManager会先调用Recycler.detachAndScrapAttachViews方法,这个方法会遍历当前RecyclerView中所有attached状态的子View,将它们的ViewHolder对象放入mAttachedScrap中(缓存就是在这里!),并将其状态从attached设为detached(这两个状态是通过位字段标识),接着LayoutManager会重新布局,重新布局时需要用到的View的信息是通过调用Recycler相关的方法获取的,Recycler查找View的顺序是:

  • mAttachedScrap / mChangedScrap 优先查找那些被“废弃”的 View,这些 View 仍然保留着布局信息,可能不需要重新测量。如果匹配到,且不需要更新数据,则直接使用。
  • mCachedViews 其次查找最近的缓存。
  • mRecyclerPool 再次查找回收池。
  • onCreateViewHolder() 如果以上缓存都没有,才创建新的 ViewHolder

其实就是我们上面讲的根据缓存优先级进行查找,这里就联系起来了。

在这个过程中每用到一个已缓存的View,就将其detached状态清除(即变为attached状态)

布局完成后仍然是detached状态的View所对应的ViewHolder会被Recycler集中处理,要么进入mCachedViews,要么进入mRecyclerPool

通过上面的过程,我们可以看出来缓存是随着布局进行而进行的,是一个连续的过程,而不是在某一个时间点进行所谓的“集中缓存”

自定义缓存策略

自定义缓存需要我们继承RecyclerView.ViewCacheExtension并实现getViewForPositionAndType方法,这里以前面所说的类似B站首页第一个组件是广告,其余组件为视频封面为例:

先编写Adapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class VideoAdAdapter(val dataList: ArrayList<String>): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
/// 视频封面的viewType
val VIDEO_TYPE = 1
/// 广告的viewType
val AD_TYPE = 2
}

/// 自定义缓存
/// 这里需要注意,RecyclerView自带缓存机制缓存的是ViewHolder,而我们为了方便起见直接缓存View
val caches = SparseArray<View>(1)

/// 用于视频封面的ViewHolder
class VideoHolder(view: View): RecyclerView.ViewHolder(view) {
val cover_img = view.findViewById<ImageView>(R.id.video_img_iv)
val title_tv = view.findViewById<TextView>(R.id.video_title_tv)
}

/// 用于广告的ViewHolder
class ADHolder(view: View): RecyclerView.ViewHolder(view) {
val ad_img = view.findViewById<ImageView>(R.id.ad_img_iv)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
// 根据传入的viewType创建不同的ViewHolder并返回
if (viewType == VIDEO_TYPE) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.video_cover_item, parent, false)
return VideoHolder(view)
}
else if (viewType == AD_TYPE) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.ad_item, parent, false)
return ADHolder(view)
}
else {
// 不是合法的viewType,直接抛出异常
throw Exception("viewType is invalid")
}
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
// 根据holder的类型进行数据绑定
when (holder) {
is VideoHolder -> {
holder.cover_img.setImageResource(R.drawable.video_cover)
holder.title_tv.text = dataList[position]
}
is ADHolder -> {
holder.ad_img.setImageResource(R.drawable.ad)
// 此时position一定为0,详见下方的getItemViewType方法
// 关键步骤,ADHolder缓存起来,以便后续我们自定义的ViewCacheExtension获取
caches.put(position, holder.itemView)
}
}
}

override fun getItemCount(): Int {
return dataList.size
}

override fun getItemViewType(position: Int): Int {
/// 第一个索引位置返回viewType AD_TYPE,除此之外返回VIDEO_TYPE
return if (position == 0) AD_TYPE else VIDEO_TYPE
}
}

VideoAdAdapter中声明的caches成员变量将在MyCacheExtension中用到

注意这里一定要重写getItemViewType方法来保证返回正确的viewType,否则后续创建ViewHolder以及缓存的时候会出现问题

承载RecyclerView的Fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class FrgFourthPage : Fragment() {
private lateinit var viewBinding: FragmentFrgFourthPageBinding
private lateinit var adapter: VideoAdAdapter

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewBinding = FragmentFrgFourthPageBinding.inflate(layoutInflater)
return viewBinding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val rv = viewBinding.videoAdRv
// 创建Adapter
adapter = VideoAdAdapter(getData())
rv.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
layoutManager.orientation = LinearLayoutManager.VERTICAL
rv.layoutManager = layoutManager
// 设置自定义缓存
rv.setViewCacheExtension(MyCacheExtension())
}

private fun getData(number: Int = 100): ArrayList<String> {
val list = ArrayList<String>()
for (i in 1..number) {
list.add(i.toString())
}
return list
}

/// 关键:自定义缓存
inner class MyCacheExtension: RecyclerView.ViewCacheExtension() {
override fun getViewForPositionAndType(
recycler: RecyclerView.Recycler,
position: Int,
type: Int
): View? {
// type == AD_TYPE时,adapter.caches中有且仅有一个元素,这个元素必为ADHolder对应的View
return if (type == AD_TYPE) { adapter.caches[position] } else { null }
}
}

}

在此Fragment的最后,我们定义了MyCacheExtension,这是自定义缓存的关键,在其getViewForPositionAndType方法中,根据当前传入的类型来返回相应的View对象,如果是AD_TYPE类型,则直接返回缓存在adapter.caches中的对象

页面布局:

fragment_frg_fourth_page.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FrgFourthPage">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/video_ad_rv"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

</FrameLayout>

video_cover_item.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<ImageView
android:id="@+id/video_img_iv"
android:layout_width="match_parent"
android:layout_height="300dp"
android:scaleType="centerInside"
android:contentDescription="" />

<TextView
android:id="@+id/video_title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"/>

</LinearLayout>

ad_item.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<ImageView
android:id="@+id/ad_img_iv"
android:layout_width="match_parent"
android:layout_height="400dp"
android:scaleType="centerCrop"
android:contentDescription=""/>

</FrameLayout>