RecyclerView剖析:搜索ViewHolder
原文: Anatomy of RecyclerView: a Search for a ViewHolder
介绍
在本系列文章中,我将分享我对RecyclerView内部工作原理的了解。为什么?试想一下:几乎每个现代Android应用都需要使用RecyclerView,因此开发人员对它的使用方式会影响数百万用户的体验。然而,我们在RecyclerView上有什么样的教育资料?您当然可以找到一些关于如何使用 RecyclerView的基本教程,但它是如何工作的呢?“黑匣子”方法绝对不够好,特别是如果您正在进行复杂的自定义或优化性能。[1]我推荐过 “最深”的材料可能是Google I/O 2016讨论的RecyclerView的来龙去脉,但是说真的,这甚至都不是“来龙去脉”,这只是冰山一角。我的目标是更深入。
我假设读者具有RecyclerView的基本知识,如:LayoutManager是什么,如何通知adapter更改制定数据或如何使用item的viewType。
在第一部分中,我们将理解RecyclerView内的一个方法:getViewByPosition()
(support-v7 27.0.2 源码中为Recycler.getViewForPosition()
)。这是源代码中最重要的部分之一,通过研究,我们将了解RecyclerView的许多方面,例如ViewHolder回收,隐藏视图,预测动画和固定ID。看到这里的预测动画您可能会惊讶。嗯,尽管Google的人们尽最大努力解耦RecyclerView不同的责任组件,但它们之间仍然共享了许多“知识”,预测动画就是其中之一。无法避免谈论到它们。
因此在laying items时,LayoutManager会询问RecyclerView“请在8号位给我一个视图”。以下是RecyclerView的响应:
- 搜索changed scrap
- 搜索attached scrap
- 搜索未删除的hidden views
- 搜索view cache
- 如果Adapter具有稳定的ID,则会针对给定的ID再次搜索attached scrap和view cache。
- 搜索ViewCacheExtension
- 搜索RecycledViewPool
如果所有这些地方都无法在找到合适的视图,它会通过调用适配器的onCreateViewHolder()
方法创建一个。然后,如果需要onBindViewHolder()
,它会绑定View ,最后返回它。
RecyclerView的响应源码:
public class RecyclerView {
public final class Recycler {
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
}
if (holder == null) {
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
}
//Find from mViewCacheExtension
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
}
if (holder == null) { // fallback to pool
holder = getRecycledViewPool().getRecycledView(type);
}
if (holder == null) {
long start = getNanoTime();
if (deadlineNs != FOREVER_NS && !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) {
// abort - we have a deadline we can't meet
return null;
}
//Create ViewHolder
holder = mAdapter.createViewHolder(RecyclerView.this, type);
}
}
boolean bound = false;
if (mState.isPreLayout() && holder.isBound()) {
// do not update unless we absolutely have to.
holder.mPreLayoutPosition = position;
} else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
if (DEBUG && holder.isRemoved()) {
throw new IllegalStateException("Removed holder should be bound and it should"
+ " come here only in pre-layout. Holder: " + holder
+ exceptionLabel());
}
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
//Bind ViewHolder
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
final LayoutParams rvLayoutParams;
if (lp == null) {
rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
holder.itemView.setLayoutParams(rvLayoutParams);
} else if (!checkLayoutParams(lp)) {
rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
holder.itemView.setLayoutParams(rvLayoutParams);
} else {
rvLayoutParams = (LayoutParams) lp;
}
//When a ViewHolder is created, the reference to it is stored in the View’s LayoutParams
rvLayoutParams.mViewHolder = holder;
rvLayoutParams.mPendingInvalidate = fromScrapOrHiddenOrCache && bound;
return holder;
}
}
}
RecycledViewPool
对每种缓存,我们希望找到以下答案:它的后备数据结构是什么,存储和检索ViewHolders的条件,最重要的是,它的目的是什么。
您可能非常了解池的用途:向下滚动时,向上消失的视图将被回收到池中,以便从底部出现的视图重用。ViewHolders进入池中的其他场景,我们将稍后讨论。但首先让我们来看看一些RecycledViewPool的代码(这是RecyclerView.Recycler的内部类):
public static class RecycledViewPool {
private SparseArray<ArrayList<ViewHolder>> mScrap =
new SparseArray<>();
private SparseIntArray mMaxScrap = new SparseIntArray();
…
public ViewHolder getRecycledView(int viewType) {
ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType);
…
首先,不要为mScrap这个变量名感到困惑 - 这与上面列表中提到的attached scrap或changed scrap无关。
我们看到每个viewType都有自己的ViewHolders池(mScrap:key为viewType)。当RecyclerView在搜索ViewHolder期间用完所有其他可能性时,它会要求池根据viewType提供ViewHolder;在这一点上,viewType是唯一重要的。
现在,每种viewType都有自己的容量。它默认为5,但您可以像这样更改它:
recyclerView.getRecycledViewPool()
.setMaxRecycledViews(SOME_VIEW_TYPE, POOL_CAPACITY);
这对灵活性是非常重要的。如果屏幕上有许多相同类型的items(通常会同时更改),请使该viewType的池更大。如果您知道,某些viewType的项目非常罕见,它们出现在屏幕上的数量永远不会超过一个,请设置该viewType池大小为1。否则,池迟早会被5个同样viewType的item填满,其中4个就会闲置在那里,这是浪费内存。
方法getRecycledView()
,putRecycledView()
,clear()
是公开的,所以你可以操纵池的内容。但是手动使用putRecycledView()
是个坏主意,例如:预先准备一些ViewHolders。您应该仅在onCreateViewHolder()
适配器的方法中创建ViewHolder ,否则ViewHolders可以出现在RecyclerView不期望的状态中。[2]
另一个很酷的功能是,除了getRecycledViewPool()
之外还有一个setRecycledViewPool()
,因此您可以为多个RecyclerView重用单个池。
最后,我会注意到每个viewType的池都是一个堆栈(后进先出)。为什么使用栈更好,我们稍后会介绍。
汇集方式
现在让我们解决ViewHolders何时被抛入池中的问题。有5种场景:
- 在滚动期间,超出了RecyclerView的界限。
(不是直接放入pool中,也可能会放入viewCache中 稍后介绍) - 数据已更改,因此不再显示该view。当消失动画结束时,会添加到池中。
- 更新或删除view cache中的item。
- 在scrap或缓存中搜到了一个我们想要位置的ViewHolder,但由于错误的viewType或id(如果适配器具有固定的ID)而被判定为不合适的。[3]
- LayoutManager在pre-layout中添加了一个视图,但没有在post-layout中添加该视图。
前两个场景非常明显。然而,有一点需要注意的是,场景2不仅在删除有问题的item时会触发,在插入其他item时也可能被触发,例如其他item插入后,被推出界限的item不显示时。
场景1
LinearLayoutManager.scrollBy() -->
LinearLayoutManager.fill() -->
LinearLayoutManager.recycleByLayoutState() -->
LinearLayoutManager.recycleViewsFromStart() -->
LinearLayoutManager.recycleChildren()-->
RecyclerView.LayoutManager.removeAndRecycleViewAt()-->
RecyclerView.Recycler.recycleView()-->
RecyclerView.Recycler.recycleViewHolderInternal() (存入viewCache、pool的逻辑)-->
RecyclerView.Recycler.addViewHolderToRecycledViewPool-->
RecycledViewPool.putRecycledView(ViewHolder scrap)
class LinearLayoutManager {
/**
* Recycles views that went out of bounds after scrolling towards the end of the layout.
* <p>
* Checks both layout position and visible position to guarantee that the view is not visible.
*/
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt){
final int childCount = getChildCount();
if (mShouldReverseLayout) {
for (int i = childCount - 1; i >= 0; i--) {
View child = getChildAt(i);
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
// stop here
recycleChildren(recycler, childCount - 1, i);
return;
}
}
} else {
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
// stop here
recycleChildren(recycler, 0, i);
return;
}
}
}
}
}
其他方案需要一些说明。我们还没有涵盖view cache和scrap,但方案3和4背后的想法很简单。池保存的是“dirty” views和需要重新绑定的view。除了池之外,所有缓存中的ViewHolders都保留了一些状态(最重要的是位置)。所有这些缓存都按位置搜索,希望一些ViewHolder可以按原样重用。相反,当视图进入池时,它的状态(所有标志,位置等)被清除。唯一剩下的就是关联视图和view type。正如我们所知,池时根据view type搜索的,当在池中找到ViewHolder时,ViewHolder会开始新的生命周期。
鉴于该情况,场景3和场景4应该不难理解:例如,如果视图缓存中的某个项被删除,那么将其保留在该缓存中是没有意义的,因为无法在原有的位置重用(原有的位置已经被删除)。但是把它扔掉是不好的,所以我们把它扔进池。(见RecyclerView.Recycler.recycleViewHolderInternal()
)
最后一个场景要求我们知道pre-layout和post-layout的内容。好吧,让我们继续吧!虽然pre-layout/post-layout不是很重要,但这种机制一般在RecyclerView的每个部分都有所体现,所以我们无论如何都要知道它。
Offtopic:预布局,布局后和预测动画
考虑一个场景,我们有a,b和c项,其中a和b适合屏幕。我们删除b,它将c带入视图:
我们希望看到的是c从底部顺利滑动到它的新位置。但怎么做呢?我们知道新布局中c的最终位置,但如何知道它应该从何处滑过来?通过查看c应该来自底部的新布局来假设RecyclerView或ItemAnimator是错误的。我们可能有一些自定义的LayoutManager,让c从侧面或其他地方进来。所以我们需要LayoutManager的更多帮助。我们可以使用以前的布局吗?不行,因为那里没有с。那时没人知道b将被删除,所以LayoutManager认为布局c是浪费资源。
谷歌的解决方案提供如下。在适配器发生更改后,RecyclerView会从LayoutManager请求两个而不是一个布局。第一个 - 预布局,在先前的适配器中布置项目的状态,但使用适配器更改作为提示,布置一些额外的视图,这可能是个好主意。在我们的例子中,因为我们现在知道b被删除了,所以我们额外列出了c,尽管它已经超出界限。第二个 - 后布局,只是一个正常的布局,对应于更改后的适配器状态。
现在,通过比较预布局和布局后c的位置,我们可以正确地为其外观设置动画。
这种动画 - 当动画视图在先前的布局中或在新的布局中都不存在时 - 被称为预测动画,这是RecyclerView中最重要的概念之一。我们将在本系列的后续部分中更详细地讨论它。但现在让我们快速看一下另一个例子:如果b不是被删而只是改变了除怎么办?
可能让你惊讶:LayoutManager仍然在预布局阶段布局c。为什么?因为b的改变可能会使它变得更小,谁知道呢?如果b变小,c可能会从底部弹出,所以我们最好在预先布局中将其布局。但后来,在后期布局中,似乎并非如此,我们只是在b中更改了一些TextView 。因此不需要c,并将其扔进池中。这就是进入pool的场景5中描述的。现在我们可以重新回到RecycledViewPool。
RecycledViewPool,续
当我们遇到ViewHolder应该进入池的场景时,还有两个障碍:它可能不是可回收的;它的View可能处于临时状态。
可回收
可回收性只是ViewHolder中的一个标志,您可以使用RecyclerView.ViewHolder.setIsRecyclable()
方法进行操作。RecycleView也通过此方法让ViewHolders在动画期间不可回收。
从不同地方操纵同一个标志通常是一个坏主意。例如,当动画结束时,RecyclerView会调用setIsRecyclable(true)
,因为程序的某些特定原因,你希望它不可回收。但是在这种情况下事情并没有真正打破,因为调用setIsRecyclable()
是配对的。也就是说,如果你调用setIsRecyclable(false)
两次,那么setIsRecyclable(true)
只调用一次不会使ViewHolder可回收,你也需要调用setIsRecyclable(true)
两次。
临时状态
View的临时状态也类似。它是View中的一个标志,由setHasTransientState()
方法操纵,并且也是配对调用的。View类本身不使用该标志,只是保留它。它可以作为ListView和RecyclerView等控件的提示,在新内容中最好不要重用临时状态下的View。
您可以自己设置此标志,但ViewPropertyAnimator
(someView.animate()…
被调用时)会在动画开始时自动将其设置为true,并在动画结束时自动设置为false。[4]请注意,如果您使用ValueAnimator
为视图设置动画,则必须自行管理临时状态。
关于临时状态的最后一点需要注意的是,它是从子节点传播到父节点,一直传播到根视图。因此,如果您为列表中的item的某个内部view设置动画,不仅仅是该item的内部view,就连ViewHolder引用root view也会进入临时状态。
OnFailedToRecycleView
如果要回收的ViewHolder无法通过可回收性或临时状态检查,则Adapter的onFailedToRecycleView()
方法会触发。这是非常重要的一点:这种方法不仅仅是一个事件的通知,而且是一个如何处理的问题。
在onFailedToRecycledView()
中直接return true
的意思是“无论如何都回收它”。其中一个适用的场景是,在绑定新项目时清除所有动画和其他此类问题的来源。或者,您可以在onFailedToRecycledView()
方法中处理这些事情。
你不该完全忽略onFailedToRecycledView()
。否则会给您带来损失,比如以下情况:想象一下,当item进入视野时,其中的图像淡入显示。如果用户滚动列表足够地快,则当图像离开视图时,图像还没有完成淡入,导致ViewHolders无法进行回收。因此,滚动会滞后,最重要的是,新的ViewHolders不停的创建,使内存变得紧张。
ViewHolder回收成功时会调用onViewRecycled()
方法,这是释放大量资源(如图像)的好地方。请记住,一些ViewHolder实例可能会在没有使用的情况下长时间留在池中,这可能会浪费大量内存。
现在我们进入下一种缓存 - view cache。
View Cache
当我说“view Cache”(视图缓存)或只是“cache”(缓存),所指的都是RecyclerView.Recycler
类中的mCachedViews
字段。它在代码中的一些注释中也称为“第一级缓存”。
这只是ViewHolders的ArrayList,这里没有按view type拆分。默认容量为2,您可以通过RecyclerView.setItemViewCacheSize()
的方法进行调整。
正如我之前提到的,pool和其他缓存(包括view cache)之间最重要的区别是,在pool中搜索ViewHolder是根据view type,而在其他缓存中搜索是根据关联的position。当ViewHolder在view cache中时,它进入缓存后与进入缓存前的位置相同,我们希望“原样”重用它而不需要重新绑定。所以让我们明确这个区别:
- 如果ViewHolder找不到,它将被创建和绑定。
- 如果在pool中找到ViewHolder ,它将被绑定。
- 如果在cache中找到ViewHolder ,则无需执行任何操作。
这时,有一个重要的事情变得很清楚:一个ViewHolder的绑定、回收到pool中(onViewRecycled()
)和它进入、移出列表的可视范围是不一样的东西。当ViewHolder进入可视范围时,ViewHolder有时会从view cache中检索到并且没有重新绑定;当它从可视范围移出时,它的ViewHolder可以缓存到view cache中而不是pool中(参考RecyclerView.Recycler.recycleViewHolderInternal()
)。如果您需要在屏幕上跟踪item的存在,请使用适配器的onViewAttachedToWindow()
和onViewDetachedFromWindow()
回调。
填充pool和cache
现在,回到下一个问题:ViewHolders如何在view cache中结束?当我谈到viewholder缓存到pool的场景时,我实际上欺骗了你一点点。在这些情况下(第三个除外),ViewHolder会转到缓存或池中。[5]
让我举例说明选择cache或pool的规则。比如说,我们最初有空cache和pool,items逐个被回收。这是cache和pool的填充方式(假设容量为默认且只有一种view type):
因此,只要cache未满,ViewHolders就会存到那里。如果它已满,则新的ViewHolder将缓存中已有的ViewHolder从缓存的“另一端”推送到池中。如果一个池已经满了,那么ViewHolder会被遗忘到垃圾收集器。[6]
Cache和Pool的运转方式
现在让我们看看cache和pool在RecyclerView的几个实际使用场景。
滚动中:
当我们向下滚动时,在当前看到的items后面有一个“尾巴”,包括cache中的item,然后是一个pool中的item。当item8出现在屏幕上时,在缓存中找不到合适的ViewHolder:没有与位置8相关联的ViewHolder。所以我们使用一个pool中的ViewHolder,它先前位于第3位。当第6项消失在顶部时,它进入缓存,将4推入池中。
当我们开始向相反方向滚动时,图片会有所不同:
在这里,我们在视图缓存中找到位置5的ViewHolder,并立即重用它,无需重新绑定。这似乎是缓存的主要用例 - 反方向滚动查看刚刚看到的item,此时效率更高。因此,如果您有新闻源,则缓存可能无用,因为用户不会经常返回。但是如果它是一个可供选择的列表,比如一个壁纸库,你可能想要扩展缓存的容量。
这里有几点需要注意。首先,如果我们向上滚动查看3怎么办?请记住,池的工作方式就像一个堆栈,所以如果我们上次看到3之后只是滚动,除此之外没有做任何事情,那么ViewHolder 3将是最后一个放入池中的,因此现在在第3位重新绑定。实际上如果数据没有改变,我们在绑定时不需要做任何事。您应该始终检查onBindViewHolder()
是否确实需要更改此TextView或ImageView等,此处不需要做更改。
其次,请注意滚动时池中总是不超过一个项目(每种视图类型)!(当然,如果你有一个包含n列的多列网格,那么你将在池中有n个项目。)通过场景2-5在池中结束的其他项目,只是在滚动期间无用地停留在那里。
现在让我们看一个场景,相比之下,很多项目都会进入池中:调用notifyDataSetChanged()
(或者notifyItemRangeChanged()
使用一些范围参数):
所有ViewHolders都变得无效,缓存不适合他们,他们都试图存入池中。池中可能没有足够的空间,因此一些不幸的item将被作为垃圾收集然后再次创建。与滚动相比,在这种情况下您可能需要更大的池。更大的池另一个有用的情况是通过调用scrollToPosition()
从一个位置跳到另一个位置。
那么池的最佳大小如何选择呢?似乎最佳策略是在你需要池之前扩充它,并在之后缩小它。实现此目的,以下是一种简单粗暴的方式:
recyclerView.getRecycledViewPool().setMaxRecycledViews(0, 20);
adapter.notifyDataSetChanged();
new Handler().post(new Runnable() {
@Override
public void run() {
recyclerView.getRecycledViewPool()
.setMaxRecycledViews(0, 1);
}
});
接下来:
Anatomy of RecyclerView: a Search for a ViewHolder (continued)
[1]事实上,即使了解RecyclerView的公共API,也需要了解一些内部工作原理。例如,javadoc to setHasStableIds()
方法不会告诉您为什么要使用它。
[2]例如,createViewHolder()
在适配器调用之后的方法中设置了正确的视图类型,并且该字段是包本地的,因此您无法自己设置它。
[3]发生这种情况时的示例:更改项目,以便更改视图类型,调用notifyItemChanged()
。此外,禁用ItemAnimator中的更改动画,否则将发生方案2。
[4]ViewView处于临时状态的另一个例子是EditText,其中选择了一些文本或正在编辑过程中。
[5]在缓存和池之间进行选择之前检查可回收性和临时状态,老实说对我没有多大意义,因为缓存中的视图应该完全以消失时的状态重新出现。
[6]在support版本23中,这种机制被一个简单的逐个索引错误打破。当我们逐个回收ViewHolders时,缓存中ViewHolders的数量在1和2之间交替变化。
结合log 看布局过程中从Recycler.mCachedViews 获取viewHolder
class Recycler{
/**
* Returns a view for the position either from attach scrap, hidden children, or cache.
*
* @param position Item position
* @param dryRun Does a dry run, finds the ViewHolder but does not remove
* @return a ViewHolder that can be re-used for this position.
*/
ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
//...
// Search in our first-level recycled view cache.
final int cacheSize = mCachedViews.size();
for (int i = 0; i < cacheSize; i++) {
final ViewHolder holder = mCachedViews.get(i);
// invalid view holders may be in cache if adapter has stable ids as they can be
// retrieved via getScrapOrCachedViewForId
if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
if (!dryRun) {
mCachedViews.remove(i);
}
if (DEBUG) {
Log.d(TAG, "getScrapOrHiddenOrCachedHolderForPosition(" + position
+ ") found match in cache: " + holder);
}
return holder;
}
}
return null;
}
}
at android.support.v7.widget.RecyclerView$Recycler.getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
at android.support.v7.widget.RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline(RecyclerView.java:5750)
at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:5589)
at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:5585)
at android.support.v7.widget.LinearLayoutManager$LayoutState.next(LinearLayoutManager.java:2231)
at android.support.v7.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1558)
at android.support.v7.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1518)
at android.support.v7.widget.LinearLayoutManager.onLayoutChildren(LinearLayoutManager.java:610)