别再只会用 drawCircle 了!一文搞懂 Android Canvas 底层机制

文章目录

  • 前言
    • [一、Android 图形渲染架构全景](#一、Android 图形渲染架构全景)
    • [二、从 invalidate 到屏幕像素](#二、从 invalidate 到屏幕像素)
    • [三、Canvas 的真实面貌](#三、Canvas 的真实面貌)
      • [3.1 本质:指令发射器与状态栈](#3.1 本质:指令发射器与状态栈)
      • [3.2 硬件加速 vs 软件渲染的 Canvas 差异](#3.2 硬件加速 vs 软件渲染的 Canvas 差异)
      • [3.3 View.draw() 的内部流水线与源码印证](#3.3 View.draw() 的内部流水线与源码印证)
    • [四、 核心几何图形绘制 API 详解](#四、 核心几何图形绘制 API 详解)
      • [4.1 矩形、圆角矩形与线段](#4.1 矩形、圆角矩形与线段)
      • [4.2 圆形与椭圆](#4.2 圆形与椭圆)
      • [4.3 弧形与扇形 ------ 高频易错点](#4.3 弧形与扇形 —— 高频易错点)
      • [4.4 Path 路径 ------ 终极武器](#4.4 Path 路径 —— 终极武器)
    • [五、 结合坐标变换绘制仪表盘](#五、 结合坐标变换绘制仪表盘)
    • [六、 总结](#六、 总结)

前言

在 Android 开发中,Canvas 是自定义 View 和图形绘制的绝对核心。但大多数开发者对 Canvas 的认知仅仅停留在 canvas.drawCircle() 的 API 层面。

本文将从宏观的系统架构调度,到中观的底层双轨渲染机制,再到微观的具体几何绘制实战,结合核心流程图与深度源码解析,带你彻底搞懂 Android Canvas 的底层运作机制与最佳实践。

一、Android 图形渲染架构全景

Canvas 并不是孤立存在的,它嵌套在 Android 庞大的多层渲染架构中。从你调用的 Java API 到屏幕上亮起的物理像素,需要经过以下六个层级:
6. 硬件层
5. 驱动与合成层
4. 渲染引擎层
3. JNI 桥接层
2. 框架层

  1. 应用层
    自定义 View.onDraw
    Canvas.drawXxx API
    android.graphics.Canvas
    android.graphics.Paint
    ViewRootImpl / Choreographer
    CanvasJNI.cpp
    SkiaCanvas / HardwareCanvas
    Skia 引擎 - 软件渲染
    OpenGL ES / Vulkan - 硬件渲染
    GPU Driver
    SurfaceFlinger
    Gralloc 缓冲区分配
    GPU 硬件
    Display 显示控制器

核心认知 :你在 Java 层调用的 Canvas,本质上只是一个指令发射器代理对象。真正的绘图工作是在 C++ 层的 Skia 引擎或 GPU 线程中完成的。

二、从 invalidate 到屏幕像素

当我们调用 view.invalidate() 触发重绘时,到底发生了什么?以下是完整的绘制调度流程:
performTraversals 三大步


调用 view.invalidate
标记当前 View 为 DIRTY

记录脏区域 bounds
脏区域向上传递

requestLayout / invalidateChildInParent
到达 ViewRootImpl

合并所有脏区域为脏矩形
调用 scheduleTraversals

postCallback 到 Choreographer
等待下一个 VSYNC 信号到来

通常 16.6ms 间隔
Choreographer 收到 VSYNC

执行 CALLBACK_TRAVERSAL
执行 performTraversals

  1. performMeasure

递归执行 onMeasure 计算大小
2. performLayout

递归执行 onLayout 确定位置
3. performDraw

创建 Canvas,递归执行 draw
是否开启

硬件加速?
创建 HardwareCanvas

绑定到 RenderNode
创建 SkiaCanvas

绑定到 Bitmap/Software Render
记录 DrawOp 到 DisplayList

不立即执行渲染
通过 JNI 直接调用 Skia

CPU 实时光栅化像素
RenderThread 异步处理

将 DisplayList 翻译成 OpenGL/Vulkan 指令
GPU 执行渲染指令

写入 Graphic Buffer
CPU 写入内存 Bitmap

提交到 Graphic Buffer
提交 Buffer 给 SurfaceFlinger
Hardware Composer 合成上屏

像素显示

流程详细解析:

  1. 异步绘制机制invalidate() 绝不是同步绘制的!它只是打个标记(设置 PFLAG_DIRTY),然后一路传递到 ViewRootImpl。真正的绘制要等 VSYNC 信号到来。
  2. Choreographer 编舞者:它是 VSYNC 信号的消费者,统一调度 Input、Animation、Traversal 三种类型的回调,保证动画和绘制的帧率与屏幕刷新率同步,避免画面撕裂。
  3. 双轨分流(关键点) :在 performDraw 阶段,根据是否开启硬件加速,Canvas 的行为产生本质分歧:
    • 软件渲染:同步阻塞主线程,CPU 直接算像素。
    • 硬件加速 :主线程只记录指令,真正的渲染在 RenderThread 异步完成,主线程被释放。

三、Canvas 的真实面貌

在了解了整个绘制链路之后,我们将镜头拉近,看看处于漩涡中心的 Canvas 到底是什么。

3.1 本质:指令发射器与状态栈

很多初学者以为 Canvas 就是一张带像素的纸。错!Canvas 本身不包含任何像素数据

  • 在软件渲染下,像素在 Bitmap 里。
  • 在硬件渲染下,像素在 GPU 的 Graphic Buffer 里。
    Canvas 只是持有这些像素目标的引用 ,你调用的 drawXxx() 是在向底层引擎发送绘制指令
    同时,Canvas 内部维护了一个非常重要的数据结构:状态栈

Canvas 状态栈
SaveCount = 1 默认

Matrix: 初始矩阵

Clip: 全屏
SaveCount = 2

Matrix: 平移 translate100,100

Clip: 缩小区域
SaveCount = 3

Matrix: 旋转 rotate45

Clip: 更小区域
canvas.save
将当前 Matrix 和 Clip 压入栈顶
修改 translate/rotate/clip
只影响当前栈顶状态
canvas.restore
弹出栈顶恢复到上一层状态
注意: Paint 不属于 Canvas 状态!

Canvas.save 不会保存 Paint 的颜色粗细等属性

save()restore() 的核心作用

  • 保存和恢复的是 Matrix(变换矩阵)Clip(裁剪区域)
  • 绝对不保存 Paint 属性 !要想恢复 Paint,需要自己用 paint.reset() 或保存副本。

3.2 硬件加速 vs 软件渲染的 Canvas 差异

这是理解现代 Android 绘制性能的核心。从 Android 4.0 开始默认开启硬件加速,彻底改变了 Canvas 的工作方式。
硬件加速路径
JNI 记录
主线程结束
翻译指令
GPU 执行
Java Canvas API
DisplayList 记录
RenderThread 处理
OpenGL / Vulkan Builder
GPU 缓冲区 FBO/Texture
软件渲染路径
JNI 同步调用
CPU 计算
写像素
Java Canvas API
Skia Canvas C++
Skia 光栅化器
CPU 内存 Bitmap

维度 软件渲染 硬件加速
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 时,内部按严格顺序执行四件事:
onDrawForeground dispatchDraw onDraw 你重写的方法 drawBackground View.draw onDrawForeground dispatchDraw onDraw 你重写的方法 drawBackground View.draw 受 padding 影响 默认空实现自定义核心区 递归调用 child.draw Android 6.0+ 画在最上层 1. 绘制背景 background Drawable 2. 绘制自身内容 3. 绘制子 View 仅 ViewGroup 4. 绘制前景 滚动条等

我们看一下源码中 Canvas 的创建过程,这能最直观地解释它的本质分歧:
软件渲染下的 Canvas(绑定 Bitmap 持有像素):

java 复制代码
public Canvas(Bitmap bitmap) {
    if (!bitmap.isMutable()) throw new IllegalStateException();
    // 在 C++ 层创建 SkCanvas,并让 SkCanvas 持有这个 Bitmap 的像素指针
    mNativeCanvasWrapper = nativeCreate(bitmap.getNativeInstance()); 
    mBitmap = bitmap;
}

硬件加速下的 Canvas(绑定 RenderNode 记录指令):

java 复制代码
// 在 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 矩形、圆角矩形与线段

kotlin 复制代码
// --- 成员变量初始化 ---
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 圆形与椭圆

kotlin 复制代码
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)
}

五、 结合坐标变换绘制仪表盘

为了将"几何图形"、"坐标变换"、"状态栈"融会贯通,我们来看一个完整的实战:带刻度的半圆仪表盘

kotlin 复制代码
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
    }
}

代码深度解析

  1. 坐标变换的艺术translate 后,RectF 直接写成负数坐标,数学直觉更清晰。
  2. 状态栈的典型场景 :绘制刻度如果不使用 save/restore,需要用 sin/cos 计算坐标。通过 rotate + save/restore,只需永远画一条垂直线,让 Canvas 旋转,复杂度骤降。
  3. 硬件加速下的行为 :这些 drawArcdrawLine 不会立刻渲染,转化为 DrawArcOp 等 DisplayList Op,等 onDraw 结束后统一交给 RenderThread 翻译为 GPU 指令,保证 60fps。

六、 总结

理解 Android Canvas,必须跳出 "画板" 的思维定势,建立以下三个核心认知:

  1. 宏观上 :Canvas 是连接 Java 世界与 Skia/GPU 世界的桥梁 。它的一举一动都被 Choreographer 的 VSYNC 节拍和 ViewRootImpl 的调度流程严格控制。
  2. 微观上 :Canvas 是一个状态机 + 指令发射器 。它维护着 Matrix 和 Clip 的栈结构,通过 save/restore 实现复杂的空间变换;它本身不生产像素,只是像素加工单的下达者。
  3. 实战上 :永远不要在 onDraw 中分配对象;善用坐标变换与状态栈化解复杂绘制逻辑;以**硬件加速(DisplayList/RenderNode)**为默认视角去思考,避免触发意外的软件渲染回退,是高端绘制性能的关键。
相关推荐
AtOR CUES2 小时前
MySQL——表操作及查询
android·mysql·adb
怣疯knight4 小时前
安卓App无法增加自定义图片作为图标功能
android
jinanwuhuaguo5 小时前
OpenClaw联邦之心——从孤岛记忆到硅基集体潜意识的拓扑学革命(第二十三篇)
android·人工智能·kotlin·拓扑学·openclaw
Gary Studio7 小时前
安卓HAL C++基础-命名域
android
诸神黄昏EX7 小时前
Android Google XTS
android
eSsO KERF7 小时前
MySQL Workbench菜单汉化为中文
android·数据库·mysql
pengyu10 小时前
【Kotlin 协程修仙录 · 筑基境 · 后阶】 | 调度器的艺术:Dispatchers 四大护法与 withContext 性能密码
android·kotlin
uElY ITER10 小时前
MySQL 中如何进行 SQL 调优
android·sql·mysql
xxjj998a10 小时前
Laravel3.x:奠定现代PHP框架的重要里程碑
android·开发语言·php