View 的测量与布局

Posted by JasonWu on November 25, 2020

View 的测量与布局

View 树的构建

Android 系统包括应用内的 UI 界面都是可以划分为一个个的 View 视图,View 是一个树形结构,多层级的 View 树示例如下:

其中,需要注意的是,包含子 View 的父 View 必然是 ViewGroup 的实现类,其实现了 ViewParent 接口。

而顶层的 ViewRooltImpl系列文章(二)可知,其并非一个真正的 View,它是整个窗口视图的管理类,同样实现了 ViewParent 接口,在 View 树的结构层次上,可以看作是 RootView 的父 View

测量与布局概述

一个复杂的视图往往是由多层级的 View 嵌套而成,每个 View 本身以一个矩形的区域表示该 View 的内容。而描述一个矩形区域视图,无非是以下两点:

  • 尺寸,即视图的宽和高
  • 位置,即视图在其父视图当中的位置

View 的测量(measure)过程负责计算 View 的尺寸,布局(layout)则是负责计算 View 的位置关系。

而每个 View 的尺寸和位置是由自身的布局属性(一般可外部设置),父 View 的布局属性、测量结果,以及子 View 的布局属性、测量结果等等共同决定。所以一个复杂 View 的视图区域的构建流程通常是自上而下递归的,下面的分析将进一步体现该设计思路。

View 的测量

先执行的是 View 的测量过程,整体流程时序图如下:

frameworks/base/core/java/android/view
    - ViewRootImpl.java
    - View.java
    - ViewGroup.java

整个 View 树的测量发起点是 ViewRootImpl 的 performTraversal 方法,顾名思义,开始遍历整个 View 树。接着触发 performMeasure,开始 View 树的测量流程。

其中,对于一个 View 而言,测量过程整体可分为两步:

  1. 根据父 View 的布局参数以及自身的布局参数计算生成两个 int 值 widthMeasureSpecheightMeasureSpec,分别代表父 View 对 子 View 在宽高上的布局要求(对应上面时序图的 wm、hm);
  2. 结合父 View 的布局要求 MeasureSpec 以及子 View 的测量结果来计算自身的测量结果宽高 MeasuredWidthMeasuredHeight

用于 View 的测量结果依赖于父 View 和子 View 的测量结果,所以一个 View 的测量过程可能会触发多次才得于计算出最终的测量结果。

MeasureSpec 的计算

MeasureSpec 表示的是父 View 传递到子 View 的布局要求,具体用一个 32 位的 int 值,可拆分为:

高 2 位 低 30 位
测量模式 SpecMode 测量要求大小 SpecSize

SpecMode 表示的是测量模式,共三种,含义如下:

SpecMode 说明
EXACTLY 精确测量模式,父 View 已经确定子 View 的测量结果,测量值为 SpecSize 的值
AT_MOST 最大值测量模式,子 View 可以根据需要的大小而定,最大不超过 SpecSize
UNSPECIFIED 不定测量模式,父 View 对子 View 大小没有任何限制,通常用于内部多次测量过程中的临时测量结果,应用开发中较少用到

先来看下 RootView 的 MeasureSpec 计算逻辑。

对于窗口顶层的 RootView 来说,它的 MeasureSpec 由窗口参数和其自身的 LayoutParams 共同决定。在 ViewRootImpl 的 getRootMeasureSpec(int windowSize, int rootDimension) 方法中可整理到对应关系为:

RootView - LayoutParams MeasureSpec
dp / px EXACTLY(viewSize)
match_parent EXACTLY(windowSize)
wrap_content AT_MOST(windowSize)

其中 windowSize 的默认值宽取 com.android.internal.R.dimen.config_prefDialogWidth,普通手机屏幕中为 320dp,高为手机屏幕高度。

对于往下层级的 View,它的 MeasureSpec 由父 View 的 MeasureSpec 和其自身的 LayoutParams 共同决定。在 ViewGroup 的 getChildMeasureSpec 方法中可整理到对应关系为:

Child - LayoutParams Parent - SpecMode
EXACTLY
Parent - SpecMode
AT_MOST
Parent - SpecMode
UNSPECIFIED
dp / px EXACTLY(childSize) EXACTLY(childSize) EXACTLY(childSize)
match_parent EXACTLY(parentSize) AT_MOST(parentSize) UNSPECIFIED(parentSize)
wrap_content AT_MOST(parentSize) AT_MOST(parentSize) UNSPECIFIED(parentSize)

实际测量宽高的计算

在一个 View 从父 View 那获取到宽高的 MeasureSpec 值之后,会在其 onMeasure 方法中完成自身实际宽高的测量。

对一个简单的 View 来说,实际的测量宽高值 mMeasuredWidthmMeasuredHeight 直接取用 MeasureSpec 里的 SpecSize 即可,因此 onMeasure 方法的默认实现为(部分方法调用已合并):

// frameworks/base/core/java/android/view/View.java
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    mMeasuredWidth = getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec);
    mMeasuredHeight = getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec);

    mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);

    switch (specMode) {
    case MeasureSpec.UNSPECIFIED: // UNSPECIFIED 默认最小默认值
        result = size;
        break;
    case MeasureSpec.AT_MOST: // AT_MOST & EXACTLY 模式取 SpecSize
    case MeasureSpec.EXACTLY:
        result = specSize;
        break;
    }
    return result;
}

然而,对于一个包含子 View 的 ViewGroup 来说,仅仅是知道父 View 对它的布局要求,是不足以完成自身宽高的计算的。原因是当它的布局参数设置为 wrap_conent 时,其宽或高会需要子 View 的尺寸来适应。这时,需要逐级向下触发并完成子 View 的测量,根据子 View 的测量结果来计算自身的测量宽高

以最常用的 FrameLayout 为例,FrameLayout 的布局特性是它的多个子 View 在其内布局是“可重叠”的,因此当 FrameLayout 自身的宽或高设置为 wrap_conent 时,它的测量宽或高的值理应大于等于它尺寸最大的那个子 View 的宽或高即可。按照这个思路,可以看到 FrameLayout 重写实现的 onMeasure 方法为:

// frameworks/base/core/java/android/widget/FrameLayout.java
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int count = getChildCount();
    ...
    int maxHeight = 0;
    int maxWidth = 0;
    int childState = 0;
    // 先依次触发子 View 的测量
    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        if (mMeasureAllChildren || child.getVisibility() != GONE) {
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            // 记录下子 View 的最大宽度和高度
            maxWidth = Math.max(maxWidth,
                    child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
            maxHeight = Math.max(maxHeight,
                    child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
            childState = combineMeasuredStates(childState, child.getMeasuredState());
            if (measureMatchParentChildren) {
                if (lp.width == LayoutParams.MATCH_PARENT ||
                        lp.height == LayoutParams.MATCH_PARENT) {
                    mMatchParentChildren.add(child);
                }
            }
        }
    }
    ...
    // 根据子 View 的最大宽度 & 高度以及 MeasureSpec 的值计算自身真正的测量宽高
    setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
            resolveSizeAndState(maxHeight, heightMeasureSpec,
                    childState << MEASURED_HEIGHT_STATE_SHIFT));
    // 在自身的宽高完成测量后,子 View 的测量可能发现变化,因此重新触发子 View 的测量
    count = mMatchParentChildren.size();
    if (count > 1) {
        for (int i = 0; i < count; i++) {
            final View child = mMatchParentChildren.get(i);
            ...
            // 重新触发子 View 的测量
            child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        }
    }
}

其中,resolveSizeAndState 方法的实现如下:

// frameworks/base/core/java/android/view/View.java
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
    final int specMode = MeasureSpec.getMode(measureSpec);
    final int specSize = MeasureSpec.getSize(measureSpec);
    final int result;
    switch (specMode) {
        case MeasureSpec.AT_MOST:
            if (specSize < size) {
                result = specSize | MEASURED_STATE_TOO_SMALL;
            } else {
                result = size;
            }
            break;
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        case MeasureSpec.UNSPECIFIED:
        default:
            result = size;
    }
    return result | (childMeasuredState & MEASURED_STATE_MASK);
}

至此,View 的一次测量过程就算完成了,其成员变量 mMeasureWidthmMeasureHeigth 对应为具体的测量结果宽高。

View 的布局

在 View 树的测量(measure)流程完成后,接着触发的是 View 的布局(layout)流程。相对于测量,布局的过程相对简单,整体时序图如下:

布局过程的核心是计算一个 View 在其父 View 当中的位置,在 Android View 系统中,是通过一个 View 相对于父 View 的 lefttoprightbottom 四个参数值来确定的(分别对应上时序图的 l、t、r、b)。其含义如下图所示:

显然,在一个 View 内,会有:

width = right - left
height = top - bottom

而后会调用 View 的 layout 方法,传入计算出来的 left、top、right、bottom 并记录,回调 onLayout 方法。代码如下(已部分省略):

public void layout(int l, int t, int r, int b) {
    boolean changed = isLayoutModeOptical(mParent) ?
            setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); // 

    if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
        onLayout(changed, l, t, r, b);
        ...
    }
}

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;
        int oldWidth = mRight - mLeft;
        int oldHeight = mBottom - mTop;
        int newWidth = right - left;
        int newHeight = bottom - top;
        boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);
        ...
        // 记录下更新的后 left、top、right、bottom
        mLeft = left;
        mTop = top;
        mRight = right;
        mBottom = bottom;
        ...            
    }
    return changed;
}

对于窗口顶层的 RootView 来说,由于没有父 View 做参照,故它的左上右下分别是:0,0,MeasureWidth,MeasureHegith。

对于往下层级的 View,它的左上右下由该 View 在父 ViewGroup 的布局属性决定。

在 ViewGroup 回调 onLayout 后,会触发到子 View 的 layout 方法,以此递归完成整个 View 树的布局过程。以 RelativeLayout 为例:

// frameworks/base/core/java/android/widget/RelativeLayout.java
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int count = getChildCount();

    for (int i = 0; i < count; i++) {
        View child = getChildAt(i);
        if (child.getVisibility() != GONE) {
            RelativeLayout.LayoutParams st =
                    (RelativeLayout.LayoutParams) child.getLayoutParams();
            child.layout(st.mLeft, st.mTop, st.mRight, st.mBottom);
        }
    }
}

至此,View 的树布局过程已完成。