Background

普通 view:系统原生控件或组合控件

自定义 view:计算每一个像素的位置、每一根线条的绘制。

使用场景:

  1. 特殊 UI 控件, 比如一个股票走势图(K 线图)、一个带动画的圆形进度条、一个像时钟一样的菜单。

  2. 性能优化**:** 比如你需要显示 1000 个简单的图标,用 1000 个 ImageView 可能会卡死(对象太多),但用一个 Custom View 在 onDraw 里循环画 1000 次 bitmap 就会非常快。

  3. 交互太复杂**:** 需要处理特殊的手势(比如可以在画布上拖拽连接节点的思维导图)。

What

每个 [ViewGroup](https://developer.android.com/reference/android/view/ViewGroup) 负责调用 [draw()](https://developer.android.com/reference/android/view/View#draw\(android.graphics.Canvas\)) 方法,请求其每个子节点进行绘制;而每个 View 则负责自行绘制。由于树采用前序遍历方式,框架会先绘制父节点(即在子节点“后方”),再绘制子节点,并按其在树中出现的顺序绘制兄弟节点。

Framework 用两个 pass 绘制布局。Measure Pass & Layout Pass

每个 View 在递归过程中将 MeasureSpec 向下传递至子节点。Measure Pass 结束时,每个 View 均会存储其测量结果。Framework 在 [layout(int, int, int, int)](https://developer.android.com/reference/android/view/View#layout\(int,%20int,%20int,%20int\)) 中执行第二遍(即 Layout Pass),同样采用自顶向下的方式。在此 pass 中,每个父节点需利用测量遍中计算出的尺寸,为其所有子节点确定位置。

Measure Pass

核心任务:确定 View 自身的大小。

一个子 View 的位置(Coordinate)是依赖于它自己或者兄弟 View 的大小(Size)的,且必须遵守其父级 View 对象所施加的约束条件。

父级对象可能先以未指定尺寸的方式对子级对象进行一次测量,以确定它们的首选尺寸;若子级对象无约束尺寸的总和过大或过小,父级对象可能再次调用 measure() ,并传入对子级对象尺寸施加约束的值。

  • 线性布局的例子:一个横向的 LinearLayout,包含 View A, View B, View C。

    • View A 放在左边 (x=0)。

    • View B 的位置取决于 View A 的宽度 (x_B = width_A)。

    • View C 的位置取决于 A 和 B 的宽度 (x_C = width_A + width_B)。

对应的 XML:

android:layout_width & android:layout_height 最直接触发 Measure 的属性。

  • wrap_content: 告诉 Measure Pass 计算内容所需的最小尺寸。

  • match_parent: 告诉 Measure Pass 扩展到父容器允许的最大尺寸。

  • 固定数值 (e.g., 100dp): 直接指定测量结果。

android:minWidth / android:minHeight 设定测量的下限。即使内容很少,Measure Pass 也会确保尺寸不小于此值。

android:padding (及其变体 paddingLeft, paddingTop 等): 虽然 Padding 看起来像布局,但它实际上属于 View 的内部尺寸。在 onMeasure 过程中,View 会把 Padding 加到内容尺寸上,从而影响最终的测量宽高。

android:layout_weight (LinearLayout 特有) 权重属性会通过两次 Measure Pass 来确定 View 最终瓜分剩余空间后的大小。

android:textSize / android:src (内容相关) 如果宽高设置为 wrap_content,那么这些决定“内容大小”的属性将直接决定 Measure Pass 的计算结果。

Layout Pass

核心任务:决定每一个 View 在父容器中的具体坐标 (Left, Top, Right, Bottom)。

requestLayout():这个 View 的尺寸、位置或约束发生了变化,请重新测量它以及受到影响的父 View。

对应的 XML:

android:layout_margin (及其变体 layout_marginLeft 等) Margin 是 View 外部的空间。在 Layout 阶段,父容器会根据 Margin 计算子 View 的左上角坐标,将其“推”到正确的位置。

**android:layout_gravity**注意区分 gravity layout_gravity

  • layout_gravity: 告诉父容器,“我”想在父容器的什么位置(左边、居中、右边)。这直接影响 Layout Pass 中的坐标计算。

ConstraintLayout / RelativeLayout 的定位属性 这些属性纯粹是为了确定位置:

  • app:layout_constraintTop_toTopOf

  • app:layout_constraintStart_toEndOf

  • android:layout_centerInParent

  • android:layout_below

Draw Pass

核心任务:渲染像素

基本上就是 canvas 那一套

override fun draw(canvas: Canvas) {
    mLayoutHelper?.draw(canvas)
    super.draw(canvas)
}
 
public override fun dispatchDraw(canvas: Canvas) {
    super.dispatchDraw(canvas)
    mLayoutHelper?.drawDividers(canvas, width, height)
    mLayoutHelper?.dispatchRoundBorderDraw(canvas)
}

invalidate():跳过 Measure 和 Layout,直接进入 Draw 阶段,性能开销小。

Full Example

/**
 * 一个简单的自定义布局:将所有子View垂直排列,并支持自定义间距
 */
class VerticalStackLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
 
    var spacing: Int = 16.dp  // 子View之间的间距
 
    // ==================== MEASURE PASS ====================
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
 
        var totalHeight = paddingTop + paddingBottom
        var maxChildWidth = 0
 
        // 测量每个子View
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility == GONE) continue
 
            // 测量子View,传入约束
            measureChildWithMargins(
                child,
                widthMeasureSpec, 0,
                heightMeasureSpec, totalHeight
            )
 
            val lp = child.layoutParams as MarginLayoutParams
            
            // 累加高度
            totalHeight += child.measuredHeight + lp.topMargin + lp.bottomMargin
            if (i < childCount - 1) {
                totalHeight += spacing
            }
 
            // 记录最大宽度
            val childTotalWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
            maxChildWidth = maxOf(maxChildWidth, childTotalWidth)
        }
 
        // 根据MeasureSpec模式决定最终尺寸
        val finalWidth = when (widthMode) {
            MeasureSpec.EXACTLY -> widthSize
            MeasureSpec.AT_MOST -> minOf(maxChildWidth + paddingLeft + paddingRight, widthSize)
            else -> maxChildWidth + paddingLeft + paddingRight
        }
 
        val finalHeight = when (heightMode) {
            MeasureSpec.EXACTLY -> heightSize
            MeasureSpec.AT_MOST -> minOf(totalHeight, heightSize)
            else -> totalHeight
        }
 
        // 设置自身测量结果
        setMeasuredDimension(finalWidth, finalHeight)
    }
 
    // ==================== LAYOUT PASS ====================
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var currentTop = paddingTop
 
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility == GONE) continue
 
            val lp = child.layoutParams as MarginLayoutParams
            
            // 计算子View的位置
            val childLeft = paddingLeft + lp.leftMargin
            val childTop = currentTop + lp.topMargin
            val childRight = childLeft + child.measuredWidth
            val childBottom = childTop + child.measuredHeight
 
            // 放置子View
            child.layout(childLeft, childTop, childRight, childBottom)
 
            // 更新下一个子View的起始位置
            currentTop = childBottom + lp.bottomMargin + spacing
        }
    }
 
    // 支持 MarginLayoutParams
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }
 
    override fun generateDefaultLayoutParams(): LayoutParams {
        return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
    }
 
    private val Int.dp: Int
        get() = (this * resources.displayMetrics.density).toInt()
}

Why

为什么需要分成 Measure Layout 两个 Pass

  • 依赖关系:一个子 View 的位置是依赖于它自己或者兄弟 View 的大小的。

  • 复杂布局:LinearLayout 的 weight,RelativeLayout 的互相依赖

  • 性能考虑:对比 CSS 的 declarative,XML 命令式编程性能更好

VS Web Dom Tree (CSS)

概念CSS (Web)Android View System区别点
大小自适应width: auto / max-contentwrap_contentCSS 更灵活,Android 必须在 Measure 阶段算死
填满剩余width: 100% / flex: 1match_parent / layout_weight=“1”match_parent 是强行填满,weight 是分配剩余空间
定位position: absolute / fixedFrameLayout / ConstraintLayoutAndroid 没有 z-index,层级由 XML 中的顺序决定(越在下面越顶层),或者用 elevation
隐藏display: none / visibility: hiddenView.GONE / View.INVISIBLEGONE 不占位(重绘布局),INVISIBLE 占位(只重绘像素)
Flex 布局display: flexLinearLayout (基础版) / FlexboxLayout (库)Android 原生 LinearLayout 只能单行/单列,不能换行
Grid 布局CSS GridGridLayout / ConstraintLayoutConstraintLayout 更像是 iOS 的 AutoLayout,基于“锚点”连接,比 CSS Grid 更适合复杂的 App 界面

Viewport

父 View 就是子 View 的 Viewport。

Refs