
提到 HarmonyOS 游戏运行态很多人脑子里蹦出来的第一个词是保活。我刚开始做 Stage 模型适配的时候也一样游戏一被切到后台就开始担心进程被回收于是到处找常驻后台、后台任务的配置结果发现游戏这种应用在 HarmonyOS 的进程优先级体系里基本没有不死的特权。后来在日志里反复看到 UIAbility 被回收、再冷启动我才真正想明白一件事在 HarmonyOS 里后台游戏被系统回收是设计内的正常行为不是事故。与其想尽办法不死不如把游戏运行态拆清楚——哪些状态必须可丢弃哪些状态丢了会出大事然后把前者真正设计成丢了也能恢复的样子。这篇文章就把我在真实项目里的拆法、落地点和验证方法完整写出来给正在做 HarmonyOS 游戏适配的开发者一个能直接抄的参考。1. 先接受一个前提后台被回收不是事故是系统机制1.1 UIAbility 生命周期里后台是一个随时可结束的状态HarmonyOS 的 Stage 模型以 UIAbility 为一个独立调度单元。应用从前台切到后台时UIAbility 会走onBackground接下来系统会根据内存压力、设备温度、用户使用习惯等多种因素决定是让你继续挂着、进入冻结态还是直接回收。这个决策不是针对你的游戏而是面向所有后台应用。游戏如果没有任何后台任务声明就是一个普通的 cached 进程回收优先级排在靠前的位置。很多从安卓迁过来的游戏团队习惯把 HarmonyOS 当成安卓换皮沿用一套杀后台保活的思路结果在 Stage 模型下处处碰壁。原因很简单HarmonyOS 的应用调度单位是能力Ability不是进程。你甚至没有一个稳定的应用级后台概念可用UIAbility 一旦走完onBackground后面的命运基本由系统说了算。你能做的是在这个终局还没到来之前把该落盘的状态落盘。冻结状态也值得单独说一句。应用还在后台挂着但主线程被冻结定时器不跑网络回调也可能被挂起。很多游戏切后台再回来发现时间对不上、倒计时没走就是因为这期间应用被冻结过不是时间真的停了。恢复之后在线游戏必须重新对时不能直接用本地时间差做活动倒计时否则玩家把游戏挂后台睡一觉每日签到和限时活动就全乱套了。1.2 LastExitReason 直接告诉你上一次是怎么死的每次 UIAbility 冷启动的时候onCreate的launchParam里会带上lastExitReason它直接说明上一次进程是怎么没的。我建议所有游戏在启动上报日志里都打这个值它是判断状态恢复逻辑有没有生效的第一手证据。lastExitReason含义恢复策略建议NORMAL正常结束用户主动退出或入口销毁走全新启动不要硬恢复KILL系统回收后台被杀最常见尝试恢复硬状态APP_FREEZE应用卡死被冻结后重启恢复现场并记录卡死日志ABILITY_NOT_RESPONDING页面无响应恢复现场并记录无响应日志JS_ERROR/CPP_CRASH脚本或原生崩溃恢复现场并收集崩溃栈恢复策略不是对所有 reason 一视同仁的。NORMAL时如果强行恢复页面反而会让用户觉得莫名其妙我都已经退出了你怎么又把我塞回房间页 所以lastExitReason不只是一个日志字段它应该直接决定启动流程走恢复分支还是全新分支。2. 把游戏运行态拆成硬状态与软状态两张表2.1 硬状态丢了会造成资损或账号问题的状态硬状态的定义不能拍脑袋。我的判断标准是丢失之后要么造成真实资产损失要么造成账号安全风险要么让对局数据错乱到无法继续。这类状态是游戏运行的事实来源必须在业务发生时主动落盘不能指望系统回收前通知你。典型的硬状态有四类登录态与账号体系uid、token、session、刷新令牌。这类数据要放进安全存储如 HUKS或私有 Preferences绝不能只放内存。支付与订单HMS IAP 的 orderId、购买凭据、服务端订单号。本地丢失不可怕可怕的是服务端不知道这笔订单存在所以必须在上架/发货成功后再标记本地状态。单机里程碑数据通关存档、体力值、已购内容。这类是本地唯一事实来源丢了就是永久丢必须用 Preferences 或 RelationalStore 持久化。联网对局标识roomId、matchId、当前局种子。客户端至少要留着 roomId才能在断线或被杀后重新进入对局。落盘时机也很关键不要在切后台这个统一时点存要在每个关键节点主动落盘关卡开始时、检查点通过时、关键道具拾取时、支付回调成功时。把存盘点埋进业务逻辑里而不是依赖生命周期回调这是硬状态管理的第一原则。2.2 软状态丢了只影响体验、不影响数据的状态软状态的定义反过来丢失后用户有感知但不会造成资产、账号、对局不可逆问题。大部分软状态是上一次操作到下一次操作之间的临时现场。最常见的软状态包括页面栈当前在第几级页面、弹窗是否展开、列表滚动位置。战斗场景内的瞬时数据怪物坐标、Buff 倒计时、飘字、动画状态。如果服务端有权威数据直接重新拉取即可。未发送的聊天草稿、局内快捷语选择。音频播放到第几秒、当前选中的皮肤页签。红点、新手引导步骤标记。这类丢了对数据没影响但用户可能会重新看一次引导体验有损。很多开发者舍不得丢软状态是因为把当前页面误当成了当前进度。比如用户正在图鉴第 12 页你杀掉进程回来后让他回首页他真正丢失的是我在图鉴第 12 页这个浏览现场。这个现场既不是关卡进度也不是资产属于丢了能接受恢复是加分项的状态。2.3 判断口诀服务端能重建的都不要当硬状态把硬状态和软状态拆清之后我总结了一句口诀团队里写状态管理时一直用服务端能重建的一律软状态本地唯一事实优先硬状态既不是本地唯一事实、又不影响资损的一律可丢弃。这句口诀解决了一个很常见的争论抽卡结果、金币数量、装备列表这类东西到底算不算硬状态如果服务端是权威数据源那客户端里这些数据都只是缓存杀掉进程后从服务端拉一次就行本质上是软状态。如果你做的是纯单机游戏这些数值没有服务端兜底那就全部升级为硬状态。还有一条补充规则任何可丢弃状态都必须配一条恢复路径。如果你决定掉落物可以丢就要保证玩家重新进关卡时掉落物要么由关卡规则重新生成要么因为服务端已经结算而不再需要。没有恢复路径的丢弃叫 bug不叫状态设计。3. 状态恢复的根onSaveState 与 lastExitReason 配对使用3.1 onSaveState 只是兜底不是主保险UIAbility 提供了一个onSaveState回调系统决定销毁能力之前会给你一个机会保存轻量状态。常规写法是往wantParam.parameters里塞数据import { AbilityConstant, UIAbility, Want } from kit.AbilityKit; export default class EntryAbility extends UIAbility { private roomId ; private scene home; onSaveState(reason: AbilityConstant.StateType, wantParam: Want): Want { // reason 的取值在不同 SDK 版本里略有差异 // 一般是 AbilityConstant.StateType.START 等枚举 wantParam.parameters { saveTime: Date.now(), scene: this.scene, roomId: this.roomId, }; return wantParam; } }这里要注意两点。第一onSaveState不是每次切后台都会调用系统只有在认为你可能需要恢复时才会触发第二它不希望你在里面做重 IO你只需要把当前场景的关键标识塞进去比如我停在哪个页面当前房间号是多少。所以我把它定义成兜底而不是主保险。硬状态必须在业务发生时主动写盘不能等onSaveState。否则一旦系统没有回调它或者回调了但数据没来得及落盘你的恢复逻辑就没有依据了。3.2 onCreate 里根据 lastExitReason 决定走哪条路恢复动作放在冷启动的onCreate里用launchParam.lastExitReason分支判断import { AbilityConstant, UIAbility, Want } from kit.AbilityKit; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const reason launchParam.lastExitReason; const savedParams want?.parameters; if ( reason AbilityConstant.LastExitReason.KILL || reason AbilityConstant.LastExitReason.APP_FREEZE || reason AbilityConstant.LastExitReason.ABILITY_NOT_RESPONDING || reason AbilityConstant.LastExitReason.JS_ERROR || reason AbilityConstant.LastExitReason.CPP_CRASH ) { // 上一次是被系统干掉的值得走恢复分支 this.tryRestore(savedParams); } else { // 正常启动或用户主动退出后的再启动走全新流程 this.startFresh(); } } }判断逻辑里有个容易被忽略的点wantParam.parameters里有保存数据说明onSaveState生效了但如果没数据不能就此放弃恢复。系统的回收路径很多可能根本没走到onSaveState进程就被清理了。所以恢复逻辑不能只依赖wantParam还要有降级路径——比如从 PersistentStorage 里读最近一次房间号或者从本地存档读最近一个检查点。onSaveState恢复是能恢复就恢复的锦上添花基于落盘状态的恢复才是真正可靠的主干道。3.3 崩溃恢复 AppRecovery适合加在游戏首页除了系统回收崩溃场景也可以走 AppRecovery 能力。它提供saveAppState、restartApp、setRestartWant这几个入口能在 JS 崩溃时自动保存状态并重启到指定页面。API 具体签名每个版本有调整接入时以当前 SDK 的 d.ts 为准但思路是一致的给重启后的进程一个明确的恢复意图。我一般只把它用在游戏首页和对局结算页。对局中间自动重启可能让玩家处于一个很尴尬的位置战斗可能已经因为掉线被判负了重启回去反而容易触发各种边界 bug。所以崩溃恢复适合低风险页面自动处理高风险页面人工介入的策略。4. ArkUI 状态容器要分清内存态与磁盘态4.1 一张表看懂状态容器的存活边界ArkUI 提供的状态容器非常多很多争议都出在选型上。把存活边界列出来其实清清楚楚容器生命周期进程被杀后建议用途State/Prop/Link组件销毁即没丢单一页面的局部 UI 展示Provide/Consume组件树存在期间丢跨层组件共享比如主题信息AppStorage应用进程存续期间丢Ability 与页面之间的进程内共享LocalStorage页面或 Ability 存续期间丢页面内的状态共享PersistentStorageStorageLink应用进程 磁盘保留需要跨启动保留的简单基础类型状态Preferences/RelationalStore磁盘保留存档、配置、Json 序列化数据HUKS安全存储区保留token、密钥、敏感数据最常见的误解是把AppStorage当持久化。AppStorage是进程内全局单例进程没了就没了。它适合做进程内的状态总线不适合做跨启动的存档。很多团队把登录 token 直接塞AppStorage用户一切后台就掉登录换个进程就重新登录问题就出在这里。4.2 PersistentStorage 的正确打开方式要让 AppStorage 里的值跨启动保留得用PersistentStorage.persistProp桥接。正确用法是这样的PersistentStorage.persistProp(playerName, ); PersistentStorage.persistProp(lastRoomId, ); Entry Component struct GameIndex { StorageLink(playerName) playerName: string ; StorageLink(lastRoomId) lastRoomId: string ; }persistProp首次启动时会把默认值写盘之后StorageLink的改动会自动同步到磁盘。但要注意只有已经用persistProp声明过的 key 才会在启动时从磁盘读回你随手写一个AppStorage.setOrCreate的新 key是不具备持久化能力的。另外PersistentStorage对值的类型支持比较保守官方建议以基础类型和扁平结构为主。复杂嵌套对象不要硬塞状态容器先本地序列化成字符串或者直接落到 Preferences 里。4.3 复杂存档不要硬塞状态容器交给 Preferences 和存档管理器游戏存档属于复杂对象我的做法是单独写一个存档管理器所有硬状态的读写都走它import { preferences } from kit.ArkData; export class SaveManager { static async writeCheckpoint(context: Context, save: GameSave) { const store await preferences.getPreferences(context, game_save); await store.put(checkpoint, JSON.stringify(save)); await store.flush(); } static async readCheckpoint(context: Context): PromiseGameSave | null { const store await preferences.getPreferences(context, game_save); const raw await store.get(checkpoint, ); return raw ? JSON.parse(raw as string) as GameSave : null; } }这段代码里最需要注意的是flush()。Preferences的put只是写内存flush才真正落盘。很多人在测试时杀掉进程数据没丢以为不用 flush其实是系统还没来得及回收在真实的内存压力下不 flush 的数据说没就没。所以硬状态的写入必须以落盘成功为完成标志不能只 put 不 flush。5. 游戏场景实操四种被杀后重进的恢复路径5.1 单机关卡中途回收单机游戏本地是唯一事实来源恢复路径相对直接启动时读SaveManager的 checkpoint有就加载 checkpoint 重新生成场景没有就进主菜单。我在单机关卡里选了三个存盘点关卡开始时、检查点通过时、关键道具拾取时。这里的关键道具尤其容易被忽略。如果玩家在 Boss 前捡到一把钥匙但钥匙只在内存里重进关卡后钥匙消失就可能导致卡关。所以拾取性道具要按硬状态处理拾取动作完成的瞬间就写入存档。至于当前怪物的血量、掉落物坐标、镜头位置这类瞬时状态全部丢弃重新生成。玩家最直观的感受是我回到了上一个存档点而不是我的进度倒退了。5.2 在线对战房间等待/断线在线对局的状态权威在服务端客户端的硬状态只需要 roomId 和匹配令牌。恢复路径是启动后拿 roomId 调服务端 rejoin 接口if (savedRoomId) { const res await gameSdk.rejoinRoom(savedRoomId); if (res.status playing) { routeToBattle(res.battleId); } else if (res.status closed) { routeToHome(); } } else { routeToHome(); }这里的软状态包括匹配队列动画、聊天框输入、当前选中模式全部可以丢。需要注意服务端要为 rejoin 设置超时策略房间可能已经解散玩家可能已经被判负客户端不能无脑重连。另外要再次强调对时。后台冻结期间本地时间还在走但服务端时间不会等任何人。活动倒计时、每日签到、限时礼包的剩余时间都要拿服务端时间重新对齐别用Date.now()直接算剩余否则本地时钟一改活动时间就崩了。5.3 支付流程中断支付是最敏感的硬状态场景。本地能存的只有 orderId但决定性的数据永远在服务端。启动时的恢复路径是查询未完成订单走补发流程初始化 HMS IAP查询当前账号的未确认订单。发现未发货的 orderId调服务端对账确认是否真实支付成功。服务端确认后补发商品再标记本地状态为已发货。这里最大的坑是本地直接标发货成功。有些团队为了省事支付回调一到就发道具结果进程被杀、回调丢失、道具没到账玩家投诉一大堆。正确做法是客户端只负责向服务端上报支付凭据服务端确认后才发货。本地状态永远只是一个缓存指示器不是事实来源。5.4 登录态失效登录态的恢复目标是杀进程重进后要么自动刷新 token 静默进游戏要么明确回登录页而不是白屏。token 要放安全存储区HUKS或私有 Preferences恢复路径是启动时读取 token调刷新接口。刷新成功就继续刷新失败就进登录页。这里有个常常被忽略的点上次登录的账号名可以持久化但登录会话必须可丢弃。账号名持久化是为了用户方便会话可丢弃是为了安全。把这两者混为一谈就会出现为了记住用户把 token 明文存在本地的严重问题。6. 把可丢弃变成可测试的工程约束6.1 杀进程重进的压测方法状态设计得再好不验证等于零。我团队里的压测方案很简单在每个关键场景手动触发杀进程再冷启动看结果。hdc shell aa forceStop com.example.game hdc shell aa start -a EntryAbility -b com.example.game不同版本的 hdc 对aa子命令拼写可能有差异如果提示找不到forceStop执行hdc shell aa help看当前版本的实际命令。除了命令行DevEco Studio 的性能分析工具也能帮助你观察进程回收情况。测试不能只在模拟器里做。真机上把游戏切后台连续切换多个大型应用触发内存回收后再回来或者用最近任务上滑清理再冷启动。这两条路径的恢复行为可能不同——上滑清理通常意味着用户主动想关掉游戏这时候过度恢复反而违背用户预期。推荐的压测矩阵测试场景操作期望结果首页强杀后启动回首页登录态自动恢复关卡内强杀后启动回到最近 checkpoint不丢关键道具房间等待强杀后启动自动重进房间或明确提示房间已解散支付流程支付后强杀启动后出现订单补发流程不丢商品在线对局杀进程 断网重进服务端正常裁决客户端能 rejoin 或明确提示6.2 状态恢复的验收清单每次改完状态管理我都用一份固定清单验收冷启动后 UI 不白屏能到达明确入口。登录态能自动恢复或降到登录页二选一没有第三种状态。单机存档恢复到最近一个 checkpoint而不是主菜单。在线对局能重连或能明确提示对局结束。支付订单能补发商品不丢。从后台冻结切回来活动倒计时和服务端时间一致。这条清单看着简单但每条背后都对应一类 bug。尤其是最后一条冻结恢复的时间校准是我做过好几个游戏之后才补进去的早期确实被玩家利用本地时间差刷过签到。6.3 工程上让状态分类可执行的三个约束为了不让硬状态/软状态只停留在文档层面我在工程上加了三个硬约束第一数据访问收口。UI 组件不能直接读写 Preferences 或 HUKS所有硬状态读写必须走SaveManager或 Repository 层。这样硬状态有哪些一眼就能查出来review 代码时也只用盯这几个入口。第二硬状态写盘必须 flush且要有写失败的重试。flush()失败说明存储异常这时候要保留内存副本并提示玩家而不是假装写成功了。第三每个软状态都必须能回答一个问题如果这条状态现在被系统干掉用户会走到哪一步能回答会回到首页重新点进来这叫可丢弃回答会卡死在半路这就不是可丢弃是隐藏 bug。代码评审时多问一句很多状态分配问题当场就能暴露。最后分享一个我实操中的体会。把状态分类写进项目规范之后团队里关于该不该保活的争论明显少了。最有意思的是之前最怕被系统回收的同事后来反而成了主动加压力测试的人——因为真的验证过状态丢不了就不怕系统回收了。这种心态转变也许比任何技术方案都重要。