Background
普通 view:系统原生控件或组合控件
自定义 view:计算每一个像素的位置、每一根线条的绘制。
使用场景:
-
特殊 UI 控件, 比如一个股票走势图(K 线图)、一个带动画的圆形进度条、一个像时钟一样的菜单。
-
性能优化**:** 比如你需要显示 1000 个简单的图标,用 1000 个 ImageView 可能会卡死(对象太多),但用一个 Custom View 在
onDraw里循环画 1000 次 bitmap 就会非常快。 -
交互太复杂**:** 需要处理特殊的手势(比如可以在画布上拖拽连接节点的思维导图)。
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-content | wrap_content | CSS 更灵活,Android 必须在 Measure 阶段算死 |
| 填满剩余 | width: 100% / flex: 1 | match_parent / layout_weight=“1” | match_parent 是强行填满,weight 是分配剩余空间 |
| 定位 | position: absolute / fixed | FrameLayout / ConstraintLayout | Android 没有 z-index,层级由 XML 中的顺序决定(越在下面越顶层),或者用 elevation |
| 隐藏 | display: none / visibility: hidden | View.GONE / View.INVISIBLE | GONE 不占位(重绘布局),INVISIBLE 占位(只重绘像素) |
| Flex 布局 | display: flex | LinearLayout (基础版) / FlexboxLayout (库) | Android 原生 LinearLayout 只能单行/单列,不能换行 |
| Grid 布局 | CSS Grid | GridLayout / ConstraintLayout | ConstraintLayout 更像是 iOS 的 AutoLayout,基于“锚点”连接,比 CSS Grid 更适合复杂的 App 界面 |
Viewport
父 View 就是子 View 的 Viewport。