核心心法:一棵树的"生长"三部曲

想象一下,整个 Android 界面就是一棵 View 树,而 DecorView 是树根。这棵树的生长,是从 ViewRootImpl 这个"园丁"那里开始的。

ViewRootImpl 里的 performTraversals 方法,就是园丁的"工作指令"。它一执行,就会严格按照 ‌measurelayoutdraw‌ 的顺序,驱动整棵树的生长。

这个顺序是单向的、严格的。measure 决定了每个节点"想占多大",layout 决定了每个节点"最终被放在哪",draw 就是"把它画出来"。


第一关:MeasureSpec —— 父子间的"尺寸谈判"

measure 过程的核心就是 MeasureSpec。它不是一个简单的数字,而是一个 ‌32 位的 int,封装了‌模式‌和‌大小‌。这背后是系统为了减少对象创建、提升性能的精心设计。

理解 MeasureSpec 的关键,在于理解它是一场父子容器间的"尺寸谈判":

  • 谈判的筹码‌:父容器的 MeasureSpec(它的空间限制) + 子 View 的 LayoutParams(它想要的大小)。
  • 谈判的结果‌:一个决定了子 View 最终可以是多少的 MeasureSpec

系统通过 ViewGroup 的 getChildMeasureSpec 方法,用一套非常精密的规则来进行这场谈判。下面这张"谈判规则表"是理解一切的核心:

子 View 的 LayoutParams 父容器 MeasureSpec 是 ‌EXACTLY‌ (精确) 父容器 MeasureSpec 是 ‌AT_MOST‌ (最大)
固定值 (ex: 100dp) 子 View 为 EXACTLY
大小就是 100dp
子 View 为 EXACTLY
大小还是 100dp
match_parent 子 View 为 EXACTLY
大小是父容器剩余空间
子 View 为 AT_MOST
最大不超过父容器剩余空间
wrap_content 子 View 为 AT_MOST
最大不超过父容器剩余空间
子 View 为 AT_MOST
最大不超过父容器剩余空间

拷问:为什么自定义 View 要重写 onMeasure 处理 wrap_content

从表中可以看到,当 wrap_content 时,子 View 的模式永远是 AT_MOST,而其大小 SpecSize 就是父容器剩余空间。如果直接继承 View 但不重写 onMeasure,系统默认的 getDefaultSize 方法会直接返回这个 SpecSize。这就导致 wrap_content 的效果和 match_parent 一模一样,填满了整个父容器。所以,你必须在 onMeasure 中,对 AT_MOST 模式做一个特殊处理,给它一个默认的内部大小。


第二关:Measure 的递归 —— 整棵树的"自顶向下"测量

measure 是整棵树从根节点 DecorView 开始,一层层递归向下的过程。

  1. 顶层触发‌:ViewRootImpl.performMeasure 调用 View.measure
  2. 核心方法‌:measure 方法是 final 的,你不能重写,它内部会回调 onMeasure。这是模板方法模式的经典应用。
  3. 分摊任务‌:ViewGroup 没有重写 onMeasure,但它提供了 measureChildrenmeasureChildWithMargins 这样的方法,遍历所有子 View,并把自己的 MeasureSpec 和子 View 的 LayoutParams 作为参数,计算出子 View 的 MeasureSpec,然后调用 child.measure(childWidthMeasureSpec, childHeightMeasureSpec)。这样,测量流程就传递下去了。
  4. 到达叶子‌:直到递归到叶子节点(一个普通的 View),它的 onMeasure 会使用传入的 MeasureSpec 计算并设置自己的测量宽高,然后递归结束,逐层返回。

特别注意‌:测量宽高(getMeasuredWidth/Height)和最终宽高(getWidth/Height)在绝大多数情况下是相等的,但赋值时机不同。测量宽高在 measure 阶段确定,最终宽高在 layout 阶段通过 setFrame 确定。在 onLayout 中获取是最可靠的。


第三关:Layout —— 父亲的"位置分配"

layout 过程相对简单,是一个‌自顶向下‌的"位置分配"过程。

  • View.layout 方法被调用,它用传入的四个顶点坐标(l, t, r, b)调用 setFrame 来确定自己的位置。getWidth() 就是 r - lgetHeight() 就是 b - t
  • 然后,layout 方法内部会调用 onLayout。对于 ViewGrouponLayout 是抽象方法,你必须实现它,在这个方法里,循环遍历所有子 View,调用 child.layout(childLeft, childTop, childRight, childBottom) 为每个子 View 分配具体的屏幕位置。这样,layout 流程就传递下去了。

第四关:Draw —— 一块画布的"接力绘制"

draw 过程的传递和上两个不太一样,它是在 draw 方法内部通过 dispatchDraw 实现的。

View.draw 方法的绘制顺序是严格定义的,就像一个绘画清单:

  1. drawBackground‌:绘制背景。
  2. onDraw‌:绘制自身内容。这是自定义 View 时最常重写的方法。
  3. dispatchDraw‌:绘制子 View。ViewGroup 重写了此方法,会遍历调用子 View 的 draw 方法,完成绘制接力。View 的这个方法是空的。
  4. onDrawForeground‌:绘制前景(如滚动条、装饰)。

性能优化点‌:如果一个 ViewGroup 本身不需要绘制任何内容(比如纯布局),我们可以通过 setWillNotDraw(true) 来告诉系统,这样系统会跳过 onDraw 步骤,直接去 dispatchDraw,从而提升性能。但如果你的 ViewGroup 需要绘制背景或分割线,你必须手动将它设为 false


第五关:解决"拿不到宽高"的终极理解

为什么在 onCreateonResume 里拿到的宽高是 0?因为这里是‌同步‌的生命周期回调,而 View 的 measure/layout 是‌异步‌的,它们还没有被执行。

你提到的四种解决方案,本质都是‌将获取宽高这个动作,延迟到 View 已经完成测量和布局之后‌:

  1. onWindowFocusChanged‌:当 Activity 获得焦点时,View 肯定已经绘制完毕。
  2. view.post(Runnable)‌:利用消息队列机制,将任务放到消息队列末尾,当它执行时,前面的 measure/layout 消息已经处理完了。
  3. ViewTreeObserver.OnGlobalLayoutListener‌:注册一个监听器,当 View 树布局状态发生变化时回调,此时必然已测量完毕。
  4. 手动 view.measure‌:强行触发测量。但这需要你根据 View 的 LayoutParams 手动构造出合适的 MeasureSpec,是一种比较"硬核"且不推荐的方法,因为容易产生不一致。

理论说得再多,不如直接上代码。我带你手写两个例子,把核心知识点全用代码串起来:一个‌自定义 View‌(处理 wrap_content),一个‌自定义 ViewGroup‌(流布局,展示 measure 和 layout 的完整流程)。


第六关、自定义 View:一个带圆角的标签

这个例子重点解决第四章反复强调的坑:‌直接继承 View 时,必须手动处理 wrap_content‌,否则效果和 match_parent 一样。

public class RoundTagView extends View {

    private Paint mPaint;
    private int mColor;
    private String mText;
    private float mTextSize;
    private Rect mTextBounds; // 用于测量文字宽高

    public RoundTagView(Context context) {
        this(context, null);
    }

    public RoundTagView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mColor = Color.parseColor("#FF6B6B");
        mText = "标签";
        mTextSize = sp2px(14);
        mTextBounds = new Rect();
    }

    /**
     * 核心!处理 wrap_content 必须重写 onMeasure
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        // 1. 先计算文字实际占用的宽高
        mPaint.setTextSize(mTextSize);
        mPaint.getTextBounds(mText, 0, mText.length(), mTextBounds);
        int textWidth = mTextBounds.width();
        int textHeight = mTextBounds.height();

        // 2. 加上内边距,得到控件真正需要的"理想尺寸"
        int paddingHorizontal = getPaddingLeft() + getPaddingRight() + dp2px(16);
        int paddingVertical = getPaddingTop() + getPaddingBottom() + dp2px(8);
        int desiredWidth = textWidth + paddingHorizontal;
        int desiredHeight = textHeight + paddingVertical;

        // 3. 根据不同的测量模式,计算最终尺寸
        int finalWidth, finalHeight;

        if (widthMode == MeasureSpec.EXACTLY) {
            // match_parent 或 固定值,直接使用父容器给的尺寸
            finalWidth = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            // wrap_content:取理想尺寸和父容器最大可用尺寸中的较小值
            finalWidth = Math.min(desiredWidth, widthSize);
        } else {
            // UNSPECIFIED:直接用理想尺寸
            finalWidth = desiredWidth;
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            finalHeight = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            finalHeight = Math.min(desiredHeight, heightSize);
        } else {
            finalHeight = desiredHeight;
        }

        // 4. 必须调用 setMeasuredDimension 保存测量结果
        setMeasuredDimension(finalWidth, finalHeight);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();

        // 绘制圆角背景
        mPaint.setColor(mColor);
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawRoundRect(0, 0, width, height, dp2px(20), dp2px(20), mPaint);

        // 绘制文字居中
        mPaint.setColor(Color.WHITE);
        mPaint.setTextSize(mTextSize);
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float textX = (width - mTextBounds.width()) / 2f;
        float textY = height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f;
        canvas.drawText(mText, textX, textY, mPaint);
    }

    // 提供外部设置文字的方法
    public void setTagText(String text) {
        this.mText = text;
        requestLayout(); // 文字变了要重新测量
        invalidate();    // 并重新绘制
    }

    private int dp2px(float dp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
                dp, getResources().getDisplayMetrics());
    }

    private float sp2px(float sp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
                sp, getResources().getDisplayMetrics());
    }
}

代码要点解析:

  • onMeasure 中分三步走:先算理想尺寸 → 再判断模式 → 最后 setMeasuredDimension
  • AT_MOST 时取 Math.min(desired, parentSize),这正是让 wrap_content 生效的关键
  • 改变内容后调用 requestLayout() + invalidate() 重新走一遍流程

第七关、自定义 ViewGroup:流布局(FlowLayout)

这个例子展示 ViewGroup 最核心的职责:‌在 onMeasure 中测量所有子 View,在 onLayout 中为它们分配位置‌。

public class FlowLayout extends ViewGroup {

    public FlowLayout(Context context) {
        this(context, null);
    }

    public FlowLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    /**
     * 测量阶段:遍历所有子 View,计算流式布局需要的总宽高
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 1. 先测量所有子 View(这一步必须做,否则子 View 的 measuredWidth 是 0)
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        int lineWidth = 0;  // 当前行已占用的宽度
        int lineHeight = 0; // 当前行的高度
        int totalHeight = 0; // 所有行的总高度
        int totalWidth = 0;  // 所有行中的最大宽度

        int childCount = getChildCount();
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int paddingBottom = getPaddingBottom();

        // 可用的最大宽度(去掉 padding)
        int availableWidth = widthSize - paddingLeft - paddingRight;

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) continue;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (lineWidth + childWidth > availableWidth) {
                // 放不下了,换行
                totalWidth = Math.max(totalWidth, lineWidth);
                totalHeight += lineHeight;
                // 重置新行
                lineWidth = childWidth;
                lineHeight = childHeight;
            } else {
                // 当前行还能放,继续往后排
                lineWidth += childWidth;
                lineHeight = Math.max(lineHeight, childHeight);
            }

            // 如果是最后一个子 View,别忘了把这行的高度加上
            if (i == childCount - 1) {
                totalWidth = Math.max(totalWidth, lineWidth);
                totalHeight += lineHeight;
            }
        }

        // 加上 padding
        totalWidth += paddingLeft + paddingRight;
        totalHeight += paddingTop + paddingBottom;

        // 根据测量模式确定最终尺寸
        int finalWidth = (widthMode == MeasureSpec.EXACTLY) ? widthSize : totalWidth;
        int finalHeight = (heightMode == MeasureSpec.EXACTLY) ? heightSize : totalHeight;

        setMeasuredDimension(finalWidth, finalHeight);
    }

    /**
     * 布局阶段:给每个子 View 分配屏幕上的实际位置
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int paddingLeft = getPaddingLeft();
        int paddingTop = getPaddingTop();
        int availableWidth = getWidth() - paddingLeft - getPaddingRight();

        int lineWidth = 0;
        int lineHeight = 0;
        int topOffset = paddingTop; // 当前行的 top 坐标

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) continue;

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            if (lineWidth + childWidth > availableWidth) {
                // 换行:更新 topOffset,重置 lineWidth
                topOffset += lineHeight;
                lineWidth = 0;
                lineHeight = 0;
            }

            // 计算子 View 的四个坐标
            int childLeft = paddingLeft + lineWidth + lp.leftMargin;
            int childTop = topOffset + lp.topMargin;
            int childRight = childLeft + child.getMeasuredWidth();
            int childBottom = childTop + child.getMeasuredHeight();

            child.layout(childLeft, childTop, childRight, childBottom);

            lineWidth += childWidth;
            lineHeight = Math.max(lineHeight, childHeight);
        }
    }

    /**
     * 必须重写,让子 View 的 margin 生效
     */
    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }
}

代码要点解析:

  • measureChildren 是 ViewGroup 提供的方法,内部会为每个子 View 生成 MeasureSpec 并调用 child.measure(),这是测量传递的关键
  • onMeasure 中做的是"换行逻辑":累加宽度直到放不下就换行,最后算出总宽高
  • onLayout 中必须手动调用 child.layout(l, t, r, b) 确定每个子 View 的位置,否则子 View 不会显示
  • 重写 generateLayoutParams 为 MarginLayoutParams,这样在 XML 中设置的 margin 才会生效

第八关、在 XML 中使用

<!-- 流布局示例 -->
<com.example.FlowLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="12dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android"
        android:background="#E8F5E9"
        android:padding="8dp"
        android:layout_margin="4dp"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="View的工作原理"
        android:background="#FFF3E0"
        android:padding="8dp"
        android:layout_margin="4dp"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="自定义View"
        android:background="#E3F2FD"
        android:padding="8dp"
        android:layout_margin="4dp"/>

    <!-- 更多标签... -->

</com.example.FlowLayout>

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐