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

8,391 阅读11分钟

RecyclerView 表项动画是怎么实现的?RecyclerView 在做表项动画时会布局几次?pre-layout 是什么意思?我带着这么多疑问在茫茫源码中苦苦搜寻,本想直接给出答案,却发现过程也很值得回味,且听我慢慢道来。

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

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

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

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

引子

最初听到pre-layout这个概念是从一道面试题:RecyclerView 为什么要预布局?

下面这个场景就要用到预布局:

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

这是怎么做到的?RecyclerView如何知道表项 3 的动画轨迹?虽然动画的终点已经有了(表项 2 的顶部),那起点呢?LayoutManager只加载所有可见表项,在删除表项 2 之前,表项 3 处于不可见状态,它并不会被 layout。

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

具体是怎么实现的?去源码里搜搜答案~

预布局生命周期

一开始,也不知道该从哪看起。但既然是预布局,肯定和布局有关系,就从RecyclerView.onLayout()开始把:

看原码就是这样,有时候它更像是漫无目的的逛街,而不是明确指向的淘宝搜索。

public class RecyclerView {
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        TraceCompat.beginSection(TRACE_ON_LAYOUT_TAG);
        dispatchLayout();// 分发布局
        TraceCompat.endSection();
        mFirstLayoutComplete = true;
    }
}

RecyclerView.onLayout()很短,一眼就可以找到其中的关键dispatchLayout()

public class RecyclerView {
    void dispatchLayout() {
        mState.mIsMeasuring = false;
        if (mState.mLayoutStep == State.STEP_START) {
            // 分发布局1
            dispatchLayoutStep1();
            mLayout.setExactMeasureSpecsFrom(this);
            // 分发布局2
            dispatchLayoutStep2();
        } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()
                || mLayout.getHeight() != getHeight()) {
            mLayout.setExactMeasureSpecsFrom(this);
            dispatchLayoutStep2();
        } else {
            mLayout.setExactMeasureSpecsFrom(this);
        }
        // 分发布局3
        dispatchLayoutStep3();
    }
}

布局分了三个步骤,从第一步骤开始看:

public class RecyclerView {
    private void dispatchLayoutStep1() {
        ...
        mState.mInPreLayout = mState.mRunPredictiveAnimations;
        ...
    }
    
    public static class State {
    	boolean mInPreLayout = false;
        ...
    }
}

在分发布局第一步中发现了一个布尔变量mInPreLayout,字面意思是“是否在 pre-layout 过程中”。

找到一点和pre-layout沾边的信息,映入脑壳的问题是 “ mInPreLayout 什么时候被置为 true,什么时候又被置为 false?”,回答这个问题就能知道pre-layout的生命周期了。

全局搜索mInPreLayout被赋值的地方,除了mState.mInPreLayout = mState.mRunPredictiveAnimations;其余都被置为 false。想必mState.mRunPredictiveAnimations一定为 true!怎么验证?看看它在哪里被赋值:

public class RecyclerView {
    private void processAdapterUpdatesAndSetAnimationFlags() {
        ...
        mState.mRunSimpleAnimations = mFirstLayoutComplete
                && mItemAnimator != null
                && (mDataSetHasChangedAfterLayout
                || animationTypeSupported
                || mLayout.mRequestedSimpleAnimations)
                && (!mDataSetHasChangedAfterLayout
                || mAdapter.hasStableIds());
        mState.mRunPredictiveAnimations = mState.mRunSimpleAnimations
                && animationTypeSupported
                && !mDataSetHasChangedAfterLayout
                && predictiveItemAnimationsEnabled();
    }
}

mRunPredictiveAnimations的值由另外 N 个布尔变量共同决定,难道我得挨个搜索其他变量才能确定它的值吗?(其实有一个更简单的方法可以验证,下面会提到)

就此打住,mRunPredictiveAnimations的值一定为 true,否则mInPreLayout就永远为 false 了。

看原码就是这样,源码无涯,回头是岸,点到为止就好。

继续走查dispatchLayoutStep1()剩余的代码:

public class RecyclerView {
    private void dispatchLayoutStep1() {
        ...
        mState.mInPreLayout = mState.mRunPredictiveAnimations;
        ...
        if (mState.mRunPredictiveAnimations) {
            ...
            mLayout.onLayoutChildren(mRecycler, mState);
            ...
        }
        ...
    }
}

发现了一个很关键的方法LayoutManager.onLayoutChildren(),它有很长的注释,大意是“该方法用于布局 Adapter 中所有的表项。若支持表项动画,则 onLayoutChildren() 会被调用 2 次,第一次称为 pre-layout,它是真正布局表项之前的一次预布局。”

搜索LayoutManager.onLayoutChildren()被调用的地方,只有两处,一次在RecyclerView.dispatchLayoutStep1()中,另一次在RecyclerView.dispatchLayoutStep2()

public class RecyclerView {
    private void dispatchLayoutStep2() {
        ...
        mState.mInPreLayout = false;// pre-layout 结束
        mLayout.onLayoutChildren(mRecycler, mState); // 开始正真的布局
        ...
    }
}

布局的第二步中,调用onLayoutChildren()前,把mInPreLayout置为了 false,pre-layout就此结束。

而且mState作为参数被传入onLayoutChildren(),在onLayoutChildren()中一定会读取mInPreLayout

看到这里,结合注释和代码走查,可以下一些结论:

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

  2. mState.mInPreLayout的值标记了预布局的生命周期。预布局的过程始于RecyclerView.dispatchLayoutStep1(),终于RecyclerView.dispatchLayoutStep2()。两次调用LayoutManager.onLayoutChildren()会因为这个标记位的不同而执行不同的逻辑分支。

预布局填充额外表项

知道了预布局的起点和终点,就为走查代码缩小了范围。只需要定位在LinearLayoutManager.onLayoutChildren()中,就可以了解预布局做了些什么。

预布局一定做了很多事情,但现在最关心的是“预布局过程中,如何将额外的不可见表项填充进来?”

RecyclerView缓存机制(咋复用?)中讲述了怎么在源码中一步步找到 “填充表项” 的逻辑,这段逻辑正好就在onLayoutChildren()中,引用如下:

public class LinearLayoutManager {
    // 布局表项
    public void onLayoutChildren() {
        // 填充表项
        fill() {
            while(列表有剩余空间){
                // 填充单个表项
                layoutChunk(){
                    // 让表项成为子视图
                    addView(view)
                }
            }
        }
    }
}

RecyclerView将布局表项的任务委托给LinearLayoutManagerLinearLayoutManager布局表项时,在fill()方法中循环不断地调用layoutChunk()逐个将表项填入,直到列表没有空间。

对于填充表项,fill()layoutChunk()是两个关键方法,添加额外表项的逻辑肯定藏在其中:

public class LinearLayoutManager {
    // 根据剩余空间填充表项
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
        ...
        // 计算剩余空间 = 可用空间 + 额外空间
        int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
        // 当剩余空间 > 0 时,继续填充更多表项
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            ...
            layoutChunk()
            ...
        }
    }
}

LinearLayoutManager在循环填充表项前会计算剩余空间,计算公式中的mExtraFillSpace引起了我的注意,它和我关心的问题“额外表项”很匹配,心想 “在 pre-layout 过程中可能是mExtraFillSpace增大,放宽了循环条件,使得额外表项被填充。” 于是乎,我开始搜索它被赋值的地方,结果显示有 11 处(有点多,好慌):

仔细一瞅,大部分的赋值都发生在onLayoutChildren()中:

if (mAnchorInfo.mLayoutFromEnd) {// 从尾部开始布局
    mLayoutState.mExtraFillSpace = extraForStart;
} else {// 从头部开始布局
    mLayoutState.mExtraFillSpace = extraForEnd;
}

而且它们分别处于不同的方向分支中,即对于一种方向的列表只有一个赋值语句被执行,随便找了一个mLayoutState.mExtraFillSpace = extraForEnd;,继续搜索extraForEnd被赋值的地方:

看源码就是这样,道路千万条,抽丝剥茧选一条。

public class LinearLayoutManager {
    private int[] mReusableIntPair = new int[2];
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        ...
        mReusableIntPair[0] = 0;
        mReusableIntPair[1] = 0;
        calculateExtraLayoutSpace(state, mReusableIntPair); // 计算值
        int extraForEnd = Math.max(0, mReusableIntPair[1]) // 赋值
    }
}

extraForEnd的值和mReusableIntPair[1]有关,而它在calculateExtraLayoutSpace()中被计算,继续跳转:

看源码就是这样,想知道一个变量的值,可能得先知道其他 N 个变量的值。

public class LinearLayoutManager {
    // 计算额外空间
    protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,@NonNull int[] extraLayoutSpace) {
        int extraLayoutSpaceStart = 0;
        int extraLayoutSpaceEnd = 0;

        int extraScrollSpace = getExtraLayoutSpace(state);// 计算值
        if (mLayoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
            extraLayoutSpaceStart = extraScrollSpace;
        } else {
            extraLayoutSpaceEnd = extraScrollSpace;
        }

        extraLayoutSpace[0] = extraLayoutSpaceStart;
        extraLayoutSpace[1] = extraLayoutSpaceEnd;// 赋值
    }
}

calculateExtraLayoutSpace()这个方法名让我更加坚信这条路没错(额外表项对应着额外空间)。

在这个方法中又调用了getExtraLayoutSpace()并将结果赋值给extraLayoutSpace[1],继续跳:

看源码就是这样,不停地跳来跳去,有时候跳远了都忘了为啥而跳。

public class LinearLayoutManager {
    protected int getExtraLayoutSpace(RecyclerView.State state) {
        if (state.hasTargetScrollPosition()) {
            return mOrientationHelper.getTotalSpace();
        } else {
            return 0;
        }
    }
}

方法要么返回 0 要么返回mOrientationHelper.getTotalSpace(),我更愿意相信后者,因为只有返回非0值才能证实猜想。为了验证,我还得跳一次:

public class RecyclerView {
    public static class State {
        public boolean hasTargetScrollPosition() {
            return mTargetPosition != RecyclerView.NO_POSITION;
        }
    }
}

看到这,我陷入了迷茫,因为删除表项操作并不会发生列表滚动,即hasTargetScrollPosition()应该返回 false,也就说返回额外空间的方法getExtraLayoutSpace()应该返回0。我无法接受这个事实。。。

看源码就是这样,千辛万苦在一条道上走了很久,到头来却发现是个死胡同。

难道列表发生滚动了?

怎么证明滚动了?

继续搜索 mTargetPosition 被赋值的地方?

不。。。我已经跳不动了。。

硬生生地看了一下午源码,也没有看到想要的结果,更致命的是硬看很容易钻牛角尖,有限的生命就耗费在这无穷的细节中。

想知道某个变量的值,最快的办法是断点调试,它也可以用到阅读源码上。写了一个简单的 Demo 模拟删除表项的场景,将断点打在计算剩余空间那一行:

public class LinearLayoutManager {
    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()
            ...
        }
    }
}

断点告诉我layoutState.mExtraFillSpace的确为0!

layoutState.mAvailable的值是否在pre-layout过程中变大?断点告诉我没有!

循环条件没有放宽!那额外的表项是如何被填充的?

我将断点打在了循环条件while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) 上,惊喜地发现了一个新的线索:在正常布局表项时,当第二个表项被填充后remainingSpace就等于0了,但同样的情况在 pre-layout 阶段,remainingSpace就不为0,这导致循环可以多走一次,即可以将表项 3 填充进来。

每次循环填充表项后remainingSpace的值应该变小,难道填充被删除的表项时跳过了这个步骤?

又到了硬看源码发挥作用的时刻:

public class LinearLayoutManager {
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
        int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
        // 填充表项结果
        LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            // 填充单个表项(将layoutChunkResult传入)
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            ...
            if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null|| !state.isPreLayout()) {
                // 在剩余空间中扣除刚填充表项消耗的空间
                remainingSpace -= layoutChunkResult.mConsumed;
            }
        }
    }
}

循环中唯一一处扣除剩余空间的代码被一个条件表达式包裹着,表达式中有三个条件做或运算,其中一个条件!state.isPreLayout()对于非pre-layout阶段来说肯定为 true,即无论其他条件如何,非pre-layout阶段一定会扣除所有表项消耗的空间,而对于pre-layout来说,填充某些表项时,可能会跳过扣除。哪些表项会跳过?

条件表达式中有一个变量 layoutChunkResult.mIgnoreConsumed,字面意思是忽略这次消耗,而且layoutChunkResult被作为参数传入layoutChunk()

public class LinearLayoutManager {
    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
        // 获取下一个该被填充的表项视图
        View view = layoutState.next(recycler);
        ...
        // 获取表项布局参数
        RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        // 如果表项被移除 则 mIgnoreConsumed 置为 true
        if (params.isItemRemoved() || params.isItemChanged()) {
            result.mIgnoreConsumed = true;
        }
        ...
    }
}

看到这里感觉八九不离十了,用断点调试验证了,的确和猜想的一样:

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

走查到这里,虽然只回答了两个问题,一是预布局的生命周期,二是预布局如何填充额外表项,但篇幅已经有点长了,关于“RecyclerView预布局的其他分析”及“RecyclerView如何实现表项动画”下回在讲。

推荐阅读

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() 为什么是昂贵的?