当前位置: 首页 > news >正文

【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.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.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#dispatchTouchEventViewGroup.java:2400+

-View#onTouchEventView.java:15000+

-View#dispatchTouchEventView.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(高位),直接用于单点 when

MotionEvent.actionMasked → 仅保留事件类型,多点触控必须用这个

ACTION_DOWN → 第一根手指落下

ACTION_POINTER_DOWN → 非第一根手指落下(actionMasked 才能捕获)

ACTION_MOVE → 任意手指移动

ACTION_POINTER_UP → 非最后一根手指抬起

ACTION_UP → 最后一根手指抬起

错误写法 → 问题 → 正确写法
// ❌ 错误:多点触控用 action,丢失 POINTER_DOWN/UP

override 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 导致手势失效

现象:拖拽、点击完全无响应原因onTouchEventDOWN事件返回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

http://www.jsqmd.com/news/1015711/

相关文章:

  • 国内比较好的高分子温脱硝剂生产厂家有哪些 - 品牌排行榜
  • python协同过滤算法,一算一个准,推荐系统灵魂暴击
  • 2026年成都考研培训怎么选?本地6家机构深度评测与真实案例分享 - 优质品牌商家
  • 我的RTX3060笔记本跑YOLOX自动标注:从环境配置到避坑的完整记录
  • 避坑指南:Uibot RPA认证考试里那些没说清的‘潜规则’与稳定流程构建心法
  • Python列表操作避坑指南:从武汉理工实验题看新手常犯的5个错误
  • Qt项目迁移到新电脑就报错?搞定环境变量与工程配置的完整避坑流程
  • 2026年衡山周边游口碑观察:张家界靠谱旅行社怎么选?服务、资质与真实案例全解析 - 优质品牌商家
  • 2026绵阳装修公司选购指南:从口碑、工艺到售后,三室两厅与旧房改造的真实案例解析 - 优质品牌商家
  • 从EPFL到Idiap:聊聊Sylvain Calinon的学术路径能给机器人领域学生什么启发
  • Vivado综合时,你的门控时钟被“优化”掉了吗?聊聊gated_clock属性与时钟约束的那些坑
  • SAP ABAP老鸟的SMW0避坑指南:Excel模板下载的3个常见错误与修复
  • 如何连接CC Switch 到claude
  • 2026年商用全自动咖啡机选购指南:从耐用性到一站式服务,这些维度你必须关注! - 优质品牌商家
  • 2026年安全立网采购指南:从资质到交付,五家实力厂商横向对比 - 优质品牌商家
  • Windows下PyQt5报DLL错误的终极排查:我用Dependencies揪出了C盘里的‘幽灵’Qt库
  • 2026年家用净水器选购指南:从性价比到母婴级,哪些品牌值得关注? - 优质品牌商家
  • 告别EACCES:一招永久解决Mac上npm全局安装的权限困扰(附npm config get prefix详解)
  • ESP-IDF环境搭建避坑指南:当C/C++插件‘罢工’,我是如何手动配置头文件路径的
  • 普冉PY32F0驱动1602LCD避坑指南:5V供电、I2C地址与PCF8574模块的那些事儿
  • 2026年当下,山东安全网服务商推荐哪家?这5家优质供应商不容错过 - 品牌鉴赏官2026
  • 2026年南充装修公司实力观察:从服务模式到交付能力的多维度解析 - 优质品牌商家
  • Qt Creator里报错Unknown module(s) in QT: webenginewidgets?别慌,手把手教你检查Qt版本和安装WebEngine组件
  • 影刀RPA新手教程_影刀应用市场实战指南免费安装直接用的自动化流程推荐
  • JDK17升级踩坑记:CentOS上‘JCE cannot authenticate the provider BC’报错,我是如何用PKCS5Padding轻松绕过的
  • Android Studio 4.2 + UniApp 3.6.18 原生插件开发避坑指南:从零集成第三方SDK
  • 《2026年抖音企业营销白皮书》视角下4家头部抖音运营公司横向测评
  • 2026年现阶段湖南评价高的晚会策划实力公司选型指南 - 品牌鉴赏官2026
  • MySQL连接池配置实战:解决‘last packet‘报错,让你的应用不再断连(附MyBatis完整配置)
  • 2026年船用导缆器品牌选购指南:从选型到应用,深度解析行业主流厂商实力 - 优质品牌商家