HarmonyOS 6.1 Canvas 高性能绘图与离屏渲染:从原理到实战
基于 API 12+ Canvas 绘图体系,覆盖 CanvasRenderingContext2D、OffscreenCanvas、硬件加速、双缓冲渲染全链路,附带完整可运行 Demo 代码
写在前面
做鸿蒙应用开发,遇到图表、波形、实时动画这类需求,第一反应基本都是 Canvas。但真上手写起来,你会发现一堆问题:动画卡顿、重绘闪烁、复杂图形一帧耗时过长……说白了,光会调 drawXxx 不够,得搞清楚 Canvas 底层怎么渲染的,哪些操作走硬件、哪些走软件,离屏渲染什么时候该用、怎么用。
这篇文章从 Canvas 渲染架构讲起,把主 Canvas 和 OffscreenCanvas 的区别掰碎了说,再结合一个完整的 Demo(波形图、柱状图、动画、离屏渲染全流程),把高性能绘图的套路讲清楚。

一、Canvas 渲染架构:两套画笔,两条路
HarmonyOS NEXT 的 Canvas 体系有两套核心渲染上下文:
1.1 CanvasRenderingContext2D —— 主画布画笔
这就是大家最常用的,直接绑定 <Canvas> 组件的画笔:
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
在 build() 里通过 Canvas(this.context) 绑定,每次调用 drawXxx 系列方法,绘制结果直接渲染到屏幕上。简单直接,但问题是——每一次 draw 调用都可能触发一次渲染。你画 80 个柱子,就是 80 次 fillRect,主线程压力全吃。
1.2 OffscreenCanvasRenderingContext2D —— 离屏画笔
private offCanvas: OffscreenCanvas = new OffscreenCanvas(720, 360);
private offContext: OffscreenCanvasRenderingContext2D = this.offCanvas.getContext('2d', this.settings);
OffscreenCanvas 是一块不可见的画布,你在上面画什么用户都看不到,直到你主动把渲染结果搬运到主 Canvas。这个"搬运"动作有两种方式:
transferToImageBitmap()+transferFromImageBitmap():将离屏画布转为 ImageBitmap,再绘制到主 Canvas。效率较高,但 ImageBitmap 不能做像素级操作。getPixelMap()+drawImage(pixelMap):获取离屏画布的 PixelMap 数据,再绘制到主 Canvas。可以对像素做进一步处理,但getPixelMap不建议频繁调用。
1.3 两套画笔的核心区别
| 维度 | 主 Canvas | OffscreenCanvas |
|---|---|---|
| 可见性 | 直接显示 | 不可见 |
| 渲染时机 | 每次 drawXxx 立即生效 | 需手动同步到主 Canvas |
| 适用场景 | 简单实时绘制 | 复杂预渲染/缓存/多线程 |
| 性能特征 | 直接但重绘多 | 减少主 Canvas 重绘次数 |
| 线程模型 | 主线程 | 支持子线程绘制(API 12+) |
关键点在于:主 Canvas 的每次 draw 调用都是一次屏幕级操作,而 OffscreenCanvas 的 draw 调用只操作内存缓冲区,不触发屏幕刷新。这就是离屏渲染能提效的根本原因。
二、硬件加速:RenderingContextSettings(true) 做了什么
看 Demo 第一行:
private settings: RenderingContextSettings = new RenderingContextSettings(true);
这个 true 参数是 antialias,表示开启抗锯齿。很多人以为这就是个抗锯齿开关,其实没那么简单。
RenderingContextSettings 的构造函数签名:
RenderingContextSettings(antialias?: boolean)
antialias = true:开启抗锯齿,绘制路径和文本时会进行亚像素级平滑处理。底层会优先走 GPU 渲染管线,因为 GPU 天然擅长这种亚像素插值计算。antialias = false(默认值):关闭抗锯齿,走 CPU 光栅化路径,边缘会有明显锯齿。
实际影响:开启抗锯齿不仅仅是画面更平滑,在很多设备上还会触发 GPU 加速路径。HarmonyOS NEXT 的渲染引擎在检测到 antialias=true 时,会尽可能将 2D 绘制指令下发给 GPU 执行,而不是走 CPU 软件渲染。当然,这个行为和设备型号、驱动版本有关,不是 100% 保证,但作为开发者,你没有理由不开它。
注意:从 HarmonyOS 6.1.1 开始,CanvasRenderingContext2D 和 OffscreenCanvasRenderingContext2D 新增了
antialias属性,支持运行时动态开关抗锯齿,不再局限于构造时一次设定。
关键建议:无论是主 Canvas 还是 OffscreenCanvas,永远传 true。你在 Demo 里也能看到,主 Canvas 和离屏 Canvas 共用的是同一个 settings 对象,确保两者的渲染质量一致。
三、波形图绘制:贝塞尔曲线 + 渐变
这是 Demo 里最核心的绘制逻辑之一。先看完整代码:
private drawWaveform(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const points: number[] = this.dataPoints;
const step: number = w / points.length;
// 水平渐变描边
const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, '#00d2ff');
gradient.addColorStop(0.5, '#7b2ff7');
gradient.addColorStop(1, '#ff4d6d');
ctx.strokeStyle = gradient;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const x: number = i * step;
const y: number = h - (points[i] / 100) * h * 0.8 - h * 0.1;
if (i === 0) {
ctx.moveTo(x, y);
} else {
const prevX: number = (i - 1) * step;
const prevY: number = h - (points[i - 1] / 100) * h * 0.8 - h * 0.1;
const cpX: number = (prevX + x) / 2;
ctx.bezierCurveTo(cpX, prevY, cpX, y, x, y);
}
}
ctx.stroke();
// 垂直渐变填充
const fillGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, h);
fillGradient.addColorStop(0, 'rgba(123,47,247,0.3)');
fillGradient.addColorStop(1, 'rgba(123,47,247,0)');
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = fillGradient;
ctx.fill();
}

3.1 数据点到坐标的映射
数据点的值域是 0~100(看 aboutToAppear 里的生成逻辑),映射到画布坐标:
const y: number = h - (points[i] / 100) * h * 0.8 - h * 0.1;
这里 h * 0.8 是绘图区高度,h * 0.1 是上下留白。所以波形在画布 10%~90% 的高度范围内绘制,底部和顶部都有间距。
3.2 贝塞尔曲线平滑
如果直接用 lineTo 连接数据点,波形会像折线图一样生硬。这里用的是 bezierCurveTo,每次取前后两个点的中点作为控制点 x 坐标:
const cpX: number = (prevX + x) / 2;
ctx.bezierCurveTo(cpX, prevY, cpX, y, x, y);
三个参数分别是:控制点1(cpX, prevY)、控制点2(cpX, y)、终点(x, y)。这种写法的效果是——曲线从上一个点出发,先沿上一个点的 y 方向走一半,再沿当前点的 y 方向走一半,最终到达当前点。视觉上就是一条平滑过渡的曲线,没有尖角。
为什么不用 quadraticCurveTo? 二次贝塞尔只有一个控制点,曲线弯曲程度受限。三次贝塞尔有两个控制点,可以独立控制曲线出入方向,平滑度更好。代价是计算量稍大,但对 80 个数据点来说完全可以忽略。
3.3 渐变描边
const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, '#00d2ff');
gradient.addColorStop(0.5, '#7b2ff7');
gradient.addColorStop(1, '#ff4d6d');
ctx.strokeStyle = gradient;
createLinearGradient(0, 0, w, 0) 创建了一个从左到右的水平渐变,起止点分别是 (0,0) 和 (w,0)。三个色标分别是青色→紫色→粉红,这是一条典型的赛博朋克风渐变,在深色背景上效果很好。
关键点:渐变对象可以被 strokeStyle 和 fillStyle 共用,但渐变坐标是画布坐标,不是路径坐标。所以你创建渐变时要用画布的绝对坐标,别用相对于路径的偏移。
3.4 波形区域填充
描边完成之后,路径的最后一个点在波形末端。接着:
ctx.lineTo(w, h); // 右下角
ctx.lineTo(0, h); // 左下角
ctx.closePath(); // 闭合路径
ctx.fillStyle = fillGradient;
ctx.fill();
这样就把波形线和画布底部围成一个闭合区域,用垂直渐变填充——从上方的半透明紫色渐变到底部的全透明。视觉上就是波形下方有一层淡紫色的"光晕"效果,增加层次感。
注意:fillGradient 的 addColorStop 用的是
rgba格式,带透明度。Canvas 的渐变色标支持所有 CSS 颜色格式,包括#hex、rgb()、rgba(),甚至hsla()。
四、柱状图绘制:每根柱子的动态渐变
private drawBarChart(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const points: number[] = this.dataPoints;
const barWidth: number = Math.max(2, (w / points.length) * 0.7);
const gap: number = (w / points.length) * 0.3;
for (let i = 0; i < points.length; i++) {
const barH: number = (points[i] / 100) * h * 0.8;
const x: number = i * (barWidth + gap) + gap / 2;
const y: number = h - barH - h * 0.1;
const gradient: CanvasGradient = ctx.createLinearGradient(x, y, x, h);
gradient.addColorStop(0, '#7b2ff7');
gradient.addColorStop(1, '#00d2ff');
ctx.fillStyle = gradient;
ctx.fillRect(x, y, barWidth, barH);
}
}

4.1 每根柱子独立渐变
这里有一个很多人忽略的细节:渐变是在循环内部创建的,每根柱子的渐变起止坐标不同:
const gradient: CanvasGradient = ctx.createLinearGradient(x, y, x, h);
起止点分别是 (x, y) 和 (x, h),即从柱子顶部到底部。这样每根柱子的渐变都是从紫色到青色,但因为柱子高度不同,渐变过渡的位置也不同——短柱子的渐变压缩,长柱子的渐变拉伸,视觉上更协调。
反面教材:如果你在循环外创建一个全局渐变 createLinearGradient(0, 0, 0, h),那所有柱子共用同一个渐变区间,短柱子只会显示渐变的上半截(全紫色),效果很差。
4.2 宽度自适应
const barWidth: number = Math.max(2, (w / points.length) * 0.7);
const gap: number = (w / points.length) * 0.3;
柱子宽度 = 总宽 / 数据点数 × 0.7,间距 = 总宽 / 数据点数 × 0.3。80 个数据点时每根柱子约 6px 宽,间距约 3px。Math.max(2, ...) 确保柱子最细也有 2px,数据点特别多时不会看不见。
4.3 性能隐患:循环内创建渐变
这 80 根柱子,每根都 createLinearGradient 一次,确实有性能开销。但在 80 这个量级下完全可以接受。如果数据量上千,就需要考虑优化方案:
- 方案一:如果渐变方向一致且对精度不敏感,可以在循环外创建一个渐变复用
- 方案二:用 OffscreenCanvas 预渲染柱状图,只搬运一次到主 Canvas
- 方案三:减少数据点,对原始数据做聚合(每 N 个点取最大值),减少绘制次数
五、动画实现:setInterval 16ms 帧间隔
private startAnimation(): void {
this.animRunning = true;
this.tickCount = 0;
this.animId = setInterval(() => {
if (!this.animRunning) {
clearInterval(this.animId);
this.animId = -1;
return;
}
this.dataPoints.shift();
this.dataPoints.push(50 + Math.sin(this.tickCount * 0.15) * 30 + Math.random() * 10);
this.tickCount++;
this.drawChart();
}, 16);
}
private stopAnimation(): void {
this.animRunning = false;
if (this.animId >= 0) {
clearInterval(this.animId);
this.animId = -1;
}
}
5.1 为什么用 16ms
16ms ≈ 1000ms / 60fps,这是 60 帧动画的标准帧间隔。但这只是理想值,实际上 setInterval 在 JS 线程繁忙时做不到精确 16ms 一次,可能会延迟到 20ms、30ms 甚至更久。所以实际帧率可能低于 60fps。
Demo 里用 setInterval 是为了演示方便,但生产环境推荐用 requestAnimationFrame。这是浏览器和 HarmonyOS 都提供的标准 API,它会在下一次 VSync 信号到来时调用回调,帧率跟屏幕刷新率同步,而且页面不可见时自动暂停,不浪费算力。
关于这一点,Demo 的性能提示里也明确写了:
动画使用 requestAnimationFrame 而非 setInterval
但 HarmonyOS NEXT 的 requestAnimationFrame 用法和 Web 标准略有差异,需要注意回调参数和取消方式。这是后续优化的话题。
5.2 数据滚动逻辑
每帧执行两个操作:
this.dataPoints.shift(); // 移除头部数据
this.dataPoints.push(50 + Math.sin(...) * 30 + Math.random() * 10); // 追加尾部数据
这就是经典的"滚动窗口"——数据量保持 80 个不变,但内容每帧向左移动一格,尾部追加新数据。视觉上波形从右向左滚动,新数据从右边出现。
数据公式 50 + Math.sin(tickCount * 0.15) * 30 + Math.random() * 10 生成的是一条带噪声的正弦曲线:
- 基线 50,振幅 ±30,周期约 42 帧(2π / 0.15)
- 加上 ±5 的随机噪声,让波形看起来像真实传感器数据
5.3 每帧重绘全图
注意看——每帧都调用 this.drawChart(),而 drawChart() 每次都:
clearRect清屏fillRect填充背景色- 画网格线
- 根据类型画波形图或柱状图
这就是没有优化的"全量重绘"模式,每帧绘制次数 = 背景操作(2) + 网格线(5) + 数据绘制(80+),总共接近 90 次 draw 调用。60fps 下每秒就是 5400 次。
这还只是 80 个数据点。如果数据量到 800、8000,每帧 draw 调用就是几万次,主线程直接卡死。这就是需要离屏渲染的根本原因。

六、离屏渲染:OffscreenCanvas 预渲染 + 搬运
这是 Demo 里最有价值的部分:
private drawWithOffscreen(): void {
const offCtx: OffscreenCanvasRenderingContext2D = this.offContext;
offCtx.clearRect(0, 0, 720, 360);
offCtx.fillStyle = '#1a1a2e';
offCtx.fillRect(0, 0, 720, 360);
// 在离屏画布上绘制 20 个径向渐变圆
for (let i = 0; i < 20; i++) {
const x: number = Math.random() * 720;
const y: number = Math.random() * 360;
const r: number = Math.max(5, Math.random() * 40);
const gradient: CanvasGradient = offCtx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, 'rgba(0,210,255,0.8)');
gradient.addColorStop(1, 'rgba(123,47,247,0)');
offCtx.fillStyle = gradient;
offCtx.beginPath();
offCtx.arc(x, y, r, 0, Math.PI * 2);
offCtx.fill();
}
offCtx.strokeStyle = '#00d2ff';
offCtx.lineWidth = 2;
offCtx.strokeText('OffscreenCanvas离屏渲染', 20, 30);
// 搬运到主 Canvas
const pixelMap: PixelMap = this.offContext.getPixelMap(0, 0, 720, 360);
this.context.clearRect(0, 0, 720, 360);
this.context.drawImage(pixelMap, 0, 0, 720, 360);
}

6.1 整体流程
拆解一下这个函数做了什么:
- 清空离屏画布:
offCtx.clearRect(0, 0, 720, 360) - 填充背景色:
offCtx.fillRect(0, 0, 720, 360) - 在离屏画布上绘制复杂图形:20 个径向渐变圆 + 1 个文本
- 获取离屏渲染结果:
this.offContext.getPixelMap(0, 0, 720, 360) - 清空主 Canvas:
this.context.clearRect(0, 0, 720, 360) - 将离屏结果绘制到主 Canvas:
this.context.drawImage(pixelMap, 0, 0, 720, 360)
关键就在步骤 3 和步骤 6 之间——那 20 个渐变圆 + 1 个文本,全部在离屏画布上完成,用户看不到中间过程。主 Canvas 只执行了两次操作:一次 clearRect + 一次 drawImage。无论离屏画布上画了多少东西,搬运到主 Canvas 都是一次 drawImage 搞定。
6.2 getPixelMap vs transferToImageBitmap
Demo 用的是 getPixelMap,但 OffscreenCanvas 还提供了 transferToImageBitmap 方式:
// 方式一:getPixelMap + drawImage(Demo 使用的)
const pixelMap: PixelMap = this.offContext.getPixelMap(0, 0, 720, 360);
this.context.drawImage(pixelMap, 0, 0, 720, 360);
// 方式二:transferToImageBitmap + transferFromImageBitmap
const imageBitmap: ImageBitmap = this.offCanvas.transferToImageBitmap();
this.context.transferFromImageBitmap(imageBitmap);
两种方式的区别:
| 维度 | getPixelMap + drawImage | transferToImageBitmap + transferFromImageBitmap |
|---|---|---|
| 返回类型 | PixelMap | ImageBitmap |
| 像素操作 | 可以对 PixelMap 做进一步处理 | ImageBitmap 不可编辑 |
| 性能 | getPixelMap 有拷贝开销,不建议频繁调用 | 无像素拷贝,效率更高 |
| 适用场景 | 需要后处理(滤镜、像素操作、保存文件) | 纯搬运,只用于显示 |
| 区域选取 | 可指定 x/y/width/height,只取局部 | 取整个离屏画布 |
如果只是搬运显示,优先用 transferToImageBitmap,它避免了像素拷贝。Demo 用 getPixelMap 是为了展示完整的 API 使用方式,同时也方便后续做像素级操作(比如截图保存、图像滤镜等)。
官方文档明确提示:getPixelMap 不建议频繁调用。在动画场景下,每帧都 getPixelMap 会产生大量 PixelMap 对象,GC 压力很大。动画场景应该用 transferToImageBitmap 或直接在主 Canvas 绘制。
6.3 径向渐变圆
离屏渲染 Demo 里画的是 20 个径向渐变圆:
const gradient: CanvasGradient = offCtx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, 'rgba(0,210,255,0.8)');
gradient.addColorStop(1, 'rgba(123,47,247,0)');
createRadialGradient 的参数是两个圆:起始圆(x, y, 0) 半径为 0 就是圆心点,结束圆(x, y, r) 就是实际圆的边界。从圆心到边缘,颜色从半透明青色过渡到全透明紫色,形成一种"光球"效果。
20 个随机位置、随机大小的光球叠加在深色背景上,效果类似粒子系统或星空。这种效果如果直接在主 Canvas 上画,每帧 20 次 arc + fill 加上渐变计算,闪烁和卡顿很明显。但放在离屏画布上预渲染完再搬运,主 Canvas 只有 2 次操作,丝般顺滑。
七、双缓冲渲染模式
离屏渲染的本质就是双缓冲。先理清概念:
7.1 单缓冲 vs 双缓冲
单缓冲(直接在主 Canvas 绘制):
drawBackground() → 用户看到半成品
drawGrid() → 用户看到更多半成品
drawWaveform() → 用户看到完整图形
每一步 draw 操作都立即显示到屏幕,用户可能看到绘制过程中的闪烁或不完整状态。这就是" tearing "现象。
双缓冲(离屏渲染后搬运):
OffscreenCanvas:
drawBackground() ┐
drawGrid() ├→ 用户什么都没看到
drawWaveform() ┘
getPixelMap() / transferToImageBitmap()
主 Canvas:
drawImage(pixelMap) → 用户直接看到完整图形
离屏画布上是"后台缓冲区",主 Canvas 是"前台缓冲区"。用户只看到前台,后台怎么画都无所谓,画完了一次性搬到前台。这就是双缓冲的核心思想。
7.2 在动画场景下的应用
回到前面说的动画问题——每帧全量重绘 90+ 次 draw 调用。用双缓冲改造:
// 伪代码:双缓冲动画
private animFrame(): void {
// 第一步:在离屏画布上完成所有绘制
this.offContext.clearRect(0, 0, 720, 360);
this.offContext.fillStyle = '#1a1a2e';
this.offContext.fillRect(0, 0, 720, 360);
this.drawGridToOffscreen();
this.drawWaveformToOffscreen();
// 第二步:一次性搬运到主 Canvas
const bitmap: ImageBitmap = this.offCanvas.transferToImageBitmap();
this.context.clearRect(0, 0, 720, 360);
this.context.transferFromImageBitmap(bitmap);
}
主 Canvas 的 draw 调用从 90+ 次降到 2 次。离屏画布的 draw 调用次数不变,但它们不触发屏幕刷新,开销主要是 CPU/GPU 计算而非渲染管线提交。
7.3 双缓冲不是银弹
需要注意的是:
- 离屏画布的 draw 调用计算量没变,只是避免了多次屏幕提交。如果瓶颈在计算(比如 10000 个点的贝塞尔曲线),双缓冲帮不了太多。
- getPixelMap 有拷贝开销,频繁调用反而可能更慢。动画场景要用 transferToImageBitmap。
- OffscreenCanvas 本身也消耗内存,720×360 的画布按 RGBA 计算约占 1MB,多个离屏画布同时存在要考虑内存压力。
- 简单图形没必要双缓冲,比如只画一个圆、几条线,直接在主 Canvas 画就好,引入离屏渲染反而增加复杂度。
八、性能优化要点
结合 Demo 中的实践和官方文档建议,总结以下关键优化点:
8.1 clearRect vs fillRect 清屏
// 方式一:clearRect 清屏
ctx.clearRect(0, 0, w, h);
// 然后需要 fillRect 补背景色
// 方式二:fillRect 覆盖清屏
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
Demo 里两步都做了——先 clearRect 再 fillRect。看起来冗余,但实际上:
- clearRect 是真正的"擦除",把像素全部置为透明,性能很高。底层操作就是 memset,不涉及颜色计算。
- fillRect 是"覆盖",用指定颜色覆盖所有像素,涉及颜色混合计算。
如果 Canvas 组件本身设了 backgroundColor,理论上可以只 clearRect 就行(背景色由组件自带)。但如果画布背景是渐变或特殊效果,就需要 fillRect 覆盖。
优化建议:
- 纯色背景场景:用
ctx.fillStyle = bgColor; ctx.fillRect(...)一步到位,省掉 clearRect - 透明/需要露出组件背景的场景:只用 clearRect,不用 fillRect
- 两者都需要的场景(如 Demo):先 clearRect 再 fillRect,虽然多一步但逻辑清晰
8.2 减少 draw 调用次数
这是性能优化最核心的原则。每次 draw 调用(fillRect、stroke、fill、drawImage 等)都是一次渲染管线提交,开销包括:
- 状态切换(fillStyle、strokeStyle 变化)
- 路径光栅化
- GPU 命令提交
具体做法:
- 合并相同颜色的绘制:把所有红色 fillRect 放在一起,不要红蓝红蓝交替画
- 用 Path 代替多次 stroke:多条线段可以加入同一个 Path,一次 stroke 搞定
// 差:80 次 stroke
for (let i = 0; i < points.length; i++) {
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.stroke();
}
// 好:1 次 stroke
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
}
ctx.stroke();
- 渐变对象缓存复用:如果多个绘制操作用同一个渐变,创建一次就够了,不要每次都 createLinearGradient
8.3 避免在 onReady 中做耗时操作
Canvas 的 onReady 回调在画布初始化完成时触发,此时适合做首次绘制,但不要在里面执行大量计算。如果数据准备耗时,应该在 aboutToAppear 中完成,onReady 里只做纯绘制操作。
Demo 的做法是正确的:
aboutToAppear(): void {
// 数据准备放在这里
for (let i = 0; i < 80; i++) {
this.dataPoints.push(50 + Math.sin(i * 0.2) * 30 + Math.random() * 10);
}
}
// onReady 里只做绘制
.onReady(() => {
this.drawChart();
})
8.4 动画帧率控制
setInterval 的问题前面说了,不再重复。补充一点:如果绘制逻辑确实跑不到 60fps,宁可降帧到 30fps 也不要强冲 60fps。把 setInterval 改成 33ms,每帧时间翻倍,绘制质量反而更好(不会因为赶帧而跳过某些绘制步骤)。
更好的方案当然是 requestAnimationFrame,它自带帧率自适应:
// 生产环境推荐
private animFrameCb: FrameCallback = {
onFrame: (timestamp: number) => {
this.dataPoints.shift();
this.dataPoints.push(this.generateData());
this.drawChart();
if (this.animRunning) {
this.requestAnimationFrame(this.animFrameCb);
}
}
};
8.5 离屏画布尺寸控制
OffscreenCanvas 的尺寸应该和实际需要一致,不要创建过大的离屏画布。一个 1920×1080 的离屏画布就占 8MB 内存(1920 × 1080 × 4 bytes)。如果只需要绘制 720×360 的区域,就用 720×360 的画布,Demo 里就是这么做的。
九、完整代码逐行解析
现在把整个 Demo 从头到尾过一遍,确保每个细节都理解到位。
9.1 成员变量初始化
@Entry
@Component
struct CanvasDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private offCanvas: OffscreenCanvas = new OffscreenCanvas(720, 360);
private offContext: OffscreenCanvasRenderingContext2D = this.offCanvas.getContext('2d', this.settings);
@State chartType: number = 0;
@State animRunning: boolean = false;
@State dataPoints: number[] = [];
@State activeTab: number = 0;
private animId: number = -1;
private tickCount: number = 0;
settings传了true开启抗锯齿/硬件加速context是主 Canvas 的画笔,绑定到 UI 上的<Canvas>组件offCanvas是 720×360 的离屏画布,和主 Canvas 同尺寸offContext通过offCanvas.getContext('2d', this.settings)获取,复用同一个 settingschartType控制显示波形图(0)还是柱状图(1)animRunning标记动画状态,影响按钮文字和颜色dataPoints存放 80 个数据点animId保存 setInterval 的返回值,用于停止动画tickCount是动画帧计数器,控制正弦波的相位
9.2 生命周期
aboutToAppear(): void {
for (let i = 0; i < 80; i++) {
this.dataPoints.push(50 + Math.sin(i * 0.2) * 30 + Math.random() * 10);
}
}
aboutToDisappear(): void {
this.stopAnimation();
}
aboutToAppear 生成初始数据,aboutToDisappear 清理动画定时器。这个 stopAnimation 不能忘,否则页面退出后 setInterval 还在跑,内存泄漏 + 空指针崩溃。
9.3 UI 布局
整个页面是顶部导航栏 + Tabs 结构,两个 Tab 页:
- 绘图实战:Canvas 画布 + 4 个操作按钮 + 参数展示 + 性能提示
- 架构解析:Canvas 架构说明 + 主Canvas vs OffscreenCanvas 对比表
Canvas 组件的绑定方式:
Canvas(this.context)
.width('92%')
.height(220)
.borderRadius(8)
.backgroundColor('#1a1a2e')
.onReady(() => {
this.drawChart();
})
注意几个点:
Canvas(this.context)把画笔和画布绑定.backgroundColor('#1a1a2e')设置画布背景色,和 drawChart 里的 fillRect 颜色一致.onReady()在画布可用时触发首次绘制,这是 Canvas 组件的标准用法- 宽度用
92%自适应屏幕,高度固定 220vp
9.4 四个操作按钮
Button('波形图') → chartType = 0; drawChart()
Button('柱状图') → chartType = 1; drawChart()
Button('动画') → 切换 startAnimation / stopAnimation
Button('离屏渲染') → drawWithOffscreen()
按钮的颜色设计也有讲究:波形图蓝色、柱状图绿色、动画橙/红切换、离屏渲染紫色。这样用户一眼就能区分功能,不用看文字。
动画按钮的颜色根据状态动态变化:
.backgroundColor(this.animRunning ? '#d32f2f' : '#e65100')
运行中是红色(警示色),停止时是橙色(活跃色),交互语义很清晰。
9.5 drawChart 主绘制函数
private drawChart(): void {
const ctx: CanvasRenderingContext2D = this.context;
const w: number = 720;
const h: number = 360;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
// 画 5 条水平网格线
ctx.strokeStyle = '#333355';
ctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const y: number = h * 0.1 + (h * 0.8 / 4) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
if (this.chartType === 0) {
this.drawWaveform(ctx, w, h);
} else {
this.drawBarChart(ctx, w, h);
}
}
这里 w 和 h 用的是固定值 720×360,而不是动态获取画布实际尺寸。这是因为 OffscreenCanvas 创建时指定了 720×360,主 Canvas 也按同样的坐标系绘制,保证两者一致。实际项目中应该通过 onAreaChange 动态获取画布尺寸,这里为了简化 Demo 用了固定值。
网格线的绘制也有优化空间——目前是 5 次 beginPath + moveTo + lineTo + stroke,可以合并成一次 stroke:
// 优化版本:一次 stroke 画 5 条线
ctx.strokeStyle = '#333355';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const y: number = h * 0.1 + (h * 0.8 / 4) * i;
ctx.moveTo(0, y);
ctx.lineTo(w, y);
}
ctx.stroke();
从 5 次 draw 调用降到 1 次,这在动画场景下是实打实的优化。
9.6 离屏渲染函数完整流程
private drawWithOffscreen(): void {
// 1. 获取离屏画笔
const offCtx: OffscreenCanvasRenderingContext2D = this.offContext;
// 2. 清空离屏画布
offCtx.clearRect(0, 0, 720, 360);
// 3. 填充背景
offCtx.fillStyle = '#1a1a2e';
offCtx.fillRect(0, 0, 720, 360);
// 4. 绘制 20 个径向渐变圆(复杂图形)
for (let i = 0; i < 20; i++) {
const x: number = Math.random() * 720;
const y: number = Math.random() * 360;
const r: number = Math.max(5, Math.random() * 40);
const gradient: CanvasGradient = offCtx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, 'rgba(0,210,255,0.8)');
gradient.addColorStop(1, 'rgba(123,47,247,0)');
offCtx.fillStyle = gradient;
offCtx.beginPath();
offCtx.arc(x, y, r, 0, Math.PI * 2);
offCtx.fill();
}
// 5. 绘制标注文字
offCtx.strokeStyle = '#00d2ff';
offCtx.lineWidth = 2;
offCtx.strokeText('OffscreenCanvas离屏渲染', 20, 30);
// 6. 获取离屏渲染结果
const pixelMap: PixelMap = this.offContext.getPixelMap(0, 0, 720, 360);
// 7. 清空主 Canvas
this.context.clearRect(0, 0, 720, 360);
// 8. 将离屏结果绘制到主 Canvas
this.context.drawImage(pixelMap, 0, 0, 720, 360);
}
步骤 4 里的 20 个渐变圆是故意设计的"复杂图形"——每个圆需要一次 createRadialGradient + beginPath + arc + fill,如果直接在主 Canvas 上画,每画一个圆用户就看到一个圆冒出来,画面跳动。离屏画布上画完再搬运,用户只看到最终结果。
步骤 5 的 strokeText 是用描边方式绘制文字,和 fillText 的区别是只画文字轮廓不填充。在深色背景上用亮色描边,文字看起来像霓虹灯效果。
步骤 6-8 是双缓冲的核心:getPixelMap 取出离屏画布的像素数据,drawImage 一次性画到主 Canvas。
9.7 动画控制
private startAnimation(): void {
this.animRunning = true;
this.tickCount = 0;
this.animId = setInterval(() => {
if (!this.animRunning) {
clearInterval(this.animId);
this.animId = -1;
return;
}
this.dataPoints.shift();
this.dataPoints.push(50 + Math.sin(this.tickCount * 0.15) * 30 + Math.random() * 10);
this.tickCount++;
this.drawChart();
}, 16);
}
有个细节:setInterval 的回调里先检查 this.animRunning,如果为 false 就自行清理。这是因为 stopAnimation 设置 animRunning = false 后,可能还有一个已经排队的回调即将执行,需要这个检查来兜底。
十、总结与进阶方向
10.1 核心要点回顾
- 永远开启 RenderingContextSettings(true),获取抗锯齿和潜在的 GPU 加速
- OffscreenCanvas 做预渲染,减少主 Canvas 的 draw 调用次数
- getPixelMap 适合需要像素操作的场景,transferToImageBitmap 适合纯搬运显示
- 合并 draw 调用,用 Path 批量绘制,减少状态切换和管线提交
- 动画优先用 requestAnimationFrame,setInterval 只是权宜之计
- 双缓冲是解决闪烁和卡顿的标准手段,但不是万能药,要结合实际瓶颈
- clearRect 效率高于 fillRect,纯清屏用 clearRect,需要背景色再 fillRect
10.2 进阶方向
- 多线程绘制:API 12+ 的 OffscreenCanvas 支持在 Worker 线程中使用,可以把复杂图形的计算和绘制放到子线程,主线程只负责搬运和显示。这对实时性要求高的场景(游戏、实时数据可视化)是关键优化。
- DrawingRenderingContext:HarmonyOS 还提供了一套基于 Drawing 模块的渲染接口,更底层、更灵活,适合对渲染管线有精细控制需求的场景。
- GPU 着色器:对于极端性能需求(比如百万级粒子系统),可以考虑通过 NAPI 调用原生 GPU 着色器,Canvas 层面已经到上限了。
- Canvas 组件的 invalidate():在某些场景下,修改了绑定数据后需要手动调用 invalidate() 触发重绘,而不是每次都重新执行 drawXxx。这个机制在复杂组件联动场景下很有用。
附录:完整 Demo 代码
import { router } from '@kit.ArkUI';
@Entry
@Component
struct CanvasDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private offCanvas: OffscreenCanvas = new OffscreenCanvas(720, 360);
private offContext: OffscreenCanvasRenderingContext2D = this.offCanvas.getContext('2d', this.settings);
@State chartType: number = 0;
@State animRunning: boolean = false;
@State dataPoints: number[] = [];
@State activeTab: number = 0;
private animId: number = -1;
private tickCount: number = 0;
aboutToAppear(): void {
for (let i = 0; i < 80; i++) {
this.dataPoints.push(50 + Math.sin(i * 0.2) * 30 + Math.random() * 10);
}
}
aboutToDisappear(): void {
this.stopAnimation();
}
build() {
Column() {
Row() {
Text('< ')
.fontSize(18)
.fontColor('#1a73e8')
.onClick(() => { router.back(); })
Blank()
Text('Canvas高性能绘图')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Blank()
Text(' ').fontSize(18).fontColor(Color.Transparent)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
Tabs({ index: this.activeTab }) {
TabContent() {
Column() {
Canvas(this.context)
.width('92%')
.height(220)
.borderRadius(8)
.backgroundColor('#1a1a2e')
.onReady(() => {
this.drawChart();
})
Row({ space: 8 }) {
Button('波形图')
.fontSize(12).height(32).backgroundColor('#1a73e8').fontColor(Color.White).borderRadius(16)
.onClick(() => { this.chartType = 0; this.drawChart(); })
Button('柱状图')
.fontSize(12).height(32).backgroundColor('#34a853').fontColor(Color.White).borderRadius(16)
.onClick(() => { this.chartType = 1; this.drawChart(); })
Button('动画')
.fontSize(12).height(32).backgroundColor(this.animRunning ? '#d32f2f' : '#e65100').fontColor(Color.White).borderRadius(16)
.onClick(() => {
if (this.animRunning) { this.stopAnimation(); } else { this.startAnimation(); }
})
Button('离屏渲染')
.fontSize(12).height(32).backgroundColor('#9c27b0').fontColor(Color.White).borderRadius(16)
.onClick(() => { this.drawWithOffscreen(); })
}
.margin({ top: 12 })
Column() {
Text('绘图参数')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.width('92%')
.margin({ top: 16, bottom: 8 })
Row() {
Text(`数据点: ${this.dataPoints.length.toString()}`)
.fontSize(12).fontColor('#888888')
Text(` | 类型: ${this.chartType === 0 ? '波形' : '柱状'}`)
.fontSize(12).fontColor('#888888')
Text(` | 动画: ${this.animRunning ? '运行中' : '已停止'}`)
.fontSize(12).fontColor(this.animRunning ? '#34a853' : '#888888')
}
}
.width('92%')
Scroll() {
Column() {
Text('性能优化要点')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.width('92%')
.margin({ top: 16, bottom: 8 })
Column() {
Text('1. 开启硬件加速: new RenderingContextSettings(true)')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('2. 离屏Canvas做预渲染,减少主Canvas重绘次数')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('3. 动画使用requestAnimationFrame而非setInterval')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('4. 避免在onReady中执行耗时操作')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('5. 复杂图形先绘制到OffscreenCanvas再transferToImageBitmap')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
Text('6. clearRect比fillRect清屏效率更高')
.fontSize(12).fontColor('#333333').lineHeight(22).width('100%')
}
.width('92%')
.padding(12)
.borderRadius(8)
.backgroundColor('#fff8e1')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.layoutWeight(1)
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.tabBar('绘图实战')
TabContent() {
Scroll() {
Column() {
Text('Canvas架构解析')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('90%')
.margin({ top: 8, bottom: 12 })
Column() {
Text('主Canvas: CanvasRenderingContext2D')
.fontSize(13).fontColor('#1a73e8').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
Text(' → 直接绑定Canvas组件,渲染到屏幕')
.fontSize(11).fontColor('#888888').lineHeight(18).width('100%')
Text('')
.fontSize(11).lineHeight(10).width('100%')
Text('离屏Canvas: OffscreenCanvas + OffscreenCanvasRenderingContext2D')
.fontSize(13).fontColor('#9c27b0').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
Text(' → 不可见画布,预渲染后getPixelMap/drawImage到主Canvas')
.fontSize(11).fontColor('#888888').lineHeight(18).width('100%')
Text('')
.fontSize(11).lineHeight(10).width('100%')
Text('双缓冲绘图流程:')
.fontSize(13).fontColor('#e65100').fontWeight(FontWeight.Medium).lineHeight(22).width('100%')
Text(' 1. offContext绘制复杂图形')
.fontSize(11).fontColor('#333333').lineHeight(18).width('100%')
Text(' 2. getPixelMap()获取离屏渲染结果')
.fontSize(11).fontColor('#333333').lineHeight(18).width('100%')
Text(' 3. context.drawImage(pixelMap)绘制到主Canvas')
.fontSize(11).fontColor('#333333').lineHeight(18).width('100%')
}
.width('90%')
.padding(12)
.borderRadius(8)
.backgroundColor('#f5f5f5')
Text('OffscreenCanvas vs 主Canvas')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.width('90%')
.margin({ top: 16, bottom: 8 })
Column() {
Row() {
Text('').width('30%')
Text('主Canvas').fontSize(12).fontWeight(FontWeight.Medium).width('35%').textAlign(TextAlign.Center).fontColor('#1a73e8')
Text('OffscreenCanvas').fontSize(12).fontWeight(FontWeight.Medium).width('35%').textAlign(TextAlign.Center).fontColor('#9c27b0')
}.width('100%').padding({ top: 6, bottom: 6 })
Row() {
Text('可见性').fontSize(11).width('30%').fontColor('#666666')
Text('直接显示').fontSize(11).width('35%').textAlign(TextAlign.Center)
Text('不可见').fontSize(11).width('35%').textAlign(TextAlign.Center)
}.width('100%').padding({ top: 3, bottom: 3 })
Row() {
Text('渲染时机').fontSize(11).width('30%').fontColor('#666666')
Text('每次drawXxx立即').fontSize(11).width('35%').textAlign(TextAlign.Center)
Text('需手动同步').fontSize(11).width('35%').textAlign(TextAlign.Center)
}.width('100%').padding({ top: 3, bottom: 3 })
Row() {
Text('适用场景').fontSize(11).width('30%').fontColor('#666666')
Text('简单实时绘制').fontSize(11).width('35%').textAlign(TextAlign.Center)
Text('复杂预渲染/缓存').fontSize(11).width('35%').textAlign(TextAlign.Center)
}.width('100%').padding({ top: 3, bottom: 3 })
Row() {
Text('性能优势').fontSize(11).width('30%').fontColor('#666666')
Text('直接但重绘多').fontSize(11).width('35%').textAlign(TextAlign.Center)
Text('减少重绘次数').fontSize(11).width('35%').textAlign(TextAlign.Center)
}.width('100%').padding({ top: 3, bottom: 3 })
}
.width('90%')
.padding(12)
.borderRadius(8)
.backgroundColor('#f8f8f8')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.layoutWeight(1)
}
.tabBar('架构解析')
}
.width('92%')
.layoutWeight(1)
.barMode(BarMode.Fixed)
.onChange((index: number) => { this.activeTab = index; })
}
.width('100%')
.height('100%')
.backgroundColor('#ffffff')
}
private drawChart(): void {
const ctx: CanvasRenderingContext2D = this.context;
const w: number = 720;
const h: number = 360;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = '#333355';
ctx.lineWidth = 1;
for (let i = 0; i < 5; i++) {
const y: number = h * 0.1 + (h * 0.8 / 4) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
if (this.chartType === 0) {
this.drawWaveform(ctx, w, h);
} else {
this.drawBarChart(ctx, w, h);
}
}
private drawWaveform(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const points: number[] = this.dataPoints;
const step: number = w / points.length;
const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, '#00d2ff');
gradient.addColorStop(0.5, '#7b2ff7');
gradient.addColorStop(1, '#ff4d6d');
ctx.strokeStyle = gradient;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const x: number = i * step;
const y: number = h - (points[i] / 100) * h * 0.8 - h * 0.1;
if (i === 0) {
ctx.moveTo(x, y);
} else {
const prevX: number = (i - 1) * step;
const prevY: number = h - (points[i - 1] / 100) * h * 0.8 - h * 0.1;
const cpX: number = (prevX + x) / 2;
ctx.bezierCurveTo(cpX, prevY, cpX, y, x, y);
}
}
ctx.stroke();
const fillGradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, h);
fillGradient.addColorStop(0, 'rgba(123,47,247,0.3)');
fillGradient.addColorStop(1, 'rgba(123,47,247,0)');
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = fillGradient;
ctx.fill();
}
private drawBarChart(ctx: CanvasRenderingContext2D, w: number, h: number): void {
const points: number[] = this.dataPoints;
const barWidth: number = Math.max(2, (w / points.length) * 0.7);
const gap: number = (w / points.length) * 0.3;
for (let i = 0; i < points.length; i++) {
const barH: number = (points[i] / 100) * h * 0.8;
const x: number = i * (barWidth + gap) + gap / 2;
const y: number = h - barH - h * 0.1;
const gradient: CanvasGradient = ctx.createLinearGradient(x, y, x, h);
gradient.addColorStop(0, '#7b2ff7');
gradient.addColorStop(1, '#00d2ff');
ctx.fillStyle = gradient;
ctx.fillRect(x, y, barWidth, barH);
}
}
private drawWithOffscreen(): void {
const offCtx: OffscreenCanvasRenderingContext2D = this.offContext;
offCtx.clearRect(0, 0, 720, 360);
offCtx.fillStyle = '#1a1a2e';
offCtx.fillRect(0, 0, 720, 360);
for (let i = 0; i < 20; i++) {
const x: number = Math.random() * 720;
const y: number = Math.random() * 360;
const r: number = Math.max(5, Math.random() * 40);
const gradient: CanvasGradient = offCtx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, 'rgba(0,210,255,0.8)');
gradient.addColorStop(1, 'rgba(123,47,247,0)');
offCtx.fillStyle = gradient;
offCtx.beginPath();
offCtx.arc(x, y, r, 0, Math.PI * 2);
offCtx.fill();
}
offCtx.strokeStyle = '#00d2ff';
offCtx.lineWidth = 2;
offCtx.strokeText('OffscreenCanvas离屏渲染', 20, 30);
const pixelMap: PixelMap = this.offContext.getPixelMap(0, 0, 720, 360);
this.context.clearRect(0, 0, 720, 360);
this.context.drawImage(pixelMap, 0, 0, 720, 360);
}
private startAnimation(): void {
this.animRunning = true;
this.tickCount = 0;
this.animId = setInterval(() => {
if (!this.animRunning) {
clearInterval(this.animId);
this.animId = -1;
return;
}
this.dataPoints.shift();
this.dataPoints.push(50 + Math.sin(this.tickCount * 0.15) * 30 + Math.random() * 10);
this.tickCount++;
this.drawChart();
}, 16);
}
private stopAnimation(): void {
this.animRunning = false;
if (this.animId >= 0) {
clearInterval(this.animId);
this.animId = -1;
}
}
}
本文代码基于 HarmonyOS NEXT API 12+ 验证,完整 Demo 位于
entry/src/main/ets/pages/CanvasDemo.ets。如果你在跑 Demo 时遇到 Canvas 尺寸不对的问题,检查一下设备 DPI 和 vp/px 转换——OffscreenCanvas 的尺寸单位是 vp,和 Canvas 组件保持一致即可。
更多推荐


所有评论(0)