自定义 View
本教程共 100 篇 · 第 94 篇 · 更新于 2026-07-28 · 约 9 分钟阅读
94. 自定义 View
本节目标:理解自定义 View 的三步(测量、布局、绘制),学会用 Canvas 画图形和文字、自定义属性、invalidate 触发重绘,知道 Compose 里怎么画自定义图形。
系统提供的 View 不够用时,就得自己画。进度条、饼图、签名板、复杂动画背景,都靠自定义 View。Compose 时代 View 体系用得少了,但理解绘制原理对 Compose 的 Canvas 也有帮助。
三步走
自定义 View(准确说是自定义 ViewGroup 或者 View)核心三个方法:
onMeasure:测量自己多大。onLayout:摆放子 View 位置(ViewGroup 才需要)。onDraw:画内容。
继承 View 重写 onDraw 就能画自定义内容。继承 ViewGroup 还要重写 onLayout 摆子 View。
打个比方,自定义 View 像自己画一幅画:先量画布多大(onMeasure),再决定画哪里(onLayout),最后动笔(onDraw)。
第一个自定义 View:圆形进度
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.STROKE
strokeWidth = 20f
}
fun setProgress(value: Int) {
progress = value.coerceIn(0, 100)
invalidate() // 触发重绘
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val cx = width / 2f
val cy = height / 2f
val radius = minOf(width, height) / 2f - paint.strokeWidth
val sweepAngle = 360f * progress / 100f
val rect = RectF(
cx - radius, cy - radius,
cx + radius, cy + radius
)
canvas.drawArc(rect, -90f, sweepAngle, false, paint)
}
}
要点:
@JvmOverloads让构造函数支持 XML 实例化。Paint配置画笔(颜色、风格、线宽)。invalidate()触发重绘,重绘会在主线程异步执行。canvas.drawXxx()画各种形状。
自定义属性
在 res/values/attrs.xml 声明:
<resources>
<declare-styleable name="CircleProgressView">
<attr name="progressColor" format="color" />
<attr name="progressStrokeWidth" format="dimension" />
<attr name="progress" format="integer" />
</declare-styleable>
</resources>
XML 里用:
<com.example.CircleProgressView
android:layout_width="200dp"
android:layout_height="200dp"
app:progressColor="#FF5722"
app:progressStrokeWidth="16dp"
app:progress="60" />
代码里读取:
init {
context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView).apply {
paint.color = getColor(R.styleable.CircleProgressView_progressColor, Color.BLUE)
paint.strokeWidth = getDimension(R.styleable.CircleProgressView_progressStrokeWidth, 20f)
progress = getInt(R.styleable.CircleProgressView_progress, 0)
recycle() // 必须回收
}
}
Note
obtainStyledAttributes返回的 TypedArray 用完必须recycle(),否则内存泄漏。
onMeasure 测量
默认 View 的 onMeasure 不一定按你期望的尺寸。要自定义测量逻辑:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSize = 200.dpToPx().toInt()
val width = resolveSize(defaultSize, widthMeasureSpec)
val height = resolveSize(defaultSize, heightMeasureSpec)
// 强制正方形
val size = minOf(width, height)
setMeasuredDimension(size, size)
}
MeasureSpec 有三种模式:
EXACTLY:match_parent 或者具体 dp。AT_MOST:wrap_content。UNSPECIFIED:没限制,少见。
resolveSize(default, spec) 帮你处理这三种情况,返回最终尺寸。
onLayout(ViewGroup)
自定义 ViewGroup 要在 onLayout 里摆放子 View:
class FlowLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var x = paddingLeft
var y = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
if (x + child.measuredWidth > r - l - paddingRight) {
x = paddingLeft
y += child.measuredHeight
}
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
x += child.measuredWidth
}
}
}
onMeasure 里还要测量子 View 尺寸,逻辑较复杂。新项目用 Compose 的 FlowRow 替代。
Canvas 常用绘制
override fun onDraw(canvas: Canvas) {
// 画圆
canvas.drawCircle(cx, cy, radius, paint)
// 画矩形
canvas.drawRect(left, top, right, bottom, paint)
// 画线
canvas.drawLine(startX, startY, endX, endY, paint)
// 画文字
canvas.drawText("Hello", x, y, textPaint)
// 画图片
canvas.drawBitmap(bitmap, srcRect, dstRect, paint)
// 画路径
val path = Path().apply {
moveTo(0f, 0f)
lineTo(100f, 100f)
lineTo(200f, 0f)
close()
}
canvas.drawPath(path, paint)
// 旋转
canvas.save()
canvas.rotate(45f, cx, cy)
canvas.drawRect(...)
canvas.restore()
}
save() / restore() 保存恢复 Canvas 状态,做变换时配对用。
触摸事件
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 按下
return true
}
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
// 移动
}
MotionEvent.ACTION_UP -> {
// 抬起
}
}
return super.onTouchEvent(event)
}
返回 true 表示消费事件,否则事件传给父 View。
invalidate 和 requestLayout
invalidate():触发onDraw重绘(不改变尺寸)。requestLayout():触发onMeasure+onLayout(尺寸变了)。
属性变化只影响外观用 invalidate,影响尺寸用 requestLayout。
双缓冲优化
频繁重绘(如动画)会卡。用硬件加速:
// 在构造函数
setLayerType(LAYER_TYPE_HARDWARE, null)
或者用 RenderEffect(Android 12+)做高级效果。
Warning
onDraw里别 new 对象(Paint、Path),GC 频繁触发卡顿。在初始化时创建,复用。
Compose 里的自定义绘制
Compose 用 Canvas Modifier 或者 drawBehind:
@Composable
fun CircleProgress(progress: Float, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.size(200.dp)) {
val strokeWidth = 20f
val radius = (size.minDimension - strokeWidth) / 2
val cx = size.width / 2
val cy = size.height / 2
// 背景圆
drawCircle(
color = Color.LightGray,
radius = radius,
center = Offset(cx, cy),
style = Stroke(width = strokeWidth)
)
// 进度弧
drawArc(
color = Color.Blue,
startAngle = -90f,
sweepAngle = 360f * progress,
useCenter = false,
topLeft = Offset(cx - radius, cy - radius),
size = Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
Compose 的 DrawScope 和 Canvas API 类似,但用声明式风格,状态一变自动重绘。
@Composable
fun AnimatedProgress() {
var progress by remember { mutableStateOf(0f) }
val animatedProgress by animateFloatAsState(progress, label = "progress")
CircleProgress(animatedProgress)
Button(onClick = { progress = if (progress < 1f) progress + 0.25f else 0f }) {
Text("增加")
}
}
animateFloatAsState 自动插值,配合 Canvas 重绘,流畅动画一行代码搞定。
自定义 Modifier
Compose 里还能封装成 Modifier 复用:
fun Modifier.circleBackground(color: Color) = this.drawBehind {
drawCircle(color)
}
// 用法
Box(
Modifier
.size(100.dp)
.circleBackground(Color.Red)
)
常见坑
- onDraw 里 new Paint:每帧 new,GC 频繁,卡顿。初始化时建。
- 没调
recycle():TypedArray 泄漏。 - invalidate 频繁:每秒 60 次以上没必要,节流。
- 触摸事件没消费:返回 false 后续 MOVE/UP 收不到。
- 自定义 View 不开硬件加速:性能差。Android 4.0+ 默认开,别关。
- onMeasure 不处理 wrap_content:wrap_content 表现像 match_parent,要自己测。
width/height在 onMeasure 用:还是 0,要等 onLayout 后才有值。
小结
自定义 View 三步:onMeasure 测尺寸、onLayout 摆位置(ViewGroup)、onDraw 画内容。Canvas 画图形文字,Paint 配画笔,自定义属性在 attrs.xml 声明 + obtainStyledAttributes 读取。invalidate 重绘、requestLayout 重测。Compose 用 Canvas Modifier + DrawScope,状态驱动自动重绘,更简洁。
下一章讲动画体系。