《移动游戏产品的架构·客户端与移动运行时》4.3 杀进程与异常退出的兜底

进程被系统杀死时没有任何回调,这是移动端恢复的第一前提。本节把脏标记、检查点节流、临时文件加原子重命名、校验和与恢复提示写成可测试的记录器,给出干净、可恢复、已丢失三种启动决策,并说明消消的单局回放与方舟的战斗令牌为何需要不同的兜底策略与权威归属。

事故:用户划掉应用,什么都没有发生

《潮汐消消》上线初期有一个稳定复现的投诉:玩家在单局中途从最近任务划掉应用,重新打开后回到主界面,棋盘和奖励都没了。开发在 onDestroy 里写了保存,也在 applicationWillTerminate 里写了保存,但两条路径都没有执行。

Android 上,从最近任务划掉、系统低内存回收、厂商省电策略清理,都可能直接终止进程,不保证 onDestroy。iOS 上,应用进入挂起状态后,系统可以在没有通知的情况下回收;willTerminate 只覆盖特定终止路径。更根本的是,进程死亡时没有可执行上下文:没有事件循环、没有网络、没有足够时间完成一次异步写盘。

所以「异常退出的兜底」不能建立在「退出时做点什么」上。它必须建立在进程还活着的时候已经把关键状态写到磁盘,以及下次启动时能判断上次是否正常结束。

进程死亡没有回调

移动端有三类终止路径,客户端能做的动作完全不同。

终止路径是否有回调客户端能做什么
正常退出(主动结束、系统温和终止)有,但不保证标记干净退出、清理临时文件
后台被系统回收通常没有只能依赖之前落盘的检查点
用户划掉或强行停止通常没有只能依赖脏标记与检查点
崩溃(未捕获异常、原生崩溃)崩溃处理器可能执行,但不保证完成异步写依赖同步写或上一次检查点

这张表决定了一个基本设计:保存动作必须前移。不要问「退出时怎么保存」,要问「哪些时刻的状态值得落盘,落盘失败最多损失多少」。前者依赖一个不可靠的回调,后者是可以计算和测试的预算。

崩溃采集与卡顿观测可以参考崩溃与卡顿 ,但崩溃上报解决的是「事后知道发生了什么」,不解决「玩家回来时状态还在不在」。两者要分开设计。

落盘时机:从「退出时保存」改为「检查点」

检查点不是每帧都写。每帧写会拖慢渲染,也会加速闪存磨损。合理的做法是选几个业务上可接受回退的边界:单局结束、回合结束、进入后台、关键奖励发放之后。

时机消消的动作方舟的动作
会话开始写脏标记,记录 sessionId写脏标记与战斗令牌
单局 / 回合结束写完整棋盘检查点写战斗检查点与服务端序号
进入后台强制写一次,随后停止主动工作强制写一次,标记需要服务端校验
回到前台读取检查点,校验后继续读取令牌,先向服务端确认
正常退出删除脏标记与临时文件删除脏标记,保留服务端结算结果

检查点之间要有节流。mga-lab 的实验参数是消消 15 秒、方舟 30 秒;这不是案例事实。节流的意义不是省磁盘,而是避免「每次小操作都同步写」把帧预算吃光。真正需要精确恢复的操作,应该在业务层合并成一次检查点,而不是逐条同步落盘。脏标记负责回答「上次是否正常结束」:会话开始时写入,正常退出时删除;下次启动如果还能读到它,就说明上次没有走到干净退出。脏标记必须和检查点使用同一个 sessionId,否则会把上一局的记录误认成本局的进度。

原子写:临时文件、sync、rename

直接覆盖主文件有一个经典问题:写到一半掉电,主文件变成半截 JSON,旧状态也没了。原子写的顺序是「写临时文件、刷盘、重命名覆盖主文件」。

步骤动作失败后的结果
1把完整记录写入 session.json.tmp,要求同步刷盘主文件仍是上一个检查点
2校验和与版本写入记录本身读取时可识别半截或篡改
3rename(tmp, session.json)原子替换,读者只会看到旧或新
4启动时丢弃遗留的 .tmp未完成 rename 的数据不被当成已提交

关键点是:只有 rename 成功,记录才算提交。临时文件即使完整,也不能在启动时直接拿来恢复;否则「写入完成但尚未确认」的数据会混进正式状态。第一次检查点尤其如此:如果主文件还不存在,rename 前崩溃,本次检查点就应当丢失,而不是从临时文件猜一个状态。

校验和解决另一个问题:闪存、文件系统或人为修改都可能让记录损坏。读取时先验证版本与校验和,失败就返回「丢失」,而不是把半截数据反序列化后继续跑。恢复提示宁可保守,也不要把错误状态带进游戏。

恢复提示与双案例差异

启动时读取脏标记与主记录,只有三种结果:干净启动、可恢复、已丢失。可恢复时还要决定「自动继续」还是「回到安全位置」,这取决于产品对状态权威的定义。

结果判据消消的提示方舟的提示
clean没有脏标记正常进入主界面正常进入大厅
recover脏标记、记录有效、sessionId 一致自动回到最近检查点提示回大厅,服务端校验令牌
lost记录缺失、校验失败或 sessionId 不一致提示本局无法恢复,回到主界面提示战斗无法恢复,回到大厅

消消是 90 秒单局、弱联网,客户端对棋盘有足够的权威,自动恢复的代价最多是一局;方舟是 8–12 分钟强联网战斗,伤害、掉落和赛季积分由服务端裁决,客户端不能仅凭本地记录宣布「战斗继续」。这条差异必须写进策略,而不是留给调用方临场判断。

服务端权威与客户端预测的边界可以参考客户端预测与服务端权威 ,反作弊校验可以参考反作弊校验 。本地存档的基本结构可以参考本地存档 ,云存档可以参考云存档 。

可运行模型:recovery.ts

下面的 recovery.ts 把脏标记、检查点节流、校验和、临时文件与原子重命名封装成一个记录器。文件系统通过 FileHost 注入,测试用内存实现模拟掉电;模型不依赖真实磁盘。

// file: src/runtime/recovery.ts
// tier: L1
// verify: node --test test/runtime/recovery.test.ts

import type { Clock } from "../core/clock.ts";

export interface FileHost {
  write(path: string, data: string, options: { readonly sync: boolean }): void;
  read(path: string): string | null;
  rename(from: string, to: string): void;
  remove(path: string): void;
  exists(path: string): boolean;
}

export interface RecoveryPaths {
  readonly primary: string;
  readonly temp: string;
  readonly dirty: string;
}

export function recoveryPaths(dir: string, name = "session"): RecoveryPaths {
  const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
  return {
    primary: `${base}/${name}.json`,
    temp: `${base}/${name}.json.tmp`,
    dirty: `${base}/${name}.dirty`,
  };
}

export interface RecordBody {
  readonly version: 1;
  readonly sessionId: string;
  readonly seq: number;
  readonly writtenAtMs: number;
  readonly payload: string;
}

export interface RecordEnvelope extends RecordBody {
  readonly checksum: string;
}

function canonicalBody(body: RecordBody): string {
  return JSON.stringify([
    body.version,
    body.sessionId,
    body.seq,
    body.writtenAtMs,
    body.payload,
  ]);
}

export function checksumOf(text: string): string {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

export function sealRecord(body: RecordBody): RecordEnvelope {
  return { ...body, checksum: checksumOf(canonicalBody(body)) };
}

export function serializeRecord(envelope: RecordEnvelope): string {
  return `${JSON.stringify(envelope)}\n`;
}

export function parseRecord(text: string): RecordEnvelope | null {
  try {
    const parsed = JSON.parse(text) as Partial<RecordEnvelope>;
    if (
      parsed.version !== 1 ||
      typeof parsed.sessionId !== "string" ||
      typeof parsed.seq !== "number" ||
      typeof parsed.writtenAtMs !== "number" ||
      typeof parsed.payload !== "string" ||
      typeof parsed.checksum !== "string"
    ) {
      return null;
    }

    const body: RecordBody = {
      version: 1,
      sessionId: parsed.sessionId,
      seq: parsed.seq,
      writtenAtMs: parsed.writtenAtMs,
      payload: parsed.payload,
    };
    return checksumOf(canonicalBody(body)) === parsed.checksum
      ? { ...body, checksum: parsed.checksum }
      : null;
  } catch {
    return null;
  }
}

export type RecoveryProduct = "match3" | "ark";

export type RecoveryHint =
  | "resume-last-match"
  | "return-to-lobby"
  | "server-reconcile";

export interface RecoveryPolicy {
  readonly product: RecoveryProduct;
  readonly checkpointEveryMs: number;
  readonly autoResume: boolean;
  readonly hint: RecoveryHint;
}

export const MATCH3_RECOVERY: RecoveryPolicy = {
  product: "match3",
  checkpointEveryMs: 15_000,
  autoResume: true,
  hint: "resume-last-match",
};

export const ARK_RECOVERY: RecoveryPolicy = {
  product: "ark",
  checkpointEveryMs: 30_000,
  autoResume: false,
  hint: "server-reconcile",
};

export function promptFor(policy: RecoveryPolicy): string {
  if (policy.product === "match3") {
    return "检测到未正常退出的对局,可回到最近检查点继续。";
  }
  return "检测到未正常退出的战斗,请回到大厅并由服务端校验战斗令牌。";
}

export interface CleanDecision {
  readonly kind: "clean";
}

export interface RecoverDecision {
  readonly kind: "recover";
  readonly record: RecordEnvelope;
  readonly policy: RecoveryPolicy;
  readonly prompt: string;
}

export interface LostDecision {
  readonly kind: "lost";
  readonly reason: string;
}

export type RecoveryDecision = CleanDecision | RecoverDecision | LostDecision;

export class CrashRecoveryRecorder {
  #files: FileHost;
  #paths: RecoveryPaths;
  #clock: Clock;
  #policy: RecoveryPolicy;
  #sessionId: string | null = null;
  #lastCheckpointAtMs: number | null = null;

  constructor(
    files: FileHost,
    paths: RecoveryPaths,
    clock: Clock,
    policy: RecoveryPolicy,
  ) {
    this.#files = files;
    this.#paths = paths;
    this.#clock = clock;
    this.#policy = policy;
  }

  get sessionId(): string | null {
    return this.#sessionId;
  }

  get lastCheckpointAtMs(): number | null {
    return this.#lastCheckpointAtMs;
  }

  beginSession(sessionId: string): void {
    if (sessionId.length === 0) {
      throw new Error("session id must not be empty");
    }
    this.#files.write(this.#paths.dirty, sessionId, { sync: true });
    this.#sessionId = sessionId;
    this.#lastCheckpointAtMs = null;
  }

  checkpoint(seq: number, payload: string, force = false): RecordEnvelope | null {
    if (this.#sessionId === null) {
      throw new Error("beginSession must be called before checkpoint");
    }
    if (!Number.isInteger(seq) || seq < 0) {
      throw new RangeError(`checkpoint seq must be a non-negative integer: ${seq}`);
    }

    const now = this.#clock.nowMs();
    if (
      !force &&
      this.#lastCheckpointAtMs !== null &&
      now - this.#lastCheckpointAtMs < this.#policy.checkpointEveryMs
    ) {
      return null;
    }

    const envelope = sealRecord({
      version: 1,
      sessionId: this.#sessionId,
      seq,
      writtenAtMs: now,
      payload,
    });

    this.#files.write(this.#paths.temp, serializeRecord(envelope), { sync: true });
    this.#files.rename(this.#paths.temp, this.#paths.primary);
    this.#lastCheckpointAtMs = now;
    return envelope;
  }

  markCleanExit(): void {
    this.#files.remove(this.#paths.dirty);
    this.#sessionId = null;
    this.#lastCheckpointAtMs = null;
  }

  inspect(): RecoveryDecision {
    this.discardTemp();

    const dirty = this.#files.read(this.#paths.dirty);
    if (dirty === null) {
      return { kind: "clean" };
    }

    const raw = this.#files.read(this.#paths.primary);
    if (raw === null) {
      return { kind: "lost", reason: "dirty marker without checkpoint" };
    }

    const record = parseRecord(raw);
    if (record === null) {
      return { kind: "lost", reason: "checkpoint failed checksum validation" };
    }

    if (record.sessionId !== dirty) {
      return { kind: "lost", reason: "checkpoint belongs to another session" };
    }

    return {
      kind: "recover",
      record,
      policy: this.#policy,
      prompt: promptFor(this.#policy),
    };
  }

  discardTemp(): boolean {
    if (!this.#files.exists(this.#paths.temp)) {
      return false;
    }
    this.#files.remove(this.#paths.temp);
    return true;
  }

  discardRecord(): void {
    this.#files.remove(this.#paths.temp);
    this.#files.remove(this.#paths.primary);
    this.#files.remove(this.#paths.dirty);
    this.#sessionId = null;
    this.#lastCheckpointAtMs = null;
  }
}

配套测试 test/runtime/recovery.test.ts 覆盖十条路径:干净启动、检查点跨进程恢复、干净退出抑制提示、临时文件写入后 rename 前掉电、记录损坏、脏标记无检查点、跨会话记录、检查点节流与强制写、校验和篡改,以及两个案例的策略差异。运行命令是 node --test test/runtime/recovery.test.ts。

这里是模型,不是设备实测;真实设备的 fsync 语义、目录项持久化、闪存掉电行为和文件系统挂载参数都会影响原子性;FileHost 只保证「调用顺序正确」,不保证「硬件一定按顺序持久化」。上线前要在目标机型上做掉电与强杀验证。

双案例:一局回放与一场战斗的兜底

消消的兜底目标是「最多丢一局」:检查点密集、记录小、自动恢复,玩家回来点一下就能继续,服务端只做最终校验。

方舟的兜底目标是「不把错误战斗带进结算」:检查点记录战斗令牌,恢复时必须回大厅让服务端确认,宁可放弃本地表现状态,也不让客户端单方面续战。

两者共用同一套原子写与脏标记,差异只在检查点频率、恢复提示和权威归属。把这三项做成策略,记录器本身不需要知道产品名。

与相邻章节的关系

4.1 定义了生命周期状态,4.2 定义了恢复路线,本节补齐最坏情况:状态机收不到 destroy 时,靠脏标记与检查点判断上次是否正常结束。第 11 章会把存档结构、迁移与反作弊展开;本节只处理「记录是否完整、是否该提示恢复」。

小结

  • 进程死亡通常没有回调,异常退出的兜底必须依赖提前落盘的检查点,而不是退出时的异步保存。
  • 脏标记回答「上次是否正常结束」;它必须与检查点使用同一个 sessionId,否则会误认上一局。
  • 原子写的顺序是写临时文件、同步刷盘、rename 覆盖主文件;只有 rename 成功才算提交。
  • 校验和让损坏记录变成「丢失」,而不是被反序列化后继续运行。
  • 消消可以自动续局,方舟必须回大厅由服务端校验;差异应写成策略,不写成产品名分支。

阅读导航:上一节:4.2 状态保存与恢复:快照与操作日志 · 下一节:5.1 配置 schema 与版本化 。

常见错误与边界

  • 在 onDestroy 或 willTerminate 里做唯一一次保存。这两条路径在强杀和后台回收时都不保证执行;保存必须前移到检查点。
  • 直接覆盖主文件,或把临时文件当成已提交数据。写到一半掉电会丢旧状态;rename 未完成的数据也不应被恢复。
  • 只校验 JSON 能解析,或脏标记与检查点不比对 sessionId。半截数据可能解析成功,上一局记录也可能被误认成本局;必须校验版本、校验和与会话号。
  • 让方舟客户端自行续战。强联网战斗的权威在服务端;本地记录只能作为表现缓存,恢复前必须校验令牌。
  • 边界:本节不实现真实文件系统与崩溃上报。FileHost 是模型接口,掉电、fsync 与闪存行为需要真机验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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