反思|Android View机制设计与实现:布局流程

6,101 阅读11分钟

反思 系列博客是我的一种新学习方式的尝试,该系列起源和目录请参考 这里

概述

Android本身的View体系非常宏大,源码中值得思考和借鉴之处众多,以View本身的绘制流程为例,其经过measure测量、layout布局、draw绘制三个过程,最终才能够将其绘制出来并展示在用户面前。

相比 测量流程布局流程 相对简单很多,如果读者不了解 测量流程 ,建议阅读这篇文章:

反思 | Android View机制设计与实现:测量流程

整体思路

测量流程 的目的是 测量控件宽高 ,但只获取控件的宽高实际上是不够的,对于ViewGroup而言还需要一套额外的逻辑,负责对所有子控件进行对应策略的布局,这就是 布局流程(layout)。

  • 1.对于叶子节点的View而言,其本身没有子控件,因此一般情况下仅需要记录自己在父控件的位置信息,并不需要处理为子控件布局的逻辑;
  • 2.对于整体的布局流程而言,子控件的位置必然交由父控件布置,和 测量流程 一样,Android中布局流程中也使用了递归思想:对于一个完整的界面而言,每个页面都映射了一个View树,其最顶端的父控件开始布局时,会通过自身的布局策略依次计算出每个子控件的位置——值得一提的是,为了保证控件树形结构的 内部自治性,每个子控件的位置为 相对于父控件坐标系的相对位置 ,而不是以屏幕坐标系为准的绝对位置。位置计算完毕后,作为参数交给子控件,令子控件开始布局;如此往复一直到最底层的控件,当所有控件都布局完毕,整个布局流程结束。

对于布局流程不甚熟悉的开发者而言,上述文字似乎晦涩难懂,但这些文字的概括其本质却是布局流程整体的设计思想,读者不应该将本文视为源码分析,而应该将自己代入到设计的过程中 ,当深刻理解整个流程的设计思路之后,布局流程代码地设计和编写自然行云流水一气呵成。

单个View的布局流程

首先思考一个问题,布局流程的本质是测量结束之后,将每个子控件分配到对应的位置上去——既然有子控件,那说明进行布局流程的主体理应是ViewGroup,那么作为叶子节点的单个View来说,为什么也会有布局流程呢?

读者认真思考可以得出,布局流程实际上是一个复杂的过程,整个流程主要逻辑顺序如下:

  • 1.决定是否需要重新进行测量流程onMeasure()
  • 2.将自身所在的位置信息进行保存;
  • 3.判断本次布局流程是否引发了布局的改变;
  • 4.若布局发生了改变,令所有子控件重新布局;
  • 5.若布局发生了改变,通知所有观察布局改变的监听发送通知。

整个布局过程中,除了4是ViewGroup自身需要做的,其它逻辑对于ViewViewGroup而言都是公共的——这说明单个View也是有布局流程的需求的。

现在将整个布局过程定义三个重要的函数,分别为:

  • void layout(int l, int t, int r, int b):控件自身整个布局流程的函数;
  • void onLayout(boolean changed, int left, int top, int right, int bottom):ViewGroup布局逻辑的函数,开发者需要自己实现自定义布局逻辑;
  • void setFrame(int left, int top, int right, int bottom):保存最新布局位置信息的函数;

为什么需要定义这样三个函数?

1.layout函数:标志布局的开始

现在我们站在单个View的角度,首先父控件需要通过调用子控件的layout()函数,并同时将子控件的位置(left、right、top、bottom)作为参数传入,标志子控件本身布局流程的开始:

// 伪代码实现
public void layout(int l, int t, int r, int b) {
  // 1.决定是否需要重新进行测量流程(onMeasure)
  if(needMeasureBeforeLayout) {
    onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec)
  }

  // 先将之前的位置信息进行保存
  int oldL = mLeft;
  int oldT = mTop;
  int oldB = mBottom;
  int oldR = mRight;
  // 2.将自身所在的位置信息进行保存;
  // 3.判断本次布局流程是否引发了布局的改变;
  boolean changed = setFrame(l, t, r, b);

  if (changed) {
    // 4.若布局发生了改变,令所有子控件重新布局;
    onLayout(changed, l, t, r, b);
    // 5.若布局发生了改变,通知所有观察布局改变的监听发送通知
    mOnLayoutChangeListener.onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
  }
}

这里笔者通过伪代码的方式对布局流程进行了描述,实际上View本身的layout()函数内部虽然多处不同,但核心思想是一致的——layout()函数实际上代表了控件自身布局的整个流程,setFrame()onLayout()函数都是layout()中的一个步骤。

2.setFrame函数:保存本次布局信息

为什么需要保存布局信息?因为我们总是有获取控件的宽和高的需求——比如接下来的onDraw()绘制阶段;而保存了布局信息,就能通过这些值计算控件本身的宽高:

public final int getWidth() { return mWidth; }

public final int getHeight() { return mHeight; }

由此可见,保存控件的布局信息确实很有必要,Android中将layout()函数的四个参数所代表的位置信息,交给了setFrame()函数去保存:

protected boolean setFrame(int left, int top, int right, int bottom) {
    // 布局是否发生了改变
    boolean changed = false;
    // 若最新的布局信息和之前的布局信息不同,则保存最新的布局信息
    if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
        changed = true;
        mLeft = left;
        mTop = top;
        mRight = right;
        mBottom = bottom;
    }
    return changed;
}

setFrame()函数被protected修饰,这意味着开发者可以通过重写该函数来定义View本身保存布局信息的逻辑,现在将目光转到mLeft、mTop、mRight、mBottom四个变量上。

顾名思义,这四个变量对应的自然是View自身所在的位置,那么View是如何通过这四个变量描述控件的位置信息呢?

3.相对位置和绝对位置

通过一张图来看一下这四个变量所代表的意义:

这时候不可避免的会面临另外一个问题,这个mLeft、mTop、mRight、mBottom的值所对应的坐标系是哪里呢?

这里需要注意的是,为了保证控件树形结构的 内部自治性,每个子控件的位置为 相对于父控件坐标系的相对位置 ,而不是以屏幕坐标系为准的绝对位置:

反过来想,如果这些位置信息是以屏幕坐标系为准,那么就意味着每个叶子节点的View会持有保存从根节点ViewGroup直到自身父ViewGroup每个控件的位置信息,在计算布局时则更为繁琐,很明显是不合理的设计。

既然View自身持有了这样的位置信息,实际上前文中获取控件自身宽高的getWidth()getHeight()方法就可以重新这样定义:

public final int getWidth() { return mRight - mLeft; }

public final int getHeight() { return mBottom - mTop; }

这也说明了在布局流程中的setFrame()函数执行完毕后(且布局确实发生了改变),开发者才能通过getWidth()getHeight()方法获取控件正确的宽高值。

4.onLayout函数:计算子控件的位置

对于叶子节点的View而言,其并没有子控件,因此一般情况下并没有为子控件布局的意义(特殊情况请参考AppCompatTextView等类),因此ViewonLayout()函数被设计为一个空的实现:

protected void onLayout(boolean changed, int left, int top, int right, int bottom) {  }

而在ViewGroup中,不同类型的ViewGroup有不同的布局策略,这些布局策略的逻辑各不相同,因此该方法被设计为抽象接口,开发者必须实现这个方法以定义ViewGroup的布局策略:

@Override
protected abstract void onLayout(boolean changed,int l, int t, int r, int b);

LinearLayout为例,其布局策略为 根据排布方向,将其所有子控件按照指定方向依次排列布局

至此单个View的测量流程结束,关于ViewGrouponLayout函数细节将在下文进行描述。

完整布局流程

相比较测量流程,布局流程相对比较简单,整体思路是,对于一个完整的界面而言,每个页面都映射了一个View树,最顶端的父控件开始布局时,会通过自身的布局策略依次计算出每个子控件的位置。位置计算完毕后,作为参数交给子控件,令子控件开始布局;如此往复一直到最底层的控件,当所有控件都布局完毕,整个布局流程结束。

ViewGroup虽然重写了Viewlayout()函数,但实质上并未进行大的变动,我们大抵可以认为ViewGroupViewlayout()逻辑一致:

@Override
public final void layout(int l, int t, int r, int b) {
    if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {
        if (mTransition != null) {
            mTransition.layoutChange(this);
        }
        // 仍然是执行View层的layout函数
        super.layout(l, t, r, b);
    } else {
        mLayoutCalledWhileSuppressed = true;
    }
}

唯一需要注意的是,开发者必须实现onLayout()函数以定义ViewGroup的布局策略,这里以 竖直布局LinearLayout的伪代码为例:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
  int childTop;
  int childLeft;

  // 遍历所有子View
  for (int i = 0; i < count; i++) {
    // 获取子View
    final View child = getVirtualChildAt(i);
    // 获取子View宽高,注意这里使用的是 getMeasuredWidth 而不是 getWidth
    final int childWidth = child.getMeasuredWidth();
    final int childHeight = child.getMeasuredHeight();

    // 令所有子控件开始布局
    setChildFrame(child, childLeft, childTop, childWidth, childHeight);   
    // 高度累加,下一个子View的 top 就等于上一个子View的 bottom ,符合竖直线性布局从上到下的布局策略   
    childTop += childHeight;      
  }
}

private void setChildFrame(View child, int left, int top, int width, int height) {
    // 这里可以看到,子控件的mRight实际上就是 mLeft + getMeasuredWidth()
    // 而在getWidth()函数中,mRight-mLeft的结果就是getMeasuredWidth()
    // 因此,getWidth() 和 getMeasuredWidth() 是一致的
    child.layout(left, top, left + width, top + height);
}

读者需要注意到一个细节,子控件的宽度的获取,我们并未使用getWidth(),而是使用了getMeasuredWidth(),这就引发了另外一个疑问,这两个函数的区别在哪里。

getWidth 和 getMeasuredWidth 的区别

首先,从上文中我们得知,getWidth()getHeight()函数的相关信息实际上是在setFrame()函数执行完毕才准备完毕的——我们大致可以认为是这两个函数 只有布局流程(layout)执行完毕才能调用,而在父控件的onLayout()函数中,获取子控件宽度和高度时,子控件还并未开始进行布局流程,因此此时不能调用getWidth()函数,而只能通过getMeasuredWidth()函数获取控件测量阶段结果的宽度。

那么当控件绘制流程执行完毕后,getWidth()getMeasuredWidth()函数的值有什么区别呢?从上述setChildFrame()函数中的源码可以得知,布局流程执行后,getWidth()返回值的本质其实就是getMeasuredWidth()——因此本质上,当我们没有手动调用layout()函数强制修改控件的布局信息的话,两个函数的返回值大小是完全一致的。

整体流程小结

在整个布局流程的设计中,设计者将流程中公共的业务逻辑(保存布局信息、通知布局发生改变的监听等)通过layout()函数进行了整合,同时,将ViewGroup额外需要的自定义布局策略通过onLayout()函数向外暴露出来,针对组件中代码的可复用性和可扩展性进行了合理的设计。

至此,布局流程整体实现完毕。借用 carson_ho 绘制的流程图对整体布局流程做一个总结:

参考


关于我

Hello,我是 却把清梅嗅 ,如果您觉得文章对您有价值,欢迎 ❤️,也欢迎关注我的 博客 或者 Github

如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢?