RecyclerView 的滚动是怎么实现的?(一) 您所在的位置:网站首页 recyclerview滚动条支持手动滑动 RecyclerView 的滚动是怎么实现的?(一)

RecyclerView 的滚动是怎么实现的?(一)

2023-10-29 12:34| 来源: 网络整理| 查看: 265

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

切入点:触摸事件

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

对于滚动这个场景,最显而易见的切入点是触摸事件,即手指在 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; } } } 读源码新方法:Profiler法

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

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

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

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

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

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

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

微信截图_20210505163628.png 调用链实在是很长,若看不清可以点击大图。

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

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

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

微信截图_20210505164944.png 一条清晰的调用链搜地一下扑面而来:

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

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

沿着关键调用链走查

有了上面的关键调用链,就节省了很多时间。现在可以对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赋值,即滚动距离。

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 面试题 | 滚动时表项是如何被填充或回收的?

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

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(false, false); } 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(false, false); } // 修改 view 的顶部和底部值 mTop += offset; mBottom += offset; mRenderNode.offsetTopAndBottom(offset); if (isHardwareAccelerated()) { invalidateViewProperty(false, false); invalidateParentIfNeededAndWasQuickRejected(); } else { if (!matrixIsIdentity) { invalidateViewProperty(false, true); } invalidateParentIfNeeded(); } notifySubtreeAccessibilityStateChangedIfNeeded(); } } }

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

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

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

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

推荐阅读

RecyclerView 系列文章目录如下:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有