【OpenHarmony/HarmonyOS】用 DisplaySync 打造稳定游戏循环:固定时间步长、120 FPS 与降级策略
游戏是否流畅,不只取决于“每秒画多少帧”,还取决于逻辑是否按稳定时间推进。本文讲解 HarmonyOS 中如何使用
DisplaySync驱动 Canvas 游戏,并用固定时间步长避免不同设备上的速度漂移。⚙️
一、普通定时器为什么不够?
很多 2D 游戏原型从下面的代码开始:
setInterval(()=>{ update(); render(); },16);它简单,但存在明显问题:
16ms并不等于严格 60 FPS,实际调度会受主线程任务影响;- 屏幕刷新与定时器触发不同步,可能出现重复绘制或错过刷新;
- 应用短暂卡顿后,游戏逻辑可能突然跳过一大段距离;
- 高刷新率设备无法充分利用 90Hz、120Hz 屏幕;
- 前后台切换后累积的时间差可能让物理系统失控。
HarmonyOS 提供@ohos.graphics.displaySync,可以让回调尽量跟随显示刷新节奏。项目优先使用DisplaySync,不可用时再退回setTimeout。
二、把更新和渲染从引擎中解耦
游戏循环类只接收两个回调:
exportclassGameLoop{privateupdateCallback:(deltaTime:number) =>void;privaterenderCallback:() =>void;constructor(update: (dt:number) =>void, render: () =>void) {this.updateCallback= update;this.renderCallback= render; } }它不认识坦克、子弹或迷宫,只负责“什么时候更新”和“什么时候绘制”。GameEngine在构造时注入具体逻辑:
this.gameLoop=newGameLoop((deltaTime:number) =>this.update(deltaTime),() =>this.render() );这样的好处是可以独立测试时间推进,也可以在暂停、后台或页面销毁时统一停止,而不必让每个实体关心调度器。
三、优先启用 DisplaySync 🖥️
初始化时尝试创建同步对象:
try{this.displaySync = displaySync.create();this.useDisplaySync =true; }catch(error) { console.warn('[GameLoop] DisplaySync not supported');this.useDisplaySync =false; }启动时设置期望刷新范围并监听帧事件:
this.displaySync.setExpectedFrameRateRange({ expected:120, min:60, max:120});this.displaySync.on('frame', () => {this.runSingleFrame(); });this.displaySync.start();expected: 120是性能目标,不是绝对保证。设备硬件、系统功耗策略、温度和当前负载都可能让实际刷新率下降。因此游戏逻辑不能假设每次回调都正好间隔8.33ms。
四、固定时间步长是什么?
项目把逻辑步长固定为1 / 120秒。每帧先计算真实经过时间,将其加入累加器;只要累加器足够执行一个逻辑步,就调用一次update(dt)。
consttargetFPS =120;consttargetFrameTime =1000/ targetFPS;constcurrentTime = Date.now(); let frameTime = currentTime -this.lastTime;this.lastTime = currentTime;this.accumulator += frameTime;constdt = targetFrameTime /1000;while(this.accumulator >= targetFrameTime) {this.updateCallback(dt);this.accumulator -= targetFrameTime; }this.renderCallback();假设某次刷新实际间隔 17ms,固定步长约为 8.33ms,那么本帧会执行两次逻辑更新,再绘制一次。这样坦克速度仍然等于“像素/秒”,AI 计时器和子弹寿命也不会因为屏幕刷新变化而改变。
固定步长的优势
- 物理结果更稳定,碰撞边界更容易复现;
- 不同刷新率设备上游戏速度一致;
- 网络对战更容易按逻辑 Tick 设计同步;
- 录制输入后可以按相同步长回放;
- 测试不需要依赖真实等待时间。
它并不等于固定渲染帧率
逻辑可以一帧更新多次,也可以某次刷新不更新只重绘。渲染频率由显示系统决定,逻辑频率由固定步长决定,二者是解耦的。
五、为什么要限制最大帧间隔?
应用被断点暂停、系统调度或切到后台后,再回来时Date.now() - lastTime可能是几秒甚至几分钟。如果把全部时间补算回来,循环会在一帧内执行成百上千次更新,主线程继续被占满,这就是常说的“死亡螺旋”。
项目先把单帧差值限制到 250ms:
if (frameTime >250) { frameTime=250;}同时限制单帧最多补算的逻辑步数:
let updateSteps =0;while(this.accumulator >= targetFrameTime) {this.updateCallback(dt);this.accumulator -= targetFrameTime; updateSteps++;if(updateSteps >=240) {this.accumulator =0;break; } }对于实时游戏,“丢掉过久的历史”通常比“补齐每一个历史步骤”更合理。玩家更在意恢复后立即可操作,而不是让游戏为了追赶十秒前的状态继续卡住。
六、DisplaySync 不可用时如何降级?
兼容路径使用递归setTimeout,而不是setInterval:
privateloopFallback = () => {if(!this.running)return;this.runSingleFrame();constfps = GameConfig.getInstance().targetFPS ||60;constframeTime =1000/ fps;this.timerId = setTimeout(this.loopFallback, Math.max(1, frameTime -5) )asnumber; }递归setTimeout会在当前帧完成后再安排下一次调用,不会像setInterval那样在主线程繁忙时不断积累待执行任务。减去少量时间是为了补偿调度误差,但真正的逻辑速度仍由累加器决定。
需要注意,当前工程中 DisplaySync 路径固定期望 120 FPS,而降级路径读取设置页中的目标帧率。若要让设置完全一致,应把统一的目标帧率传入runSingleFrame(),避免配置只影响降级分支。
七、开始与停止必须幂等
页面切换、Canvas 重建和重新开始游戏都可能重复调用start()。如果没有保护,就会出现两个循环同时更新同一个引擎,表现为速度翻倍、音效重复和子弹异常。
start() {if(this.running)return;this.running =true;this.lastTime = Date.now();this.accumulator =0;// 启动 DisplaySync 或 fallback} stop() {this.running =false;this.displaySync?.stop();if(this.timerId !== -1) { clearTimeout(this.timerId);this.timerId = -1; } }停止时除了更新标志,还必须停止系统同步对象并清理定时器。页面的aboutToDisappear、游戏退出和重新初始化都应该走同一个停止入口。
八、暂停游戏应该暂停什么? ⏸️
暂停有两种常见实现:
方案 A:停止整个 GameLoop
优点是省电,暂停时不会继续绘制。恢复时必须重置lastTime和累加器,否则会把暂停时间当作游戏时间。
方案 B:循环继续,但跳过世界更新
优点是暂停菜单背景仍可保持粒子动画;缺点是仍有 CPU/GPU 开销。项目的引擎在非playing状态下保留部分粒子更新,然后提前返回,这属于折中设计。
无论选择哪一种,都要明确这些计时来源:
- 固定步长累计的逻辑时间;
Date.now()计算的真实时间;- ArkUI 定时器更新的 HUD 时间。
如果限时模式直接用Date.now() - startTime,暂停期间也会继续倒计时。若产品要求真正暂停,应累计有效运行时间,或在恢复时补偿暂停时长。
九、120 FPS 下的性能预算
120 FPS 意味着每帧总预算约8.33ms,其中还包括 ArkUI、系统合成和其他任务。仅仅设置期望帧率并不能获得流畅体验,逻辑和绘制都必须控制开销。
项目采用了几类配套措施:
- 子弹和粒子使用对象池,减少 GC;
- 死亡对象使用尾部交换删除,避免
splice搬移数组; - 迷宫只遍历可见区域;
- 同色墙体、子弹和晶石批量绘制;
- AI 每 1 秒重新计算路径,而不是每帧执行 A*;
- 网络状态约每 3 帧广播一次,而非每个显示帧发送;
- 高频路径原地修改坐标,减少临时
Vector2。
建议增加的监控指标
- 一秒内实际回调次数;
- 每帧逻辑更新次数;
update和render各自耗时;- 当前子弹、粒子和 AI 数量;
- 被丢弃的累计时间;
- GC 或长任务造成的峰值帧间隔。
只有测量这些指标,才能判断瓶颈来自绘制、AI、碰撞还是系统调度。
十、时间戳与精度的改进建议
项目的帧事件虽然提供时间戳,但为了统一逻辑使用了Date.now()。这是可行的原型方案,不过高精度游戏可以进一步改进:
- 使用单调时钟或帧回调时间戳,避免系统时间调整影响;
- 明确时间戳单位,纳秒应转换为毫秒;
- 为渲染增加插值因子
alpha = accumulator / step; - 保存上一逻辑位置和当前逻辑位置,渲染时插值得到平滑坐标;
- 后台恢复时显式重置计时,而非只依赖 250ms 截断。
插值的基本形式如下:
constalpha= accumulator / targetFrameTime;constrenderX = previousX * (1-alpha) + currentX *alpha;这样即便逻辑固定 60Hz,也可以在 120Hz 屏幕上获得更顺滑的视觉运动。
十一、总结 ✨
一个可靠的 HarmonyOS 游戏循环至少要做到:
- 优先跟随
DisplaySync,并准备定时器降级路径; - 使用真实经过时间驱动累加器;
- 用固定
deltaTime更新世界; - 对超长帧和补算次数设置上限;
- 明确前后台、暂停、重启时的计时重置;
- 让更新和渲染保持解耦;
- 结合对象池、批绘制和降频 AI 才能真正冲击高帧率。
高刷新率只是显示能力,稳定的时间模型才是游戏手感和可复现性的基础。🚀
推荐标签:HarmonyOSOpenHarmonyDisplaySync游戏循环Canvas性能优化