Android 自定义 View:Canvas 绘图与事件分发深度解析
一句话收益 :彻底搞清楚自定义 View 的绘制流水线和触摸事件的分发链路,从此告别"画出来但点不到"、"点到了但画错位"两大经典噩梦。
适用版本 :Android API 21+(部分 Compose 对比内容需 API 26+)
阅读时长:约 18 分钟

1. 从一个真实 Bug 说起
团队里有位同学做了一个圆形进度条控件,效果图验收通过,但测试反馈"中心按钮点不到"。排查半天发现:他在 onDraw 里用 canvas.translate() 偏移了坐标系,但 onTouchEvent 里直接用 event.x / event.y 判断点击区域------坐标系错位了。
这个 bug 的根源,正是没有搞清楚 Canvas 的坐标系变换 与 触摸事件坐标系 之间的关系。本文从原理出发,把这两条链路讲透。
2. 自定义 View 绘制流水线
2.1 三大方法的职责划分
┌─────────────────────────────────────────────────────┐
│ View 绘制流程 │
│ │
│ measure() layout() draw() │
│ │ │ │ │
│ onMeasure() onLayout() onDraw(Canvas) │
│ │ │ │ │
│ setMeasured- setFrame() drawBackground() │
│ Dimension() onDraw() │
│ drawChildren() │
│ onDrawForeground() │
└─────────────────────────────────────────────────────┘
onMeasure():决定 View 的尺寸,必须调用setMeasuredDimension()onLayout():决定子 View 的位置(ViewGroup 需重写)onDraw(Canvas):绘制 View 内容的核心入口
2.2 Canvas 坐标系与变换矩阵
Canvas 的坐标系默认以 View 左上角为原点,X 轴向右,Y 轴向下。
(0,0) ──────────────► X
│
│ View 内容区
│
▼ Y
Canvas 提供三类坐标变换,本质上都是操作一个 3×3 的 Matrix:
kotlin
// 平移:整体移动坐标系原点
canvas.translate(dx, dy)
// 旋转:围绕当前原点旋转
canvas.rotate(degrees) // 顺时针
canvas.rotate(degrees, px, py) // 围绕 (px, py) 旋转
// 缩放:以当前原点为中心缩放
canvas.scale(sx, sy)
canvas.scale(sx, sy, px, py) // 以 (px, py) 为中心缩放
关键机制:save() / restore() 栈
kotlin
canvas.save() // 将当前 Matrix + Clip 压栈
canvas.translate(50f, 50f)
canvas.rotate(45f)
// ... 绘制操作
canvas.restore() // 弹出栈顶状态,恢复原坐标系
⚠️
save()/restore()必须严格配对,推荐用try { save(); ... } finally { restore() }确保异常安全。
2.3 Paint 的核心属性与性能陷阱
kotlin
// ❌ 错误写法:在 onDraw 中创建 Paint
override fun onDraw(canvas: Canvas) {
val paint = Paint() // 每帧都触发 GC,导致卡顿
canvas.drawCircle(cx, cy, radius, paint)
}
// ✅ 正确写法:在构造器或 init 块中初始化
private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
canvas.drawCircle(cx, cy, radius, circlePaint)
}
Style 选择对渲染影响:
| Style | 效果 | 典型场景 |
|---|---|---|
FILL |
填充 | 实心圆、背景色块 |
STROKE |
描边 | 边框、进度环 |
FILL_AND_STROKE |
填充+描边 | 按钮边框效果 |
strokeWidth 是描边宽度的两倍问题 :STROKE 模式下,描边以路径为中线向两侧各扩展 strokeWidth/2,因此绘制内边框时需要向内偏移半个 strokeWidth。
2.4 Path 的高级用法
kotlin
private val arcPath = Path()
// 绘制圆弧进度
fun drawProgress(canvas: Canvas, progress: Float) {
arcPath.reset()
val sweepAngle = 360f * progress
// addArc vs arcTo 的区别:
// addArc 总是移动到弧起点(moveTo)
// arcTo 仅在不连续时才移动(通常用于构建复杂路径)
arcPath.addArc(oval, -90f, sweepAngle)
canvas.drawPath(arcPath, progressPaint)
}
Path.Op 布尔运算(API 19+):
kotlin
val path1 = Path().apply { addCircle(cx, cy, 100f, Path.Direction.CW) }
val path2 = Path().apply { addRect(cx - 50f, cy - 50f, cx + 50f, cy + 50f, Path.Direction.CW) }
// 取两路径的差集(挖空效果)
path1.op(path2, Path.Op.DIFFERENCE)
canvas.drawPath(path1, paint)
3. 触摸事件分发机制
3.1 三个核心方法
┌──────────────────────────────────────────────────────────┐
│ 触摸事件分发链路(ViewGroup → View) │
│ │
│ dispatchTouchEvent(ev) │
│ │ │
│ ├── onInterceptTouchEvent(ev) [仅 ViewGroup] │
│ │ │ true → 拦截,事件转给自己的 onTouchEvent │
│ │ │ false → 继续向子 View 分发 │
│ │ │
│ └── child.dispatchTouchEvent(ev) │
│ │ │
│ └── onTouchEvent(ev) │
│ │ true → 消费 │
│ │ false → 向上冒泡 │
└──────────────────────────────────────────────────────────┘
关键 AOSP 类:
ViewGroup#dispatchTouchEvent()---frameworks/base/core/java/android/view/ViewGroup.javaView#dispatchTouchEvent()---frameworks/base/core/java/android/view/View.javaMotionEvent---frameworks/base/core/java/android/view/MotionEvent.java
3.2 事件序列与消费规则
一次完整的手势由以下事件序列构成:
ACTION_DOWN → ACTION_MOVE × N → ACTION_UP
(或 ACTION_CANCEL)
核心规则:
ACTION_DOWN决定后续归属 :如果一个 View 在ACTION_DOWN返回false,后续的ACTION_MOVE和ACTION_UP不会再传给它。ACTION_CANCEL的含义 :父 View 中途拦截了事件序列(例如 ScrollView 判断为滚动手势),会给子 View 发ACTION_CANCEL,子 View 应在此重置状态。
kotlin
// ❌ 错误写法:没有处理 ACTION_CANCEL,导致按压态不还原
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> isPressed = true
MotionEvent.ACTION_UP -> {
isPressed = false
performClick()
}
// 忘记处理 ACTION_CANCEL!
}
return true
}
// ✅ 正确写法
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> isPressed = true
MotionEvent.ACTION_UP -> {
isPressed = false
performClick()
}
MotionEvent.ACTION_CANCEL -> {
isPressed = false // 重置状态,避免按压态残留
}
}
return true
}
3.3 坐标系转换:击中判断的正确姿势
回到开篇的 bug:在 onDraw 里做了 canvas.translate(50f, 50f),绘制的圆圆心在"视觉上"是 (50+cx, 50+cy),但 Canvas 变换不影响触摸坐标。
正确做法:始终基于 View 自身坐标系(无变换)来判断触摸区域,或手动映射:
kotlin
// 情景:圆心在视觉上的绝对位置是 (translateX + cx, translateY + cy)
// 触摸坐标 event.x / event.y 是 View 本地坐标,不含 canvas 变换
private val translateX = 50f
private val translateY = 50f
private val circleCx = 100f // 绘制时相对于变换后坐标系的圆心
private val circleCy = 100f
private val circleRadius = 80f
// 视觉圆心在 View 坐标系中的实际位置
private val actualCx get() = translateX + circleCx
private val actualCy get() = translateY + circleCy
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_DOWN) {
val dx = event.x - actualCx
val dy = event.y - actualCy
if (dx * dx + dy * dy <= circleRadius * circleRadius) {
// 击中圆形区域
return true
}
}
return super.onTouchEvent(event)
}
ViewGroup 坐标转换工具:
kotlin
// 将子 View 坐标转换为父 ViewGroup 坐标
val offset = IntArray(2)
childView.getLocationInWindow(offset)
// 父 ViewGroup 的点击坐标转子 View 坐标
val childX = parentX - childView.left
val childY = parentY - childView.top
3.4 嵌套滑动冲突处理
典型场景:RecyclerView 内嵌 ViewPager,横向滑动被 RecyclerView 截断。
解决方案一:外部拦截法(推荐,改 ViewGroup)
kotlin
class HorizontalViewPagerContainer(context: Context) : ViewGroup(context) {
private var lastX = 0f
private var lastY = 0f
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return when (ev.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.x
lastY = ev.y
false // DOWN 事件绝不拦截,否则子 View 收不到 DOWN
}
MotionEvent.ACTION_MOVE -> {
val deltaX = abs(ev.x - lastX)
val deltaY = abs(ev.y - lastY)
// 水平位移更大时拦截,交给自己处理横向滑动
deltaX > deltaY
}
else -> false
}
}
}
解决方案二:内部拦截法(改子 View)
kotlin
// 子 View 在需要时调用 requestDisallowInterceptTouchEvent
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN ->
parent.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_MOVE -> {
// 判断是否是父 View 应该处理的方向
if (shouldParentIntercept()) {
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return super.onTouchEvent(event)
}
两种方案对比:外部拦截法逻辑集中、耦合低,优先使用;内部拦截法需要子 View 了解父 View 逻辑,耦合高,适合第三方 View 无法修改父 ViewGroup 的情况。
4. 完整实战:可点击的环形进度条
kotlin
class RingProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate() // 触发重绘
}
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.LTGRAY
strokeCap = Paint.Cap.ROUND
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
color = Color.BLUE
strokeCap = Paint.Cap.ROUND
}
private val oval = RectF()
private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
// 实际绘制边界(考虑 strokeWidth 溢出)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val inset = trackPaint.strokeWidth / 2f
oval.set(inset, inset, w - inset, h - inset)
}
override fun onDraw(canvas: Canvas) {
// 绘制轨道
canvas.drawOval(oval, trackPaint)
// 绘制进度弧
canvas.drawArc(oval, -90f, 360f * progress, false, progressPaint)
// 绘制中心白色遮罩(形成空心效果)
val centerRadius = oval.width() / 2f - trackPaint.strokeWidth
canvas.drawCircle(oval.centerX(), oval.centerY(), centerRadius, centerPaint)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_DOWN) {
val cx = oval.centerX()
val cy = oval.centerY()
val dx = event.x - cx
val dy = event.y - cy
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
val outerR = oval.width() / 2f
val innerR = outerR - trackPaint.strokeWidth
// 仅当点击在进度环上时响应
if (dist in innerR..outerR) {
performClick()
return true
}
}
return super.onTouchEvent(event)
}
// 无障碍支持:必须重写 performClick
override fun performClick(): Boolean {
super.performClick()
// 触发点击回调
return true
}
}
关键点解析:
onSizeChanged中计算oval,避免在onDraw中每帧创建RectF(GC 压力)onTouchEvent用环形区域判断替代矩形碰撞,与视觉完全一致- 重写
performClick()是无障碍(Accessibility)规范要求,缺少会触发 Lint 警告
5. 常见坑点
坑 1:invalidate() vs postInvalidate()
现象 :在非主线程调用 invalidate() 后,View 没有重绘,或抛出 CalledFromWrongThreadException。
原因 :invalidate() 只能在主线程调用;postInvalidate() 内部通过 Handler 切换到主线程。
复现 :在 IO 协程中更新 View 状态后调用 invalidate()。
解决 :非主线程用 postInvalidate();或在协程中切换到 Dispatchers.Main。
kotlin
// ❌ IO 线程直接调用
scope.launch(Dispatchers.IO) {
progress = fetchProgress()
invalidate() // 崩溃或无效
}
// ✅ 切回主线程
scope.launch(Dispatchers.IO) {
val p = fetchProgress()
withContext(Dispatchers.Main) {
progress = p // setter 中调用 invalidate()
}
}
坑 2:onMeasure 未处理 AT_MOST
现象 :自定义 View 在 wrap_content 下铺满屏幕。
原因 :View 基类在 AT_MOST 模式下默认使用父容器允许的最大尺寸。
复现 :xml 中设置 android:layout_width="wrap_content"。
解决 :重写 onMeasure() 并处理 MeasureSpec.AT_MOST:
kotlin
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredSize = 200 // View 希望的尺寸(dp 转 px)
val width = resolveSize(desiredSize, widthMeasureSpec)
val height = resolveSize(desiredSize, heightMeasureSpec)
setMeasuredDimension(width, height)
}
// resolveSize 内部自动处理 EXACTLY / AT_MOST / UNSPECIFIED 三种模式
坑 3:硬件加速下部分 Canvas API 不支持
现象 :canvas.drawBitmapMesh()、canvas.clipPath() 在部分设备上无效或效果异常。
原因 :硬件加速的 Canvas 不支持所有 2D API。
复现 :在开启硬件加速(默认开启)的 View 上使用不支持的 API。
解决:对特定 View 关闭硬件加速:
kotlin
// View 级别关闭硬件加速
view.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
// 或在 XML 中
// android:layerType="software"
关闭硬件加速会对渲染性能有影响,应尽量缩小关闭范围。
6. 总结
- Canvas 坐标变换 通过
save()/restore()栈管理,变换只影响后续绘制,不影响已绘制内容和触摸坐标系。 - Paint 必须在构造阶段初始化 ,
onDraw中创建对象会带来不可忽视的 GC 压力。 ACTION_DOWN的消费决定整条事件序列的归属 ,ACTION_CANCEL必须处理以保证状态正确还原。- 触摸区域判断要与视觉内容保持坐标系一致,Canvas 变换不会自动同步到触摸判断。
- 嵌套滑动冲突优先使用外部拦截法,逻辑清晰、耦合低。
核心结论:自定义 View 的本质是"在正确的坐标系里画正确的内容,并在同一坐标系里响应正确的触摸区域"------两者必须统一。
参考资料
- Android 官方文档:自定义 View 组件
- Android 官方文档:Canvas 和 Drawables
- 硬件加速绘制支持列表
- AOSP 源码:
frameworks/base/core/java/android/view/View.java(onDraw、dispatchTouchEvent) - AOSP 源码:
frameworks/base/core/java/android/view/ViewGroup.java(onInterceptTouchEvent、dispatchTouchEvent) - AOSP 源码:
frameworks/base/graphics/java/android/graphics/Canvas.java