图文详解LinearLayoutManager填充、测量、布局过程

LinearLayoutManager并不是一个View,而是一个工具类,但是LinearLayoutManager承担了一个View(当然指的是RecyclerView)的布局、测量、子View 创建 复用 回收 缓存 滚动等等操作。

一、回忆一下

上一篇文章Android Render(三)supportVersion 27.0.0源码RecyclerView绘制流程解析已经说了 RecyclerView的绘制流程,dispatchLayoutStep1 dispatchLayoutStep2 dispatchLayoutStep3这三步都一定会执行,只是在RecyclerView的宽高是写死或者是match_parent的时候会提前执行dispatchLayoutStep1 dispatchLayoutStep2者两个方法。会在onLayout阶段执行dispatchLayoutStep3第三步。在RecyclerView 写死宽高的时候onMeasure阶段很容易,直接设定宽高。但是在onLayout阶段会把dispatchLayoutStep1 dispatchLayoutStep2 dispatchLayoutStep3三步依次执行。

1 - LayoutManager绘制三步骤

二、onLayoutChildren开始布局准备工作

上图是在RecyclerView中绘制三步骤对dispatchLayoutStep三个方法的调用。看到代码我们可以知道是在dispatchLayoutStep2方法中调用LayoutManageronLayoutChildren方法来布局ItemView的。

    private void dispatchLayoutStep2() {
      
        ......略

        // Step 2: Run layout
        mState.mInPreLayout = false;
        // 调用`LayoutManager`的`onLayoutChildren`方法来布局`ItemView`
        mLayout.onLayoutChildren(mRecycler, mState);

        ......略      

    }

下图是LinearLayoutManager对循环布局所有的ItemView的流程图:

2 - LinearLayoutManager绘制分析

虽然在RecyclerView的源码中会三步绘制处理,但是都不是真正做绘制布局测量的地方,真正的绘制布局测量都放在了不同的LayoutManager中了,我们就以LinearLayoutManager为例来分析一下。
在三中LayoutManager中,LinearLayoutManager应该是最为简单的一种了吧。GridLayoutManager也是继承LinearLayoutManager实现的,只是在layoutChunk方法中实现了不同的布局。

LinearLayoutManager布局从onLayoutChildren方法开始:

   
   //LinearLayoutManager布局从onLayoutChildren方法开始
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {

        // layout algorithm:  布局算法
        // 1) by checking children and other variables, find an anchor coordinate and an anchor item position. 
        // 通过检查孩子和其他变量,找到锚坐标和锚点项目位置   mAnchor为布局锚点 理解为不具有的起点.
        // mAnchor包含了子控件在Y轴上起始绘制偏移量(coordinate),ItemView在Adapter中的索引位置(position)和布局方向(mLayoutFromEnd)
        // 2) fill towards start, stacking from bottom 开始填充, 从底部堆叠
        // 3) fill towards end, stacking from top 结束填充,从顶部堆叠
        // 4) scroll to fulfill requirements like stack from bottom. 滚动以满足堆栈从底部的要求

        ......略

        ensureLayoutState();
        mLayoutState.mRecycle = false;
        // resolve layout direction 设置布局方向(VERTICAL/HORIZONTAL)
        resolveShouldLayoutReverse();

        //重置绘制锚点信息
        mAnchorInfo.reset();

        // mStackFromEnd需要我们开发者主动调用,不然一直未false
        // VERTICAL方向为mLayoutFromEnd为false HORIZONTAL方向是为true   
        mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;

        // calculate anchor position and coordinate
        // ====== 布局算法第 1 步 ======: 计算更新保存绘制锚点信息
        updateAnchorInfoForLayout(recycler, state, mAnchorInfo);

        ......略

        // HORIZONTAL方向时开始绘制

        if (mAnchorInfo.mLayoutFromEnd) {
            //  ====== 布局算法第 2 步 ======: fill towards start 锚点位置朝start方向填充ItemView
            updateLayoutStateToFillStart(mAnchorInfo);
            mLayoutState.mExtra = extraForStart;
            // 填充第一次
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;
            final int firstElement = mLayoutState.mCurrentPosition;
            if (mLayoutState.mAvailable > 0) {
                extraForEnd += mLayoutState.mAvailable;
            }
            //  ====== 布局算法第 3 步 ======: fill towards end 锚点位置朝end方向填充ItemView
            updateLayoutStateToFillEnd(mAnchorInfo);
            mLayoutState.mExtra = extraForEnd;
            mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
            // 填充第二次
            fill(recycler, mLayoutState, state, false);
            endOffset = mLayoutState.mOffset;
            ......略
        } else {

            // VERTICAL方向开始绘制

            //  ====== 布局算法第 2 步 ======: fill towards end 锚点位置朝end方向填充ItemView
            updateLayoutStateToFillEnd(mAnchorInfo);
            mLayoutState.mExtra = extraForEnd;
            // 填充第一次
            fill(recycler, mLayoutState, state, false);
            endOffset = mLayoutState.mOffset;
            final int lastElement = mLayoutState.mCurrentPosition;
            if (mLayoutState.mAvailable > 0) {
                extraForStart += mLayoutState.mAvailable;
            }
            //  ====== 布局算法第 3 步 ======: fill towards start 锚点位置朝start方向填充ItemView
            updateLayoutStateToFillStart(mAnchorInfo);
            mLayoutState.mExtra = extraForStart;
            mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
            // 填充第二次
            fill(recycler, mLayoutState, state, false);
            startOffset = mLayoutState.mOffset;

            ......略
      }

        //  ===布局算法第 4 步===: 计算滚动偏移量,如果有必要会在调用fill方法去填充新的ItemView
        layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
    
    }

layout algorithm: 布局算法:

  • 1.通过检查孩子和其他变量,找到锚坐标和锚点项目位置 mAnchor为布局锚点 理解为不具有的起点,mAnchor包含了子控件在Y轴上起始绘制偏移量(coordinate),ItemView在Adapter中的索引位置(position)和布局方向(mLayoutFromEnd)。
  • 2.开始填充, 从底部堆叠
  • 3.结束填充,从顶部堆叠
  • 4.滚动以满足堆栈从底部的要求

这四步骤我都在代码中标记出来了。

至于为什么有好几次会调用到fill方法,什么formEnd,formStart,这个请看图:


3 - RecyclerView的ItemView填充方向

示意图图来源:http://blog.csdn.net/qq_23012315/article/details/50807224

圆形红点就是我们布局算法在第一步updateAnchorInfoForLayout方法中计算出来的填充锚点位置。

第一种情况是屏幕显示的位置在RecyclerView的最底部,那么就只有一种填充方向为formEnd

第二种情况是屏幕显示的位置在RecyclerView的顶部,那么也只有一种填充方向为formStart

第三种情况应该是最常见的,屏幕显示的位置在RecyclerView的中间,那么填充方向就有formEndformStart两种情况,这就是 fill 方法调用两次的原因。

上面是RecyclerView的方向为VERTICAL的情况,当为HORIZONTAL方向的时候填充算法是不变的。

二、fill 开始布局ItemView

fill核心就是一个while循环,while循环执行了一个很核心的方法就是:

layoutChunk ,此方法执行一次就填充一个ItemView到屏幕。

看一下 fill 方法的代码:

    // fill填充方法, 返回的是填充ItemView需要的像素,以便拿去做滚动
    int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
            RecyclerView.State state, boolean stopOnFocusable) {
        // 填充起始位置
        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
            //如果有滚动就执行一次回收
            recycleByLayoutState(recycler, layoutState);
        }
        // 计算剩余可用的填充空间
        int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
        // 用于记录每一次while循环的填充结果
        LayoutChunkResult layoutChunkResult = mLayoutChunkResult;

        // ================== 核心while循环 ====================

        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            
            // ====== 填充itemView核心填充方法 ====== 屏幕还有剩余可用空间并且还有数据就继续执行

            layoutChunk(recycler, state, layoutState, layoutChunkResult);

        }

        ......略

        // 填充完成后修改起始位置
        return start - layoutState.mAvailable;
    }

代码看起来还是很简洁明了的。解释都加了注释,就不再罗列出来了。看到这里我们就知道了 fill 下一步的核心方法就是 layoutChunk , 此方法执行一次就是填充一个ItemView。

三、layoutChunk 创建 填充 测量 布局 ItemView

layoutChunk 方法主要功能标题已经说了 创建填充测量布局 一个ItemView,一共有四步:

  • 1 layoutState.next(recycler) 方法从一二级缓存中获取或者是创建一个ItemView
  • 2 addView方法加入一个ItemViewViewGroup中。
  • 3 measureChildWithMargins方法测量一个ItemView
  • 4 layoutDecoratedWithMargins方法布局一个ItemView。布局之前会计算好一个ItemView的left, top, right, bottom位置。

其实就是这四个关键步骤:

    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
        LayoutState layoutState, LayoutChunkResult result) {

        // ====== 第 1 步 ====== 从一二级缓存中获取或者是创建一个ItemView
        View view = layoutState.next(recycler);
        if (view == null) {
            if (DEBUG && layoutState.mScrapList == null) {
                throw new RuntimeException("received null view when unexpected");
            }
            // if we are laying out views in scrap, this may return null which means there is
            // no more items to layout.
            result.mFinished = true;
            return;
        }

        // ====== 第 2 步 ====== 根据情况来添加ItemV,最终调用的还是ViewGroup的addView方法
        LayoutParams params = (LayoutParams) view.getLayoutParams();
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addView(view);
            } else {
                addView(view, 0);
            }
        } else {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                addDisappearingView(view);
            } else {
                addDisappearingView(view, 0);
            }
        }

        // ====== 第 3 步 ====== 测量一个ItemView的大小包含其margin值
        measureChildWithMargins(view, 0, 0);
        result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);

        // 计算一个ItemView的left, top, right, bottom坐标值
        int left, top, right, bottom;
        if (mOrientation == VERTICAL) {
            if (isLayoutRTL()) {
                right = getWidth() - getPaddingRight();
                left = right - mOrientationHelper.getDecoratedMeasurementInOther(view);
            } else {
                left = getPaddingLeft();
                right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
            }
            if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
                bottom = layoutState.mOffset;
                top = layoutState.mOffset - result.mConsumed;
            } else {
                top = layoutState.mOffset;
                bottom = layoutState.mOffset + result.mConsumed;
            }
        } else {
            top = getPaddingTop();
            bottom = top + mOrientationHelper.getDecoratedMeasurementInOther(view);

            if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
                right = layoutState.mOffset;
                left = layoutState.mOffset - result.mConsumed;
            } else {
                left = layoutState.mOffset;
                right = layoutState.mOffset + result.mConsumed;
            }
        }
        // We calculate everything with View's bounding box (which includes decor and margins)
        // To calculate correct layout position, we subtract margins.
        // 根据得到的一个ItemView的left, top, right, bottom坐标值来确定其位置


        // ====== 第 4 步 ====== 确定一个ItemView的位置
        layoutDecoratedWithMargins(view, left, top, right, bottom);
        if (DEBUG) {
            Log.d(TAG, "laid out child at position " + getPosition(view) + ", with l:"
                    + (left + params.leftMargin) + ", t:" + (top + params.topMargin) + ", r:"
                    + (right - params.rightMargin) + ", b:" + (bottom - params.bottomMargin));
        }
        // Consume the available space if the view is not removed OR changed
        if (params.isItemRemoved() || params.isItemChanged()) {
            result.mIgnoreConsumed = true;
        }
        result.mFocusable = view.hasFocusable();
    }


四、LinearLayoutManager填充、测量、布局过程总结

RecyclerView 绘制触发的一开始,就会把需要绘制的ItemView做一次while循环绘制一次,中间要经历好多个步骤,还设计到缓存。RecyclerView的绘制处理等还是比较复杂的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容