HarmonyOS 游戏与图形渲染入门:Canvas、渲染管线与掉帧排查
📱 鸿蒙 HarmonyOS 全栈专栏 · 第 35 篇 / 共 39 篇(第 5 季·收官季)
上一篇:第 34 篇 上架级项目 | 下一篇:PC 端适配:断点、分栏与键鼠交互
目录
三、游戏循环:requestAnimationFrame 与 deltaTime
接第 34 篇结尾的投票,这一篇是得票方向之一:游戏与图形渲染。先说结论帮你省时间:大多数人想做的「游戏感」,其实到不了游戏引擎那一层 —— 一张 Canvas、一个循环、三招性能优化就够了。这一篇把这条路走通。

图 1:做图形的三条路 —— 选错路径,后面每一帧都在还债
📖 本篇导读
全文约 16 分钟,代码可直接跑。
- 选型:引擎、XComponent、Canvas 三条路怎么挑
- Canvas 基础:从静态绘制到能动起来
- 游戏循环:requestAnimationFrame 与 deltaTime
- 动画取舍:什么时候用 animateTo,什么时候逐帧画
- 渲染管线与掉帧排查:16.7ms 都花在哪了
- XComponent:性能上限更高的一条路
- 接入 Cocos / Unity 的注意点
- 六个坑与排错表
一、选型:三条路的判断标准
上一张图说了结论,这里展开一点。判断标准只有一个:你每一帧要画多少东西、容忍多大的性能开销。
| 场景 | 推荐路径 | 理由 |
|---|---|---|
| 图表、进度环、小游戏元素(几十个) | Canvas / 系统动画 | 实现最快,性能完全够 |
| 自研 2D 游戏(几百上千个精灵) | XComponent + C++ | 绕过 UI 树直渲,性能上限高 |
| 重 3D / 复杂玩法 | Unity / Cocos 鸿蒙版 | 物理、音频、资源管线现成 |
| 页面转场、属性动画 | animateTo / transition | 声明式动画交给系统做最省心 |
💡 不要一上来就装引擎。先画一张会动的图,理解渲染的直觉,再决定要不要上引擎 —— 很多时候你会发现需求根本不需要。
二、Canvas 基础:从画一个圆到动起来
ArkUI 的 Canvas 组件用法和 HTML5 Canvas 几乎一致:声明组件,在 onReady 回调里拿 CanvasRenderingContext2D,之后就是熟悉的 API。
Canvas 静态绘制
// 组件里声明画布
Canvas(this.ctx)
.width('100%')
.height('100%')
.onReady(() => {
const ctx = this.ctx as CanvasRenderingContext2D
ctx.fillStyle = '#0A59F7'
ctx.beginPath()
ctx.arc(100, 100, 40, 0, Math.PI * 2)
ctx.fill()
})
几个要点:
① 坐标系以组件为原点,注意用 vp 单位换算;② 绘制发生在 onReady 之后,之前画不上去;③ Canvas 尺寸变化(比如窗口改变)不会保留内容,需要重画 —— 这一点在 PC 适配(下一篇)时会变成坑。
三、游戏循环:requestAnimationFrame 与 deltaTime
能画之后,第二步是「持续地画」。鸿蒙提供了 requestAnimationFrame,语义与 Web 相同:下一帧渲染前回调一次。
游戏循环
private lastTs: number = 0
private x: number = 50
frame(ts: number) {
// deltaTime:这一帧距上一帧多少 ms
const dt = this.lastTs ? ts - this.lastTs : 16.7
this.lastTs = ts
// 按时间移动而不是按帧移动:60Hz 和 90Hz 设备速度一致
this.x += 0.2 * dt
const ctx = this.ctx as CanvasRenderingContext2D
ctx.clearRect(0, 0, ctx.width, ctx.height) // 每帧清屏
ctx.fillStyle = '#0A59F7'
ctx.fillRect(this.x, 100, 40, 40)
requestAnimationFrame(() => this.frame(Date.now()))
}
// 启动:首次 onReady 里调一次
requestAnimationFrame(() => this.frame(Date.now()))
⚠️ 最经典的错误:
this.x += 1 这种「按帧移动」。设备刷新率不同(60 / 90 / 120Hz),游戏速度就不一样 —— 老玩家称之为「高刷赛车更快」。
一切运动按 deltaTime 换算。
再补一个实用例子 —— 用 Canvas 画进度环,这是最常被问到、也最能说明「Canvas 够用论」的场景:
进度环
// 进度环:一个属性 + 一次重绘,完全不需要引擎
private progress: number = 0
drawRing() {
const ctx = this.ctx as CanvasRenderingContext2D
ctx.clearRect(0, 0, ctx.width, ctx.height)
ctx.lineWidth = 10
ctx.strokeStyle = '#E8F0FF'
ctx.beginPath()
ctx.arc(120, 120, 80, 0, Math.PI * 2) // 背景环
ctx.stroke()
ctx.strokeStyle = '#0A59F7'
ctx.beginPath()
ctx.arc(120, 120, 80, -Math.PI / 2,
-Math.PI / 2 + this.progress * Math.PI * 2) // 进度弧
ctx.stroke()
}
💡 图标、图表、进度、装饰性动画 —— 这类「一个属性就能表达」的图形场景,Canvas 的性能余量大到可以忽略。真正需要掂量的只有两点:元素数量(几百个以上)和刷新频率(每帧全量重绘)。
四、动画取舍:animateTo 还是逐帧画
不是所有「动」都需要游戏循环。判断方法:这个动画是属性变化还是自由绘制?
| 情况 | 用哪种 | 原因 |
|---|---|---|
| 弹窗出现、卡片位移、透明度变化 | animateTo | 声明式,系统插值,掉帧也平滑 |
| 粒子、笔迹、跟手的拖尾效果 | Canvas 逐帧 | 无法表达成属性变化 |
| 列表滚动加载进度环 | Canvas 或 Progress 组件 | 一个属性就能表达,别上逐帧 |
属性动画
// 声明式动画:一行搞定,不要为它写循环
@State scale: number = 1
Button('开始')
.scale({ x: this.scale, y: this.scale })
.onClick(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.scale = 1.2
})
})
💡 经验法则: 能用 animateTo 表达的,绝不用逐帧画。逐帧循环是最后手段,因为它每帧都占主线程 —— 这就是下一节的主题。
五、渲染管线与掉帧排查
说性能之前,先看一帧是怎么画出来的:

图 2:一帧的旅程 —— 掉帧排查沿这条链从左往右找
屏幕一般 60Hz(高刷机 90/120Hz),意味着每一帧只有 16.7ms 的预算(90Hz 是 11.1ms)。你的状态变化、布局、绘制、合成全部挤在这段时间里,超了就是掉帧。
排查顺序(从最常见到最少见):
| 症状特征 | 大概率原因 | 怎么确认与修 |
|---|---|---|
| 卡顿不规律,滑动列表时明显 | 主线程有长任务(同步 IO、大 JSON 解析) | Profiler 看主线程长任务;挪到 TaskPool |
| 每次进入某个页面卡一下 | 首帧布局过深或一次性渲染太多组件 | 拆 LazyForEach、控制嵌套层级 |
| 游戏循环持续微卡 | 每帧创建大对象触发 GC | 循环外预分配数组,复用对象 |
| 动画整体发糊 | 过度绘制或未用图层合成 | 减少透明叠加层数 |
避免每帧分配
// 反面教材:每帧都新建对象
frame() {
const points: number[][] = [] // 每帧 60 次 × 每次数百个内层数组
for (let i = 0; i < 300; i++) {
points.push([Math.random() * 100, Math.random() * 100])
}
// ...
}
// 正确:预分配 + 复用,只在数据变化时更新
private points: number[][] = Array.from({ length: 300 }, () => [0, 0])
frame() {
for (let i = 0; i < 300; i++) {
this.points[i][0] = Math.random() * 100 // 原地改,零分配
this.points[i][1] = Math.random() * 100
}
}
⚠️ Profiler 里看帧耗时的入口: DevEco Studio → Profiler → Frame。它能列出每一帧的耗时分布,超预算的帧直接标红 —— 别靠肉眼数动画卡不卡。
五点五、XComponent:性能上限更高的一条路
在 Canvas 和完整引擎之间,还有一个中间档:XComponent。它提供一个原生渲染表面,你可以用 C/C++ 直接往上面画(OpenGL ES / Vulkan),或者把第三方 C++ 图形库接进来。它的价值在于:绘制完全绕开 ArkUI 的组件树和 JS 桥,性能上限接近原生。
XComponent 声明
// 页面里声明一个原生渲染表面
XComponent({ id: 'game', type: 'surface',
library: 'game_render' }) // 对应 C++ 侧的 NAPI 模块
.width('100%')
.height('100%')
.onLoad((ctx) => {
// ctx 传给 C++ 侧,之后每帧由 C++ 直接渲染
nativeInit(ctx)
})
什么时候选它?两个信号:① Canvas 已经优化到头(预分配、脏区、降频)仍然掉帧;② 你有现成的 C++ 渲染代码想复用。两者都不满足,就别碰它 —— Native 工具链的成本(见第 32 篇)会吃掉你几周时间。
调性能之前,先让帧率可见。一个 20 行的自制 FPS 计数器,比肉眼判断靠谱得多:
自制 FPS 计数器
private frames: number = 0
private lastMark: number = 0
@State fps: number = 0
frame(ts: number) {
this.frames++
if (ts - this.lastMark >= 1000) { // 每秒结算一次
this.fps = this.frames
this.frames = 0
this.lastMark = ts
}
// ...正常绘制...
requestAnimationFrame(() => this.frame(Date.now()))
}
build() {
Stack() {
Canvas(this.ctx).width('100%').height('100%').onReady(...)
Text(\`FPS: \${this.fps}\`) // 挂在角落,打包前移除
.fontSize(12).margin(8)
}
}
优化动作的顺序建议:先加 FPS 显示拿到基线 → 做一个优化 → 对比数字。没有基线的优化都是玄学,你以为的「应该更快」经常测出来没变化甚至更慢。
六、要上引擎时的三个注意点
如果评估完确实需要引擎(Cocos Creator、Unity 鸿蒙版都有官方方案),从 2D Canvas 迁过去时注意:
① 性能模型完全不同:引擎走 XComponent + C++ 直渲,不再经过 ArkUI 的组件树,之前调 Canvas 的经验大部分用不上;② 打包产物按引擎的鸿蒙导出模板走,签名、上架流程与普通 App 一致(见第 10 篇);③ 引擎版本与 SDK 版本要匹配,升级 SDK 后先跑引擎的最小示例工程再迁业务。具体导出步骤以引擎官方文档为准。
七、六个坑与排错表
| 坑 | 现象 | 处理 |
|---|---|---|
| onReady 前绘制 | 画了个寂寞,控制台还不报错 | 所有绘制放 onReady 之后 |
| 窗口尺寸变化后空白 | Canvas 内容没跟随重画 | 监听尺寸变化,主动重画一帧 |
| 按帧移动 | 高刷设备游戏变快 | 统一用 deltaTime 换算 |
| 每帧 new 对象 | 持续微卡,GC 间隔性停顿 | 预分配、原地更新 |
| 画布坐标没换算 vp | 不同设备位置错位 | 用 ctx.width / ctx.height 做相对坐标 |
| 该用 animateTo 的写了循环 | 代码长、费电、掉帧 | 属性动画交给系统 |
这一篇你该记住:选型只看每帧元素数量与掉帧容忍度;游戏循环必用 deltaTime;能用 animateTo 就不用逐帧;掉帧排查从主线程长任务开始,Profiler 的 Frame 页是依据。
下一篇是第二个投票方向:鸿蒙 PC 端适配 —— 你的应用怎么从「手机上能跑」变成「PC 上好用」。
如果这个专栏帮你把鸿蒙学明白了,点赞 + 收藏 + 关注 支持一下。
你还有哪个场景没跑通?评论区说清楚你的目标 + 现在怎么问的,我帮你改成能用的提示词。
更多推荐



所有评论(0)