自定义 View 的完整方法论
自定义 View 解决什么问题
系统提供的 View(TextView、ImageView 等)是通用控件,它们无法满足所有业务场景的视觉需求。真正复杂的图表、弧形进度条、骰子动画、特殊形状的头像——这些都需要自定义 View。
自定义 View 有两个常见误区:一是认为"只要能画出来就行",忽略 Measure/Layout 逻辑;二是在 onDraw 里频繁创建对象,导致 GC 卡顿。
理解自定义 View 的关键是理解系统对 View 提出的三个问题:你多大?放哪里?怎么画?
Measure:你到底占多大地方
MeasureSpec 是什么
父 View 不是直接把像素数字传给子 View 说"你就是这么大",而是给一个"建议 + 模式"的组合,这就是 MeasureSpec。
MeasureSpec 是一个 32 位 int:高 2 位是模式,低 30 位是尺寸。
// 解包
val mode = MeasureSpec.getMode(measureSpec)
val size = MeasureSpec.getSize(measureSpec)
// 打包
val spec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY)
三种模式的含义:
| 模式 | 常量 | 含义 | 对应布局参数 |
|---|---|---|---|
EXACTLY |
00 |
父 View 指定了精确大小 | match_parent 或固定 dp |
AT_MOST |
10 |
不能超过这个大小 | wrap_content |
UNSPECIFIED |
01 |
父 View 不限制(ScrollView 内部) | 极少见 |
自定义 View 的 onMeasure 模板
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 计算内容本身需要的尺寸(不考虑外部约束)
val desiredWidth = CONTENT_WIDTH + paddingLeft + paddingRight
val desiredHeight = CONTENT_HEIGHT + paddingTop + paddingBottom
// 根据约束模式决定最终尺寸
val measuredWidth = resolveSize(desiredWidth, widthMeasureSpec)
val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec)
// 必须调用 setMeasuredDimension,否则会 IllegalStateException
setMeasuredDimension(measuredWidth, measuredHeight)
}
resolveSize(size, spec) 是系统提供的工具方法,等价于:
fun resolveSize(size: Int, measureSpec: Int): Int {
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
return when (specMode) {
MeasureSpec.EXACTLY -> specSize // 父说多少就多少
MeasureSpec.AT_MOST -> minOf(size, specSize) // 不超过父
else -> size // 随便,就要内容大小
}
}
常见错误:直接调用 super.onMeasure() 而不处理 wrap_content。对于大多数自定义 View,父类的默认实现会把 wrap_content 当作 match_parent 处理,导致撑满父容器。
Layout:放在哪里
普通自定义 View(继承自 View 而非 ViewGroup)不需要重写 onLayout,父 ViewGroup 会根据 onMeasure 的结果和 LayoutParams 来放置它。
需要 onLayout 的场景:继承 ViewGroup,自己管理子 View 的位置。
// 自定义流式布局(简化版)
class FlowLayout(ctx: Context, attrs: AttributeSet?) : ViewGroup(ctx, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 先让所有子 View 测量自己
measureChildren(widthMeasureSpec, heightMeasureSpec)
// 再根据子 View 的尺寸计算自己的尺寸
var totalHeight = 0; var lineWidth = 0
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
var lineHeight = 0
for (child in nonGoneChildren()) {
if (lineWidth + child.measuredWidth > maxWidth) {
totalHeight += lineHeight
lineWidth = 0; lineHeight = 0
}
lineWidth += child.measuredWidth
lineHeight = maxOf(lineHeight, child.measuredHeight)
}
totalHeight += lineHeight
setMeasuredDimension(
resolveSize(maxWidth, widthMeasureSpec),
resolveSize(totalHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var x = 0; var y = 0; var lineHeight = 0
val maxWidth = width
for (child in nonGoneChildren()) {
if (x + child.measuredWidth > maxWidth) {
x = 0; y += lineHeight; lineHeight = 0
}
// 核心:用 layout() 设置子 View 的四个边界
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
x += child.measuredWidth
lineHeight = maxOf(lineHeight, child.measuredHeight)
}
}
}
Draw:怎么把自己画出来
Canvas 与 Paint 的分工
Canvas 是"画布"(决定画什么、画在哪),Paint 是"笔"(决定颜色、粗细、风格)。
// Paint 应当在 View 初始化时创建,不能在 onDraw 里创建(会频繁 GC)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.FILL // FILL=填充,STROKE=描边,FILL_AND_STROKE=两者
strokeWidth = 4f
textSize = 48f
}
override fun onDraw(canvas: Canvas) {
// 画圆
canvas.drawCircle(cx, cy, radius, paint)
// 画矩形
canvas.drawRect(left, top, right, bottom, paint)
// 画圆弧(startAngle: 起始角度,sweepAngle: 扫过角度,useCenter: 是否连接到圆心)
canvas.drawArc(rectF, startAngle, sweepAngle, false, paint)
// 画文字
canvas.drawText("Hello", x, y, paint)
// 画路径(最通用,可以画任意形状)
val path = Path()
path.moveTo(0f, 0f)
path.lineTo(100f, 100f)
path.quadTo(150f, 50f, 200f, 100f) // 贝塞尔曲线
canvas.drawPath(path, paint)
}
Canvas 的坐标变换
Canvas 支持平移、旋转、缩放等坐标变换,这些变换是叠加的:
override fun onDraw(canvas: Canvas) {
// save 保存当前变换矩阵状态
canvas.save()
canvas.translate(cx, cy) // 移动原点到圆心
canvas.rotate(angle) // 旋转坐标系
// 此时绘制操作都相对于圆心
canvas.drawLine(-armLength, 0f, armLength, 0f, paint)
// restore 恢复到 save 时的状态,不影响后续绘制
canvas.restore()
}
save()/restore() 是写复杂绘制逻辑时的核心工具,避免多个组件的变换相互污染。
绘制顺序
重写 onDraw 时,系统调用顺序是:
View的 onDraw 执行顺序:
1. drawBackground() ← 画背景(你控制不了,系统做)
2. onDraw() ← 画内容(这里画的在前景下面)
3. dispatchDraw() ← 画子 View(ViewGroup 用)
4. onDrawForeground() ← 画前景(滚动条、前景drawable)
自定义属性:在 XML 中配置 View
把参数硬编码在 View 里是坏习惯。通过自定义属性,可以在 XML 中配置 View,让它像系统控件一样灵活。
第一步:在 res/values/attrs.xml 中声明属性:
<declare-styleable name="ArcProgressView">
<attr name="arcColor" format="color" />
<attr name="arcWidth" format="dimension" />
<attr name="progress" format="float" />
<attr name="maxProgress" format="float" />
</declare-styleable>
第二步:在 View 的构造函数中读取属性:
class ArcProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var arcColor = Color.BLUE
private var arcWidth = 8f
private var progress = 0f
init {
// 读取 XML 中配置的属性
context.obtainStyledAttributes(attrs, R.styleable.ArcProgressView).use { ta ->
arcColor = ta.getColor(R.styleable.ArcProgressView_arcColor, Color.BLUE)
arcWidth = ta.getDimension(R.styleable.ArcProgressView_arcWidth, 8f)
progress = ta.getFloat(R.styleable.ArcProgressView_progress, 0f)
}
// use 扩展函数会自动调用 recycle(),avoid 内存泄漏
}
}
第三步:在布局 XML 中使用:
<com.example.ArcProgressView
android:layout_width="200dp"
android:layout_height="200dp"
app:arcColor="#FF5722"
app:arcWidth="12dp"
app:progress="0.75" />
动画:让自定义 View 动起来
属性动画驱动重绘
最常见的方式:用 ValueAnimator 驱动自定义属性变化,在更新回调中调用 invalidate() 触发重绘。
private var sweepAngle = 0f
fun animateTo(targetAngle: Float) {
ValueAnimator.ofFloat(sweepAngle, targetAngle).apply {
duration = 600
interpolator = DecelerateInterpolator()
addUpdateListener { animator ->
sweepAngle = animator.animatedValue as Float
invalidate() // 触发 onDraw,重新绘制
}
}.start()
}
override fun onDraw(canvas: Canvas) {
canvas.drawArc(rectF, -90f, sweepAngle, false, paint)
}
直接支持属性动画
如果想让 ObjectAnimator 直接操作 View 的属性(如 progress),需要提供 setter 和 getter:
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate() // setter 里触发重绘
}
// 然后可以这样使用
ObjectAnimator.ofFloat(view, "progress", 0f, 1f).apply {
duration = 800
start()
}
性能规则:onDraw 的三不原则
onDraw 可能每帧被调用 60 次,里面的代码是性能敏感区域:
不要创建对象:在 onDraw 里 new Paint() 或 new Path() 会在每帧产生大量垃圾,触发 GC 导致掉帧。所有绘制用的对象应当在成员变量中初始化。
// ❌ 错误
override fun onDraw(canvas: Canvas) {
val paint = Paint() // 每帧都创建
paint.color = Color.RED
canvas.drawCircle(cx, cy, r, paint)
}
// ✓ 正确
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.RED }
override fun onDraw(canvas: Canvas) {
canvas.drawCircle(cx, cy, r, paint)
}
不要做耗时运算:布局计算、文字测量等尽量放在 onSizeChanged() 或属性的 setter 中提前算好。
不要无意义调用 invalidate():只在视觉状态真正改变时才调用。比如设置同一个颜色两次,第二次不用 invalidate()。
边界情况检查清单
一个自定义 View 要达到"生产可用",需要检查:
-
onMeasure正确处理了wrap_content(不能简单调用 super) -
onDraw里没有对象创建 - 考虑了
padding(绘制时减去 padding 值) - 支持自定义属性,并提供合理默认值
- 可通过属性动画驱动(setter 里调
invalidate) - 如果有交互,实现了
onTouchEvent并支持 accessibility(如contentDescription) - 在
onDetachedFromWindow中停止了正在运行的动画(防泄漏) - 考虑了横竖屏切换(不要把像素值硬编码,用 dp 转 px)
硬编码数值转换 dp → px:
private val Float.dp: Float
get() = this * resources.displayMetrics.density