前言
在 Android 开发中,Canvas 是自定义 View 和图形绘制的绝对核心。但大多数开发者对 Canvas 的认知仅仅停留在 canvas.drawCircle() 的 API 层面。
本文将从宏观的系统架构调度,到中观的底层双轨渲染机制,再到微观的具体几何绘制实战,结合核心流程图与深度源码解析,带你彻底搞懂 Android Canvas 的底层运作机制与最佳实践。
一、Android 图形渲染架构全景
Canvas 并不是孤立存在的,它嵌套在 Android 庞大的多层渲染架构中。从你调用的 Java API 到屏幕上亮起的物理像素,需要经过以下六个层级:

核心认知:你在 Java 层调用的 Canvas,本质上只是一个指令发射器或代理对象。真正的绘图工作是在 C++ 层的 Skia 引擎或 GPU 线程中完成的。
二、从 invalidate 到屏幕像素
当我们调用 view.invalidate() 触发重绘时,到底发生了什么?以下是完整的绘制调度流程:
流程详细解析:
- 异步绘制机制:
invalidate()绝不是同步绘制的!它只是打个标记(设置PFLAG_DIRTY),然后一路传递到ViewRootImpl。真正的绘制要等 VSYNC 信号到来。 - Choreographer 编舞者:它是 VSYNC 信号的消费者,统一调度 Input、Animation、Traversal 三种类型的回调,保证动画和绘制的帧率与屏幕刷新率同步,避免画面撕裂。
- 双轨分流(关键点):在
performDraw阶段,根据是否开启硬件加速,Canvas的行为产生本质分歧:- 软件渲染:同步阻塞主线程,CPU 直接算像素。
- 硬件加速:主线程只记录指令,真正的渲染在
RenderThread异步完成,主线程被释放。
三、Canvas 的真实面貌
在了解了整个绘制链路之后,我们将镜头拉近,看看处于漩涡中心的 Canvas 到底是什么。
3.1 本质:指令发射器与状态栈
很多初学者以为 Canvas 就是一张带像素的纸。错!Canvas 本身不包含任何像素数据。
- 在软件渲染下,像素在
Bitmap里。 - 在硬件渲染下,像素在 GPU 的
Graphic Buffer里。Canvas只是持有这些像素目标的引用,你调用的drawXxx()是在向底层引擎发送绘制指令。
同时,Canvas 内部维护了一个非常重要的数据结构:状态栈。

save() 和 restore() 的核心作用:
- 保存和恢复的是 Matrix(变换矩阵) 和 Clip(裁剪区域)。
- 绝对不保存 Paint 属性!要想恢复 Paint,需要自己用
paint.reset()或保存副本。3.2 硬件加速 vs 软件渲染的 Canvas 差异
这是理解现代 Android 绘制性能的核心。从 Android 4.0 开始默认开启硬件加速,彻底改变了 Canvas 的工作方式。

| 维度 | 软件渲染 | 硬件加速 |
| :--- | :--- | :--- |
| Canvas实现类 | SkiaCanvas (基于 SkCanvas 封装) | HardwareCanvas (基于 RenderNode 封装) |
| 执行线程 | 主线程(同步阻塞,耗时长掉帧) | 主线程记录 + RenderThread 异步渲染 |
| 执行方式 | 立即执行:调一次 draw,算一次像素 | 延迟执行:调 draw 只是往 DisplayList 加一条 Op |
| 属性动画 | 每一帧都要完整走 onDraw 重新记录指令 | 直接修改 RenderNode 属性(X/Y/Alpha),无需 onDraw |
| saveLayer 性能 | 极差:CPU 内存中 new 临时 Bitmap,大量拷贝 | 较好:GPU 中创建 Frame Buffer Object (FBO) |
| clipPath 抗锯齿 | 支持 | 不支持(会强制降级走软件渲染,性能大坑!) |
3.3 View.draw() 的内部流水线与源码印证
当 performDraw 最终走到你的自定义 View 时,内部按严格顺序执行四件事:

我们看一下源码中 Canvas 的创建过程,这能最直观地解释它的本质分歧:
软件渲染下的 Canvas(绑定 Bitmap 持有像素):
public Canvas(Bitmap bitmap) {
if (!bitmap.isMutable()) throw new IllegalStateException();
// 在 C++ 层创建 SkCanvas,并让 SkCanvas 持有这个 Bitmap 的像素指针
mNativeCanvasWrapper = nativeCreate(bitmap.getNativeInstance());
mBitmap = bitmap;
}
硬件加速下的 Canvas(绑定 RenderNode 记录指令):
// 在 ViewRootImpl.draw 硬件加速分支
private boolean draw(boolean fullRedrawNeeded) {
// 返回 HardwareCanvas,底层没有绑定 CPU Bitmap,而是绑定了 RenderNode
hwCanvas = mSurface.lockHardwareCanvas();
mView.draw(hwCanvas); // 传递给 View 树
}
四、 核心几何图形绘制 API 详解
在理解了底层原理后,回到最落地的部分:如何使用 Canvas 绘制几何图形。
前置铁律:调用
drawXxx()时,Canvas 决定“画在哪、怎么变换”,Paint 决定“画成什么样(颜色、粗细、样式)”。以下所有Paint均作为成员变量提前初始化,严格禁止在onDraw中 new 对象。4.1 矩形、圆角矩形与线段
// --- 成员变量初始化 --- private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#00E5A0") style = Paint.Style.FILL // 填充模式 } private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#FFFFFF") style = Paint.Style.STROKE // 描边模式 strokeWidth = 10f } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 1. 纯色填充矩形 canvas.drawRect(50f, 50f, 300f, 200f, fillPaint) // 2. 描边矩形 canvas.drawRect(50f, 50f, 300f, 200f, strokePaint) // 3. 圆角矩形 (rx, ry 为圆角半径) val roundRect = RectF(50f, 250f, 300f, 400f) canvas.drawRoundRect(roundRect, 20f, 20f, strokePaint) // 4. 线段 (strokeWidth 向线条两侧均匀扩展) canvas.drawLine(50f, 450f, 300f, 450f, strokePaint) // 5. 批量线段 (性能更好,减少 GPU 调用) val linePts = floatArrayOf(50f, 500f, 150f, 550f, 150f, 550f, 300f, 500f) canvas.drawLines(linePts, strokePaint) }4.2 圆形与椭圆
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 1. 正圆 canvas.drawCircle(200f, 200f, 100f, fillPaint) // 2. 椭圆 (被限制在外接矩形内) canvas.drawOval(50f, 350f, 350f, 500f, strokePaint) // 底层原理:Skia 的 drawCircle() 实际上就是算出一个正方形边界,直接调用 drawOval() }4.3 弧形与扇形 —— 高频易错点
drawArc是绘制饼图、进度条的核心,也是面试高频考点。
```kotlin
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val oval = RectF(50f, 50f, 300f, 300f)
// API: drawArc(oval, startAngle, sweepAngle, useCenter, paint)
// 易错:3点钟方向为 0°,顺时针增加。12点钟方向是 -90°
// 1. 弧线 (月牙边) - useCenter = false
canvas.drawArc(oval, 0f, 90f, false, strokePaint)
// 2. 扇形 (饼图切块) - useCenter = true,连接圆心
canvas.drawArc(oval, 0f, 90f, true, Color.RED)
// 3. 实战:顶部开始的 75% 进度弧
val progressOval = RectF(50f, 350f, 300f, 600f)
canvas.drawArc(progressOval, -90f, 360f * 0.75f, false, fillPaint)
}
### 4.4 Path 路径 —— 终极武器
当标准图形无法满足需求(如波浪、多边形),`Path` 记录几何轨迹。
```kotlin
private val pathPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#00C2FF"); style = Paint.Style.STROKE
strokeWidth = 8f; strokeJoin = Paint.Join.ROUND; strokeCap = Paint.Cap.ROUND
}
private val wavePath = Path() // 成员变量复用,禁止在 onDraw new
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
wavePath.reset()
// 1. 直线多边形
wavePath.moveTo(50f, 200f) // 移动画笔不画线
wavePath.lineTo(150f, 100f) // 画线
wavePath.lineTo(250f, 200f)
wavePath.close() // 自动闭合回起点
canvas.drawPath(wavePath, pathPaint)
// 2. 二阶贝塞尔曲线 (平滑波浪)
wavePath.reset()
wavePath.moveTo(50f, 400f)
// quadTo(控制点x, 控制点y, 终点x, 终点y)
wavePath.quadTo(125f, 300f, 200f, 400f)
wavePath.quadTo(275f, 500f, 350f, 400f)
canvas.drawPath(wavePath, pathPaint)
}
五、 结合坐标变换绘制仪表盘
为了将“几何图形”、“坐标变换”、“状态栈”融会贯通,我们来看一个完整的实战:带刻度的半圆仪表盘。
class DashboardView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#1A2332"); style = Paint.Style.STROKE
strokeWidth = 20f; strokeCap = Paint.Cap.ROUND
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#00E5A0"); style = Paint.Style.STROKE
strokeWidth = 20f; strokeCap = Paint.Cap.ROUND
}
private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE; style = Paint.Style.STROKE
strokeWidth = 4f; strokeCap = Paint.Cap.ROUND
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 36f
}
private val arcRect = RectF()
private var radius = 0f
private var progress = 0.7f // 70%
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
super.onSizeChanged(w, h, oldW, oldH)
radius = Math.min(w, h) / 2f - 60f
arcRect.set(-radius, -radius, radius, radius)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// ================= 核心技巧:坐标系平移 =================
// 将原点移到 View 正中心,后续数学计算摆脱 left/top 偏移烦恼
canvas.save()
canvas.translate(width / 2f, height / 2f)
// 1. 底部灰色圆弧 (半圆,180°到360°)
canvas.drawArc(arcRect, 180f, 180f, false, bgPaint)
// 2. 进度圆弧
canvas.drawArc(arcRect, 180f, 180f * progress, false, progressPaint)
// 3. 利用旋转 + 状态栈 绘制刻度线 (核心体现 save/restore 价值)
val totalTicks = 10
for (i in 0..totalTicks) {
canvas.save() // 【关键】每次循环 save 隔离旋转影响
// 旋转坐标系:从180°开始,每个刻度旋转 18°
canvas.rotate(180f + (i * 180f / totalTicks))
// 旋转后 Y轴负方向就是刻度指向,只需画一条固定的垂直线
canvas.drawLine(0f, -radius + 30f, 0f, -radius - 10f, tickPaint)
canvas.restore() // 【关键】恢复坐标系,进入下一次循环
}
// 4. 绘制中心文字 (使用 FontMetrics 精确垂直居中)
val percentText = "${(progress * 100).toInt()}%"
val fm = textPaint.fontMetrics
val textHeight = fm.descent - fm.ascent
canvas.drawText(percentText, 0f, textHeight / 2f - fm.descent, textPaint)
canvas.restore() // 恢复最外层 translate
}
}
代码深度解析:
- 坐标变换的艺术:
translate后,RectF直接写成负数坐标,数学直觉更清晰。 - 状态栈的典型场景:绘制刻度如果不使用
save/restore,需要用sin/cos计算坐标。通过rotate + save/restore,只需永远画一条垂直线,让 Canvas 旋转,复杂度骤降。 - 硬件加速下的行为:这些
drawArc、drawLine不会立刻渲染,转化为DrawArcOp等 DisplayList Op,等onDraw结束后统一交给RenderThread翻译为 GPU 指令,保证 60fps。
六、 总结
理解 Android Canvas,必须跳出 "画板" 的思维定势,建立以下三个核心认知:
- 宏观上:Canvas 是连接 Java 世界与 Skia/GPU 世界的桥梁。它的一举一动都被
Choreographer的 VSYNC 节拍和ViewRootImpl的调度流程严格控制。 - 微观上:Canvas 是一个状态机 + 指令发射器。它维护着 Matrix 和 Clip 的栈结构,通过
save/restore实现复杂的空间变换;它本身不生产像素,只是像素加工单的下达者。 - 实战上:永远不要在
onDraw中分配对象;善用坐标变换与状态栈化解复杂绘制逻辑;以硬件加速(DisplayList/RenderNode)为默认视角去思考,避免触发意外的软件渲染回退,是高端绘制性能的关键。