事故:把 onDestroy 当成进程终点
《潮汐消消》的一次活动版本复盘里,客服收到一类进度回退:玩家在单局中途接电话,回来发现棋盘回到了上一局结束时的状态。开发最初把存档写在 onDestroy 里,理由是「退出时总要保存一次」。问题在于,移动系统并不保证这个回调会来。
Android 上,用户从最近任务划掉应用、系统因内存压力回收进程、或应用被强行停止,都不保证走到 onDestroy。iOS 上,applicationWillTerminate 只在应用主动终止且未被挂起时相对可靠;被系统挂起后回收的路径同样没有回调。把保存挂在「销毁」上,等于把数据交给一个不确定会执行的函数。
更隐蔽的错误是把 onPause 当成「进入后台」。Android 的 onPause 只表示界面失去焦点,它可能仍然可见:来电横幅、分屏、权限弹窗、画中画都会触发。真正不可见是 onStop。如果 onPause 就暂停玩法循环、断开网络、释放资源,玩家在弹窗关闭后会看到一个卡住的界面。
这两个错误指向同一个结论:生命周期不能按回调名逐个记忆,而要建模成一台显式状态机。回调只是平台方言,状态机才是客户端各层共享的契约。
六个状态与两条正交轴
mga-lab 把生命周期收敛为六个状态。它们不是平台回调的别名,而是客户端内部真正关心的阶段。
| 状态 | 含义 | 可见性 | 典型入口 |
|---|---|---|---|
| created | 进程已创建,界面尚未呈现 | hidden | 冷启动入口 |
| resumed | 正在恢复,首帧与恢复任务尚未完成 | inactive | 回到前台、启动恢复 |
| foregrounded | 可交互,渲染与输入循环运行 | active | 恢复完成 |
| backgrounded | 不可见,进程仍可能存活 | hidden | 进入后台 |
| suspended | 系统可随时回收,客户端应停止主动工作 | hidden | 后台超时或平台通知 |
| destroyed | 本次生命周期的终点 | hidden | 主动退出或壳销毁 |
第二条轴是可见性 active / inactive / hidden。它和状态正交:foregrounded 可能因为来电变成 inactive,但状态仍是 foregrounded;resumed 正在恢复时也是 inactive。把两条轴混成一个枚举,就会出现「暂停算前台还是后台」这种无法回答的问题。
resumed 容易被误解为「已经从后台回来」。在本模型里,它表示恢复流程正在执行:资源重新绑定、存档读取、网络重连、首帧提交都可能还没完成。只有 resume-complete 之后才进入 foregrounded。这样拆分的收益是,恢复期的耗时、失败和取消都有地方挂,而不是全部塞进一个「已回前台」的布尔值。
把平台回调翻译成同一条事件流
平台适配层负责把 Android 与 iOS 的方言翻译成八个事件。玩法、系统、表现三层只订阅事件,不直接读平台回调。
| 平台 | 回调或通知 | 归一化事件 | 容易误判的点 |
|---|---|---|---|
| Android | onCreate | launch | 只表示壳创建,不代表资源就绪 |
| Android | onStart | return-foreground | 界面开始可见,冷启动也会触发 |
| Android | onResume | resume-complete | 可交互,但首帧可能还没稳定 |
| Android | onPause | deactivate | 仍可能可见,不能当作后台 |
| Android | onStop | enter-background | 完全不可见,进程仍可能存活 |
| Android | onDestroy | destroy | 不保证进程退出 |
| iOS | didFinishLaunching | launch | 启动完成,界面尚未可交互 |
| iOS | willEnterForeground | return-foreground | 恢复流程开始,不是完成 |
| iOS | didBecomeActive | resume-complete | 获得焦点,可能从弹窗返回 |
| iOS | willResignActive | deactivate | 失去焦点,仍可能可见 |
| iOS | didEnterBackground | enter-background | 系统可能随时回收 |
| iOS | willTerminate | destroy | 仅特定终止路径可靠 |
这张表的价值在于把「平台差异」限制在一个文件里。Android 的 onPause 与 iOS 的 willResignActive 都映射到 deactivate;Android 的 onStop 与 iOS 的 didEnterBackground 都映射到 enter-background。上层不需要知道当前是哪个平台,也就不会写出「Android 在 onPause 存档、iOS 在 didEnterBackground 存档」这种两套逻辑。
原生桥接的线程与回调时序可以参考原生 JSB 桥接 ,崩溃与卡顿的观测口径可以参考崩溃与卡顿 。本节只处理状态归一化,不展开桥接实现。
冷启动与热启动的分界
「冷启动」通常指进程从无到有,「热启动」指进程仍在、从后台回到前台。但在移动端,这两个词还混着第三层含义:资源是否还新鲜。一个进程即使没被杀,后台停留过久后页缓存、纹理、配置快照也可能已经被系统回收;此时按热启动处理,会低估恢复成本。
mga-lab 用注入的 Clock 计算后台时长,把「驱逐阈值」作为可配置参数。案例设定里,消消的冷启动 P50 是 1.8 秒、热启动 P50 是 0.6 秒;方舟分别是 3.5 秒和 1.1 秒。这些数字用于说明两类产品的差距,不是本节模型的输出。
| 判定输入 | 冷启动 | 热启动 |
|---|---|---|
| 进程 | 新建,或已销毁 | 仍存活 |
| 后台时长 | 达到驱逐阈值,按冷处理 | 小于阈值 |
| 恢复动作 | 重建缓存、重拉配置、重新登录态校验 | 复用内存,只补尾部状态 |
| 消消 / 方舟案例 P50 | 1.8s / 3.5s | 0.6s / 1.1s |
驱逐阈值不是案例事实表里的指标,而是策略参数。mga-lab 默认取 30 分钟,真实项目要按平台版本、机型内存和产品形态校准。把它写进配置而不是散落在代码里,才能让「同一台设备在不同版本上表现不同」变成一次参数调整,而不是一轮逻辑重写。
冷热判定还会直接影响 4.2 的恢复路线:判为冷启动时,客户端不能假设内存里还有完整棋盘;判为热启动时,也不代表可以跳过服务端校验。判定是恢复预算的输入,不是性能报表的标签。
可运行模型:注入时钟的状态机
下面的 lifecycle.ts 把上述规则落成可执行代码。它只依赖第 1 章的 Clock 与 Result,不碰文件、网络和渲染。平台回调通过 signal() 进入,非法迁移返回带原因的错误,而不是静默忽略。
// file: src/runtime/lifecycle.ts
// tier: L2
// verify: node --test test/runtime/lifecycle.test.ts
import type { Clock } from "../core/clock.ts";
import { fail, ok, type Result } from "../core/result.ts";
export const LIFECYCLE_STATES = [
"created",
"foregrounded",
"backgrounded",
"suspended",
"resumed",
"destroyed",
] as const;
export type LifecycleState = (typeof LIFECYCLE_STATES)[number];
export type Visibility = "active" | "inactive" | "hidden";
export type Platform = "android" | "ios";
export type LifecycleEvent =
| "launch"
| "activate"
| "deactivate"
| "enter-background"
| "return-foreground"
| "resume-complete"
| "suspend"
| "destroy";
export interface PlatformBinding {
readonly platform: Platform;
readonly signal: string;
readonly event: LifecycleEvent;
readonly note: string;
}
export const PLATFORM_BINDINGS: readonly PlatformBinding[] = [
{ platform: "android", signal: "onCreate", event: "launch", note: "进程与 Activity 首次创建" },
{ platform: "android", signal: "onStart", event: "return-foreground", note: "界面开始可见,冷启动时也会触发" },
{ platform: "android", signal: "onResume", event: "resume-complete", note: "界面可交互,恢复流程完成" },
{ platform: "android", signal: "onPause", event: "deactivate", note: "仍可能可见,被对话框、分屏或来电遮挡" },
{ platform: "android", signal: "onStop", event: "enter-background", note: "完全不可见,进程仍可能存活" },
{ platform: "android", signal: "onDestroy", event: "destroy", note: "Activity 销毁,不保证进程退出" },
{ platform: "ios", signal: "didFinishLaunching", event: "launch", note: "启动完成,界面尚未可交互" },
{ platform: "ios", signal: "willEnterForeground", event: "return-foreground", note: "即将回到前台,恢复流程开始" },
{ platform: "ios", signal: "didBecomeActive", event: "resume-complete", note: "获得焦点并开始接收输入" },
{ platform: "ios", signal: "willResignActive", event: "deactivate", note: "失去焦点,仍可能可见" },
{ platform: "ios", signal: "didEnterBackground", event: "enter-background", note: "进入后台,系统可能随时回收进程" },
{ platform: "ios", signal: "willTerminate", event: "destroy", note: "仅在被主动终止且未被挂起时可靠" },
];
export function eventForSignal(platform: Platform, signal: string): LifecycleEvent | null {
const binding = PLATFORM_BINDINGS.find(
(item) => item.platform === platform && item.signal === signal,
);
return binding?.event ?? null;
}
export interface LifecyclePolicy {
readonly suspendAfterMs: number;
readonly evictAfterMs: number;
}
export const DEFAULT_LIFECYCLE_POLICY: LifecyclePolicy = {
suspendAfterMs: 300_000,
evictAfterMs: 1_800_000,
};
export type StartupKind = "cold" | "hot";
export interface Transition {
readonly from: LifecycleState;
readonly to: LifecycleState;
readonly event: LifecycleEvent;
readonly atMs: number;
}
export type TransitionError = "destroyed-terminal" | "illegal-transition" | "unknown-signal";
interface Rule {
readonly from: readonly LifecycleState[];
readonly to: LifecycleState;
readonly visibility: Visibility;
}
const RULES: Record<LifecycleEvent, Rule> = {
launch: { from: ["created"], to: "resumed", visibility: "hidden" },
activate: { from: ["created", "resumed"], to: "foregrounded", visibility: "active" },
deactivate: { from: ["foregrounded"], to: "foregrounded", visibility: "inactive" },
"enter-background": { from: ["foregrounded", "resumed"], to: "backgrounded", visibility: "hidden" },
"return-foreground": { from: ["backgrounded", "suspended", "resumed"], to: "resumed", visibility: "inactive" },
"resume-complete": { from: ["resumed"], to: "foregrounded", visibility: "active" },
suspend: { from: ["backgrounded"], to: "suspended", visibility: "hidden" },
destroy: {
from: ["created", "foregrounded", "backgrounded", "suspended", "resumed"],
to: "destroyed",
visibility: "hidden",
},
};
export class LifecycleMachine {
#clock: Clock;
#policy: LifecyclePolicy;
#state: LifecycleState = "created";
#visibility: Visibility = "hidden";
#startupKind: StartupKind = "cold";
#backgroundedAtMs: number | null = null;
#lastBackgroundAgeMs: number | null = null;
#transitions: Transition[] = [];
constructor(clock: Clock, policy: LifecyclePolicy = DEFAULT_LIFECYCLE_POLICY) {
this.#clock = clock;
this.#policy = policy;
}
get state(): LifecycleState {
return this.#state;
}
get visibility(): Visibility {
return this.#visibility;
}
get startupKind(): StartupKind {
return this.#startupKind;
}
get backgroundedAtMs(): number | null {
return this.#backgroundedAtMs;
}
get lastBackgroundAgeMs(): number | null {
return this.#lastBackgroundAgeMs;
}
get transitions(): readonly Transition[] {
return [...this.#transitions];
}
backgroundAgeMs(): number | null {
if (this.#backgroundedAtMs === null) {
return null;
}
return this.#clock.nowMs() - this.#backgroundedAtMs;
}
signal(platform: Platform, signal: string): Result<Transition, TransitionError> {
const event = eventForSignal(platform, signal);
if (event === null) {
return fail("unknown-signal", `${platform}:${signal}`);
}
return this.dispatch(event);
}
dispatch(event: LifecycleEvent): Result<Transition, TransitionError> {
if (this.#state === "destroyed") {
return fail("destroyed-terminal", event);
}
const rule = RULES[event];
if (!rule.from.includes(this.#state)) {
return fail("illegal-transition", `${this.#state} -> ${event}`);
}
const now = this.#clock.nowMs();
const from = this.#state;
if (event === "return-foreground") {
const age = this.backgroundAgeMs();
if (age !== null) {
this.#lastBackgroundAgeMs = age;
this.#startupKind = age >= this.#policy.evictAfterMs ? "cold" : "hot";
}
this.#backgroundedAtMs = null;
}
if (event === "launch") {
this.#startupKind = "cold";
}
if (event === "enter-background") {
this.#backgroundedAtMs = now;
this.#lastBackgroundAgeMs = null;
}
if (event === "destroy") {
this.#backgroundedAtMs = null;
}
this.#state = rule.to;
this.#visibility = rule.visibility;
const transition: Transition = { from, to: rule.to, event, atMs: now };
this.#transitions.push(transition);
return ok(transition);
}
suspendIfIdle(): Result<Transition, TransitionError> | null {
if (this.#state !== "backgrounded") {
return null;
}
const age = this.backgroundAgeMs();
if (age === null || age < this.#policy.suspendAfterMs) {
return null;
}
return this.dispatch("suspend");
}
}
配套测试 test/runtime/lifecycle.test.ts 覆盖九条路径:Android 冷启动、activate 快捷路径、onPause 与 onStop 的可见性差异、iOS 前后台往返、驱逐阈值边界、后台挂起、平台映射、非法迁移,以及 destroyed 的终态性。运行命令是 node --test test/runtime/lifecycle.test.ts。
这里是模型,不是设备实测;真实设备上 onStart 与 onResume 的时序、分屏与画中画的可见性、厂商省电策略对后台的干预,都需要在机型矩阵上验证;模型负责把「应该发生什么」写死,设备验证负责回答「实际发生了什么」。
双案例:同一状态机,不同恢复动作
消消是 90 秒单局、弱联网,后台回来优先复用内存棋盘;超过驱逐阈值就按冷启动重建,恢复动作最多回放一局,用户几乎无感。
方舟是 8–12 分钟强联网战斗,后台回来不能直接续战;即使进程存活,也要先向服务端校验战斗令牌,客户端状态只作为表现层缓存。
两者的状态机相同,区别在恢复动作的代价与权限:消消可以在本地决定「继续上一局」,方舟必须把决定权交给服务端。把这条差异写进策略表,比在代码里散落 if (product === "ark") 更可维护。
与相邻章节的关系
3.2 讨论桥接层的消息、线程与生命周期,本节只接管「状态如何归一化」;桥接层负责把原生回调安全地送到客户端线程,状态机负责判断这个回调意味着什么。4.2 会把冷热判定作为输入,选择快照或操作日志的恢复路线。4.3 会处理状态机收不到 destroy 的情况:进程被系统杀死时,连 suspended 都可能来不及进入。
冷启动优化的通用手段可以参考首屏冷启动优化 ,但本节的重点不是把 1.8 秒压到多少,而是让恢复动作与状态匹配。
小结
- 生命周期是状态机,不是回调清单;
onDestroy与willTerminate都不是可靠的保存时机。 - 状态与可见性正交:
onPause/willResignActive只让人失去焦点,onStop/didEnterBackground才让人不可见。 resumed表示恢复进行中,不是「已经回到前台」;恢复完成才进入foregrounded。- 冷热分界由进程存活性与后台时长共同决定,后台时长用注入
Clock计算,驱逐阈值是策略参数。 - 同一状态机可以服务两个案例,但恢复动作的权限不同:消消可本地续局,方舟必须服务端校验。
阅读导航:上一节:3.3 双案例壳层差异:轻壳与重壳 · 下一节:4.2 状态保存与恢复:快照与操作日志 。
常见错误与边界
- 把
onDestroy或willTerminate当保存时机。这两个回调在进程被杀死时都不保证执行;保存必须前移到检查点或后台事件。 - 把
onPause当后台。onPause后界面可能仍可见,此时停玩法循环会让弹窗关闭后的界面卡住;应等onStop或didEnterBackground。 - 用
Date.now()代替注入时钟。真实时间会让边界测试随机器负载漂移;冷热分界必须能用ManualClock精确复现。 - 把
resumed当foregrounded。恢复期的资源绑定、存档读取和网络重连都可能失败;合并成一个状态会丢失失败与取消的位置。 - 把 30 分钟驱逐阈值当成所有机型的事实。它是策略参数,不是案例指标;不同平台版本和内存档位需要重新校准。
- 边界:本模型不模拟厂商省电策略与分屏可见性。这些属于机型矩阵验证,不能靠 Node 进程给出结论。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。