【Android】Android 自定义 View:Canvas 绘图与事件分发全解析
Android 自定义 ViewCanvas 绘图与事件分发全解析一句话收益掌握 Canvas 绘图 API 与触摸事件分发链从零构建任意复杂的自定义控件告别只会改颜色的初级阶段。适用版本Android API 21Lollipop 及以上阅读时长约 18 分钟---1. 从需求出发为什么官方控件不够用你正在开发一个数据可视化 AppPM 给了一张设计稿带动画的环形进度条、可拖拽排序的时间轴、多点触控的涂鸦板。翻遍官方控件库没有任何现成组件能满足需求。自定义 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 / PathEffectAOSP 关键类-android.graphics.Canvasframeworks/base/graphics/java/android/graphics/Canvas.java-android.graphics.Paintframeworks/base/graphics/java/android/graphics/Paint.java-android.graphics.Pathframeworks/base/graphics/java/android/graphics/Path.java2.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.STROKEstrokeWidth 24fstrokeCap Paint.Cap.ROUNDcolor Color.parseColor(#E0E0E0)}private val progressPaint Paint(Paint.ANTI_ALIAS_FLAG).apply {style Paint.Style.STROKEstrokeWidth 24fstrokeCap Paint.Cap.ROUNDcolor Color.parseColor(#4CAF50)}private val textPaint Paint(Paint.ANTI_ALIAS_FLAG).apply {textAlign Paint.Align.CENTERcolor Color.BLACK}private val oval RectF()var progress: Float 0fset(value) {field value.coerceIn(0f, 1f)invalidate() // 仅标记需要重绘不立即绘制}override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {val inset trackPaint.strokeWidth / 2foval.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()) / 2fcanvas.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 → 不消费冒泡给父级 onTouchEventAOSP 关键方法-ViewGroup#dispatchTouchEventViewGroup.java:2400-View#onTouchEventView.java:15000-View#dispatchTouchEventView.java:138003.2 事件序列与消费规则一次完整触摸序列ACTION_DOWN → ACTION_MOVE* → ACTION_UP核心规则1. 若某 View 在ACTION_DOWN返回false后续MOVE/UP不再发给它2.onInterceptTouchEvent返回true后会向子 View 补发ACTION_CANCEL3. 子 View 可调用parent.requestDisallowInterceptTouchEvent(true)阻止父级拦截3.3 滑动冲突解决内部拦截法// 场景可拖拽 View 嵌套在 ScrollView 中class DraggableCardView(context: Context) : View(context) {private var downX 0fprivate var downY 0foverride fun onTouchEvent(event: MotionEvent): Boolean {when (event.action) {MotionEvent.ACTION_DOWN - {downX event.rawXdownY event.rawY// 关键DOWN 时就声明不允许父 View 拦截parent.requestDisallowInterceptTouchEvent(true)return true // 必须消费 DOWN}MotionEvent.ACTION_MOVE - {val dx event.rawX - downXval dy event.rawY - downYtranslationX dxtranslationY dydownX event.rawXdownY 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.actionIndexval 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 专用摆放子 ViewonSizeChanged(w, h, oldw, oldh) ← 尺寸确定后更新绘图相关计算onDraw(canvas)└── 执行绘制指令4.2 正确处理 wrap_contentoverride fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {// 将 dp 转为 pxval 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_contentelse - desired // UNSPECIFIEDScrollView 内}}setMeasuredDimension(resolveSize(widthMeasureSpec, desiredSizePx),resolveSize(heightMeasureSpec, desiredSizePx))}不重写 onMeasure 的后果wrap_content等价于match_parentView 会充满父容器。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 strokePxtrackPaint.strokeWidth strokePxprogress ta.getFloat(R.styleable.RingProgressView_rpv_progress, 0f)}// 确保 View 可点击使 onTouchEvent 默认返回 trueisClickable true}4.4 属性动画驱动进度// 用 ValueAnimator 平滑更新 progress比 Thread.sleep 更可靠fun animateToProgress(target: Float, durationMs: Long 600L) {ValueAnimator.ofFloat(progress, target).apply {duration durationMsinterpolator 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只调用已有对象坑 2ACTION_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() } // 方案 Athread { post { progress 0.5f; invalidate() } } // 方案 B坑 4clipPath 硬件加速兼容性现象API 17 以下设备圆角裁剪不生效出现白色方块原因非矩形clipPath在 API 18 以下硬件加速模式不支持复现minSdk16 项目使用canvas.clipPath(roundedPath)解决对该 View 关闭硬件加速或改用BitmapShaderPaint.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通过ValueAnimatorinvalidate()驱动自定义 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