基于 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 颜色格式,包括 #hexrgb()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() 每次都:

  1. clearRect 清屏
  2. fillRect 填充背景色
  3. 画网格线
  4. 根据类型画波形图或柱状图

这就是没有优化的"全量重绘"模式,每帧绘制次数 = 背景操作(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 整体流程

拆解一下这个函数做了什么:

  1. 清空离屏画布offCtx.clearRect(0, 0, 720, 360)
  2. 填充背景色offCtx.fillRect(0, 0, 720, 360)
  3. 在离屏画布上绘制复杂图形:20 个径向渐变圆 + 1 个文本
  4. 获取离屏渲染结果this.offContext.getPixelMap(0, 0, 720, 360)
  5. 清空主 Canvasthis.context.clearRect(0, 0, 720, 360)
  6. 将离屏结果绘制到主 Canvasthis.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 双缓冲不是银弹

需要注意的是:

  1. 离屏画布的 draw 调用计算量没变,只是避免了多次屏幕提交。如果瓶颈在计算(比如 10000 个点的贝塞尔曲线),双缓冲帮不了太多。
  2. getPixelMap 有拷贝开销,频繁调用反而可能更慢。动画场景要用 transferToImageBitmap。
  3. OffscreenCanvas 本身也消耗内存,720×360 的画布按 RGBA 计算约占 1MB,多个离屏画布同时存在要考虑内存压力。
  4. 简单图形没必要双缓冲,比如只画一个圆、几条线,直接在主 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 命令提交

具体做法

  1. 合并相同颜色的绘制:把所有红色 fillRect 放在一起,不要红蓝红蓝交替画
  2. 用 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();
  1. 渐变对象缓存复用:如果多个绘制操作用同一个渐变,创建一次就够了,不要每次都 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) 获取,复用同一个 settings
  • chartType 控制显示波形图(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();
  })

注意几个点:

  1. Canvas(this.context) 把画笔和画布绑定
  2. .backgroundColor('#1a1a2e') 设置画布背景色,和 drawChart 里的 fillRect 颜色一致
  3. .onReady() 在画布可用时触发首次绘制,这是 Canvas 组件的标准用法
  4. 宽度用 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 核心要点回顾

  1. 永远开启 RenderingContextSettings(true),获取抗锯齿和潜在的 GPU 加速
  2. OffscreenCanvas 做预渲染,减少主 Canvas 的 draw 调用次数
  3. getPixelMap 适合需要像素操作的场景,transferToImageBitmap 适合纯搬运显示
  4. 合并 draw 调用,用 Path 批量绘制,减少状态切换和管线提交
  5. 动画优先用 requestAnimationFrame,setInterval 只是权宜之计
  6. 双缓冲是解决闪烁和卡顿的标准手段,但不是万能药,要结合实际瓶颈
  7. 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 组件保持一致即可。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐