LinearLayoutManager源码阅读(滚动分析)

实现自定义的通用的LayoutManager,但是卡住了,遂看下Android 官方的几种LayoutManager是如何优雅实现的,大致的以及一些细节都看懂了,但是还是没找到什么好办法解决自己的问题,不如趁着热度把自己的分析过程写下来,也给其他需要的Androider.其实真的要对RecyclerView有个全面的认识,其实LayoutManager、Adapter、动画以及测量流程等细节都是要清楚的,因为虽然说RV给人使用上非常灵活解耦,但是其实内部也是这几者的紧密配合才达到的效果,所以有一点不明白其他地方可能也就会很模糊看不下去。也不细分章节了,就按照滚动的流程来写。至于为什么从滚动开始分析,是因为看源码还是讲究切入点,从RecyclerView的滑动开始是最佳切入点,很直观,要是第一次直接就从onLayoutChildren看起,我觉得看不了几行就得放弃。当然这篇文章的内容很多没说清楚的其实都是onLayoutChildren的一些逻辑,所以都略过了,只关注滑动。

由于自定的LayoutManager如果要(肯定要,不然还定义啥)支持滚动都必须至少重写以下两个方法中的一个,并且返回true,分别表示支持垂直滚动和水平滚动

 public boolean canScrollHorizontally() {
            return true;
        }
 public boolean canScrollVertically() {
            return true;
        }

在发生滚动的时候,会在以下两个方法回调滚动的距离dy/dx

    public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
            return 0;
        }

    public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
            return 0;
        }

这里我们从LinearLayoutManager的垂直滚动分析起,进入scrollBy方法,

    int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getChildCount() == 0 || dy == 0) {
            return 0;
        }
        //滚动发生时,是需要回收View的
        mLayoutState.mRecycle = true;
        ensureLayoutState();
        //手指向上滑动时dy>0
        final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
        final int absDy = Math.abs(dy);
        //更新LayoutState
        updateLayoutState(layoutDirection, absDy, true, state);
        final int consumed = mLayoutState.mScrollingOffset
                + fill(recycler, mLayoutState, state, false);
        if (consumed < 0) {
            if (DEBUG) {
                Log.d(TAG, "Don't have any more elements to scroll");
            }
            return 0;
        }
        final int scrolled = absDy > consumed ? layoutDirection * consumed : dy;
        //layout view 结束,所有 View 整体平移;这里需要注意的是 LLM 并非是从头到尾一个个 layout view,而是先根据偏移把需要回收的 view 回收掉,会显示的view显示出来,最后进行整体的平移。想一想这样效率确实要高
        mOrientationHelper.offsetChildren(-scrolled);
        if (DEBUG) {
            Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled);
        }
        mLayoutState.mLastScrollDelta = scrolled;
        return scrolled;
    }

写了部分注释,具体分析一下 updateLayoutState 方法
分析这个方法前,先看下LayoutState这个类,了解一下我们需要关注的几个重要参数的含义

mRecycle 表示是否需要回收View,滑动情况下这个值是true,但是在有item 添加删除的情况是false,因为锚点什么的得靠view来确定,不能回收

mOffset layout View时候的起始坐标(垂直方向的LinearLayoutManager 表示y值),e.g.比如发生滑动后,下一个item需要显示出来,那么mOffset的值就等于最后一个可见item的bottom值(不考虑margin,向上滑动)

mAvailable 表示可用距离,在layout View的时候用到

mCurrentPosition 表示获取View的起始索引,在layout View的时候循环取View的时候用到

mItemDirection 获取item 数据的方向,是从前到后(值为1),还是从后往前(值为-1),本篇分析的是正序情况

mExtra 在LayoutManager支持predictive动画的时候这个值很有用,具体的需要了解RV的动画机制才明白这个值怎么回事,简单的说就是即使一个item此时(当他即将进入RV可见范围时)对用户不可见,但是还是得把他layout出来,虽然已经超出了RV的边界用户看不到,这样做的目的是为了更好的动画体验(因为需要两次layout,一次pre-layout 一次post-layout来确定动画的起始和终止位置,不然就只能做最简单的fadeIn fadeOut。只有当item add remove发生时才有值,一般为0

再回过来看updateLayoutState方法(并不喜欢贴太长串的代码。。。)

     private void updateLayoutState(int layoutDirection, int requiredSpace,
            boolean canUseExistingSpace, RecyclerView.State state) {
        // If parent provides a hint, don't measure unlimited.
        mLayoutState.mInfinite = resolveIsInfinite();
        mLayoutState.mExtra = getExtraLayoutSpace(state);
        mLayoutState.mLayoutDirection = layoutDirection;
        int scrollingOffset;
        if (layoutDirection == LayoutState.LAYOUT_END) {
            mLayoutState.mExtra += mOrientationHelper.getEndPadding();
            // get the first child in the direction we are going
            final View child = getChildClosestToEnd();
            // the direction in which we are traversing children
            mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD
                    : LayoutState.ITEM_DIRECTION_TAIL;
            mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
            mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child);
            // calculate how much we can scroll without adding new children (independent of layout)
            scrollingOffset = mOrientationHelper.getDecoratedEnd(child)
                    - mOrientationHelper.getEndAfterPadding();

        } else {
            final View child = getChildClosestToStart();
            mLayoutState.mExtra += mOrientationHelper.getStartAfterPadding();
            mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL
                    : LayoutState.ITEM_DIRECTION_HEAD;
            mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
            mLayoutState.mOffset = mOrientationHelper.getDecoratedStart(child);
            scrollingOffset = -mOrientationHelper.getDecoratedStart(child)
                    + mOrientationHelper.getStartAfterPadding();
        }
        mLayoutState.mAvailable = requiredSpace;
        if (canUseExistingSpace) {
            mLayoutState.mAvailable -= scrollingOffset;
        }
        mLayoutState.mScrollingOffset = scrollingOffset;
    }

前面几行就是简单的赋值更新状态,然后是根据 layoutDirection 的方向进行其他参数的计算,我们这里是分析的手指上滑,对应的 layoutDireciton 是 LAYOUT_END ,我们进入layoutDirection == LayoutState.LAYOUT_END 成立的情况下去看:

1.通过 getChildClosestToEnd 方法拿到RV最接近 End 的 child (如果是水平布局,那么end就是RV的right,垂直布局end就是RV的bottom)

2.根据 mShouldReverseLayout 变量给 mItemDirection 赋值,我们一般都不使用逆序布局,所以 mItemDirection 的值是 ITEM_DIRECTION_HEAD ,也就是说待会在fill方法内部取 child 来放的时候是正序,也就是从前往后依次取,反之亦然

3.根据刚才取到的 child 获取到其在 adapter 中的位置,加上mItemDirection后赋值给mCurrentPosition,这个好理解,mCurrentPosition表示的就是取child的开始索引,LayoutState里面有个next方法就是这么做的,可以看下代码

        View next(RecyclerView.Recycler recycler) {
            if (mScrapList != null) {
                return nextViewFromScrapList();
            }
            final View view = recycler.getViewForPosition(mCurrentPosition);
            mCurrentPosition += mItemDirection;
            return view;
        }

4.给mOffset赋值,也就是下图最后一个item的bottom值,是后续依次放child的起始坐标

5.至于scrollingOffset就是上面这张图里面最后一个 item 底部距离 RV底部的距离,官方也有注释—-“不需要添加新的 children 的情况下滚动的最大距离”—–也就是说最后一个 item 刚好完全滚进来,但是又不会有新的 item 滚进来的意思

6最后给 mAvailable 赋值为 requiredSpace,也就是此次滚动的距离,然后判断canUseExistingSpace 为 true 就减去刚才的 scrollingOffset;这里为什么要减去这个scrollingOffset呢,其实就是把这个零头减掉方便计算而已。这里可以详细解释一下为什么方便计算,比如 滑动的距离不够把最底部的一个 item 滑进来,那么是不是根本不涉及到 添加新 view ,也不用回收 view,这样就是高效的。具体表现在代码里就是 mAvailable 是负值, fill 方法 里面的 那个 for 循环根本不会执行。所以最后一句 mScrollingOffset 又把 scrollingOffset 保存起来了。最后所有 child 都放置好之后,返回消耗的滑动距离时候,在scrollBy方法那里
最后又把这个值加上去了,就这句:

     final int consumed = mLayoutState.mScrollingOffset
                + fill(recycler, mLayoutState, state, false);

好了,这个方法看完了,进去fill方法,没有贴全部的代码,还是一块一块看紧凑点

        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            // TODO ugly bug fix. should not happen
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            recycleByLayoutState(recycler, layoutState);
        }

这句确实不太明白什么意思也不敢说我现在看明白了。外层这个if是为了避开首次初始化的情况,只有正常滑动的情况时候才会进来,但是滑动情况下 layoutState.mAvailable < 0 这个条件只有在滑动距离过小不足以把最后一个item的底部完全滑进来的情况才满足,不过看官方的注释好像是有bug,可能就做了一个防御性的if判断,防止特殊情况发生把,就假设这个条件满足了,不做if里面的处理,好像也不会发生什么问题把,不过回头想一下mScrollingOffset这个字段的意思是“在不需要添加新的View时候能滑动的最大距离”,按照这么理解,当mAvailable<0时候,说明滑动距离太小,没法把item底部全滑进来,最多也就只能滑动此次滑动的距离,所以这么处理之后mSrollingOffset就是此次滑动距离;所以这个TODO注释看的挺烦的,还以为是bug,让人很纠结是个什么bug-_-,最后recycleByLayoutState方法回收了一下此次滚动发生之后会越界不见的View

       private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
        if (!layoutState.mRecycle || layoutState.mInfinite) {
            return;
        }
        if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
            recycleViewsFromEnd(recycler, layoutState.mScrollingOffset);
        } else {
            recycleViewsFromStart(recycler, layoutState.mScrollingOffset);
        }
    }

根据滑动方向选择是从后往前回收还是从前往后回收,我们考虑手指上滑,所以可能会有头部的View出界被滑出去,所以是调用的recycleViewsFromStart方法

  private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt) {
        if (dt < 0) {
            if (DEBUG) {
                Log.d(TAG, "Called recycle from start with a negative value. This might happen"
                        + " during layout changes but may be sign of a bug");
            }
            return;
        }
        // ignore padding, ViewGroup may not clip children.
        final int limit = dt;
        final int childCount = getChildCount();
        if (mShouldReverseLayout) {
            for (int i = childCount - 1; i >= 0; i--) {
                View child = getChildAt(i);
                if (mOrientationHelper.getDecoratedEnd(child) > limit
                        || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                    // stop here
                    recycleChildren(recycler, childCount - 1, i);
                    return;
                }
            }
        } else {
            for (int i = 0; i < childCount; i++) {
                View child = getChildAt(i);
                if (mOrientationHelper.getDecoratedEnd(child) > limit
                        || mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
                    // stop here
                    recycleChildren(recycler, 0, i);
                    return;
                }
            }
        }
    }

看代码的第一句,如果 dt<0 就直接返回结束了,这也解释了为什么前面的纠结为什么当mAvailable < 0时候让 mScrollingOffset 加上 mAvailable,就是为了让这里传入的dt是正值,也就是实际发生的滑动距离。由于不考虑逆序布局,直接看第二个 for循环,其实这个循环要表达的意思是从头到尾遍历所有View直到找到一个滑动之后底部还没出界的View,那么在这个View之前的View全部要被回收掉。所谓回收掉就是把View节点从ViewHierarchy上删除掉了,但是被缓存起来了供重新绑定和重用。

继续往fill方法下面看,进入while循环

      while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            if (VERBOSE_TRACING) {
                TraceCompat.beginSection("LLM LayoutChunk");
            }
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            if (VERBOSE_TRACING) {
                TraceCompat.endSection();
            }
            if (layoutChunkResult.mFinished) {
                break;
            }
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
            /**
             * Consume the available space if:
             * * layoutChunk did not request to be ignored
             * * OR we are laying out scrap children
             * * OR we are not doing pre-layout
             */
            if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null
                    || !state.isPreLayout()) {
                layoutState.mAvailable -= layoutChunkResult.mConsumed;
                // we keep a separate remaining space because mAvailable is important for recycling
                remainingSpace -= layoutChunkResult.mConsumed;
            }

            if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
                layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
                if (layoutState.mAvailable < 0) {
                    layoutState.mScrollingOffset += layoutState.mAvailable;
                }
                recycleByLayoutState(recycler, layoutState);
            }
            if (stopOnFocusable && layoutChunkResult.mFocusable) {
                break;
            }
        }

只要还有可用空间就依次取 View 并添加layout出来,之后更新mOffset和mAvailable,当然如果某个view是有焦点的,那么直接结束

看LayoutChunk方法,顾名思义,就是layout小块的意思,就是把单个的itemView放置到合适的位置,并且传入了一个LayoutResult用于记录放置Item后的信息,就几个字段:
mConsumed 消耗的距离
mFinished 是否结束layout
mIgnoreConsumed 是否忽略此次消耗的距离,滑动情况下这个值一直都是false
mFocusable 当前item是否有焦点

layoutChunk方法里面的逻辑,也没什么,就是测量,然后计算left top right bottom值。有一段逻辑比较重要,判断了mScrapList 是否为null,如果是null就调用了addDisappearingView方法,反之调用了addView;addView方法不用说就是简单的添加了View,但是addDisappearingView就是告诉RV,添加的这个View是马上就要移出屏幕的,注意是不可见了并不代表就是item被移除了也有可能是在屏幕之外。好了,我们再回头想象为什么是判断mScrapList为null就调用addDisappearingView。具体原因是mScapList其实绝大部分情况都是null,只有发生layout时候才不为空,而这个时候都是发生了item的增删改操作,导致有些View可能会超出RV的边界,也就是变成所谓的“hidden view”,不要被这个方法名迷惑,只是尝试加入hidden view,方法内部实际还是会根据flag判断之后决定是否需要hide 这个view。反过来mScrapList为null的时候就是对应滑动情况。

layoutChunk 方法内容不多,另外还需要注意的是这句:

    if (params.isItemRemoved() || params.isItemChanged()) {
            result.mIgnoreConsumed = true;
        }

意思很简单,就是Item被删除了或者变化了,就忽略消耗,也就是不计入消耗。最开始我也觉得奇怪,后来知道动画之后明白这么做是有意义的,虽然这个Item被删除了,但是你不能立马就给不显示了还是添加进来,毕竟还有动画在这个Item要执行,所以就得等到这个Item的动画完了才删除。那么不计入消耗的好处就是,会多layout一个Item出来,就是在底部,屏幕外面,虽然不可见,如图:

item 2已经被移除了,并且item5会被加进来,但是在屏幕外我们看不到,等到item2动画结束item5就会滑进来。当然这个if判断在滑动情况下是不会进来的。

最后,所有的view添加完之后,其实 view 并没有在正确的位置,所以整体又进行平移,至此整个滑动流程都结束。

上面只是滑动的流程。实际上比较复杂的还是 出现 item 增删的时候,多次布局,并且配合 动画的时候。所以 RecyclerView 的架构设计真的很厉害,把很多东西组合在一起,还很优雅。

the end