【OpenHarmony/HarmonyOS】游戏应用生命周期治理:页面、Canvas、定时器、音频与网络如何正确收放

游戏项目常见的“第二次进入变快两倍”“退出后仍耗电”“BGM 重复播放”,本质上都是生命周期没有闭环。本文从 UIAbility 到 ArkUI 组件,整理一套资源创建、暂停、恢复和释放策略。🔄

一、HarmonyOS 游戏里有哪些生命周期?

至少存在五层:

  1. 应用/Ability:onCreateonForegroundonBackgroundonDestroy
  2. WindowStage:窗口创建和销毁;
  3. 页面:aboutToAppearaboutToDisappearonPageShowonPageHide
  4. ArkUI 组件:Canvas ready、AreaChange、组件出现/消失;
  5. 游戏会话:初始化、开始、暂停、重开、结束、退出。

同一个资源可能跨越不同层。比如 AudioManager 跨页面存在,GameLoop 只属于一场游戏,GalaxyBackground 的定时器只属于一个组件实例。

二、建立资源所有权表

资源 创建者 应释放位置
Preferences Manager EntryAbility/Application 应用结束或长期复用
SoundPool、AVPlayer AudioManager Manager.release / Ability 销毁
GameLoop、实体数组 GameEngine 游戏退出/引擎销毁
Galaxy 定时器 GalaxyBackground aboutToDisappear
启动页悬浮定时器 StartPage onPageHide/aboutToDisappear
P2P 发现广播 P2PConnectionManager 页面退出或停止发现
UDP Socket P2P Manager 会话/应用结束
Display/Fold 监听 Index aboutToDisappear
WebView Controller WebViewPage 页面销毁时停止加载/释放

只要某个资源没有明确所有者,就很容易泄漏或被重复初始化。

三、Ability 负责长生命周期服务

项目在窗口创建时初始化 Manager:

onWindowStageCreate(windowStage: window.WindowStage): void {
  AudioManager.getInstance().init(this.context);
  DataManager.getInstance().init(this.context);
  ScoreManager.getInstance().init(this.context);
  UpgradeManager.getInstance().init(this.context);
  UserManager.getInstance().init(this.context);

  windowStage.loadContent('pages/StartPage');
}

适合放在这里的能力是:跨页面共享、创建成本较高、依赖 Ability Context。初始化函数应该幂等,多次调用不会创建第二个 SoundPool 或覆盖正在使用的 Preferences。

四、前后台切换要区分音频和游戏

项目回到前台时恢复 BGM:

onForeground(): void {
  AudioManager.getInstance().resumeBGM();
}

但游戏逻辑也需要响应后台:

  • 停止或暂停 DisplaySync;
  • 清零输入向量,防止恢复后继续移动;
  • 记录暂停时间,修正限时模式;
  • 暂停网络状态发送;
  • 返回前台时弹出暂停菜单,而不是直接继续战斗。

可以建立 AppLifecycleBus,Ability 只发布前后台事件,当前游戏页面订阅并决定行为。

五、GameLoop 的 start/stop 必须幂等

start() {
  if (this.running) return;
  this.running = true;
  this.lastTime = Date.now();
  this.accumulator = 0;
  // 启动 DisplaySync
}

stop() {
  this.running = false;
  this.displaySync?.stop();
  if (this.timerId !== -1) {
    clearTimeout(this.timerId);
    this.timerId = -1;
  }
}

重新开始游戏前先停止旧循环。页面退出时也必须调用 gameEngine.stopGameLoop(),仅把 isGameRunning 改成 false 不会自动停止底层回调。

六、Canvas ready 不等于会话 ready

Canvas 的生命周期可能因为页面重建或尺寸变化多次触发。项目将“上下文可用”和“首次初始化”分开:

.onReady(() => {
  this.gameEngine?.setContext(this.context);
})
.onAreaChange((_, area) => {
  if (!this.isGameInitialized) {
    this.gameEngine?.initGame(width, height, mode, difficulty);
    this.isGameInitialized = true;
  } else {
    this.gameEngine?.updateScreenSize(width, height);
  }
})

如果每次 AreaChange 都 initGame,旋转、折叠或布局动画会不断重置关卡;如果只在 onReady 初始化,又可能拿到 0 尺寸。

七、页面定时器必须保存句柄 ⏲️

GalaxyBackground 正确保存 renderInterval 并在消失时清理。启动页的悬浮动画目前直接 setInterval,没有保留 ID,属于典型隐患。

推荐封装:

private hoverTimer: number = -1;

aboutToAppear() {
  if (this.hoverTimer === -1) {
    this.hoverTimer = setInterval(() => {
      this.updateHover();
    }, 1000) as number;
  }
}

aboutToDisappear() {
  if (this.hoverTimer !== -1) {
    clearInterval(this.hoverTimer);
    this.hoverTimer = -1;
  }
}

任何 setTimeout 也要考虑页面退出后回调是否仍会修改状态。波次过渡的两层 timeout 应保存会话版本或在回调中验证当前 sessionId

八、回调引用也会造成泄漏

组队页把闭包赋给 P2P 单例:

manager.onDeviceFound = (device) => {
  this.nearbyDevices.push(...);
};

页面退出只停止发现,但如果不把 onDeviceFoundonReceiveInviteonGameStart 等设回 null,单例仍然持有页面实例引用。下一次消息可能修改已经销毁的页面。

更好的 API 是返回取消订阅函数:

const unsubscribe = manager.onDeviceFound((device) => { ... });

aboutToDisappear() {
  unsubscribe();
}

九、UDP Socket 与发现广播是两个资源

stopDiscovery() 清除了广播 interval 和设备发现监听,但 UDP Socket 仍绑定端口。长期单例复用时这是有意行为;如果希望退出近场功能后完全释放,需要单独 close() 并清空 Peer。

因此 API 应区分:

startDiscovery / stopDiscovery
openSession / closeSession
init / release

不要让一个“stop”名字模糊地承担所有层级。

十、音频资源的释放

AudioManager 已避免重复创建 SoundPool,但还需要完整释放路径:

  • 取消 AVPlayer 的 stateChange 监听;
  • 停止并 release AVPlayer;
  • 卸载/释放 SoundPool;
  • 清空 soundMap;
  • 标记未初始化;
  • 处理正在进行的异步 load。

如果应用仅存在一个 Ability,泄漏可能暂时不明显;在热重载、Ability 重建或自动化测试中会快速暴露。

十一、异步初始化的竞态

用户可能在音效仍预加载时进入游戏,或在升级数据读取完成前退出。异步回调中都应验证当前所有者仍有效:

const generation = this.sessionGeneration;
const state = await upgradeManager.getUpgradeState();
if (generation !== this.sessionGeneration || !this.playerTank) {
  return;
}
this.applyState(state);

每次重开增加 generation,旧任务即使完成也不会污染新会话。

十二、页面路由栈与背景动画

pushUrl 后旧页面可能仍在路由栈中。应确认其 onPageHide/aboutToDisappear 是否触发并暂停背景。多个页面各运行一套 60 FPS 星空、BGM 控制和定时器,会让性能问题看似来自游戏引擎,实际来自隐藏页面。

一次性启动页使用 replaceUrl 是合理选择;设置、商城等返回型页面使用 push,但隐藏时必须安静。

十三、建议使用统一会话状态机

type SessionState =
  'idle' | 'initializing' | 'running'
  | 'paused' | 'ending' | 'disposed';

所有操作先检查状态:

  • start 只允许从 idle;
  • pause 只允许从 running;
  • resume 只允许从 paused;
  • end 只执行一次;
  • dispose 可以从任意非 disposed 状态执行且幂等。

比多个 isRunning/isPaused/isGameOver/isInitialized 布尔值更不容易形成矛盾组合。

十四、验证生命周期的测试方法 ✅

  • 连续进入退出战斗 20 次,确认循环数量不增长;
  • 页面切后台 30 秒后恢复,坦克不瞬移、限时规则符合预期;
  • 多次进入启动页,动画速度不翻倍;
  • 进入退出组队页后端口和广播数量正确;
  • 隐藏页面 CPU 使用下降;
  • Ability 重建后 BGM 只有一路;
  • 旋转/折叠时关卡不重置;
  • 异步加载完成时页面已退出,不发生状态写入;
  • 内存快照中旧页面实例可以回收;
  • dispose() 重复调用不抛异常。

十五、总结 ✨

游戏资源治理可以归纳为四个问题:

  1. 谁创建?
  2. 谁拥有?
  3. 什么时候暂停/恢复?
  4. 什么时候最终释放?

在 HarmonyOS 工程中,Ability 管长生命周期服务,页面管订阅与页面定时器,组件管自己的动画源,GameEngine 管会话循环和实体,Manager 管底层系统资源。再配合幂等 start/stop、会话版本和显式取消订阅,就能避免绝大多数“第二次进入才出现”的诡异问题。🔄


推荐标签: HarmonyOS OpenHarmony 生命周期 ArkUI 资源管理 游戏开发

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐