【Android】Android 自定义 View:Canvas 绘图与事件分发全解析
Android 自定义 View:Canvas 绘图与事件分发全解析
>一句话收益:掌握 Canvas 绘图 API 与触摸事件分发链,从零构建任意复杂的自定义控件,告别"只会改颜色"的初级阶段。
适用版本:Android API 21+(Lollipop 及以上)阅读时长:约 18 分钟---
1. 从需求出发:为什么官方控件不够用
你正在开发一个数据可视化 App,PM 给了一张设计稿:带动画的环形进度条、可拖拽排序的时间轴、多点触控的涂鸦板。翻遍官方控件库,没有任何现成组件能满足需求。
自定义 View 涉及两大核心模块:
-Canvas 绘图:告诉系统"画什么、画在哪"
-事件分发:告诉系统"触摸事件谁来处理"
这两块理解透了,90% 的自定义控件需求都能覆盖。
---
2. Canvas 绘图机制
2.1 绘图核心类关系
View.onDraw(Canvas)│
├── Canvas ─── 绘图指令集(坐标、裁剪、变换)
│ ├── drawCircle / drawRect / drawPath / drawText
│ ├── drawBitmap / drawArc / drawLine
│ └── save() / restore() / clipRect() / rotate()
│
└── Paint ──── 画笔属性(颜色、样式、特效)
├── color / strokeWidth / style (FILL/STROKE)
├── typeface / textSize / textAlign
└── shader / maskFilter / PathEffect
AOSP 关键类:
-android.graphics.Canvas(frameworks/base/graphics/java/android/graphics/Canvas.java)
-android.graphics.Paint(frameworks/base/graphics/java/android/graphics/Paint.java)
-android.graphics.Path(frameworks/base/graphics/java/android/graphics/Path.java)
2.2 环形进度条:Canvas 核心 API 实战
class RingProgressView @JvmOverloads constructor(context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 24f
strokeCap = Paint.Cap.ROUND
color = Color.parseColor("#E0E0E0")
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 24f
strokeCap = Paint.Cap.ROUND
color = Color.parseColor("#4CAF50")
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
color = Color.BLACK
}
private val oval = RectF()
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate() // 仅标记需要重绘,不立即绘制
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val inset = trackPaint.strokeWidth / 2f
oval.set(inset, inset, w - inset, h - inset)
textPaint.textSize = w / 5f
}
override fun onDraw(canvas: Canvas) {
// 1. 背景圆弧(完整 360°)
canvas.drawArc(oval, -90f, 360f, false, trackPaint)
// 2. 进度圆弧(从 12 点方向顺时针扫)
canvas.drawArc(oval, -90f, 360f * progress, false, progressPaint)
// 3. 居中文字(修正 baseline 偏移)
val cy = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText("${(progress * 100).toInt()}%", width / 2f, cy, textPaint)
}
}
2.3 坐标变换与 save/restore 状态栈
canvas.save() ← 压栈:保存当前矩阵和裁剪状态│
├── canvas.translate(dx, dy)
├── canvas.rotate(degrees, px, py)
├── canvas.scale(sx, sy, px, py)
├── canvas.drawXxx(...)
│
canvas.restore() ← 弹栈:恢复到 save 前的状态
错误写法 → 问题 → 正确写法// ❌ 错误:忘记 save/restore,坐标系永久旋转override fun onDraw(canvas: Canvas) {
canvas.rotate(45f)
canvas.drawBitmap(arrow, 0f, 0f, null)
canvas.drawRect(10f, 10f, 100f, 100f, paint) // 此时也是旋转状态!
}
// ✅ 正确:用 save/restore 隔离变换,互不影响
override fun onDraw(canvas: Canvas) {
val saveCount = canvas.save()
canvas.rotate(45f, width / 2f, height / 2f) // 绕中心旋转
canvas.drawBitmap(arrow, arrowLeft, arrowTop, null)
canvas.restoreToCount(saveCount) // 比 restore() 更安全,防止嵌套不对称
canvas.drawRect(10f, 10f, 100f, 100f, paint) // 正常坐标系
}
2.4 Path 自定义路径
val path = Path().apply {moveTo(50f, 200f) // 起点
quadTo(150f, 50f, 250f, 200f) // 二阶贝塞尔曲线
lineTo(250f, 300f)
close() // 闭合路径
}
canvas.drawPath(path, paint)
// 沿路径绘制文字
canvas.drawTextOnPath("沿曲线排列的文字", path, 0f, 0f, textPaint)
2.5 硬件加速兼容性
API 14+ 默认开启硬件加速,部分 Canvas API 在硬件加速下受限:
| API | 软件绘制 | 硬件加速 |
|-----|---------|---------|
|drawBitmapMesh| ✅ | ❌ API 18 以下 |
|clipPath(非矩形) | ✅ | ✅ API 18+ |
|drawPicture| ✅ | ❌ |
|setXfermode部分模式 | ✅ | ⚠️ 部分支持 |
遇到绘制异常,先排查硬件加速:
// 对特定 View 关闭硬件加速(尽量不用,影响性能)setLayerType(LAYER_TYPE_SOFTWARE, null)
---
3. 事件分发机制
3.1 三层传递结构
Activity.dispatchTouchEvent(ev)│
└── ViewGroup.dispatchTouchEvent(ev)
│
├──[1] onInterceptTouchEvent(ev)
│ ├── return true → 拦截,自己的 onTouchEvent 处理
│ └── return false → 不拦截,继续往下传
│
└──[2] child.dispatchTouchEvent(ev)
│
└── View.onTouchEvent(ev)
├── return true → 消费,事件终止
└── return false → 不消费,冒泡给父级 onTouchEvent
AOSP 关键方法:
-ViewGroup#dispatchTouchEvent(ViewGroup.java:2400+)
-View#onTouchEvent(View.java:15000+)
-View#dispatchTouchEvent(View.java:13800+)
3.2 事件序列与消费规则
一次完整触摸序列:ACTION_DOWN → ACTION_MOVE* → ACTION_UP
1. 若某 View 在ACTION_DOWN返回false,后续MOVE/UP不再发给它
2.onInterceptTouchEvent返回true后,会向子 View 补发ACTION_CANCEL
3. 子 View 可调用parent.requestDisallowInterceptTouchEvent(true)阻止父级拦截
3.3 滑动冲突解决:内部拦截法
// 场景:可拖拽 View 嵌套在 ScrollView 中class DraggableCardView(context: Context) : View(context) {
private var downX = 0f
private var downY = 0f
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.rawX
downY = event.rawY
// 关键:DOWN 时就声明不允许父 View 拦截
parent.requestDisallowInterceptTouchEvent(true)
return true // 必须消费 DOWN!
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - downX
val dy = event.rawY - downY
translationX += dx
translationY += dy
downX = event.rawX
downY = event.rawY
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
// 释放拦截控制,恢复父 View 正常行为
parent.requestDisallowInterceptTouchEvent(false)
}
}
return true
}
}
3.4 多点触控处理
MotionEvent.action → 包含 actionIndex(高位),直接用于单点 whenMotionEvent.actionMasked → 仅保留事件类型,多点触控必须用这个
ACTION_DOWN → 第一根手指落下
ACTION_POINTER_DOWN → 非第一根手指落下(actionMasked 才能捕获)
ACTION_MOVE → 任意手指移动
ACTION_POINTER_UP → 非最后一根手指抬起
ACTION_UP → 最后一根手指抬起
错误写法 → 问题 → 正确写法// ❌ 错误:多点触控用 action,丢失 POINTER_DOWN/UPoverride fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> { /* 只能捕获第一根手指 */ }
MotionEvent.ACTION_POINTER_DOWN -> { /* 永远不会触发! */ }
}
return true
}
// ✅ 正确:使用 actionMasked 处理多点触控
override fun onTouchEvent(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_POINTER_DOWN -> {
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
activePointers[pointerId] = PointF(x, y) // 记录每根手指位置
}
MotionEvent.ACTION_MOVE -> {
for (i in 0 until event.pointerCount) {
val id = event.getPointerId(i)
activePointers[id]?.set(event.getX(i), event.getY(i))
}
invalidate()
}
MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> {
activePointers.remove(pointerId)
}
}
return true
}
---
4. 完整自定义 View 开发流程
4.1 measure → layout → draw 三步流水线
onMeasure(widthSpec, heightSpec)└── 调用 setMeasuredDimension(w, h) 确定自身尺寸
onLayout(changed, l, t, r, b) ← ViewGroup 专用,摆放子 View
onSizeChanged(w, h, oldw, oldh) ← 尺寸确定后更新绘图相关计算
onDraw(canvas)
└── 执行绘制指令
4.2 正确处理 wrap_content
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {// 将 dp 转为 px
val desiredSizePx = (120 * resources.displayMetrics.density).toInt()
fun resolveSize(spec: Int, desired: Int): Int {
return when (MeasureSpec.getMode(spec)) {
MeasureSpec.EXACTLY -> MeasureSpec.getSize(spec) // match_parent 或精确值
MeasureSpec.AT_MOST -> minOf(desired, MeasureSpec.getSize(spec)) // wrap_content
else -> desired // UNSPECIFIED:ScrollView 内
}
}
setMeasuredDimension(
resolveSize(widthMeasureSpec, desiredSizePx),
resolveSize(heightMeasureSpec, desiredSizePx)
)
}
不重写 onMeasure 的后果:wrap_content等价于match_parent,View 会充满父容器。4.3 自定义属性 + 构造器标准写法
init {context.obtainStyledAttributes(attrs, R.styleable.RingProgressView).use { ta ->
progressPaint.color = ta.getColor(
R.styleable.RingProgressView_rpv_ringColor, Color.parseColor("#4CAF50")
)
trackPaint.color = ta.getColor(
R.styleable.RingProgressView_rpv_trackColor, Color.parseColor("#E0E0E0")
)
val strokePx = ta.getDimension(R.styleable.RingProgressView_rpv_strokeWidth, 24f)
progressPaint.strokeWidth = strokePx
trackPaint.strokeWidth = strokePx
progress = ta.getFloat(R.styleable.RingProgressView_rpv_progress, 0f)
}
// 确保 View 可点击(使 onTouchEvent 默认返回 true)
isClickable = true
}
4.4 属性动画驱动进度
// 用 ValueAnimator 平滑更新 progress,比 Thread.sleep 更可靠fun animateToProgress(target: Float, durationMs: Long = 600L) {
ValueAnimator.ofFloat(progress, target).apply {
duration = durationMs
interpolator = DecelerateInterpolator()
addUpdateListener { progress = it.animatedValue as Float }
start()
}
}
---
5. 常见坑点
坑 1:在 onDraw 中创建对象
现象:滑动时帧率波动,Profiler 显示高频 GC原因:onDraw每帧(16ms)可能调用一次,频繁new Paint()/new RectF()触发 GC复现:在onDraw里写val p = Paint(),用 Android Profiler 观察内存锯齿解决:所有绘图对象在init块或成员变量中初始化,onDraw只调用已有对象坑 2:ACTION_DOWN 返回 false 导致手势失效
现象:拖拽、点击完全无响应原因:onTouchEvent对DOWN事件返回false,系统认为该 View 不消费,后续 MOVE/UP 不再发来复现:重写onTouchEvent但忘记在ACTION_DOWN分支返回true解决:只要 View 需要处理手势,ACTION_DOWN必须返回true坑 3:非主线程调用 invalidate
现象:CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.原因:invalidate()内部会直接操作 View 的绘制状态,必须在主线程复现:Thread { progress = 0.5f; invalidate() }.start()解决:改用postInvalidate()(线程安全版),或通过Handler(Looper.getMainLooper())切主线程// ❌ 子线程直接调用thread { progress = 0.5f; invalidate() }
// ✅ 两种安全方案
thread { progress = 0.5f; postInvalidate() } // 方案 A
thread { post { progress = 0.5f; invalidate() } } // 方案 B
坑 4:clipPath 硬件加速兼容性
现象:API 17 以下设备圆角裁剪不生效,出现白色方块原因:非矩形clipPath在 API 18 以下硬件加速模式不支持复现:minSdk=16 项目使用canvas.clipPath(roundedPath)解决:对该 View 关闭硬件加速,或改用BitmapShader+Paint.setShader实现圆角---
6. 最佳实践
1.绘图对象成员化:Paint/Path/RectF 全部在init初始化,严禁 onDraw 内 new 对象。
原因:onDraw 每帧调用,频繁分配会触发 GC 抖动,造成丢帧。
对比:不这样做时,Profiler 会显示明显的 GC 锯齿,滑动帧率从 60fps 跌至 45fps 以下。
2.局部刷新优先:优先调用invalidate(dirtyRect)而非无参invalidate()。
原因:减少重绘面积,GPU 只处理变更区域,复杂界面性能提升明显。
对比:全量刷新在包含大量子 View 的场景下,GPU 渲染时间增加 2~3 倍。
3.事件序列完整性:每次触摸在ACTION_DOWN返回true,在UP/CANCEL清理状态。
原因:不完整的事件消费会导致手势状态机错乱,出现"僵尸手势"。
对比:丢失 CANCEL 处理会导致 View 永远处于"按下"状态,下次点击行为异常。
4.wrap_content 必须重写 onMeasure:任何自定义 View 若支持wrap_content,必须重写。
原因:默认 onMeasure 将 wrap_content 等同于 match_parent。
对比:不重写的自定义 View 在 LinearLayout 中会撑满整个屏幕。
5.动画用 ValueAnimator:通过ValueAnimator+invalidate()驱动自定义 View 动画。
原因:ValueAnimator 接入 Choreographer 的 VSYNC 信号,帧时序准确,不会撕裂。
对比:手动 Thread.sleep(16) 的动画存在 ±5ms 误差,肉眼可见抖动。
---
7. 总结
- Canvas + Paint 是绘图双引擎:Canvas 管"在哪画",Paint 管"怎么画"
-save()/restore()隔离坐标变换,每次 rotate/translate 必须成对出现
- 事件分发遵循"DOWN 决定消费者"原则,ACTION_DOWN 必须返回 true 才能收到后续序列
- 多点触控用actionMasked,用action会丢失 POINTER_DOWN/UP 事件
- 自定义 View 三要素:正确测量(onMeasure)、高效绘制(onDraw 无对象创建)、清晰事件链(消费规则)
核心结论:自定义 View 的本质是正确响应 measure/layout/draw 三步流水线,同时维护完整的事件消费链。---
参考资料
- 官方文档:自定义 View 组件
- 官方文档:Canvas 和 Drawables
- 官方文档:输入事件处理
- 官方文档:多点触控手势
- AOSP 源码:frameworks/base/core/java/android/view/View.java
- AOSP 源码:frameworks/base/core/java/android/view/ViewGroup.java
- AOSP 源码:frameworks/base/graphics/java/android/graphics/Canvas.java
