【OpenHarmony/HarmonyOS】从开始到结算:ArkUI 游戏页面的暂停、重开与状态机治理 【OpenHarmony/HarmonyOS】从开始到结算ArkUI 游戏页面的暂停、重开与状态机治理声明式 UI 很擅长“状态决定界面”但当页面同时拥有currentPage、isGameRunning、isPaused、isGameOver、isLoading、isGameInitialized等多个变量时状态组合会快速膨胀。某个按钮少改一个字段就可能出现游戏循环在跑但暂停层仍显示、结算层和操作摇杆同时存在、重开后难度丢失等问题。本篇结合 ArkUI 游戏主页分析现有布尔状态协作方式并给出渐进演进为显式状态机的方案。一、页面同时管理了哪些状态Index.ets不只是游戏画布还承载主页、难度页、战斗、资料、HUD、暂停和结算。与游戏流程直接相关的字段包括StatecurrentPage:home|game|difficultyhome;StateisGameRunning: boolean false;StateisPaused: boolean false;StateisGameOver: boolean false;StateisLoading: boolean false;StateisGameInitialized: boolean false;StategameResult:win|loselose;StategameMode: GameModeType pve;StategameDifficulty:easy|normal|nightmarenormal;字段表达的问题currentPage当前显示主页、难度选择还是游戏isGameRunning页面是否认为游戏会话正在进行isPaused是否显示暂停层并停止循环isGameOver是否显示结算层isLoading是否显示进入游戏前的加载反馈isGameInitializedCanvas 尺寸是否已经交给引擎初始化每个字段单看都合理但 5 个布尔值理论上有 32 种组合其中大多数没有业务意义。例如currentPagehome isPausedtrue、isPausedtrue isGameOvertrue都属于应该避免的组合。二、当前页面其实已经有“隐式状态机”虽然代码没有定义enum GamePhase但方法之间已经形成状态迁移stateDiagram-v2 [*] -- Home Home--Difficulty: 选择 PvE Home--Loading: 选择其他模式 Difficulty--Loading: 选择难度并支付 Loading--Playing: Canvas 尺寸就绪并初始化 Playing--Paused: 点击菜单 Paused--Playing: 继续 Playing--Settling: 引擎触发 onGameEnd Settling--GameOver: 同步统计并显示弹窗 Paused--Home: 退出 GameOver--Loading: 再来一局 GameOver--Home: 返回主页问题不在于“没有状态机就一定错”而在于状态被分散写入多个方法和异步回调迁移规则只能靠阅读者脑中拼接。三、startGame一次迁移包含同步和异步两部分进入游戏时先打开 loading再用 50ms 延迟让 ArkUI 有机会渲染然后批量重置会话字段startGame( mode: GameModeType, difficulty:easy|normal|nightmarenormal, multiplayerConfig: string {}): void {this.isLoading true; setTimeout(() {this.currentPage game;this.isGameRunning true;this.isPaused false;this.isGameOver false;this.gameMode mode;this.gameDifficulty difficulty;this.pendingMultiplayerConfig multiplayerConfig;this.isGameInitialized false;this.currentWave 1;this.survivalTime 0;this.sessionCoins 0;// 根据 Canvas 尺寸决定立即初始化或等待 onAreaChange},50); }这段逻辑解决了真实的声明式 UI 时序问题如果在主页树还没切换到 Canvas 时就执行重初始化宽高可能为 0。先改变页面再等待onAreaChange能用真实布局尺寸初始化引擎。但“固定等待 50ms”并不是布局完成的可靠契约。设备负载、动画和系统调度都可能变化。当前代码已经有Canvas.onReady()与onAreaChange()更稳健的设计应以事件是否到达作为条件而不是把毫秒数当成状态。四、Canvas 初始化是另一个子状态Canvas 就绪只设置上下文不立即初始化Canvas(this.context) .onReady(() {this.gameEngine?.setContext(this.context); }) .onAreaChange((_oldArea, newArea) {constwidth newArea.widthasnumber;constheight newArea.heightasnumber;if(width 0|| height 0)return;this.screenWidth width;this.screenHeight height;if(this.isGameRunning this.gameEngine) {if(this.isGameInitialized) {this.gameEngine.updateScreenSize(width, height); }else{this.gameEngine.initGame( width, height,this.gameMode,this.gameDifficulty,this.pendingMultiplayerConfig );this.isGameInitialized true; } } })isGameInitialized防止折叠屏变化或普通尺寸回调重复创建整局游戏。第一次走initGame()后续只更新屏幕尺寸。这个布尔值表达的其实是waiting_canvas与playing两个状态。如果初始化抛异常当前代码仍可能在调用后把它设为true页面认为成功但引擎未必可用。更好的接口是await engine.initGame()或返回显式结果再进入 Playing。五、暂停UI 状态和引擎命令必须成对暂停函数同时修改 ArkUI 状态和游戏循环togglePause(): void {this.isPaused !this.isPaused;if(this.isPaused) {this.gameEngine?.stopGameLoop(); }else{this.gameEngine?.startGameLoop(); } }页面通过if (this.isPaused)显示遮罩通过if (!this.isPaused !this.isGameOver)隐藏摇杆与开火按钮。也就是说一个状态同时控制视觉、输入和引擎时间闭环较完整。风险在于toggle依赖当前值。异步回调、快速连点或其他方法提前修改isPaused后再调用 toggle 可能走向相反状态。命令式接口通常更安全pauseGame(): void {if(this.phase !playing)return;this.phase paused;this.gameEngine?.stopGameLoop(); } resumeGame(): void {if(this.phase !paused)return;this.phase playing;this.gameEngine?.startGameLoop(); }这是演进方案。明确的 pause/resume 具有幂等性多次调用也不会反复翻转。六、暂停菜单的重开时序值得警惕 ⚠️暂停菜单当前重开动作是{ text:$r(app.string.btn_restart), action:():void{ this.restartGame(); this.togglePause(); } }而restartGame()同步执行this.gameEngine?.stopGameLoop();this.isGameOver false;this.isPaused false;this.isGameInitialized false; setTimeout(() {this.startGame(this.gameMode); },10);restartGame()已经把isPaused设为 false随后togglePause()又会把它改成 true 并执行 stop。10ms 后startGame()最终又会重设 false。多数情况下最后状态正确但中间存在无意义翻转逻辑依赖多个定时器先后到达。结算弹窗的重开只调用restartGame()暂停菜单却多调用一次 toggle两个入口不一致。更清晰的做法是让restartGame()独自完成全部迁移调用者不再补状态。七、重开会丢失哪些会话参数restartGame()最终调用this.startGame(this.gameMode);只传了模式难度回退为默认normal多人配置回退为{}。所以从 Nightmare 重开可能变成 Normal从自定义多人局重开也可能丢失槽位配置。页面明明已经保存gameDifficulty和pendingMultiplayerConfig应该完整传递this.startGame(this.gameMode,this.gameDifficulty,this.pendingMultiplayerConfig );这段是直接可行的演进思路但本文不修改业务源码。它说明状态机不只管理“处于哪一阶段”还要携带该阶段的上下文。八、结束回调从引擎状态进入 UI 结算GameEngine 通过onGameEnd将结果上抛页面同步统计并切换 UIthis.gameEngine.onGameEnd (result) {this.gameResult result;this.gameStats new GameStats();this.gameStats.targetsDestroyed this.gameEngine!.gameStats.targetsDestroyed;this.gameStats.survivalTime this.gameEngine!.gameStats.survivalTime;this.gameStats.score this.gameEngine!.gameStats.score;this.isGameOver true;this.isGameRunning false;// 保存排行榜与用户统计刷新资产和资料};isGameOvertrue显示结算弹窗isGameRunningfalse停止页面的 HUD 轮询分支。引擎内部在调用前把gameState设为game_over因此世界更新也会停止主要逻辑。但页面没有在回调中显式stopGameLoop()。引擎循环可能仍执行帧回调只是在 update 中提前返回并继续 render。这样能保留最终画面却会持续占用一定资源。产品可以选择“冻结并继续渲染”或“停止循环保留 Canvas 像素”但应明确而不是依赖早退的副作用。九、结算层和暂停层为什么目前不会同时出现绘制条件分别是if(this.isPaused) {this.OverlayMenu(...); }if(this.isGameOver) { GameOverDialog(...); }它们没有else互斥。如果状态同时为 true两个全屏遮罩会叠加。当前正常流程中结算发生前一般没有暂停暂停后循环停止也不会触发新结算因此实际很少重叠。但这只是时序上的“通常不会”不是模型层保证。比如网络回调可能在暂停时通知比赛结束。显式状态枚举天然互斥可以从根上避免两个主阶段同时成立。十、退出游戏页面、经济与循环一起收尾stopGame()承担三类工作保存中途退出晶石、恢复主页状态、停止游戏循环。stopGame(): void {if(this.gameEngine !this.isGameOver this.gameEngine.gameStats.coinsCollected 0) {// 根据模式结算中途收益}this.isGameRunning false;this.isPaused false;this.isGameOver false;this.currentPage home;this.refreshCoins();this.gameEngine?.stopGameLoop(); }这个方法本质上是Playing/Paused/GameOver - Home的统一出口。问题是结算副作用和 UI 迁移耦合如果资产保存失败页面仍立即回主页。长期来看可以先由 SessionController 完成endSession(reason)返回结算结果再让页面导航。十一、把状态收敛成“阶段 上下文”无需一次性重构整个页面可以先定义一个主阶段type AppPhase home|difficulty|loading_game|playing|paused|settling|game_over; interface GameSessionContext { mode: GameModeType; difficulty: easy| normal | nightmare;multiplayerConfig: string; initialized: boolean; }UI 条件变成if(this.phase paused) {this.PauseOverlay(); }if(this.phase game_over) { GameOverDialog({ result:this.gameResult, stats:this.gameStats }); }isGameRunning可以由phase推导不再单独写入isPaused、isGameOver同理。initialized仍可放在会话上下文因为它是 Playing 内部与 Canvas 生命周期相关的子状态。十二、迁移函数必须验证合法来源状态枚举只是第一步真正价值来自集中迁移privatetransitionTo(next: AppPhase):boolean{constallowed: RecordAppPhase, AppPhase[] { home: [difficulty,loading_game], difficulty: [home,loading_game], loading_game: [playing,home], playing: [paused,settling,home], paused: [playing,loading_game,home,settling], settling: [game_over,home], game_over: [loading_game,home] };if(!allowed[this.phase].includes(next))returnfalse;this.phase next;returntrue; }这是示例方案。生产代码还要在迁移钩子中启动/停止循环、清理输入、取消定时器。关键是非法迁移能够被日志捕获而不是悄悄形成矛盾布尔组合。十三、异步操作需要“会话代号”页面使用多个setTimeout加载延迟、重开延迟、波次 Banner、引擎结算延迟。用户快速返回主页再开始新局时旧回调可能晚到并修改新局状态。可以为每次开局生成递增sessionIdprivatesessionVersion:number0;startGame(...):void{constversion this.sessionVersion;setTimeout(() {if(version !this.sessionVersion)return;// 只允许当前会话继续初始化},50); }stopGame():void{this.sessionVersion;// 旧异步回调随后会失效}这种“版本令牌”不能替代清除定时器但能作为第二层防线尤其适合无法取消的 Promise 回调。十四、状态机测试矩阵 初始阶段事件期望阶段额外断言Home选普通模式LoadingCanvas 未就绪前不初始化Loading尺寸有效Playing引擎只初始化一次Playing点击暂停Paused循环停止、输入隐藏Paused点击继续Playing循环只启动一次Paused重开Loading保留原难度与多人配置PlayingonGameEndGameOver结算只执行一次GameOver再来一局Loading旧弹窗先移除任意游戏阶段退出Home循环停止、旧回调失效Paused网络比赛结束GameOver不出现双遮罩还应做不变量测试phase ! playing时不接受开火只有 Playing/Paused/Settling/GameOver 持有有效会话Home 不允许游戏循环运行。十五、总结 ✨当前页面已经通过startGame()、togglePause()、restartGame()、stopGame()和onGameEnd建立了可运行的隐式状态机也正确考虑了 Canvas 尺寸就绪、声明式 UI 先渲染和循环暂停等实际问题。它的主要风险来自状态分散多个布尔值可以组成非法状态暂停重开存在多余翻转重开没有完整传递难度与多人配置固定延时替代了事件契约旧回调还可能越过会话边界。渐进治理的核心不是立刻重写 1900 行页面而是先引入唯一主阶段、保留会话上下文、让迁移显式且幂等再用会话版本和测试保护异步边界。这样 ArkUI 的“状态驱动界面”才能真正与游戏引擎的状态保持一致。推荐标签OpenHarmonyHarmonyOSArkTSArkUI状态机Canvas游戏开发生命周期