RecyclerView的剖析:搜索ViewHolder(续)

342 阅读11分钟

帕维尔·史马科夫

为了便于参考,我在此处复制了RecyclerView的视图搜索算法的概述。
  1. 搜索  **changed scrap**
  2. 搜索  **attached scrap**
  3. 搜索未被移除的 **hidden views**
  4. 搜索 **view cache**
  5. 如果Adapter具有稳定的ID, 通过指定ID再次搜索 **attached scrap** 和 **view cache**
  6. 搜索  ViewCacheExtension
  7. 搜索  RecycledViewPool
到目前为止,我们已经介绍了RecycledViewPool和视图缓存。

ViewCacheExtension

ViewCacheExtension的想法很吸引人:它是一种按照您想要的方式运行的缓存。它默认是不存在,除非您通过RecyclerView的方法`setViewCacheExtension()`显式设置它并实现一个简单的接口
View getViewForPositionAndType(Recycler recycler, int position, int type);

咋一看简单好用,但是仔细检查后发现结果既不简单又不好。

首先,您不能只返回所需的任何视图。您需要返回某个ViewHolder拥有的View。创建ViewHolder时,ViewHolder的引用存储在View的LayoutParams中,这就是通过ViewCacheExtension返回View时RecyclerView的外观。如果在该处找不到ViewHolder,则会崩溃。这很奇怪,不是吗?**为什么实际上需要ViewHolder时要求返回View?**
但是还有一个更大的问题。请记住,ViewHolders不是无状态的,它们是可变的,具有由RecyclerView内部管理的位置,标志和其他内容。如果返回与ViewHolder关联的View,该View属于非参数传递位置时,则RecyclerView和/或LayoutManager会感到困惑并产生错误。
位置导致问题似乎超出了您的控制范围。让我们更仔细地了解它的来源。假设,您添加或删除一些条目,在布局过程的开始,在LayoutManager进行任何工作之前,要求AdapterHelper处理自上次布局以来发生的适配器更改(我们将在本系列的后续部分介绍AdapterHelper)。
然后,它回调RecyclerView以将偏移量应用于ViewHolder的位置。RecyclerView循环浏览当前显示的Views的ViewHolders并应用偏移量(请参见offsetPositionsForInsert()和类似方法)。但是它不知道您将自己缓存在某个地方的ViewHolders,所以它无法抵消它们的位置!
而且您也不可以,因为该职位是本地包裹。由于这些限制,似乎ViewCacheExtension的用途非常有限。对于我来说,想一个可行的示例并带来一些好处并不容易,但是确实如此。
**ViewCacheExtension示例:**
假设您有一些具有以下行为的“特殊”条目:
  1. 他们的位置是固定的。例如,这些是广告,您必须始终将它们放置在特定位置。
  2. 他们从不改变视觉。
  3. 它们数量合理,因此可以将所有视图保留在内存中。
您要避免的是重新绑定这些项目。假设您在列表中有3个相距甚远。然后通常会有一个ViewHolder可以在您来回滚动时在这3个项目之间反弹。重新绑定可能会损害滚动的平滑性,因此代价很高,因此您宁愿将所有3个视图都保留在内存中。该池无法为您提供帮助,因为进入该池始终意味着以后需要重新绑定,而且视图缓存也不能,这很遗憾,它不关心视图类型。
ViewCacheExtension!
[¹](https://android.jlelse.eu/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91#3048)就是解药
您可以编写如下代码:
SparseArray<View> specials = new SparseArray<>();
...
recyclerView.getRecycledViewPool().setMaxRecycledViews(SPECIAL, 0);
recyclerView.setViewCacheExtension(new RecyclerView.ViewCacheExtension() {
    @Override
    public View getViewForPositionAndType(RecyclerView.Recycler recycler, int position, int type) {
        return type == SPECIAL ? specials.get(position) : null;   
    }
});
...
class SpecialViewHolder extends RecyclerView.ViewHolder {
	   ...
    public void bindTo(int position) {
       ...       
        specials.put(position, itemView);   
    }
}

它实际上有效:每个“特殊” ViewHolder均被创建并绑定到数据一次。但是,一旦您引入了“特殊”项目的任何位置更改(例如,由于周围项目的删除或添加),所有这些都将崩溃。您可能会认为,重新计算SparseArray的键就足够了,但事实并非如此:正如我们之前所讨论的,ViewHolders本身会保持位置变旧。

Hidden Views

我们的下一站是神秘的“搜索未删除的Hidden Views”。

什么是Hidden Views**?**它们是当前正在超出RecyclerView范围的视图。它们仍作为RecyclerView的child,但仅用退出动画处理的目的。从LayoutManager的角度来看,它们已消失,不应包含在任何计算中。例如,如果getChildAt()在由于某项被删除而导致某些视图消失时调用LayoutManager的方法,则该视图将不计数,这是有意义的。所有的调用getChildAt()getChildCount()addView()来自LayoutManager等的数据在应用到RecyclerView的实际child之前,将通过ChildHelper进行路由ChildHelper可能是RecyclerView系列中最简单,最好的类。它的责任是重新计算非隐藏children列表和所有children列表之间的索引。²

现在,在我刚才说了一个事实之后,即在搜索ViewHolder中使用了**Hidden Views**的事实似乎更加神秘。请记住,我们正在搜索要提供给LayoutManager的视图,但是LayoutManager不应了解**Hidden Views**
好吧,我实际上并不了解它们,但是RecyclerView知道,并且在特定情况下可以将它们反馈给LayoutManager。这种有点奇怪的“从隐藏的视图弹跳”机制对于处理以下情况只是必要的。想象一下,我们插入一个项目,然后在插入动画完成之前快速删除该项目:
![Image for post](https://miro.medium.com/max/2014/0*JUYgRioJvlRMa6O5.png)
我们希望看到的是bc移除时的确切位置开始上升但是在那一刻,b是一个隐藏的视图!如果我们只是忽略它,则会在现有的b下方创建一个新的b,并且随着新b的增加,它们会重叠,而旧b会继续下降。为了避免此类史诗般的错误,在搜索ViewHolder的较早步骤之一中,RecyclerView询问ChildHelper是否具有合适的**Hidden Views**适当地,它表示与我们需要的位置相关联的视图,具有正确的视图类型,并且隐藏该视图的原因不能消除(显然,我们不应该使这些视图重新变得生动)。
如果有这样的视图,RecyclerView会将其返回到LayoutManager并将其添加到布局前以标记应从中进行动画处理的位置(请参见recordAnimationInfoIfBouncedHiddenView()方法)。现在,如果您一直在关注我,那么在阅读上一句话之后,您应该会具有强烈的“ wtf”感觉。在布局前或布局后添加内容必须是LayoutManager的工作,现在RecyclerView本身正在在布局前添加内容。是的,这种机制看起来有些杂乱无章,这是了解它的足够充分的理由。

Scrap

Scrap列表是RecyclerView搜索ViewHolder时查找的第一位,它与Pool或View Cache有很大的不同。Scrap并非在布局期间为空当LayoutManager启动布局(之前或之后)时,当前添加到布局中的所有视图都将转储到Scrap中(请参阅中的detachAndScrapAttachedViews()调用LinearLayoutManager#onLayoutChildren())。然后,LayoutManager逐个检索视图,除非视图发生问题,否则它会直接从报废中返回:

![Image for post](https://miro.medium.com/max/2248/0*KgoerRKWCbps5ks-.png)

在此示例中,我们删除b,这将导致重新布局。将a,bc丢弃到Scrap中,然后Scrap中回收ac作为一个小题外话,b会发生什么在布局过程的最后阶段,RecyclerView看到b从未添加到布局后。它解开它,使其隐藏并开始消失动画。动画结束后,b进入Pool

为什么LayoutManager不能立即使用未更改的子级?
我认为,**Scrap**存在的原因是LayoutManager和RecyclerView.**Recycler**之间的关注点分离。
LayoutManager不必知道某个特定的孩子是否可以随处携带,而是应该查看**Pool**或其他地方。
这是**Recycler**职责。
除了仅在布局期间为非空以外,似乎还有另一个与**Scrap**有关的不变式:所有**Scrap**视图均与RecyclerView分离。ViewGroupattachView()detachView()方法与addView()相似removeView(),但是不会引起布局请求或无效之类的事情。他们只是从ViewGroup的子级列表中删除一个子级,并将该子级的父级设置为null。分离状态必须是临时的,然后再附加或卸下。在已经有许多添加的子代的情况下计算新布局时,首先将它们全部分离很方便,这就是RecyclerView所做的。

Attached vs Changed scrap

**Recycler**中,您可以看到两个单独的**Scrap**容器:**mAttachedScrap****mChangedScrap**
为什么我们需要两个?让我们看看如何选择两个废料容器之一。仅当关联的项目已更改(

notifyItemChanged()<span>或被</span>``notifyItemRangeChanged()调用)时,ViewHolder才会进入已更改的剪贴簿,并且在询问其是否为ItemAnimator时会说“否”canReuseUpdatedViewHolder()

否则,ViewHolder会转到**AttachedScrap**。“否”表示我们想要一个更改动画,其中一个View替换另一个View,例如淡入淡出动画。“是”表示更改动画将在一个视图中发生。

ChangedScrapAttachedScrap的用法只有一个区别:AttachedScrap既可以用于预布局,也可以用于后布局,而ChangedScrap只能在预布局中使用。这是有道理的:在布局后,新的ViewHolder应该替换“更改的”视图,因此ChangedScrap在布局后无用。更改动画完成后,ChangedScrap将按预期方式转储到池中。³

默认的**ItemAnimator**可以在3种情况下重用更新的ViewHolder:
  1. 调用setSupportsChangeAnimations(false)
  2. 调用notifyDataSetChanged()来代替notifyItemChanged()notifyItemRangeChanged()
  3. 您提供了这样的更改有效负载:adapter.notifyItemChanged(index, anyObject)
最后一种情况显示了一种很好的方法,当您只想更改一些内部元素时,可以避免创建和/或绑定新的ViewHolder。

稳定ID

关于稳定ID功能要了解的最重要的事情是,它仅会在notifyDataSetChanged()调用后影响RecyclerView的行为当我较早地谈论**Scrap**时,我说过在新布局的开始时,所有孩子都被扔进**Scrap**容器中。实际上有一个例外。如果调用notifyDataSetChanged()并且没有稳定的ID,则RecyclerView不会知道发生了什么,发生了什么更改,因此它假定一切都已更改,每个ViewHolder都是无效的,并且被扔到了池中,而不是被丢弃。因此,我们有一张我们之前已经看过的照片:

Image for post

如果您有稳定的ID,则图片会大不相同:
![Image for post](https://miro.medium.com/max/2152/0*rGkt-nbbIBRxNsLu.png)
因此,ViewHolders转到**Scrap**而不是**Pool**,然后在废料中搜索具有特定ID(通过`getItemId()`适配器的方法进行检索)而不是特定位置的ViewHolder
**有什么好处?**

第一个是我们没有Pool溢出的问题,因此除非必要,否则不会创建新的ViewHolders。但是,ViewHolders仍然会反弹,因为id不变的事实并不意味着内容没有变化。第二个好处,也是更大的好处是,我们可以获得动画!在上面的图片中,我将项目4移到了位置6。通常,您必须调用notifyItemMoved(4, 6)以获得运动动画,但是具有稳定的IDnotifyDataSetChanged()就足够了。RecyclerView可以查看具有特定ID的视图在以前的布局中的位置以及它在新布局中的位置。或者它可以看到不再存在一些ID,因此必须已删除该项目,依此类推。但是要注意的一件事是,您只能以这种方式获得简单(非预测性)的动画。

确实,预测动画甚至在这里如何工作?

如果我们在新布局中看到一些ID,而在以前的布局中没有,那么我们如何知道它是插入的项目还是从某个地方移入的项目,在后一种情况下它究竟是从哪里来的呢?通常,这些问题的答案会在预布局中找到,根据适配器的更改,该布局已超出RecyclerView的范围,但现在我们不知道这些更改是什么。
总体而言,稳定ID的用途似乎受到限制。不过,我可以想到一个使用它的好理由:如果您要从ListView迁移到RecyclerView,将所有调用转换为特定更改的通知可能会很痛苦在这种情况下,稳定ID会免费为您提供简单的RecyclerView动画。下一步,您可能要尝试[DiffUtil](https://medium.com/@nullthemall/diffutil-is-a-must-797502bc1149)

notifyDataSetChanged()到此为止,我们进入RecyclerView内部工作之旅的第1部分结束了。

[^](https://android.jlelse.eu/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91#1186)¹我们还假设在编译时不知道“特殊”项目的数量,否则您可以通过为每个视图分配一个单独的视图类型并忽略除第一个视图之外的绑定调用来解决该问题。如果数量未知,则此方法仍然可行,但很麻烦。
[^](https://android.jlelse.eu/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91#d736)²如果您查看ChildHelper的代码,则Bucket内部类乍一看可能会令人恐惧。
实际上,这只是一个可扩展的位字符串,其中的那些标记了**Hidden Views**的索引。
[^](https://android.jlelse.eu/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-continued-d81c631a2b91#862e)³这是ItemAnimator dispatchAnimationFinished()以旧ViewHolder作为参数调用的结果