RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

8,562 阅读13分钟

RecyclerView 缓存之一的 scrap 结构中缓存的是什么?为什么需要 scrap 缓存?pre-layout 及 post-layout 过程中 scrap 缓存内容会如何变化?这一篇继续通过 走查源码 + 断点调试的方式解答这些疑问。

这是 RecyclerView 动画原理的第二篇,系列文章目录如下:

  1. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  2. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  3. RecyclerView 动画原理 | 如何存储并应用动画属性值?

引子

这一篇源码分析还是基于下面这个 Demo 场景:

列表中有两个表项(1、2),删除 2,此时 3 会从屏幕底部平滑地移入并占据原来 2 的位置。

为了实现该效果,RecyclerView的策略是:为动画前的表项先执行一次pre-layout,将不可见的表项 3 也加载到布局中,形成一张布局快照(1、2、3)。再为动画后的表项执行一次post-layout,同样形成一张布局快照(1、3)。比对两张快照中表项 3 的位置,就知道它该如何做动画了。

在此援引上一篇已经得出的结论:

  1. RecyclerView为了实现表项动画,进行了 2 次布局(预布局 + 后布局),在源码上表现为LayoutManager.onLayoutChildren()被调用 2 次。

  2. 预布局的过程始于RecyclerView.dispatchLayoutStep1(),终于RecyclerView.dispatchLayoutStep2()

  3. 在预布局阶段,循环填充表项时,若遇到被移除的表项,则会忽略它占用的空间,多余空间被用来加载额外的表项,这些表项在屏幕之外,本来不会被加载。

其中第三点表现在源码上,是这样的:

public class LinearLayoutManager {
    // 布局表项
    public void onLayoutChildren() {
        // 不断填充表项
        fill() {
            while(列表有剩余空间){
                // 填充单个表项
                layoutChunk(){
                    // 让表项成为子视图
                    addView(view)
                }
                if (表项没有被移除) {
                    剩余空间 -= 表项占用空间	
                }
                ...
            }
        }
    }
}

这是RecyclerView填充表项的伪码。以 Demo 为例,预布局阶段,第一次执行onLayoutChildren(),因表项 2 被删除,所以它占用的空间不会被扣除,导致while循环多执行一次,这样表项 3 就被填充进列表。

后布局阶段,会再次执行onLayoutChildren(),再把表项 1、3 填入列表。那此时列表中不是得有两个表项 1,两个表项 3,和一个表项 2 吗?

这显然是不可能的,用上一篇介绍的断点调试,运行 Demo,把断点断在addView(),发现后布局阶段再次调用该方法时,RecyclerView的子控件个数为 0。

先清空表项再填充

难道每次布局之前都会删掉现有布局中所有的表项?

fill()开始,往上走查代码,果然发现了一个线索:

public class LinearLayoutManager {
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        ...
        // detach 并 scrap 表项
        detachAndScrapAttachedViews(recycler);
        ...
        // 填充表项
        fill()
}

在填充表项之前,有一个 detach 操作:

public class RecyclerView {
    public abstract static class LayoutManager {
        public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
            // 遍历所有子表项
            final int childCount = getChildCount();
            for (int i = childCount - 1; i >= 0; i--) {
                final View v = getChildAt(i);
                // 回收子表项
                scrapOrRecycleView(recycler, i, v);
            }
        }
    }
}

果不其然,在填充表项之前会遍历所有子表项,并逐个回收它们:

public class RecyclerView {
    public abstract static class LayoutManager {
        // 回收表项
        private void scrapOrRecycleView(Recycler recycler, int index, View view) {
            final ViewHolder viewHolder = getChildViewHolderInt(view);
            if (viewHolder.isInvalid() && !viewHolder.isRemoved()&& !mRecyclerView.mAdapter.hasStableIds()) {
                removeViewAt(index);
                recycler.recycleViewHolderInternal(viewHolder);
            } else {
                // detach 表项
                detachViewAt(index);
                // scrap 表项
                recycler.scrapView(view);
                ...
            }
        }
    }
}

回收表项时,根据viewHolder的不同状态执行不同分支。硬看源码很难快速判断会走哪个分支,果断运行 Demo,断点调试一把。在上述场景中,所有表项都走了第二个分支,即在布局表项之前,对现有表项做了两个关键的操作:

  1. detach 表项detachViewAt(index)
  2. scrap 表项recycler.scrapView(view)

detach 表项

先看看 detach 表项是个什么操作:

public class RecyclerView {
    public abstract static class LayoutManager {
        ChildHelper mChildHelper;
        // detach 指定索引的表项
        public void detachViewAt(int index) {
            detachViewInternal(index, getChildAt(index));
        }
        
        // detach 指定索引的表项
        private void detachViewInternal(int index, @NonNull View view) {
            ...
            // 将 detach 委托给 ChildHelper
            mChildHelper.detachViewFromParent(index);
        }
    }
}

//  RecyclerView 子表项管理类
class ChildHelper {
    // 将指定位置的表项从 RecyclerView detach
    void detachViewFromParent(int index) {
        final int offset = getOffset(index);
        mBucket.remove(offset);
        // 最终实现 detach 操作的回调
        mCallback.detachViewFromParent(offset);
    }
}

LayoutManager会将 detach 任务委托给ChildHelperChildHelper再执行detachViewFromParent()回调,它在初始化ChildHelper时被实现:

public class RecyclerView {
    // 初始化 ChildHelper
    private void initChildrenHelper() {
        // 构建 ChildHelper 实例
        mChildHelper = new ChildHelper(new ChildHelper.Callback() {
            @Override
            public void detachViewFromParent(int offset) {
                final View view = getChildAt(offset);
                ...
                // 调用 ViewGroup.detachViewFromParent()
                RecyclerView.this.detachViewFromParent(offset);
            }
            ...
        }
    }
}

RecyclerView detach 表项的最后一步调用了ViewGroup.detachViewFromParent()

public abstract class ViewGroup {
    // detach 子控件
    protected void detachViewFromParent(int index) {
        removeFromArray(index);
    }
    
    // 删除子控件的最后一步
    private void removeFromArray(int index) {
        final View[] children = mChildren;
        // 将子控件持有的父控件引用置空
        if (!(mTransitioningViews != null && mTransitioningViews.contains(children[index]))) {
            children[index].mParent = null;
        }
        final int count = mChildrenCount;
        // 将父控件持有的子控件引用置空
        if (index == count - 1) {
            children[--mChildrenCount] = null;
        } else if (index >= 0 && index < count) {
            System.arraycopy(children, index + 1, children, index, count - index - 1);
            children[--mChildrenCount] = null;
        }
        ...
    }
}

ViewGroup.removeFromArray()是容器控件移除子控件的最后一步(ViewGroup.removeView()也会调用这个方法)。

至此可以得出结论:

在每次向RecyclerView填充表项之前都会先清空现存表项。

目前看来,detach viewremove view差不多,它们都会将子控件从父控件的孩子列表中删除,唯一的区别是detach更轻量,不会触发重绘。而且detach是短暂的,被detach的 View 最终必须被彻底 remove 或者重新 attach。(下面就会马上把他们重新 attach)

scrap 表项

scrap 表项的意思是回收表项并将其存入mAttachedScrap列表,它是回收器Recycler中的成员变量:

public class RecyclerView {
    public final class Recycler {
        // scrap 列表
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
    }
}

mAttachedScrap是一个 ArrayList 结构,用于存储ViewHolder实例。

RecyclerView 填充表项前,除了会 detach 所有可见表项外,还会同时 scrap 它们:

public class RecyclerView {
    public abstract static class LayoutManager {
        // 回收表项
        private void scrapOrRecycleView(Recycler recycler, int index, View view) {
            final ViewHolder viewHolder = getChildViewHolderInt(view);
            ...
            // detach 表项
            detachViewAt(index);
            // scrap 表项
            recycler.scrapView(view);
            ...
        }
    }
}

scrapView()是回收器Recycler的方法,正是这个方法将表项回收到了mAttachedScrap列表中:

public class RecyclerView {
    public final class Recycler {
        void scrapView(View view) {
            final ViewHolder holder = getChildViewHolderInt(view);
            // 表项不需要更新,或被移除,或者表项索引无效时,将被会收到 mAttachedScrap
            if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
                    || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
                holder.setScrapContainer(this, false);
                // 将表项回收到 mAttachedScrap 结构中
                mAttachedScrap.add(holder);
            } else {
                // 只有当表项没有被移除且有效且需要更新时才会被回收到 mChangedScrap
                if (mChangedScrap == null) {
                    mChangedScrap = new ArrayList<ViewHolder>();
                }
                holder.setScrapContainer(this, true);
                mChangedScrap.add(holder);
            }
        }
    }
}

scrapView()中根据ViewHolder状态将其会收到不同的结构中,同样地,硬看源码很难快速判断执行了那个分支,继续断点调试,Demo 场景中所有的表项都会被回收到mAttachedScrap结构中。(关于 mAttachedScrap 和 mChangedScrap 的区别会在后续文章分析)

分析至此,进一步细化刚才得到的结论:

在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。

将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap列表中。

从缓存拿填充表项

预布局与 scrap 缓存的关系

缓存定是为了复用,啥时候用呢?紧接着的“填充表项”中就立马会用到:

public class LinearLayoutManager {
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        ...
        // detach 表项
        detachAndScrapAttachedViews(recycler);
        ...
        // 填充表项
        fill()
    }
    
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
        // 计算剩余空间
        int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
        // 不停的往列表中填充表项,直到没有剩余空间
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            // 填充单个表项
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            ...
        }
    }
    
    // 填充单个表项
    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
        // 获取下一个被填充的视图
        View view = layoutState.next(recycler);
        ...
        // 填充视图
        addView(view);
        ...
    }
}

填充表项时,通过layoutState.next(recycler)获取下一个该被填充的表项视图:

public class LinearLayoutManager {
    static class LayoutState {
        View next(RecyclerView.Recycler recycler) {
            ...
            // 委托 Recycler 获取下一个该填充的表项
            final View view = recycler.getViewForPosition(mCurrentPosition);
            ...
            return view;
        }
    }
}

public class RecyclerView {
    public final class Recycler {
        public View getViewForPosition(int position) {
            return getViewForPosition(position, false);
        }
     }
     
     View getViewForPosition(int position, boolean dryRun) {
         // 调用链最终传递到 tryGetViewHolderForPositionByDeadline()
         return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
     }
}

沿着调用链一直往下,最终走到了Recycler.tryGetViewHolderForPositionByDeadline(),在RecyclerView缓存机制(咋复用?)中对其做过详细介绍,援引结论如下:

  1. 在 RecyclerView 中,并不是每次绘制表项,都会重新创建 ViewHolder 对象,也不是每次都会重新绑定 ViewHolder 数据。
  2. RecyclerView 填充表项前,会通过Recycler获取表项的 ViewHolder 实例。
  3. RecyclertryGetViewHolderForPositionByDeadline()方法中,前后尝试 5 次,从不同缓存中获取可复用的 ViewHolder 实例,其中第一优先级的缓存即是scrap结构。
  4. scrap缓存获取的表项不需要重新构建,也不需要重新绑定数据。

从 scrap 结构获取 ViewHolder 的源码如下:

public class RecyclerView {
    public final class Recycler {
        ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
            ViewHolder holder = null;
            ...
            // 从 scrap 结构中获取指定 position 的 ViewHolder 实例 
            if (holder == null) {
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
                ...
            }
            ...
        }
        
        ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
            final int scrapCount = mAttachedScrap.size();
            // 遍历 mAttachedScrap 列表中所有的 ViewHolder 实例
            for (int i = 0; i < scrapCount; i++) {
                final ViewHolder holder = mAttachedScrap.get(i);
                // 校验 ViewHolder 是否满足条件,若满足,则缓存命中
                if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
                        && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
                    holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                    return holder;
                }
            }
            ...
        }
    }
}

mAttachedScrap列表中获取的ViewHolder实例后,得进行校验。校验的内容很多,其中最重要的的是:ViewHolder索引值和当前填充表项的位置值是否相等,即:

scrap 结构缓存的 ViewHolder 实例,只能复用于和它回收时相同位置的表项。

也就是说,若当前列表正准备填充 Demo 中的表项 2(position == 1),即使 scrap 结构中有相同类型 ViewHolder,只要viewHolder.getLayoutPosition()的值不为 1,缓存不会命中。

分析至此,可以把上面得到的结论进一步拓展:

在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。在紧接着的填充表项阶段,就立马从mAttachedScrap中取出刚被 detach 的表项并重新 attach 它们。

(弱弱地问一句,这样折腾意义何在?可能接着往下看就知道了。。)

将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap 列表中。然后又在填充表项阶段从 mAttachedScrap 中重新获取了表项 1、2 并填入列表。

上一篇的结论说“Demo 场景中,预布局阶段还会额外加载列表第三个位置的表项 3”,但mAttachedScrap只缓存了表项 1、2。所以在填充表项 3 时,scrap 缓存未命中。不仅如此,因表项 3 是从未被加载过的表项,遂所有的缓存都不会命中,最后只能沦落到重新构建表项并绑定数据

public class RecyclerView {
    public final class Recycler {
        ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
               if (holder == null) {
                    ...
                    // 构建 ViewHolder
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                    ...
                }
                // 获取表项偏移的位置
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                // 绑定 ViewHolder 数据
                bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
            }
        }
    }
}

沿着上述代码的调用链往下走查,就能找到熟悉的onCreateViewHolder()onBindViewHolder()

在绑定 ViewHolder 数据之前,先调用了mAdapterHelper.findPositionOffset(position)获取了“偏移位置”。断点调试告诉我,此时它会返回 1,即表项 2 被移除后,表项 3 在列表中的位置。

AdapterHelper将所有对表项的操作都抽象成UpdateOp并保存在列表中,当获取表项 3 偏移位置时,它发现有一个表项 2 的删除操作,所以表项 3 的位置会 -1。(有关 AdapterHelper 的内容就不展开了~)

至此,预布局阶段的填充表项结束了,LayoutManager 中现有表项 1、2、3,形成了第一张快照(1,2,3)。

后布局与 scrap 缓存的关系

再次援引上一篇的结论:

  1. RecyclerView 为了实现表项动画,进行了 2 次布局,第一次预布局,第二次后布局,在源码上表现为 LayoutManager.onLayoutChildren() 被调用 2 次。

  2. 预布局的过程始于 RecyclerView.dispatchLayoutStep1(),终于 RecyclerView.dispatchLayoutStep2()。

在紧接着执行的dispatchLayoutStep2()中,开始了后布局

public class RecyclerView {
    void dispatchLayout() {
            ...
            dispatchLayoutStep1();// 预布局
            mLayout.setExactMeasureSpecsFrom(this);
            dispatchLayoutStep2();// 后布局
            ...
    }
    
    private void dispatchLayoutStep2() {
        mState.mInPreLayout = false;// 预布局结束
        mLayout.onLayoutChildren(mRecycler, mState); // 第二次 onLayoutChildren()
}

布局子表项的老花样要再来一遍,即先 detach 并 scrap 现有表项,然后再填充。

但这次会有一些不同:

  1. 因为 LayoutManager 中现有表项 1、2、3,所以 scrap 完成后,mAttachedScrap中存有表项1、2、3 的 ViewHolder 实例(position 依次为 0、0、1,被移除表项的 position 会被置 0)。
  2. 因为第二次执行onLayoutChildren()已不属于预布局阶段,所以不会加载额外的表项,即LinearLayoutManager.layoutChunk()只会执行 2 次,分别填充位置为 0 和 1 的表项。
  3. mAttachedScrap缓存的 ViewHolder 中,有 2 个 position 为 0,1 个 position 为 1。毫无疑问,填充列表位置 1 的表项时,表项 3 必会命中(因为 position 相等)。但填充列表位置 0 的表项时,是表项 1 还是 表项 2 命中?(它们的 position 都为 0)再回看一遍,缓存命中前的校验逻辑:
public class RecyclerView {
    public final class Recycler {
        // 从 缓存中获取 ViewHolder 实例
        ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
            final int scrapCount = mAttachedScrap.size();
            // 遍历 mAttachedScrap
            for (int i = 0; i < scrapCount; i++) {
                final ViewHolder holder = mAttachedScrap.get(i);
                if (!holder.wasReturnedFromScrap() 
                    && holder.getLayoutPosition() == position // 位置相等
                    && !holder.isInvalid() 
                    && (mState.mInPreLayout || !holder.isRemoved()) // 在预布局阶段 或 表项未被移除
                ) {
                    holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                    return holder;
                }
            }
        }
    }
}

当遍历到mAttachedScrap的表项 2 时,虽然它的位置满足了要求,但校验的最后一个条件把它排除了,因为现在已经不再是预布局阶段,且表项 2 是被移除的。所以列表的位置 0 只能被剩下的表项 1 填充。

分别用表项 1、3 填充了列表的位置 0、1 ,后布局的填充表项也结束了。

此时就形成第二张快照(1,3),和预布局形成的快照(1,2,3)比对之后,就知道表项 2 需要做消失动画,而表项 3 需要做移入动画。那动画具体是怎么实现的?限于篇幅,下次再析。

总结

回到篇中的那个问题:“何必这样折腾?即先 detach 并 缓存表项到 scrap 结构中,然后紧接着又在填充表项时从中取出?”

因为 RecyclerView 要做表项动画,

为了确定动画的种类和起终点,需要比对动画前和动画后的两张“表项快照”,

为了获得两张快照,就得布局两次,分别是预布局和后布局(布局即是往列表中填充表项),

为了让两次布局互不影响,就不得不在每次布局前先清除上一次布局的内容(就好比先清除画布,重新作画),

但是两次布局中所需的某些表项大概率是一摸一样的,若在清除画布时,把表项的所有信息都一并清除,那重新作画时就会花费更多时间(重新创建 ViewHolder 并绑定数据),

RecyclerView 采取了用空间换时间的做法:在清除画布时把表项缓存在 scrap 结构中,以便在填充表项可以命中缓存,以缩短填充表项耗时。

推荐阅读

RecyclerView 系列文章目录如下:

  1. RecyclerView 缓存机制 | 如何复用表项?

  2. RecyclerView 缓存机制 | 回收些什么?

  3. RecyclerView 缓存机制 | 回收到哪去?

  4. RecyclerView缓存机制 | scrap view 的生命周期

  5. 读源码长知识 | 更好的RecyclerView点击监听器

  6. 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂

  7. 更好的 RecyclerView 表项子控件点击监听器

  8. 更高效地刷新 RecyclerView | DiffUtil二次封装

  9. 换一个思路,超简单的RecyclerView预加载

  10. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  11. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  12. RecyclerView 动画原理 | 如何存储并应用动画属性值?

  13. RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?

  14. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?

  15. RecyclerView 性能优化 | 把加载表项耗时减半 (一)

  16. RecyclerView 性能优化 | 把加载表项耗时减半 (二)

  17. RecyclerView 性能优化 | 把加载表项耗时减半 (三)

  18. RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势

  19. RecyclerView 的滚动时怎么实现的?(二)| Fling

  20. RecyclerView 刷新列表数据的 notifyDataSetChanged() 为什么是昂贵的?