《移动游戏产品的架构·客户端与移动运行时》4.1 冷启动、热启动与后台挂起

生命周期不是回调清单,而是一台可验证的状态机。本节把 Android 的 onPause、onStop、onDestroy 与 iOS 的前后台通知映射为 created、foregrounded、backgrounded、suspended、resumed、destroyed 六个状态,用注入 Clock 判定冷热启动分界,并比较消消与方舟在恢复动作上的差异。

事故:把 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 的方言翻译成八个事件。玩法、系统、表现三层只订阅事件,不直接读平台回调。

平台回调或通知归一化事件容易误判的点
AndroidonCreatelaunch只表示壳创建,不代表资源就绪
AndroidonStartreturn-foreground界面开始可见,冷启动也会触发
AndroidonResumeresume-complete可交互,但首帧可能还没稳定
AndroidonPausedeactivate仍可能可见,不能当作后台
AndroidonStopenter-background完全不可见,进程仍可能存活
AndroidonDestroydestroy不保证进程退出
iOSdidFinishLaunchinglaunch启动完成,界面尚未可交互
iOSwillEnterForegroundreturn-foreground恢复流程开始,不是完成
iOSdidBecomeActiveresume-complete获得焦点,可能从弹窗返回
iOSwillResignActivedeactivate失去焦点,仍可能可见
iOSdidEnterBackgroundenter-background系统可能随时回收
iOSwillTerminatedestroy仅特定终止路径可靠

这张表的价值在于把「平台差异」限制在一个文件里。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 秒。这些数字用于说明两类产品的差距,不是本节模型的输出。

判定输入冷启动热启动
进程新建,或已销毁仍存活
后台时长达到驱逐阈值,按冷处理小于阈值
恢复动作重建缓存、重拉配置、重新登录态校验复用内存,只补尾部状态
消消 / 方舟案例 P501.8s / 3.5s0.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 进程给出结论。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查