
Android 自定义 ViewCanvas 绘图与事件分发深度解析一句话收益彻底搞清楚自定义 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 内容区 │ ▼ YCanvas 提供三类坐标变换本质上都是操作一个3×3 的 Matrix// 平移整体移动坐标系原点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()栈canvas.save()// 将当前 Matrix Clip 压栈canvas.translate(50f,50f)canvas.rotate(45f)// ... 绘制操作canvas.restore()// 弹出栈顶状态恢复原坐标系⚠️save()/restore()必须严格配对推荐用try { save(); ... } finally { restore() }确保异常安全。2.3 Paint 的核心属性与性能陷阱// ❌ 错误写法在 onDraw 中创建 PaintoverridefunonDraw(canvas:Canvas){valpaintPaint()// 每帧都触发 GC导致卡顿canvas.drawCircle(cx,cy,radius,paint)}// ✅ 正确写法在构造器或 init 块中初始化privatevalcirclePaintPaint(Paint.ANTI_ALIAS_FLAG).apply{colorColor.BLUE stylePaint.Style.FILL}overridefunonDraw(canvas:Canvas){canvas.drawCircle(cx,cy,radius,circlePaint)}Style 选择对渲染影响Style效果典型场景FILL填充实心圆、背景色块STROKE描边边框、进度环FILL_AND_STROKE填充描边按钮边框效果strokeWidth是描边宽度的两倍问题STROKE模式下描边以路径为中线向两侧各扩展strokeWidth/2因此绘制内边框时需要向内偏移半个 strokeWidth。2.4 Path 的高级用法privatevalarcPathPath()// 绘制圆弧进度fundrawProgress(canvas:Canvas,progress:Float){arcPath.reset()valsweepAngle360f*progress// addArc vs arcTo 的区别// addArc 总是移动到弧起点moveTo// arcTo 仅在不连续时才移动通常用于构建复杂路径arcPath.addArc(oval,-90f,sweepAngle)canvas.drawPath(arcPath,progressPaint)}Path.Op 布尔运算API 19valpath1Path().apply{addCircle(cx,cy,100f,Path.Direction.CW)}valpath2Path().apply{addRect(cx-50f,cy-50f,cx50f,cy50f,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.java3.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 应在此重置状态。// ❌ 错误写法没有处理 ACTION_CANCEL导致按压态不还原overridefunonTouchEvent(event:MotionEvent):Boolean{when(event.action){MotionEvent.ACTION_DOWN-isPressedtrueMotionEvent.ACTION_UP-{isPressedfalseperformClick()}// 忘记处理 ACTION_CANCEL}returntrue}// ✅ 正确写法overridefunonTouchEvent(event:MotionEvent):Boolean{when(event.action){MotionEvent.ACTION_DOWN-isPressedtrueMotionEvent.ACTION_UP-{isPressedfalseperformClick()}MotionEvent.ACTION_CANCEL-{isPressedfalse// 重置状态避免按压态残留}}returntrue}3.3 坐标系转换击中判断的正确姿势回到开篇的 bug在onDraw里做了canvas.translate(50f, 50f)绘制的圆圆心在视觉上是(50cx, 50cy)但 Canvas 变换不影响触摸坐标。正确做法始终基于 View 自身坐标系无变换来判断触摸区域或手动映射// 情景圆心在视觉上的绝对位置是 (translateX cx, translateY cy)// 触摸坐标 event.x / event.y 是 View 本地坐标不含 canvas 变换privatevaltranslateX50fprivatevaltranslateY50fprivatevalcircleCx100f// 绘制时相对于变换后坐标系的圆心privatevalcircleCy100fprivatevalcircleRadius80f// 视觉圆心在 View 坐标系中的实际位置privatevalactualCxget()translateXcircleCxprivatevalactualCyget()translateYcircleCyoverridefunonTouchEvent(event:MotionEvent):Boolean{if(event.actionMotionEvent.ACTION_DOWN){valdxevent.x-actualCxvaldyevent.y-actualCyif(dx*dxdy*dycircleRadius*circleRadius){// 击中圆形区域returntrue}}returnsuper.onTouchEvent(event)}ViewGroup 坐标转换工具// 将子 View 坐标转换为父 ViewGroup 坐标valoffsetIntArray(2)childView.getLocationInWindow(offset)// 父 ViewGroup 的点击坐标转子 View 坐标valchildXparentX-childView.leftvalchildYparentY-childView.top3.4 嵌套滑动冲突处理典型场景RecyclerView 内嵌 ViewPager横向滑动被 RecyclerView 截断。解决方案一外部拦截法推荐改 ViewGroupclassHorizontalViewPagerContainer(context:Context):ViewGroup(context){privatevarlastX0fprivatevarlastY0foverridefunonInterceptTouchEvent(ev:MotionEvent):Boolean{returnwhen(ev.action){MotionEvent.ACTION_DOWN-{lastXev.x lastYev.yfalse// DOWN 事件绝不拦截否则子 View 收不到 DOWN}MotionEvent.ACTION_MOVE-{valdeltaXabs(ev.x-lastX)valdeltaYabs(ev.y-lastY)// 水平位移更大时拦截交给自己处理横向滑动deltaXdeltaY}else-false}}}解决方案二内部拦截法改子 View// 子 View 在需要时调用 requestDisallowInterceptTouchEventoverridefunonTouchEvent(event:MotionEvent):Boolean{when(event.action){MotionEvent.ACTION_DOWN-parent.requestDisallowInterceptTouchEvent(true)MotionEvent.ACTION_MOVE-{// 判断是否是父 View 应该处理的方向if(shouldParentIntercept()){parent.requestDisallowInterceptTouchEvent(false)}}}returnsuper.onTouchEvent(event)}两种方案对比外部拦截法逻辑集中、耦合低优先使用内部拦截法需要子 View 了解父 View 逻辑耦合高适合第三方 View 无法修改父 ViewGroup 的情况。4. 完整实战可点击的环形进度条classRingProgressViewJvmOverloadsconstructor(context:Context,attrs:AttributeSet?null,defStyleAttr:Int0):View(context,attrs,defStyleAttr){varprogress:Float0fset(value){fieldvalue.coerceIn(0f,1f)invalidate()// 触发重绘}privatevaltrackPaintPaint(Paint.ANTI_ALIAS_FLAG).apply{stylePaint.Style.STROKE strokeWidth20fcolorColor.LTGRAY strokeCapPaint.Cap.ROUND}privatevalprogressPaintPaint(Paint.ANTI_ALIAS_FLAG).apply{stylePaint.Style.STROKE strokeWidth20fcolorColor.BLUE strokeCapPaint.Cap.ROUND}privatevalovalRectF()privatevalcenterPaintPaint(Paint.ANTI_ALIAS_FLAG).apply{colorColor.WHITE stylePaint.Style.FILL}// 实际绘制边界考虑 strokeWidth 溢出overridefunonSizeChanged(w:Int,h:Int,oldw:Int,oldh:Int){valinsettrackPaint.strokeWidth/2foval.set(inset,inset,w-inset,h-inset)}overridefunonDraw(canvas:Canvas){// 绘制轨道canvas.drawOval(oval,trackPaint)// 绘制进度弧canvas.drawArc(oval,-90f,360f*progress,false,progressPaint)// 绘制中心白色遮罩形成空心效果valcenterRadiusoval.width()/2f-trackPaint.strokeWidth canvas.drawCircle(oval.centerX(),oval.centerY(),centerRadius,centerPaint)}overridefunonTouchEvent(event:MotionEvent):Boolean{if(event.actionMotionEvent.ACTION_DOWN){valcxoval.centerX()valcyoval.centerY()valdxevent.x-cxvaldyevent.y-cyvaldistMath.sqrt((dx*dxdy*dy).toDouble()).toFloat()valouterRoval.width()/2fvalinnerRouterR-trackPaint.strokeWidth// 仅当点击在进度环上时响应if(distininnerR..outerR){performClick()returntrue}}returnsuper.onTouchEvent(event)}// 无障碍支持必须重写 performClickoverridefunperformClick():Boolean{super.performClick()// 触发点击回调returntrue}}关键点解析onSizeChanged中计算oval避免在onDraw中每帧创建RectFGC 压力onTouchEvent用环形区域判断替代矩形碰撞与视觉完全一致重写performClick()是无障碍Accessibility规范要求缺少会触发 Lint 警告5. 常见坑点坑 1invalidate()vspostInvalidate()现象在非主线程调用invalidate()后View 没有重绘或抛出CalledFromWrongThreadException。原因invalidate()只能在主线程调用postInvalidate()内部通过 Handler 切换到主线程。复现在 IO 协程中更新 View 状态后调用invalidate()。解决非主线程用postInvalidate()或在协程中切换到Dispatchers.Main。// ❌ IO 线程直接调用scope.launch(Dispatchers.IO){progressfetchProgress()invalidate()// 崩溃或无效}// ✅ 切回主线程scope.launch(Dispatchers.IO){valpfetchProgress()withContext(Dispatchers.Main){progressp// setter 中调用 invalidate()}}坑 2onMeasure未处理AT_MOST现象自定义 View 在wrap_content下铺满屏幕。原因View基类在AT_MOST模式下默认使用父容器允许的最大尺寸。复现xml 中设置android:layout_widthwrap_content。解决重写onMeasure()并处理MeasureSpec.AT_MOSToverridefunonMeasure(widthMeasureSpec:Int,heightMeasureSpec:Int){valdesiredSize200// View 希望的尺寸dp 转 pxvalwidthresolveSize(desiredSize,widthMeasureSpec)valheightresolveSize(desiredSize,heightMeasureSpec)setMeasuredDimension(width,height)}// resolveSize 内部自动处理 EXACTLY / AT_MOST / UNSPECIFIED 三种模式坑 3硬件加速下部分 Canvas API 不支持现象canvas.drawBitmapMesh()、canvas.clipPath()在部分设备上无效或效果异常。原因硬件加速的 Canvas 不支持所有 2D API。复现在开启硬件加速默认开启的 View 上使用不支持的 API。解决对特定 View 关闭硬件加速// View 级别关闭硬件加速view.setLayerType(View.LAYER_TYPE_SOFTWARE,null)// 或在 XML 中// android:layerTypesoftware关闭硬件加速会对渲染性能有影响应尽量缩小关闭范围。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.javaonDraw、dispatchTouchEventAOSP 源码frameworks/base/core/java/android/view/ViewGroup.javaonInterceptTouchEvent、dispatchTouchEventAOSP 源码frameworks/base/graphics/java/android/graphics/Canvas.java