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 停止记录。就能得到这样的画面:

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

图片上方有一条红色的线段,表示这段时间内发生用户交互,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()中庞杂的逻辑纠缠了,沿着这个调用走查,所有的关键信息一个都不会漏掉。

沿着关键调用链走查

有了上面的关键调用链,就节省了很多时间。现在可以对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 根据滚动位移长度在滚动方向上填充额外的表项,然后将所有表项向滚动的反方向平移相同的位移值,以此实现滚动。

文末

感谢大家关注我,分享Android干货,交流Android技术。

对文章有何见解,或者有何技术问题,都可以在评论区一起留言讨论,都会看的哦~

Android架构师系统进阶学习路线、58万字学习笔记、教学视频免费分享地址:我的GitHub

死磕到底RecyclerView | RecyclerView 的滚动是怎么实现的?的更多相关文章

  1. 死磕mysql(4)

    想把论坛和博客上所有关于mysql的都看一遍,死磕到底 看到关于数据库快照的东西.......不懂,百度......然后就跑题了,看到了表锁这种东西unlock tables; 用来锁定表..... ...

  2. AdapterView 和 RecyclerView 的连续滚动

    AdapterView 和 RecyclerView 的连续滚动 android RecyclerView tutorial 概述 ListView 和 GridView 的实现方式 Recycler ...

  3. RecyclerView 实现快速滚动

    RecyclerView 实现快速滚动 https://www.cnblogs.com/mamamia/p/8311449.html

  4. RecyclerView 实现快速滚动 (转)

    RecyclerView 实现快速滚动 极小光  简书作者   简评:Android Support Library 26 中终于实现了一个等待已久的功能:RecyclerView 的快速滚动. An ...

  5. RecyclerView 实现横向滚动效果

    我相信很久以前,大家在谈横向图片轮播是时候,优先会选择具有HorizontalScrollView效果和ViewPager来做,不过自从Google大会之后,系统为我们提供了另一个控件Recycler ...

  6. Android RecyclerView实现横向滚动

    我相信很久以前,大家在谈横向图片轮播是时候,优先会选择具有HorizontalScrollView效果和ViewPager来做,不过自从Google大会之后,系统为我们提供了另一个控件Recycler ...

  7. mysql每秒最多能插入多少条数据 ? 死磕性能压测

    前段时间搞优化,最后瓶颈发现都在数据库单点上. 问DBA,给我的写入答案是在1W(机械硬盘)左右. 联想起前几天infoQ上一篇文章说他们最好的硬件写入速度在2W后也无法提高(SSD硬盘) 但这东西感 ...

  8. 【死磕 Spring】----- IOC 之 获取 Document 对象

    原文出自:http://cmsblogs.com 在 XmlBeanDefinitionReader.doLoadDocument() 方法中做了两件事情,一是调用 getValidationMode ...

  9. 死磕 Fragment 的生命周期

    死磕 Fragment 的生命周期 本文原创,转载请注明出处.欢迎关注我的 简书 ,关注我的专题 Android Class 我会长期坚持为大家收录简书上高质量的 Android 相关博文.本篇文章已 ...

随机推荐

  1. spring事务:事务控制方式,使用AOP控制事务,七种事务传播行为,声明事务,模板对象,模板对象原理分析

    知识点梳理 课堂讲义 1)事务回顾 1.1)什么是事务-视频01 事务可以看做是一次大的活动,它由不同的小活动组成,这些活动要么全部成功,要么全部失败. 1.2)事务的作用 事务特征(ACID) 原子 ...

  2. Why系列:谨慎使用delete

    题外话 这里大家可能要笑了,这不就一个操作符吗,还用单独来讲. 有这时间,还不如去看看react源码,vue源码. 我说:react源码会去看的,但是这个也很重要. delete你了解多少 这里提几个 ...

  3. P2516 [HAOI2010]最长公共子序列 题解(LCS)

    题目链接 最长公共子序列 解题思路 第一思路: 1.用\(length[i][j]\)表示\(a\)串的前\(i\)个字符与\(b\)串的前\(j\)个字符重叠的最长子串长度 2.用\(num[i][ ...

  4. 七种join的书写规范

    在mysql中的两表进行连接时,总共有7种连接情况,具体可见下图 由图的从左到右的顺序 图1.左连接(left join):返回左表中的所有记录和右表中的连接字符字段相等的记录,若右表没有匹配值则补N ...

  5. python两个字典相加

    x = {"a":1,"b":2} 2 y = {"c":3} 3 from collections import Counter 4 X, ...

  6. 面向对象进阶时,if语句写错位置

    这周blog我也不知道要写什么,因为这章我其实学得有点懵,前面那几天我纠结了好久代码,一直不知道原因错在哪里.后来经过询问老师才知道自己调用错了构造方法,相信也有跟我一样的新手会犯这个错误.我在创建关 ...

  7. PTA 数组循环右移

    6-2 数组循环右移 (20 分)   本题要求实现一个对数组进行循环右移的简单函数:一个数组a中存有n(>)个整数,将每个整数循环向右移m(≥)个位置,即将a中的数据由(a​0​​a​1​​⋯ ...

  8. Object类中的常用方法

    1.getClass方法 源码: 功能: 返回此Object的运行时类. 什么是运行时类? 如上图所示,类从被加载到虚拟机内存开始,到卸载出内存为止,他的生命周期一共包含7个阶段.其中加载阶段虚拟机需 ...

  9. java面试-阻塞队列

    一.阻塞队列 当阻塞队列是空,从队列中获取元素的操作会被阻塞 当阻塞队列是满,往队列中添加元素的操作会被阻塞 二.为什么用,有什么好处? 我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为 ...

  10. Python是啥?为什么这么多职业人和学生就算报班也要学它?!

    嗨,大家好 这里是汐仔 首先我们先来考究一下近几年的头条和新闻. 1.早在2018年python就已经被纳入高考之一了 2.Python加入全国计算机等级考试,从2018年九月起新增为大学计算机二级考 ...