一次 onPause 之后,游戏还在跑
《潮汐消消》的一个版本里,玩家切到后台接电话,回到前台发现关卡倒计时仍在走,体力也被扣了一次。排查记录有两个独立现象:Android 的 onPause 只给引擎传了一个布尔标志,引擎主循环并没有因此停帧;支付回调从商店 SDK 的后台线程直接改了游戏状态对象,和渲染线程撞在一起。
这两个现象看起来分别是生命周期问题和线程问题,根因却是同一个:没有人写清楚 onPause 之后哪些职责暂停、由谁执行、执行在哪个线程。团队当时的共识是「引擎管游戏,壳管系统」,这句话在评审时无法反驳,也无法执行,因为它没有回答「支付回调算游戏还是算系统」。
本节不讨论引擎怎么渲染,也不讨论 Activity 的生命周期回调顺序,那些内容属于引擎与平台文档。本节只做一件事:把「谁拥有什么」拆成可枚举、可查询、可审计的职责表,再用一个 Host 接口把平台事实注入引擎。
边界不是「引擎 vs 原生」,而是十项职责
「引擎负责游戏,壳负责系统」之所以没用,是因为“游戏”和“系统”在具体调用上会重叠。支付是系统能力,但订单结果要改游戏状态;传感器是系统能力,但采样率和降级策略属于游戏体验;渲染是引擎能力,但后台时必须由壳通知引擎停帧。
把重叠的职责继续拆,直到每一项都能回答三个问题:谁接收系统回调、谁承担这项能力失败时的后果、谁能在没有对方的情况下单独测试。拆完之后得到十项职责,覆盖了绝大多数移动游戏客户端的边界争议。
| 职责 | 所有权 | 判定理由 | 交接物 |
|---|---|---|---|
| 应用生命周期 | 壳 | Activity / UIViewController 是唯一能收到系统回调的一方 | 统一的 resume / pause / destroy 事件 |
| 主线程 | 壳 | UI 与输入由平台主线程调度,引擎不能假设自己拥有它 | 每帧回到主线程的回调点 |
| 渲染线程 | 引擎 | 渲染循环与 GPU 资源必须由同一线程持有 | 只暴露提交与停帧接口 |
| 权限 | 壳 | 申请、拒绝、「不再询问」都由系统弹窗决定 | 权限状态快照,不含申请细节 |
| 支付 | 壳 | 商店 SDK、订单回调、恢复购买都绑定平台账号 | 商品 id 进,订单结果出 |
| 推送 | 壳 | token 注册、通知点击、前后台展示都由系统通道处理 | 通知点击事件与 token |
| 隐私弹窗 | 壳 | 弹窗必须使用原生控件并遵守平台审核要求 | 同意项集合与结果 |
| 文件路径 | 共享 | 壳知道可写目录,引擎知道资源该如何组织 | 壳给根目录,引擎只在其下创建 |
| 网络传输 | 共享 | 壳提供网络状态与代理配置,引擎持有连接与重试 | 状态变化事件 + 传输实现 |
| 传感器 | 共享 | 壳采集原始数据,引擎决定采样率与降级 | 原始样本 + 采样配置 |
这张表里出现「共享」的三项,是最容易出问题的地方。共享不等于双方都能改,而是一方提供事实、另一方提供策略,并且交接物必须写进接口。比如文件路径:壳提供根目录,引擎不得自己拼系统路径;网络传输:壳报告「当前无网」,引擎决定是否重试以及退避多久。
三个问题决定归属
给一项新能力找归属时,不要从“它像游戏还是像系统”开始,而是依次问三个问题。
第一,谁接收系统回调? 只有 Activity、UIViewController、Application 这些平台对象能收到生命周期、权限结果和商店回调。接收方必须是所有权方,否则它会变成一个只会转发的空壳,而真正的状态藏在引擎里。
第二,谁承担失败后果? 支付失败导致订单丢失,责任在壳的 SDK 适配;渲染线程卡死导致 ANR,责任在引擎的线程模型。所有权应该跟着后果走,而不是跟着代码放在哪个目录走。
第三,谁能在没有对方的情况下测试? 生命周期、权限、支付这些可以通过 mock 平台对象单独测试,归壳;渲染与资源加载需要引擎运行时,归引擎。共享项则要求双方都能对着接口测试,这也是 Host 接口存在的理由。
这三个问题没有一条提到编程语言或技术栈。边界是责任边界,不是 Java/Kotlin 与 C++/C# 的边界;把边界画成语言边界,最后一定会出现「这段逻辑用哪种语言写都行,所以谁都不管」的灰区。
Host 接口:把平台事实变成可注入的依赖
引擎不应该 import 任何 Android 或 iOS 的 SDK,也不应该直接持有 Activity 引用。它只需要一组平台事实:当前是什么平台、壳是哪一种、主线程和渲染线程叫什么、现在几点、某个权限是什么状态。把这些事实收进一个 Host 接口,引擎代码就只依赖接口,不依赖设备。
Host 的五个成员对应三类事实。platform 与 shell 是身份事实,决定壳的类型;mainThread 与 renderThread 是线程事实,决定回调应该投递到哪里;nowMs() 与 permissionState() 是运行时事实,分别用于超时计算和权限分支。
身份事实和线程事实为什么不能写死?因为同一份引擎代码要同时跑在 Android 和 iOS 上,而两边的壳类型、线程命名并不一致。把它们放进 Host,测试里就能用快照构造出任意组合,而不需要真的启动一个模拟器。
运行时事实之所以用方法而不是字段,是为了让 Host 能表达「随时间变化」。hostFromSnapshot() 是一个最小实现:它把一份快照固定下来,权限缺省为 not-determined。它不是生产实现,而是给 L1 测试用的确定性种子;真正的 mock 会在 3.2 里接上手动时钟和可变权限。
代码:职责表与 Host 契约
下面的文件把十项职责写成数据,并提供两个查询:ownerOf() 回答单项归属,auditOwnership() 检查是否缺项或重复。测试不验证“架构图好不好看”,只验证这张表仍然完整、Android 与 iOS 的壳类型没有写反、快照 Host 的缺省值符合预期。
// file: src/platform/host.ts
// tier: L1
// verify: node --test test/platform/host.test.ts
export type Platform = "android" | "ios";
export type ShellKind = "activity" | "ui-view-controller";
export type Owner = "shell" | "engine" | "shared";
export const CONCERNS = [
"app-lifecycle",
"main-thread",
"render-thread",
"permissions",
"billing",
"push",
"consent-ui",
"file-paths",
"network-transport",
"sensors",
] as const;
export type Concern = (typeof CONCERNS)[number];
export interface Ownership {
readonly concern: Concern;
readonly owner: Owner;
readonly contract: string;
}
export const OWNERSHIP: readonly Ownership[] = [
{
concern: "app-lifecycle",
owner: "shell",
contract: "onCreate/onResume/onPause/onDestroy 或 viewDidAppear/viewDidDisappear 转成统一事件",
},
{
concern: "main-thread",
owner: "shell",
contract: "UI 与输入只在主线程;引擎每帧回调必须回到主线程",
},
{
concern: "render-thread",
owner: "engine",
contract: "渲染循环与 GPU 资源由引擎线程持有,壳不得直接调用",
},
{
concern: "permissions",
owner: "shell",
contract: "申请、拒绝与「不再询问」由原生壳处理,引擎只读状态",
},
{
concern: "billing",
owner: "shell",
contract: "商店 SDK、订单回调与恢复购买由壳适配,引擎只发商品 id",
},
{
concern: "push",
owner: "shell",
contract: "token 注册、通知点击与前后台展示由壳处理",
},
{
concern: "consent-ui",
owner: "shell",
contract: "隐私弹窗与系统级跳转由壳渲染,引擎只声明需要哪些同意项",
},
{
concern: "file-paths",
owner: "shared",
contract: "壳给出可写目录,引擎只在该目录内创建文件",
},
{
concern: "network-transport",
owner: "shared",
contract: "壳提供网络状态与代理配置,引擎持有连接与重试策略",
},
{
concern: "sensors",
owner: "shared",
contract: "壳采集原始传感器,引擎负责采样率与降级",
},
];
export interface OwnershipAudit {
readonly ok: boolean;
readonly missing: readonly Concern[];
readonly duplicated: readonly Concern[];
}
export function ownerOf(concern: Concern): Owner {
const rule = OWNERSHIP.find((item) => item.concern === concern);
if (!rule) {
throw new Error(`unknown concern: ${concern}`);
}
return rule.owner;
}
export function auditOwnership(rules: readonly Ownership[] = OWNERSHIP): OwnershipAudit {
const seen = new Map<Concern, number>();
for (const rule of rules) {
seen.set(rule.concern, (seen.get(rule.concern) ?? 0) + 1);
}
const missing = CONCERNS.filter((concern) => !seen.has(concern));
const duplicated = [...seen.entries()]
.filter(([, count]) => count > 1)
.map(([concern]) => concern);
return { ok: missing.length === 0 && duplicated.length === 0, missing, duplicated };
}
export type PermissionKind = "notifications" | "tracking" | "storage" | "network";
export type PermissionState = "granted" | "denied" | "not-determined";
export interface HostSnapshot {
readonly platform: Platform;
readonly nowMs: number;
readonly permissions?: Partial<Record<PermissionKind, PermissionState>>;
}
export interface Host {
readonly platform: Platform;
readonly shell: ShellKind;
readonly mainThread: string;
readonly renderThread: string;
nowMs(): number;
permissionState(kind: PermissionKind): PermissionState;
}
export function shellKindFor(platform: Platform): ShellKind {
return platform === "android" ? "activity" : "ui-view-controller";
}
export function hostFromSnapshot(snapshot: HostSnapshot): Host {
const permissions = snapshot.permissions ?? {};
return {
platform: snapshot.platform,
shell: shellKindFor(snapshot.platform),
mainThread: "main",
renderThread: "render",
nowMs: () => snapshot.nowMs,
permissionState: (kind) => permissions[kind] ?? "not-determined",
};
}
测试里有一条容易被忽略:修改职责表时,OWNERSHIP.length 必须等于 CONCERNS.length。这条断言的价值不在数字本身,而在于它强迫新增职责的人同时更新枚举和规则。只加枚举不加规则,auditOwnership() 会报缺项;只加规则不加枚举,类型系统在编译期就会拒绝。两边的约束合起来,职责表才不会在几次迭代后悄悄漂移。
// file: test/platform/host.test.ts
// tier: L1
// verify: node --test test/platform/host.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
CONCERNS,
OWNERSHIP,
auditOwnership,
hostFromSnapshot,
ownerOf,
shellKindFor,
} from "../../src/platform/host.ts";
test("android and ios map to different shell kinds", () => {
assert.equal(shellKindFor("android"), "activity");
assert.equal(shellKindFor("ios"), "ui-view-controller");
});
test("every concern has exactly one owner", () => {
const audit = auditOwnership();
assert.equal(audit.ok, true);
assert.deepEqual(audit.missing, []);
assert.deepEqual(audit.duplicated, []);
assert.equal(OWNERSHIP.length, CONCERNS.length);
});
test("lifecycle, main thread and permissions belong to the shell", () => {
assert.equal(ownerOf("app-lifecycle"), "shell");
assert.equal(ownerOf("main-thread"), "shell");
assert.equal(ownerOf("permissions"), "shell");
assert.equal(ownerOf("render-thread"), "engine");
});
test("audit reports missing and duplicated concerns", () => {
const withoutBilling = OWNERSHIP.filter((rule) => rule.concern !== "billing");
const duplicated = [
...withoutBilling,
{ concern: "push" as const, owner: "shell" as const, contract: "dup" },
];
const audit = auditOwnership(duplicated);
assert.equal(audit.ok, false);
assert.deepEqual(audit.missing, ["billing"]);
assert.deepEqual(audit.duplicated, ["push"]);
});
test("snapshot host defaults unset permissions to not-determined", () => {
const host = hostFromSnapshot({
platform: "ios",
nowMs: 1_000,
permissions: { notifications: "granted" },
});
assert.equal(host.shell, "ui-view-controller");
assert.equal(host.nowMs(), 1_000);
assert.equal(host.permissionState("notifications"), "granted");
assert.equal(host.permissionState("tracking"), "not-determined");
assert.equal(host.mainThread, "main");
assert.equal(host.renderThread, "render");
});
谁拥有状态,谁就拥有恢复责任
职责表还有一个下游影响:状态恢复。生命周期归壳,意味着「应用被系统杀掉后回到哪个页面」的第一手信息在壳;但游戏进度归引擎,意味着「恢复到哪一帧」必须由引擎提供快照。3.1 只确定所有权,不展开恢复协议;第 4 章会把这两类状态接起来。
支付和推送同样如此。壳拿到订单结果或通知点击,但它不应该知道当前关卡打到一半还是已经结算。正确做法是壳把结果转成一条带类型的事件,交给引擎在正确的线程上处理。这条事件怎么带版本、怎么超时、怎么取消,是 3.2 的主题。
与相邻章节的关系
第 2 章把客户端分成 platform、system、gameplay、presentation 四层,本节只处理 platform 层内部的引擎与壳。分层解决的是「谁能依赖谁」,本节解决的是「同一层里谁负责什么」。两者不能互相替代:只分层不分工,platform 层会变成一个什么都装的大口袋;只分工不分层,职责表会被上层直接绕过。
通用分层原则可以参考四层职责划分 和依赖方向 。原生桥接的实现细节可以参考 native-jsb 桥接 ,那里讨论的是调用机制;本节讨论的是调用之前先决定谁该拥有这项能力。
小结
- 边界不按技术栈划分,而按职责所有权划分;「引擎管游戏、壳管系统」无法评审。
- 十项职责覆盖生命周期、线程、权限、支付、推送、隐私、文件、网络与传感器。
- 所有权跟着系统回调、失败后果和可测试性走,不跟着代码目录走。
- 共享项必须写成「一方提供事实、另一方提供策略」,并把交接物放进接口。
Host把平台事实变成可注入依赖;职责表可查询、可审计,新增职责必须同时改枚举和规则。
阅读导航:上一节:2.3 边界测试:依赖规则写成可执行断言 · 下一节:3.2 桥接层设计:消息、线程与生命周期 。
常见错误与边界
- 把「共享」当成「双方都能改」。共享项必须指定事实提供方和策略持有方;两边都能写状态,最终一定出现两套真相。
- 让引擎直接持有 Activity 或 UIViewController 引用。一旦持有,引擎就无法脱离设备测试,也无法复用到另一个平台;只传 Host 接口。
- 用语言边界代替责任边界。Kotlin 写的模块也可能归引擎,C++ 写的模块也可能归壳;按回调来源和失败后果判断。
- 权限只判断「已授权/未授权」。iOS 与 Android 都有「未决定」和「不再询问」,缺省成未授权会让首次启动的引导逻辑永远走错分支。
- 新增职责只改文档不改表。
CONCERNS与OWNERSHIP必须同时更新,否则审计会报缺项;这条约束要写进测试,而不是写在评审意见里。 - 边界:本节不定义桥接协议。Host 只描述平台事实,消息版本、错误码、线程队列和超时取消都在 3.2;不要把两者混成一个接口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。