查看原文
其他

RecyclerView 中的秘密探索 | 滚动

唐子玄 鸿洋 2021-10-12

本文作者


作者:唐子玄

链接:

https://juejin.cn/post/6958962329220284453

本文由作者授权发布。


作者写了多篇 RecyclerView内部细节原理剖析的,会作为一个系列「RecyclerView 中的秘密探索」推送给大家。


RecyclerView 是一个展示列表的控件,其中的子控件可以被滚动。这是怎么实现的?以走查源码的方式一探究竟。


1切入点:触摸事件


阅读源码时,如何在浩瀚的源码中选择合适的切入点很重要,选好了能少走弯路。


对于滚动这个场景,最显而易见的切入点是触摸事件,即手指在 RecyclerView 上滑动,列表跟手滚动。


就以RecyclerView.OnTouchEvent()为切入点。手指滑动,列表随之而动的逻辑应该在ACTION_MOVE中,其源码如下(略长可跳过):


public class RecyclerView {
    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                final int index = e.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    Log.e(TAG, "Error processing scroll; pointer index for id "
                            + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                    return false;
                }

                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;

                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    boolean startScroll = false;
                    if (canScrollHorizontally) {
                        if (dx > 0) {
                            dx = Math.max(0, dx - mTouchSlop);
                        } else {
                            dx = Math.min(0, dx + mTouchSlop);
                        }
                        if (dx != 0) {
                            startScroll = true;
                        }
                    }
                    if (canScrollVertically) {
                        if (dy > 0) {
                            dy = Math.max(0, dy - mTouchSlop);
                        } else {
                            dy = Math.min(0, dy + mTouchSlop);
                        }
                        if (dy != 0) {
                            startScroll = true;
                        }
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }

                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mReusableIntPair[0] = 0;
                    mReusableIntPair[1] = 0;
                    if (dispatchNestedPreScroll(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            mReusableIntPair, mScrollOffset, TYPE_TOUCH
                    )) {
                        dx -= mReusableIntPair[0];
                        dy -= mReusableIntPair[1];
                        // Updated the nested offsets
                        mNestedOffsets[0] += mScrollOffset[0];
                        mNestedOffsets[1] += mScrollOffset[1];
                        // Scroll has initiated, prevent parents from intercepting
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }

                    mLastTouchX = x - mScrollOffset[0];
                    mLastTouchY = y - mScrollOffset[1];

                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            e)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    if (mGapWorker != null && (dx != 0 || dy != 0)) {
                        mGapWorker.postFromTraversal(this, dx, dy);
                    }
                }
            } break;
        }
    }
}


2读源码新方法:Profiler法


虽然已经精准定位到滑动相关逻辑,但ACTION_MOVE这个分支中的源码还是太长,头痛!


如何快速地在庞杂的源码中定位到关键逻辑?


RecyclerView 动画原理 | 换个姿势看源码(pre-layout)中介绍过一种方法:“断点调试法”。即写一个最简单的 demo 来模拟场景,然后通过断点调试确定源码调用链上的关键路径。

https://juejin.cn/post/6890288761783975950


今天再介绍一种更加快捷的方法:“Profiler法”。


还是写一个 demo 加载一个列表,打开 AndroidStudio 自带的性能调试工具 Profiler,选中 CPU 栏,用手指触发列表滚动,然后点击 Record 按钮,开始记录列表滚动过程中完整的函数调用链,待列表滚动完毕后,点击 Stop 停止记录。就能得到这样的画面:



横轴表示时间,纵轴表示该时间点发生的函数调用,调用链的方向是从上到下的,即上面的是调用者,下面的是被调用者。


图片上方有一条红色的线段,表示这段时间内发生用户交互,demo 场景中的交互就是手指滑动列表。触发列表滚动的逻辑应该就包含在红色线段对应的时间内,按 w 键把这段调用链放大查看:



调用链实在是很长,若看不清可以点击大图。


调用链的最顶端是Looper.loop()方法,因为所有主线程的逻辑都在其中执行。


沿着调用链往下看,Looper 调用了 MessageQueue.next(),表示取出消息队列中的下一条消息,并紧接着执行了Hanlder.dispatchMessage()Hander.handleCallback(),表示分发并处理这条消息。


因为这条消息是触摸事件的处理,所以Choreographer又委托ViewRootImpl分发触摸事件,经过一条很长的分发链,终于看到一个熟悉的方法Activity.dispatchTouchEvent(),表示触摸事件已经传递到 Activity。然后根据界面的层级结构,一层层地分发到RecyclerView.onTouchEvent(),走到这里,我们关心的列表滑动逻辑就一下子全部展现在面前,将这个布局再放大看一下:



一条清晰的调用链嗖地一下扑面而来:


RecyclerView.onTouchEvent()
RecyclerView.scrollByInternal()
RecyclerView.scrollStep()
LinearLayoutManager.scrollVerticallyBy()
LinearLayoutManager.scrollBy()
OrientationHelper.offsetChildren()
LayoutManager.offsetChildrenVertical()
RecyclerView.offsetChildrenVertical()
View.offsetTopAndBottom()

已经不需要和RecyclerView.onTouchEvent()中庞杂的逻辑纠缠了,沿着这个调用走查,所有的关键信息一个都不会漏掉。


3沿着关键调用链走查


有了上面的关键调用链,就节省了很多时间。现在可以对RecyclerView.onTouchEvent()中的逻辑披沙拣金:


public class RecyclerView {
    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                ...
                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mReusableIntPair[0] = 0;
                    mReusableIntPair[1] = 0;
                    // 1. 触发嵌套滚动,让嵌套滚动中的父控件优先消费滚动距离
                    if (dispatchNestedPreScroll(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            mReusableIntPair, mScrollOffset, TYPE_TOUCH
                    )) {
                        dx -= mReusableIntPair[0];
                        dy -= mReusableIntPair[1];
                        mNestedOffsets[0] += mScrollOffset[0];
                        mNestedOffsets[1] += mScrollOffset[1];
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    ...

                    // 2. 触发列表自身的滚动
                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            e)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    ...
                }
            } break;
        }
    }
}

在关键调用链scrollByInternal()的上面,意外地发现了处理嵌套滚动的逻辑,这是为了在列表消费滚动距离之前优先让其父控件消费。


public class RecyclerView {
    boolean scrollByInternal(int x, int y, MotionEvent ev) {
        int unconsumedX = 0;
        int unconsumedY = 0;
        int consumedX = 0;
        int consumedY = 0;

        consumePendingUpdateOperations();
        if (mAdapter != null) {
            mReusableIntPair[0] = 0;
            mReusableIntPair[1] = 0;
            // 触发列表滚动(手指滑动距离被传入)
            scrollStep(x, y, mReusableIntPair);
            // 记录列表滚动消耗的像素值和剩余未消耗的像素值
            consumedX = mReusableIntPair[0];
            consumedY = mReusableIntPair[1];
            unconsumedX = x - consumedX;
            unconsumedY = y - consumedY;
        }
        ...
        mReusableIntPair[0] = 0;
        mReusableIntPair[1] = 0;
        // 将列表未消耗的滚动距离继续留给其父控件消耗
        dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
                TYPE_TOUCH, mReusableIntPair);
        unconsumedX -= mReusableIntPair[0];
        unconsumedY -= mReusableIntPair[1];
        ...
    }
}

scrollByInternal()是触发列表滚动调用链的起点,它先调用了scrollStep()以触发列表自身的滚动,紧接着还调用了dispatchNestedScroll()将自身消费后剩下的滚动余量继续交给其父控件消费。


沿着关键调用链继续往下走查:


public class RecyclerView {
    LayoutManager mLayout;
    void scrollStep(int dx, int dy, @Nullable int[] consumed) {
        // 在滚动之前禁止重新布局
        startInterceptRequestLayout();
        onEnterLayoutOrScroll();

        int consumedX = 0;
        int consumedY = 0;
        // 横向滚动 dx
        if (dx != 0) {
            consumedX = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
        }
        // 纵向滚动 dy
        if (dy != 0) {
            consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
        }
        ...
        // 将滚动消耗通过数组传递出去
        if (consumed != null) {
            consumed[0] = consumedX;
            consumed[1] = consumedY;
        }
    }
}

scrollStep()把触发滚动的任务委托给了LayoutManager,调用了它的scrollVerticallyBy()


public class RecyclerView {
    public abstract static class LayoutManager {
        // 空实现
        public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
            return 0;
        }
    }
}

public class LinearLayoutManager extends RecyclerView.LayoutManager {
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 若是横向布局则不会发生纵向滚动
        if (mOrientation == HORIZONTAL) {
            return 0;
        }
        // 触发纵向滚动
        return scrollBy(dy, recycler, state);
    }

    int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
        ...
        final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
        final int absDelta = Math.abs(delta);
        // 计算和滚动相关的各种数据并将其保存在 mLayoutState 中
        updateLayoutState(layoutDirection, absDelta, true, state);
        // 填充额外的表项,并计算实际消耗的滚动值
        final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
        ...
        final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta;
        // 将列表所有孩子都想滚动的反方向平移对应像素
        mOrientationHelper.offsetChildren(-scrolled);
        ...
        mLayoutState.mLastScrollDelta = scrolled;
        return scrolled;
    }
}

若阅读过RecyclerView 动画原理 | 换个姿势看源码(pre-layout),对LinearLayoutManager.fill()方法一定不陌生。它用来向列表中填充额外的表项,填充个数由额外空间mLayoutState.mAvailable说了算,它在updateLayoutState()方法里被absDelta赋值,即滚动距离。

https://juejin.cn/post/6890288761783975950


fill()的源码如下:


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()
            ...
        }
    }

    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
        // 获取下一个该被填充的表项视图
        View view = layoutState.next(recycler);
        ...
        addView(view);
        ...
    }

}

fill()方法会根据剩余空间来循环地调用layoutChunk()向列表中填充表项,滚动列表的场景中,剩余空间的值由滚动距离决定。


关于列表滚动时,填充和复用表项的细节分析可以点击RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?

https://juejin.cn/post/6903290882095579143


layoutChunk()中获取下一个该被填充表项方法layoutState.next()最终会触发onCreateViewHolder()onBindViewHolder(),所以这俩方法执行的速度,即表项加载速度,也会影响列表滑动的流畅度,关于如何提高表项加载速度可以点击RecyclerView 性能优化 | 把加载表项耗时减半 (一)

https://juejin.cn/post/6939666015500369950


scrollBy()方法会根据滚动距离,在列表滚动方向上填充额外的表项。填充完,再调用mOrientationHelper.offsetChildren()将所有表项向滚动的反方向平移:


public abstract class OrientationHelper {
    // 抽象的平移子表项
    public abstract void offsetChildren(int amount);

    public static OrientationHelper createVerticalHelper(RecyclerView.LayoutManager layoutManager) {
        return new OrientationHelper(layoutManager) {
            @Override
            public void offsetChildren(int amount) {
                // 委托给 LayoutManager 在垂直方向上平移子表项
                mLayoutManager.offsetChildrenVertical(amount);
            }
            ...
        }
}

public class RecyclerView {
    public abstract static class LayoutManager {
        public void offsetChildrenVertical(@Px int dy) {
            if (mRecyclerView != null) {
                // 委托给 RecyclerView 在垂直方向上平移子表项
                mRecyclerView.offsetChildrenVertical(dy);
            }
        }
    }

    public void offsetChildrenVertical(@Px int dy) {
        // 遍历所有子表项
        final int childCount = mChildHelper.getChildCount();
        for (int i = 0; i < childCount; i++) {
            // 在垂直方向上平移子表项
            mChildHelper.getChildAt(i).offsetTopAndBottom(dy);
        }
    }
}

经过一系列调用链,最终执行了View.offsetTopAndBottom()


public class View {
    public void offsetTopAndBottom(int offset) {
        if (offset != 0) {
            final boolean matrixIsIdentity = hasIdentityMatrix();
            if (matrixIsIdentity) {
                if (isHardwareAccelerated()) {
                    invalidateViewProperty(falsefalse);
                } else {
                    final ViewParent p = mParent;
                    if (p != null && mAttachInfo != null) {
                        final Rect r = mAttachInfo.mTmpInvalRect;
                        int minTop;
                        int maxBottom;
                        int yLoc;
                        if (offset < 0) {
                            minTop = mTop + offset;
                            maxBottom = mBottom;
                            yLoc = offset;
                        } else {
                            minTop = mTop;
                            maxBottom = mBottom + offset;
                            yLoc = 0;
                        }
                        r.set(0, yLoc, mRight - mLeft, maxBottom - minTop);
                        p.invalidateChild(this, r);
                    }
                }
            } else {
                invalidateViewProperty(falsefalse);
            }

            // 修改 view 的顶部和底部值
            mTop += offset;
            mBottom += offset;
            mRenderNode.offsetTopAndBottom(offset);
            if (isHardwareAccelerated()) {
                invalidateViewProperty(falsefalse);
                invalidateParentIfNeededAndWasQuickRejected();
            } else {
                if (!matrixIsIdentity) {
                    invalidateViewProperty(falsetrue);
                }
                invalidateParentIfNeeded();
            }
            notifySubtreeAccessibilityStateChangedIfNeeded();
        }
    }
}

该方法会修改 View 的 mTop 和 mBottom 值,并触发轻量级的重绘。


分析至此,已经可以回答开篇的问题了:


RecyclerView 在处理 ACTION_MOVE 事件时计算出手指滑动距离,以此作为滚动位移值。


RecyclerView 根据滚动位移长度在滚动方向上填充额外的表项,然后将所有表项向滚动的反方向平移相同的位移值,以此实现滚动。




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读


Google 还发布了这个库? 告别shape、各种 drawable...
一个困惑很久的问题,Android中有子窗口吗?
开启B站少女心,探究APP换肤


点击 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存