Android 自定义 View:Canvas 绘图与事件分发深度解析

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.java
  • View#dispatchTouchEvent() --- frameworks/base/core/java/android/view/View.java
  • MotionEvent --- frameworks/base/core/java/android/view/MotionEvent.java

3.2 事件序列与消费规则

一次完整的手势由以下事件序列构成:

复制代码
ACTION_DOWN → ACTION_MOVE × N → ACTION_UP
             (或 ACTION_CANCEL)

核心规则

  1. ACTION_DOWN 决定后续归属 :如果一个 View 在 ACTION_DOWN 返回 false,后续的 ACTION_MOVEACTION_UP 不会再传给它。
  2. 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. 总结

  1. Canvas 坐标变换 通过 save() / restore() 栈管理,变换只影响后续绘制,不影响已绘制内容和触摸坐标系。
  2. Paint 必须在构造阶段初始化onDraw 中创建对象会带来不可忽视的 GC 压力。
  3. ACTION_DOWN 的消费决定整条事件序列的归属ACTION_CANCEL 必须处理以保证状态正确还原。
  4. 触摸区域判断要与视觉内容保持坐标系一致,Canvas 变换不会自动同步到触摸判断。
  5. 嵌套滑动冲突优先使用外部拦截法,逻辑清晰、耦合低。

核心结论:自定义 View 的本质是"在正确的坐标系里画正确的内容,并在同一坐标系里响应正确的触摸区域"------两者必须统一。


参考资料

相关推荐
Android小码家7 小时前
Framework之Launcher小窗开发
android·framework·虚拟屏·小窗
赏金术士7 小时前
第七章:状态管理实战与架构总结
android·ui·kotlin·compose
颂love8 小时前
MySQL的执行流程
android·数据库·mysql
云起SAAS13 小时前
抖音小游戏源码 - 消消乐 | 含激励广告+成就系统 | 开箱即用商业级消除游戏模板
android·游戏·广告联盟·看激励广告联盟流量主·抖音小游戏源码 - 消消乐
大貔貅喝啤酒14 小时前
基于Windows下载安装Android Studio 3.3.2版本教程(2026详细图文版)
android·java·windows·android studio
程序员码歌14 小时前
OpenSpec 到 Superpowers:AI 编码从说清到做对
android·前端·人工智能
2501_9151063214 小时前
深入解析无源码iOS加固原理与方案,保护应用安全
android·安全·ios·小程序·uni-app·cocoa·iphone
黄林晴18 小时前
重磅官宣:Android UI 开发正式进入 Compose-first 时代
android·google io
Kapaseker18 小时前
搞懂变换!精通 Compose 绘制(二)
android·kotlin