深入理解RecyclerView:布局管理器实现原理和使用方法

一、背景

作为 Android 应用开发中不可或缺的UI组件,RecyclerView 相对于传统的 ListView 在可定制性、性能和扩展性方面都有了巨大的进步。它采用了视图回收重用机制,可以展示极大量的数据,并支持线性布局、网格布局和瀑布流布局等各种不同的布局方式。作为一名 Android 开发者,掌握 RecyclerView 的技术是非常必要的。

想深入了解RecyclerView的实现原理和使用方法,我们可以由浅入深地学习。首先要了解RecyclerView的整体框架,其中布局管理器是一个必不可少的组件。通过简单的介绍框架提供的自带布局管理器来作为学习RecyclerView的第一步。接着,当我们要想创建一个自定义布局管理器,需要理解组件之间是如何相互协作以及RecyclerView的实现原理,例如滑动、测量、布局和缓存处理等细节问题;最终,通过以上的学习和了解,我们可以对 RecyclerView 的整体框架有更深入的理解。话不多说,让我们开始吧!

二、framework提供的布局管理器

为了在实际开发中满足不同的需求,RecyclerView提供对可复用的集合创建自定义的布局、动画可以更加灵活易用使用复用机制。可以看到我们常见的使用RecyclerView代码中必须提供一个布局管理器。为了应对研发中会遇到的常见场景,框架为我们提供了几个自带的布局管理器,能解决大多数情况下的问题,我们先对它们进行简单的了解,后续以LinearLayoutManager的源码为例展开我们对LayoutManager实现原理的学习。

LinearLayoutManager

LinearLayoutManager是一个列表项均匀分布的单一列表,基本上可以当做ListView的替代品。与ListView不同的是,它具有设定垂直或水平方向参数的特性,只需要在实例时指定垂直或水平方向的参数即可。这使得开发者能够更加灵活地控制列表的排版方式

java 复制代码
LinearLayoutManager manager = new LinearLayoutManager(
  this,LinearLayoutManager.VERTIVAL,
  false);//是否需要逆序布局

GridLayoutManager

GridLayoutManager由均分的网格布局列表项集合组成,和LinearLayoutManager一样具有指定方向的参数,除此之外提供了控制每行item所能占用的最大span数。例如在下图中span被定义为2,每行就被均分成了两部分。而通过span是无法将每行不均等分(1/3、2/3),GridLayoutManager提供了一个相当有意思的特性SpanSizeLook,可以让我们对均等性质进行改变。除此之外,GridLayoutManager不论其使用何种的方向,其表项的高度(垂直)或宽度(水平)在所占的块中必须是均等的,也意味着行高取决于最高的item的高度,那么当一行中有一个item比其他的高,则会留下空白。那么StaggeredGridLayoutManager正好解决了这个问题。

java 复制代码
GridLayoutManager manager  = new GridLayoutManager(
  this,
  2,
  GridLayoutManager.VERTIVAL,
  false,
);
manager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLook(){
  @Override
  public int getSpanSize(int position){
  	return(position%3==0?2:1)
  }
});

StaggeredGridLayoutManager

StaggeredGridLayoutManager具有更广泛的灵活性和布局自由度,可以自动地调整不同item的高度,以便完全填充整个布局。它可以为每个item设置不同的高度,使得在布局中形成一个错落有致的视觉效果。

java 复制代码
StaggeredGridLayoutManager manager  = new StaggeredGridLayoutManager(
  2,//网格列数
  StaggeredGridLayoutManager.VERTIVAL,
);

三、核心组件

那除了以上提供的已有的,想实现一个在framework中找不到,就需要我们自己手动去创建布局管理器了。在创建属于我们自己的布局管理器之前我们需要对其中使用到的各个组件有一个基本认知:

  1. Recycler:是构建自定义的布局管理器的核心帮助类,几乎干了所有的获取视图、缓存视图等和回收相关的活动。能让RecyclerView能快速的获取一个新视图来填充数据或者快速丢弃不再需要的视图。
  2. Adapter:是所有数据的来源,负责提供数据并创建ViewHolders以及将数据绑定到ViewHolders上的重要组件,可以视为是recycler对外的工作的对接者
  3. ViewHolder:存取状态信息,在recycler内部也对viewHolder进行了状态信息的存取(是否正在被改变,是否被删除或添加)
  4. LayoutManager:决定RecyclerView中Items的排列方式,包含了Item View的获取与回收;当数据集发生变化时,LayoutManager会自动调整子view的位置和大小,以保证RecyclerView的正常显示和性能。

Recycler

这个类体现了回收利用的概念,对视图的回收利用机制使设备可以展现非常大的数据集合的数据项给用户。但无需创建跟数据项个数一样多的视图来展示数据,仅仅用有限的几个视图便可达到目的。一旦用户通过上下滑开始跟数据集交互。当这个视图被划出屏幕,这个视图就不需要了。对于将要到来的新数据,不采用创建一个新视图的方式 而是把旧视图复用填充到一个新的位置,然后让它划入屏幕即可。在这个过程中一方面我们从recycler中获取视图,也会将废弃的视图丢给recycler。而recycler有两种级别的机制来对数据进行回收利用。

Scrap Heap可以视为是第一道防线,是一种轻量级的操作,会检索视图和丢弃视图,从中获取数据不需要重新绑定数据(也就是可以不走适配器),所以通常我们都是先用Scrap Heap来存丢掉的视图,如果你确定某些视图丢弃后永远用不上时,Recycle Pool派上了用场,我们能从中快速获取视图但是获取到的视图的meta-data丢失了,需要从适配器中重新绑定数据,更详细的回收流程将在后续的章节介绍。与之对应的, 如果你想要临时整理并且希望稍后在同一布局中重新使用某个 View 的话, 可以对它调用 detachAndScrapView(),该方法的作用是将指定的View从RecyclerView中分离(Detach)并且添加到RecyclerView的回收池中(Scrap)。如果基于当前布局 你不再需要某个 View 的话,对其调用 removeAndRecycleView(),该方法的作用是移除指定的View,并将该View添加到Recycler Pool中。

Adapter

Adapter通过继承RecyclerView.Adapter类,并重写其中的方法来实现RecyclerView的功能。其中有三个重要的方法:onCreateViewHolderonBindViewHoldergetItemCount。onCreateViewHolder方法在RecyclerView初始加载或者需要新的ViewHolder时被调用,它创建ViewHolder并返回。onBindViewHolder方法则将ViewHolder中的数据绑定到对应的视图中。getItemCount方法返回adapter数据列表的长度。

ViewHolder 作为RecyclerView的视图保持者,其主要作用是提高RecyclerView的性能,它继承自RecyclerView.ViewHolder类,并且重写其中的方法,例如onBindViewHolder方法用于将数据与视图绑定。ViewHolder中的数据来自于Adapter中的model。通过ViewHolder的重用,RecyclerView不再需要重复创建视图,从而大大提高了性能。ViewHolder的数据绑定只需要更新数据,而不需要重新创建整个视图。这样就能够避免频繁地调用findViewById等方法,从而提高RecyclerView的流畅度和响应速度。

LayoutManager

LayoutManager则决定了RecyclerView中子视图的排列方式、方向、位置等等。它的工作过程涉及到measure和layout操作。在measure过程中,LayoutManager会遍历RecyclerView中的所有子视图,并确定它们的尺寸大小。LayoutManager使用子视图自身的measure方法获得它们的期望尺寸,再对应的给子View分配合理的空间,确保子视图能够合理地显示出来。

在layout过程中,LayoutManager会确定子视图在RecyclerView中的位置,并将它们放置到相应的位置上。这个过程也涉及到指定LayoutManager的排列方式,除此之外LayoutManager还支持Decorations。Decorations能够在RecyclerView中添加额外的边距、分隔符和间隔等,从而提高了RecyclerView的外观和性能。Decorations是应用在所有RecyclerView元素上的,LayoutManager不需要关心子视图的边距问题,API会自动计算并应用Decorations的效果。

四、LayoutManager实现原理

绘制流程

布局管理器 顾名思义,作用就是负责measure和layout的。RecyclerView作为ViewGroup的子类,也是通过 onMeasure() 来实现测量工作的,那我们就以onMeasure()为切入点来了解LayoutManager的绘制流程:

默认开启了该自动测量功能也就是mLayout.mAutoMeasure的值为true,此时RecyclerView会在不影响性能的前提下自动测量并更新所有可见item的大小和位置。若有需要也可以在重写LayoutManager时通过setAutoMeasureEnabled(false)关闭自动布局。可以看到在开启自动布局的情况下,是通过dispatchLayoutStep1 ,dispatchLayoutStep2 两个方法,Setp1是进行预布局,Setp2循环对子布局进行测量和布局,除这两个之外还有用来实际执行动画的dispatchLayoutStep3,通过mLayoutStep判断布局执行的状态。

测量完RecyclerView和子View的大小,就要调用onLayout方法对所有子View进行布局。在onLayout()中主要是通过dispatchLayout()方法来对可见的item进行布局:

java 复制代码
void dispatchLayout() {
  if (mAdapter == null) {//没有设置adapter,返回
    Log.e(TAG, "No adapter attached; skipping layout");
    // leave the state in START
    return;
  }
  if (mLayout == null) {//没有设置LayoutManager,返回
    Log.e(TAG, "No layout manager attached; skipping layout");
    // leave the state in START
    return;
  }
  mState.mIsMeasuring = false;
  //在onMeasure阶段,如果宽高是固定的,那么mLayoutStep == State.STEP_START 
  // 而且dispatchLayoutStep1和dispatchLayoutStep2不会调用
  //所以这里就会调用一下
  if (mState.mLayoutStep == State.STEP_START) {
    dispatchLayoutStep1();
    mLayout.setExactMeasureSpecsFrom(this);
    dispatchLayoutStep2();
  } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth() ||
             mLayout.getHeight() != getHeight()) {
    //在onMeasure阶段,如果执行了dispatchLayoutStep1,但是没有执行dispatchLayoutStep2,就会执行dispatchLayoutStep2
    mLayout.setExactMeasureSpecsFrom(this);
    dispatchLayoutStep2();
  } else {
    mLayout.setExactMeasureSpecsFrom(this);
  }
  //最终调用dispatchLayoutStep3
  dispatchLayoutStep3();
}

从描述中就可以看出来,在这3个步骤中,step2就是执行了最重要的子View的测量布局的一步,比较关键的就两点。第一点关系到当我们在写自定义布局时必须要重写的一个方法onLayoutChildren(),来规定放置子 view 的算法寻找锚点填充 view;第二点就是将 mState.mLayoutStep 置为 State.STEP_ANIMATIONS,通过mLayoutStep就知道 layout 这个过程进行到哪一步了。

java 复制代码
private void dispatchLayoutStep2() {
 ...
  // Step 2: Run layout
  ...
  mLayout.onLayoutChildren(mRecycler, mState);
  // onLayoutChildren may have caused client code to disable item animations; re-check
  mState.mRunSimpleAnimations = mState.mRunSimpleAnimations && mItemAnimator != null;
  mState.mLayoutStep = State.STEP_ANIMATIONS;
	...
}

例如在快手的注册界面会有推荐昵称这样的流式布局,在网上搜索Flowlayout实现会发现大多的最佳实践会告诉你实现步骤:

  1. 继承ViewGroup
  2. 重写 onMeasure,计算子控件的大小从而确定父控件的大小
  3. 重写 onLayout ,确定子控件的布局 以上仅仅是实现测量和布局,如果要实现动态的数据添加,参考鸿洋的FlowLayout也是以setAdapter形式注入数据

而通过创建自定义布局管理器只需要重写的一个onLayoutChildren(),此时子View的测量和布局工作都将由LayoutManager完成

java 复制代码
  @Override
  public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    detachAndScrapAttachedViews(recycler);//暂时分离并废弃所有当前附加的子视图
    int sumWidth = getWidth();
    int curLineWidth = 0, curLineTop = 0;
    int lastLineMaxHeight = 0;
    for (int i = 0; i < getItemCount(); i++) {
      View view = recycler.getViewForPosition(i);//Recycler中获取合适的View
      addView(view);
      measureChildWithMargins(view, 0, 0);//度量子视图
      int width = getDecoratedMeasuredWidth(view);
      int height = getDecoratedMeasuredHeight(view);
      //换行策略:标签放不下时的剩余宽度超过100dp时不换行,文字省略
      boolean exceedSumWidth = curLineWidth + width > sumWidth;
      if (!exceedSumWidth || (sumWidth - curLineWidth) >= CommonUtil.dip2px(100)) {
        //不需要换行
        if (exceedSumWidth) {
          ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
          layoutParams.width = sumWidth - curLineWidth;
          view.setLayoutParams(layoutParams);
        }
        //根据边距等数据 绘制给定的子View
        layoutDecorated(view, curLineWidth, curLineTop, Math.min(curLineWidth + width, sumWidth),
            curLineTop + height);
        curLineWidth += width;
        lastLineMaxHeight = Math.max(lastLineMaxHeight, height);
      } else {
        curLineWidth = width;
        if (lastLineMaxHeight == 0) {
          lastLineMaxHeight = height;
        }
        curLineTop += lastLineMaxHeight;
        layoutDecorated(view, 0, curLineTop, width, curLineTop + height);
        lastLineMaxHeight = height;
      }
    }
  }

对以上方法进行拆分其实核心就做了2件事:

第一步:把所有的item所对应的view加进来:

java 复制代码
for (int i = 0; i < getItemCount(); i++) {
  View view = recycler.getViewForPosition(i);//Recycler中获取合适的View
  addView(view);
	...
}

第二步:把所有的Item摆放在它应在的位置:

java 复制代码
for (int i = 0; i < getItemCount(); i++) {
  ...
  measureChildWithMargins(view, 0, 0);//度量子视图
  //根据边距等数据 绘制给定的子View
  layoutDecorated(view, curLineWidth, curLineTop, Math.min(curLineWidth + width, sumWidth),
	...
}

完成了子view的测量和布局后,布局的填充其实是交由一个重要的方法fill(),如何去运行一次布局、如何去构建一个布局管理器的概念都是基于它:

java 复制代码
 int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
        //找到第一个可视元素(锚点)
        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            ...
            //将滑出屏幕的View回收掉
            recycleByLayoutState(recycler, layoutState);
        }
        //剩余绘制空间=可用区域+扩展空间。
        int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
        LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
        //循环布局直到没有剩余空间了或者没有剩余数据了
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            //*重点  添加一个child,然后将绘制的相关信息保存到layoutChunkResult
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            if (layoutChunkResult.mFinished) {//如果布局结束了(没有view了),退出循环
                break;
            }
            //根据所添加的child消费的高度更新layoutState的偏移量。mLayoutDirection为+1或者-1,通过乘法来处理是从底部往上布局,还是从上往底部开始布局
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
            if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null || !state.isPreLayout()) {
                layoutState.mAvailable -= layoutChunkResult.mConsumed;
                //消费剩余可用空间
                remainingSpace -= layoutChunkResult.mConsumed;
            }
            ...
        }
        //返回本次布局所填充的区域
        return start - layoutState.mAvailable;
    }

简单来说fill就是对布局当前的子View进行枚举,根据视图的状态来决定哪个视图会是布局的第一个可见视图,可以利用第一个可视视图的位置和偏移量等初始信息,从初始位置开始依次往后布局,计算Gap得知后面没有剩下的空间。期间在layoutChunk()方法中不断的从recycler中取出视图,然后对视图进行layout,这样就无需考虑哪一个是第一个视图,以及后边的视图距离第一个视图的距离有多远:

  1. 找到第一个可视元素
  2. 计算Gap
  3. 将所有视图Scrap掉,丢给recycler
java 复制代码
public void layoutChunk() {
  ...
     View view = layoutState.next(recycler); //调用了getViewForPosition()
     addView(view);  //加入View
     measureChildWithMargins(view, 0, 0); //计算View的大小
     layoutDecoratedWithMargins(view, left, top, right, bottom); //布局View
  ...
 }

layoutChunk()这里主要做了5个处理:

  1. 通过 layoutState 获取要展示的View,next()实际上调用了getViewForPosition(currentPosition),该方法是从RecyclerView的回收机制实现类Recycler中获取合适的View
  2. 通过 addView方法将子View添加到布局中
  3. 调用 measureChildWithMargins方法测量子View
  4. 调用 layoutDecoratedWithMargins方法布局子View
  5. 根据处理的结果,填充LayoutChunkResult的相关信息,以便返回之后,能够进行数据的计算。

总的来说onLayoutChildren()是对RecyclerView进行布局的入口方法 ,也是我们自定义布局管理器必须重写 的方法,而fill()则是实际负责填充视图的核心方法。这样重写onLayoutChildren()后item就能简单的显示出来了,现在还不能滑动,如果我们要给它添加上滑动,我们接着往下看RecyclerView的滑动原理。

滑动原理

完成了布局的绘制后,别忘了RecyclerView 是一个展示列表的控件,它的滑动原理也是我们需要掌握的。RecyclerView的滑动事件处理是通过onTouchEvent()触控事件响应的 。我们就以RecyclerView.OnTouchEvent()为切入点来看看RecyclerView的滑动原理。onTouchEvent()方法中逻辑庞杂,且包含MotionEvent.ACTION_DOWN、MotionEvent.ACTION_MOVE、MotionEvent.ACTION_CANCEL、MotionEvent.ACTION_SCROLL等多种事件,其中ACTION_MOVE事件是处理滑动事件的核心:

java 复制代码
public class RecyclerView {
  @Override
  public boolean onTouchEvent(MotionEvent e) {
    switch (action) {
      case MotionEvent.ACTION_MOVE: {//手指移动
        //根据mScrollPointerId获取触摸点下标
        final int index = e.findPointerIndex(mScrollPointerId);
        //1.根据move事件产生的x,y来计算偏移量dx,dy 
        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) {
          mReusableIntPair[0] = 0;//mReusableIntPair父view消耗的滑动距离
          mReusableIntPair[1] = 0;
          //2.将嵌套的预滑动操作的一个步骤分派给当前嵌套的滚动父View,如果为true表示父View优先处理滑动事件。
          //如果消耗,dx dy会分别减去父View消耗的那一部分距离,mScrollOffset表示RecyclerView的滚动位置
          if (dispatchNestedPreScroll(
              canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0,
              mReusableIntPair, mScrollOffset, TYPE_TOUCH
          )) {
            dx -= mReusableIntPair[0];//减去父View消耗的那一部分距离
            dy -= mReusableIntPair[1];
            //更新嵌套的偏移量
            mNestedOffsets[0] += mScrollOffset[0];
            mNestedOffsets[1] += mScrollOffset[1];
            //开始滑动,防止父View被拦截
            getParent().requestDisallowInterceptTouchEvent(true);
          }

          mLastTouchX = x - mScrollOffset[0];
          mLastTouchY = y - mScrollOffset[1];
          //3.最终实现的滚动效果
          if (scrollByInternal(canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) {
            getParent().requestDisallowInterceptTouchEvent(true);
          }
          //4.从缓存中预取一个ViewHolder
          if (mGapWorker != null && (dx != 0 || dy != 0)) {
            mGapWorker.postFromTraversal(this, dx, dy);
          }
        }
      } break;
    }
  }
}

总结来说Move事件中只做了以下几件事:

  1. 根据move事件产生的x、y计算偏移量dx,dy;(当手指由下往上滑时dy>0,当手指由上往下滑时dy<0)
  2. dispatchNestedPreScroll():触发嵌套滚动,让嵌套滚动中的父控件优先消费滚动距离
  3. 判断滑动方向,调用scrollByInternal()最终实现滚动效果;
  4. 调用mGapWorker.postFromTraversal()从RecyclerView缓存中预取一个ViewHolder。

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

java 复制代码
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];
        ...
    }
}

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

java 复制代码
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,RecyclerView.State state) {
  if (mOrientation == VERTICAL) {
    return 0;
  }
  return scrollBy(dy, recycler, state);
}

int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
  if (getChildCount() == 0 || dy == 0) {
    return 0;
  }
  //标记正在滚动
  mLayoutState.mRecycle = true;
  ensureLayoutState();
  //确认滚动方向
  final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
  final int absDy = Math.abs(dy);
  //1.更新layoutState,会更新其展示的屏幕区域,偏移量等。比如说当往上滑动的时候,底部会有dy距离的空白区域,这时候,需要调用fill来填充这个dy距离的区域
  updateLayoutState(layoutDirection, absDy, true, state);
  //2.调用fill进行填充将滑进来的view布局进来,并回收滑出去的view
  final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
  //3.给所有子View添加偏移量,按照计算滑动的距离动距离移动View的位置
  mOrientationHelper.offsetChildren(-scrolled);//移动
  mLayoutState.mLastScrollDelta = scrolled;//记录本次滑动的距离
  return scrolled;
}

也就是当滚动时主要做了三个处理:

  1. 通过 updateLayoutState方法更新layoutState内部的相关属性的。
  2. 调用 fill()进行数据的填充
  3. 通过offsetChildren()给所有子View添加偏移量

首先来看看LayoutState这个类中的几个变量:

  • mOffset:布局起始位置的偏移量
  • mAvailable:在布局方向上的可以填充的像素值,也就是空闲的区域
  • mScrollingOffset:表示在不创建新视图的情况下可以进行滚动的距离,使用这个变量来判断是否需要创建新的子View。比如某个View上半部分显示了一半,那么往上滑动一半距离的话以内,是不需要创建新的子View的。

updateLayoutState内部实现:

java 复制代码
//LinearLayoutaManager.java
    private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) {
        int scrollingOffset;
        if (layoutDirection == LayoutState.LAYOUT_END) {
            //获取当前显示的最底部的View
            final View child = getChildClosestToEnd();
            //设置当前显示的子View的底部的偏移量(包括了Decor的高度)
            mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child);
            //底部锚点位置减去RecyclerView的高度的话,剩下的就是我们滚动scrollingOffset以内,不会绘制新的View
            //getEndAfterPadding=RecyclerView的高度-padding的高度
            scrollingOffset = mOrientationHelper.getDecoratedEnd(child) - mOrientationHelper.getEndAfterPadding();
        } 
        ....

前边提到fill()方法会根据剩余空间来循环地调用layoutChunk()向列表中填充表项,滚动列表的场景中,剩余空间的值由滚动距离决定。scrollBy()方法会根据滚动距离,在列表滚动方向上填充额外的表项。填充完,再调用mOrientationHelper.offsetChildren()将所有表项向滚动的反方向平移,最终执行了View.offsetTopAndBottom()

所以总结来说RecyclerView 在处理 ACTION_MOVE 事件时计算出手指滑动距离,以此作为滚动偏移量,根据偏移量在滚动方向上填充额外的表项,然后将所有表项向滚动的反方向平移相同的位移值,以此实现滚动。当我们自定义LayoutManager时希望item能在屏幕上滑动时只需要做的就是:

第一步:设置对应方向上允许滚动

java 复制代码
//canScrollHorizontally()
@Override
public boolean canScrollVertically() {
  return true;
}

第二步:重写对应的滚动方法

java 复制代码
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
  //边界判定等操作
  ...
  // 平移容器内的子View
  offsetChildrenVertical(-dy);
  return dy;
}

RecyclerView的滑动流程图如下:

回收复用

RecyclerView最强大的功能之一是通过回收复用和绑定机制,无需创建与数据项一样多的视图来展示大量数据集合。一旦用户通过上下滑动开始与数据集合交互,当视图滑出屏幕时,它就不再需要。对于即将到来的新数据,不需要创建新的视图,而是通过复用旧视图,将其填充到新的位置,然后让其进入屏幕即可。这个强大的功能是由前文提到过的Recycler类实现的。

RecyclerView 可以算作是四级缓存,这四个对象就是作为每一级缓存的结构的:

  1. mAttachedScrap

Scrap是RecyclerView中最轻量的缓存,它不参与滑动时的回收复用,只是作为重新布局时的一种临时缓存。它的目的是,缓存当界面重新布局的前后都出现在屏幕上的ViewHolder,以此省去不必要的重新加载与绑定工作。Scrap实际上包括了两个ViewHolder类型的ArrayList。mAttachedScrap负责保存将会原封不动的ViewHolder,而mChangedScrap负责保存位置会发生移动的ViewHolder。

java 复制代码
//包括mAttachedScrap 和 mChangedScrap
public final class Recycler {
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
        ArrayList<ViewHolder> mChangedScrap = null;
        ...
}

若绿色区域是可见区域,随着上划,itemB被移出屏幕,放进mAttachedScrap时,会被额外标记一个"REMOVED"标记表示这个ViewHolder已经被删除了,需要在之后被移除。C和D在删除B后会向上移动位置会被存储到mChangedScrap中,此时itemE并没有出现在屏幕中,它不属于Scrap管辖的范围。

简单来说就是当RecyclerView移除一个ViewHolder时,它会被放进mAttachedScrap中,并标记为REMOVED,Scrap缓存了这些ViewHolder信息,以便在局部刷新时快速使用 。通过调用notifyItemRemoved()notifyItemChanged()等方法来告诉RecyclerView哪些位置发生了变化。RecyclerView会根据这些信息,在处理变化时,使用Scrap来缓存其它内容没有发生变化的ViewHolder,以完成局部刷新。

  1. mCachedViews

mCachedView是RecyclerView中的一个缓存池,它的作用是在列表项不可见时对ViewHolder进行缓存,以便在某些情况下能够快速地复用这些ViewHolder,避免频繁创建新的ViewHolder对象和重新绑定数据的开销。mCachedView可以缓存多个ViewHolder对象,默认情况下最大限制是2个。当RecyclerView将ViewHolder从屏幕中移除时,它会将ViewHolder添加到mCachedView中,并且不会对ViewHolder进行任何更改,以便在需要时直接取出并快速使用。

java 复制代码
public final class Recycler {
        ...
        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
        int mViewCacheMax = DEFAULT_CACHE_SIZE;
        ...
}

向上滑动itemB划出屏幕时被缓存到CachedViews 中,当向下滑itemB重新回到屏幕时,进行精准匹配,这个itemView还是 之前的itemView,那么会从CacheView中获取ViewHolder进行复用,由于DEFAULT_CACHE_SIZE默认情况下为2,所以如果我们总是沿着同一方向滑动,mCachedView就不会提供很大的帮助,而由于存储个数限制,那些被替换的ViewHolder就会丢进RecycledViewPool中

  1. mViewCacheExtension

是额外提供了一层缓存池给开发者。开发者视情况而定是否使用ViewCacheExtension增加一层缓存池,一般不用到。

  1. mRecyclerPool

可以说是Recycler中的一个终极回收站 ,RV中所有缓存都是以 LRU(Least Recently Used)管理 itemView 的,如果缓存池已满,在Srap和CacheView中不接收的就会丢进mRecyclerPool进行回收

java 复制代码
public static class RecycledViewPool {
        private SparseArray<ArrayList<ViewHolder>> mScrap =
                new SparseArray<ArrayList<ViewHolder>>();
        private SparseIntArray mMaxScrap = new SparseIntArray();
        private int mAttachCount = 0;

        private static final int DEFAULT_MAX_SCRAP = 5;

与前两者不同,RecycledViewPool在进行回收的时候,目标只是回收一个该viewType的ViewHolder对象,并没有保存下原来ViewHolder的内容,在复用时,将会调用bindViewHolder()重新绑定,从而变成了一个新的列表项展示出来。RecycledViewPool有一个默认的最大数量限制,即为5。当Recycler需要回收ViewHolder时,会尽可能将它们放入RecycledViewPool中,如果没有超过最大数量限制,那么这些ViewHolder可以被其他RecyclerView复用。值得注意的是,RecycledViewPool只按照ViewType进行区分,因此可以在不同的RecyclerView中共享一个RecycledViewPool,只要它们使用相同的ViewType就可以实现复用。

简单总结就是:

  1. 当RecyclerView需要更新数据的时候,如果当前数据在可视范围之内,就会直接从Scrap中获取,它们是不参与滚动的回收和复用的。
  2. 上下小幅度的滑动的时候,会先在CachedView中找是否有viewType、position一致的 ViewHolder,并且ViewHolder未被remove的view复用
  3. 当所需的View在CachedView中没有对应的position,再从 RecyclePool 中拿到一个合适的view,然后使用Adapter将必要的数据绑定到它上面(bindViewHolder())。如果Recycle Pool中也不存在有效的视图,就在绑定数据前创建新的视图(createViewHolder()),最后返回数据

有了以上基础以后,接着回到 fill ()中来看看RecyclerView是如何来回收视图,在前边fill()代码中可以看到回收view主要是通过recycleByLayoutState(recycler, layoutState)实现,

java 复制代码
private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
  if (!layoutState.mRecycle || layoutState.mInfinite) {
    return;
  }
  if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
    //从End端开始回收视图
    recycleViewsFromEnd(recycler, layoutState.mScrollingOffset);
  } else {
    //从Start端开始回收视图
    recycleViewsFromStart(recycler, layoutState.mScrollingOffset);
  }
}
//从头部回收View
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt) {
  //既mScrollingOffset:在不创建新视图的情况下可以进行滚动的距离
  final int limit = dt;
  //返回附加到父视图的当前子View的数量
  final int childCount = getChildCount();
  ...
    //遍历子View
    for (int i = 0; i < childCount; i++) {
      //获取到子View
      View child = getChildAt(i);
      //如果当前的View的底部位置>limit,那么也就是会有View需要绘制,顶部的View也就需要回收了
      if (mOrientationHelper.getDecoratedEnd(child) > limit || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
        recycleChildren(recycler, 0, i);
        return;
      }
    }
}

最终会调用recycleView()这个方法进行回收工作

java 复制代码
//RecyclerView.java
public void recycleView(View view) {
  recycleViewHolderInternal(holder);
}

void recycleViewHolderInternal(ViewHolder holder) {
  //判断各种无法回收的情况
  ...
    if (forceRecycle || holder.isRecyclable()) {
      //符合回收条件
      if (mViewCacheMax > 0 && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
        //滑动的视图,先保存在mCachedViews中
        int cachedViewSize = mCachedViews.size();
        if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
          //mCachedViews只能缓存mViewCacheMax个,那么需要将最久的那个移到RecycledViewPool
          recycleCachedViewAt(0);
          cachedViewSize--;
        }

        int targetCacheIndex = cachedViewSize;
        if (ALLOW_THREAD_GAP_WORK && cachedViewSize > 0  && !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {
          // when adding the view, skip past most recently prefetched views
          int cacheIndex = cachedViewSize - 1;
          while (cacheIndex >= 0) {
            int cachedPos = mCachedViews.get(cacheIndex).mPosition;
            if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) {
              break;
            }
            cacheIndex--;
          }
          targetCacheIndex = cacheIndex + 1;
        }
        //将本次回收的ViewHolder放到mCachedViews中
        mCachedViews.add(targetCacheIndex, holder);
        cached = true;
      }
      if (!cached) {//如果已经缓存了。那么此处不会执行了。
        addViewHolderToRecycledViewPool(holder, true);
        recycled = true;
      }
    }
  ...
    mViewInfoStore.removeViewHolder(holder);
  if (!cached && !recycled && transientStatePreventsRecycling) {
    holder.mOwnerRecyclerView = null;
  }
}

因为回收机制的入口有很多,在mAttachedScrap,mCachedViews中都涉及到,以上这部分代码属于在滑动时调用scrollVerticallyBy()方法然后调用 fill() 方法进行数据填充时最终调用上述 recyclerView()。可以看到回收时,都是把 ViewHolder 放在 mCachedViews 里面,如果 mCachedViews 满了根据LRU算法,那就移除最早的那个 ViewHolder 扔到 ViewPool 缓存里。而存放线程池 RecycledViewPool 会按照ViewType来缓存到不同的队列,每个类型的队列最多缓存5个。如果已经满了,则不再缓存

至于复用在前文中其实已经提起过fill()对填充视图时在layoutChunk()方法中不断的从recycler中取出视图,然后对视图进行layout。而对于复用的调用则是在 layoutChunk中的 layoutState.next(recycler) 来触发,最终对于ViewHolder的复用逻辑是由 tryGetViewHolderForPositionByDeadline来处理的。

java 复制代码
//RecyclerView.java
View getViewForPosition(int position, boolean dryRun) {
    return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}

tryGetViewHolderForPositionByDeadline ()这个方法很长完整的展示了如何在每个层级的缓存中,取出来 ViewHolde,但是其实逻辑很简单,这里放上伪代码便于理解:

java 复制代码
//RecyclerView.java
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
 	...
  //1. 从 mChangedScrap 中尝试取出缓存的 ViewHolder ,若不存在,holder赋空。
  if (mState.isPreLayout()) {
    holder = getChangedScrapViewForPosition(position);
    fromScrapOrHiddenOrCache = holder != null;
  }
  //2.去 mAttachedScrap 中取,没有就依次去mHiddenViews、mCachedViews中取缓存的ViewHolder
  if (holder == null) {
    holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
    if (holder != null) {
      //检验获取的holder是否合法。不合法,就会将holder进行回收。如果合法,则标记fromScrapOrHiddenOrCache为true。表明holder是从这缓存中获取的。
      if (!validateViewHolderForOffsetPosition(holder)) {
        ...
          holder = null;
      } else {
        fromScrapOrHiddenOrCache = true;
      }
    }
  }
  
  if (holder == null) {
    ...
      if (mAdapter.hasStableIds()) {
        //根据id依次尝试从mAttachedScrap、mCachedViews中获取
        holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),type, dryRun);
        ...
      }
    //3. 尝试从我们自定义的mViewCacheExtension中去获取
    if (holder == null && mViewCacheExtension != null) {
      final View view = mViewCacheExtension .getViewForPositionAndType(this, position, type);
      ...
    }
    //4. 从缓存池中根据 type 取 ViewHolder
    if (holder == null) {
      holder = getRecycledViewPool().getRecycledView(type);
      ...
    }
    if (holder == null) {
      long start = getNanoTime();
      if (deadlineNs != FOREVER_NS&& !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) {
        return null;
      }
      //5. 如果仍然无法获取的话,调用Adatper的createViewHolder方法创建一个ViewHolder
      holder = mAdapter.createViewHolder(RecyclerView.this, type);
      ...
    }
  }
  ...
    boolean bound = false;
  if (mState.isPreLayout() && holder.isBound()) {
    //数据不需要绑定(一般从mChangedScrap,mAttachedScrap中得到的缓存Holder是不需要进行重新绑定的)
    holder.mPreLayoutPosition = position;
  } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
    //holder没有绑定数据,或者需要更新或者holder无效,则需要重新进行数据的绑定
    if (DEBUG && holder.isRemoved()) {
      throw new IllegalStateException("Removed holder should be bound and it should"+ " come here only in pre-layout. Holder: " + holder);
    }
    final int offsetPosition = mAdapterHelper.findPositionOffset(position);
    //这里会进行数据的绑定
    bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
  }
  ...
    return holder;
}

概况来说tryGetViewHolderForPositionByDeadline方法中主要:

  1. 从缓存查找 ViewHolder
  2. 缓存没有,那么就创建一个 ViewHolder;
  3. 判断 ViewHolder需不需要更新数据,如果需要就会调用 tryBindViewHolderByDeadline (调用bindViewHolder()绑定数据;

总的来说RecyclerView 滑动场景下的回收复用涉及到的结构体两个:mCachedViews 和 RecyclerViewPool;mCachedViews 优先级高于 RecyclerViewPool复用时,也是先到 mCachedViews 里找 ViewHolder如果 mCachedViews 里没有,那么才去 ViewPool 里找。在 ViewPool 里的 ViewHolder 都是跟全新的 ViewHolder 一样,只要 type 一样,就可以重新绑定数据拿出来复用。

通过以上内容,将RecyclerView大致流程过了一遍,在最后看一下RecyclerView的结构:

最后我们再回顾一下对于LayoutManager来说,比较重要的几个方法:

  • onLayoutChildren(): 对RecyclerView进行布局的入口方法。
  • fill(): 负责填充RecyclerView。
  • scrollVerticallyBy():根据手指的移动滑动一定距离,并调用fill()填充。
  • canScrollVertically()canScrollHorizontally(): 判断是否支持纵向滑动或横向滑动。

RecyclerView作为Android中十分优美的组件,底层涉及到的知识点非常复杂而广泛。由于篇幅原因本文中只介绍了RecyclerView自定义布局管理器的相关知识,包括基本概念、实现原理、重要方法等。但是ItemDecoration 项装饰器、动画ItemAnimator监听器 、提供高效局部刷新的能力的DiffUtil等都是RecyclerView底层实现中的核心,值得我们进一步学习和掌握。

hi, 我是快手社交的Seino

快手社交技术团队 正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理, 测试开发... 大量 HC 等你来呦~ 内部推荐请发简历至 >>>我们的邮箱: social-tech-team@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘

相关推荐
Kapaseker1 小时前
2026年,我们还该不该学编程?
android·kotlin
雨白17 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk17 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING18 小时前
RN容器启动优化实践
android·react native
恋猫de小郭20 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker1 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 天前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab2 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe2 天前
Now in Android 架构模式全面分析
android·android jetpack