为什么需要实验室,而不是一张架构图
前两节给出了两个判断:客户端架构不能按品类复制;移动端约束要写成可计算的清单。问题是,判断写在文章里,下一个版本就容易被另一张更漂亮的架构图覆盖。评审时最常见的争论不是「谁对」,而是「怎么证明上次的结论还成立」。
mga-lab 就是为这个问题建的。它的目标不是模拟引擎,也不是在 Node 里复刻一台手机,而是把能在本机验证的部分固定下来,把不能验证的部分明确标记出来。它有三条原则。
第一,能在纯逻辑层验证的,必须有断言。层顺序、依赖方向、预算换算、配置校验,这些都不依赖设备,应该在没有引擎、没有网络、没有渲染的 Node 进程里跑完。
第二,需要完整链路的,用进程内端到端。启动、事件分发、状态恢复这类流程,可以用 mock Host 和手动时钟在同一个进程里走一遍。它验证的是「接线是否正确」,不是「设备上是否流畅」。
第三,不能在本机复现的,登记为建模项。发热、功耗、原生桥接时序、崩溃采集,这些必须上真机;实验室只记录假设、验证章节和设备矩阵,绝不把它们算作「通过」。
这三条原则听起来朴素,却能避免最常见的两种自欺:把纯逻辑测试说成「端到端验证」,以及把建模项的假设说成「已验证」。
四层骨架与依赖方向
mga-lab 沿用第 2 章要展开的四层:platform、system、gameplay、presentation。本节先把骨架立起来,层内细节留给后续章节。
| 层 | 负责 | 允许依赖 | 典型产物 |
|---|---|---|---|
| platform | 引擎与原生壳、渠道 SDK、文件与网络、传感器 | 无 | Host 适配器、平台能力接口 |
| system | 账号、支付、配置、埋点、资源索引、存档 | platform | 能力客户端、配置快照 |
| gameplay | 纯玩法状态机、规则、结算 | platform、system 的接口 | 纯函数、reducer、状态机 |
| presentation | UI、场景、动画、输入表现 | platform、system、gameplay | 页面、组件、视图模型 |
规则只有一条:依赖只能向下。presentation 可以调用 gameplay,gameplay 可以调用 system,system 可以调用 platform;反过来一律不允许。platform 不允许 import system,gameplay 不允许 import presentation。
这条规则在真实项目里经常被「就这一次」打破:UI 为了省事直接读 platform 的文件句柄,玩法为了弹一个提示直接调用 presentation 的弹窗。每一次破例都能省下几行代码,代价是这一层再也无法被单独测试。实验室的作用不是禁止所有破例,而是让破例变成一条可见的记录。
关于分层职责和依赖方向的通用论证,可以参考四层职责划分 、依赖方向 和分层测试 。本节不再重复那套推导,只把结论落成移动游戏可运行的骨架。
三档验收口径
一个架构实验最怕把所有检查混成一个「测试通过」。mga-lab 把验收分成三档,每档的判据和失败处理都不同。
| 档位 | 运行位置 | 判据 | 失败处理 |
|---|---|---|---|
| L1 纯逻辑 | Node,无 Host、无网络 | 输入输出确定,可在毫秒级重复运行 | 阻断合并,必须修复 |
| L2 进程内端到端 | Node,使用 mock Host 与手动时钟 | 事件顺序、状态迁移、失败原因符合预期 | 阻断合并,先修接线 |
| 建模项 | 不能在本机复现 | 记录假设、验证章节、设备矩阵与观察指标 | 不计入通过率,禁止写成「已验证」 |
L1 的关键词是「确定」。同样的输入必须得到同样的输出,不能依赖当前时间、随机数、网络或文件系统。凡是需要时间的逻辑,都通过 Clock 注入;凡是可能失败的逻辑,都用 Result 返回原因。
L2 的关键词是「接线」。它不证明设备上的帧率,只证明各层之间的调用顺序、事件订阅和失败分支是通的。mock Host 提供平台、语言和安全区这些固定值,让同一套用例可以在 CI 里稳定重放。
建模项的关键词是「诚实」。它没有通过率,只有一张待验证清单。把建模项混进 L1 或 L2 的通过数,是实验室最容易犯的错,因为它会让报告看起来更绿,却让真正需要上设备的问题消失。
复用已有的三个原语
mga-lab 不重新发明基础设施。src/core/ 下已经有三个原语,本节的运行器直接复用它们。
Clock与ManualClock:测试里的时间由advance(ms)推进,而不是等真实时间。运行器的耗时统计因此可复现,不会因为机器负载不同而漂移。Result<T, E>:成功携带值,失败携带错误类别和原因。运行器把失败原因原样收进报告,评审时能看到「期望 3 层,实际 4 层」,而不是只看到一个红叉。EventBus:运行器在每个用例开始和结束时发出check:start与check:end,订阅方可以接日志、指标或 UI,而不需要修改运行器本身。
这三个原语都很小,单独看没有多少技术含量。它们的价值在于约束了实验的写法:时间必须注入、失败必须带原因、过程必须可订阅。如果每个章节各自用 Date.now()、throw 和全局回调,实验室很快就会变成一堆无法组合的脚本。
运行器骨架
下面的文件把三档验收落成一个可注册、可运行、可报告的结构。它不依赖任何第三方包,只导入 src/core/ 里的三个原语。
// file: src/lab/skeleton.ts
// tier: L2
// verify: node --test test/core/skeleton.test.ts
import type { Clock } from "../core/clock.ts";
import { EventBus } from "../core/events.ts";
import { fail, type Result } from "../core/result.ts";
export type Tier = "L1" | "L2";
export interface LabHost {
readonly platform: "android" | "ios";
readonly locale: string;
readonly safeAreaTopPx: number;
}
export interface LabEvents {
"check:start": { readonly id: string };
"check:end": { readonly id: string; readonly pass: boolean };
}
export interface LabContext {
readonly clock: Clock;
readonly host: LabHost;
readonly bus: EventBus<LabEvents>;
}
export interface LabCase {
readonly id: string;
readonly tier: Tier;
readonly summary: string;
readonly run: (context: LabContext) => Result<string, string>;
}
export interface ModeledItem {
readonly id: string;
readonly assumption: string;
readonly verifyIn: string;
}
export interface LabFailure {
readonly id: string;
readonly reason: string;
}
export interface LabReport {
readonly tier: Tier;
readonly total: number;
readonly passed: number;
readonly failed: number;
readonly failures: readonly LabFailure[];
readonly elapsedMs: number;
}
export const MOCK_HOST: LabHost = {
platform: "android",
locale: "zh-CN",
safeAreaTopPx: 88,
};
export class LabRunner {
#clock: Clock;
#host: LabHost;
#bus: EventBus<LabEvents>;
#cases = new Map<string, LabCase>();
#modeled = new Map<string, ModeledItem>();
constructor(clock: Clock, host: LabHost = MOCK_HOST, bus = new EventBus<LabEvents>()) {
this.#clock = clock;
this.#host = host;
this.#bus = bus;
}
get bus(): EventBus<LabEvents> {
return this.#bus;
}
register(testCase: LabCase): void {
if (this.#cases.has(testCase.id)) {
throw new Error(`duplicate lab case: ${testCase.id}`);
}
this.#cases.set(testCase.id, testCase);
}
registerModeled(item: ModeledItem): void {
if (this.#modeled.has(item.id)) {
throw new Error(`duplicate modeled item: ${item.id}`);
}
this.#modeled.set(item.id, item);
}
modeledItems(): readonly ModeledItem[] {
return [...this.#modeled.values()];
}
run(tier: Tier): LabReport {
const startedMs = this.#clock.nowMs();
const failures: LabFailure[] = [];
let passed = 0;
const context: LabContext = {
clock: this.#clock,
host: this.#host,
bus: this.#bus,
};
for (const testCase of this.#cases.values()) {
if (testCase.tier !== tier) {
continue;
}
this.#bus.emit("check:start", { id: testCase.id });
let outcome: Result<string, string>;
try {
outcome = testCase.run(context);
} catch (error) {
outcome = fail("exception", `${testCase.id}: ${String(error)}`);
}
if (outcome.ok) {
passed += 1;
} else {
failures.push({ id: testCase.id, reason: outcome.reason });
}
this.#bus.emit("check:end", { id: testCase.id, pass: outcome.ok });
}
return {
tier,
total: passed + failures.length,
passed,
failed: failures.length,
failures,
elapsedMs: this.#clock.nowMs() - startedMs,
};
}
}
这段代码有几个刻意的设计。
LabCase 把 tier 写成 "L1" | "L2",而不是一个自由字符串。这样注册时就能区分纯逻辑和端到端,运行报告也不会出现「l1」「L1」「unit」三种写法。
LabRunner 用 Map 保存用例,并在 register 时拒绝重复 id。重复 id 在真实项目里很常见:两个分支各自加了一个 boot 用例,合并后其中一个被静默覆盖,报告数字看起来正常,实际少跑了一条。
run(tier) 只执行指定档位的用例,把异常转换成 fail("exception", ...),而不是让整个进程崩掉。一个用例的异常不应该阻止其余用例继续跑;报告里的失败原因会带上用例 id,方便定位。
registerModeled 与 run 是分开的。建模项不进入通过率,只能通过 modeledItems() 读取。这是把「诚实」写进类型,而不是写进文档。
elapsedMs 来自注入的 Clock,不是 Date.now()。在测试里,一个用例推进 25ms,报告就精确显示 25ms;在真实运行里,换成 SystemClock 就能得到墙钟时间。运行器本身不需要知道当前是哪种时钟。
测试即验收
运行器自己也需要被验收。下面的测试覆盖了三件事:L1 与 L2 分开统计、异常被转换成失败、建模项不进入通过数。
// file: test/core/skeleton.test.ts
// tier: L2
// verify: node --test test/core/skeleton.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { ManualClock } from "../../src/core/clock.ts";
import { fail, ok } from "../../src/core/result.ts";
import { LabRunner } from "../../src/lab/skeleton.ts";
test("runner separates L1, L2 and modeled items", () => {
const clock = new ManualClock(1_000);
const runner = new LabRunner(clock);
const finished: boolean[] = [];
runner.bus.on("check:end", (event) => finished.push(event.pass));
runner.register({
id: "layers",
tier: "L1",
summary: "dependency direction",
run: () => ok("downward only"),
});
runner.register({
id: "broken",
tier: "L1",
summary: "intentional failure",
run: () => fail("assertion", "expected 3 layers, found 4"),
});
runner.register({
id: "boot",
tier: "L2",
summary: "in-process boot with mock host",
run: (context) => {
clock.advance(25);
return ok(`${context.host.platform}:${context.host.locale}`);
},
});
runner.registerModeled({
id: "thermal",
assumption: "device temperature rises under sustained 60fps load",
verifyIn: "chapter 9.3",
});
const l1 = runner.run("L1");
assert.equal(l1.total, 2);
assert.equal(l1.passed, 1);
assert.equal(l1.failed, 1);
assert.deepEqual(l1.failures, [{ id: "broken", reason: "expected 3 layers, found 4" }]);
const l2 = runner.run("L2");
assert.equal(l2.total, 1);
assert.equal(l2.passed, 1);
assert.equal(l2.elapsedMs, 25);
assert.deepEqual(finished, [true, false, true]);
assert.equal(runner.modeledItems().length, 1);
assert.equal(runner.modeledItems()[0]?.verifyIn, "chapter 9.3");
});
test("runner turns thrown errors into failures", () => {
const runner = new LabRunner(new ManualClock());
runner.register({
id: "boom",
tier: "L1",
summary: "throws",
run: () => {
throw new Error("host missing");
},
});
const report = runner.run("L1");
assert.equal(report.failed, 1);
assert.equal(report.failures[0]?.reason, "boom: Error: host missing");
});
test("duplicate case ids are rejected", () => {
const runner = new LabRunner(new ManualClock());
const item = { id: "same", tier: "L1" as const, summary: "x", run: () => ok("x") };
runner.register(item);
assert.throws(() => runner.register(item), /duplicate lab case/);
});
第一条测试注册了两个 L1 用例、一个 L2 用例和一个建模项。L1 报告是 2 条、通过 1、失败 1;L2 报告是 1 条、通过 1;建模项只能通过 modeledItems() 读到,不出现在任何通过率里。事件总线收到的 check:end 顺序是「通过、失败、通过」,和注册顺序一致。
第二条测试故意让用例抛异常,确认报告里的原因是 boom: Error: host missing,而不是一个空的失败项。这条断言很重要:如果运行器只捕获异常但不记录原因,报告会显示「失败 1」,团队仍然不知道发生了什么。
第三条测试注册同一个 id 两次,确认第二次会抛出 duplicate lab case。它不是防呆,而是防止报告在合并后被悄悄缩水。
本节新增产物与后续章节如何扩展
本节在 mga-lab 下新增了三个源文件和三个测试文件:
| 文件 | 作用 | 对应验收 |
|---|---|---|
src/core/layers.ts | 四层顺序、依赖方向、共享/隔离能力、双案例驱动快照 | L1 |
src/core/constraints.ts | 帧预算、内存余量、资源比、稳定性密度、双案例比较 | L1 |
src/lab/skeleton.ts | 用例注册、分档运行、失败报告、建模项登记 | L2 |
test/core/layers.test.ts | 依赖方向与双案例差异断言 | L1 |
test/core/constraints.test.ts | 整数口径与比较方向断言 | L1 |
test/core/skeleton.test.ts | 分档统计、异常转换、重复 id 断言 | L2 |
后续章节不需要另建一套框架。第 2 章会往 layers.ts 增加依赖边和边界测试;第 4 章会往 skeleton.ts 注册启动与恢复用例;第 6 章、第 9 章会新增资源和帧预算的 L1 用例,并把发热、功耗登记为建模项。每个章节只增加用例和纯函数,不改变运行器的契约。
与相邻章节的关系
2.1 会逐层展开四层的职责与常见误用,2.2 会把玩法内核从引擎里拆出来,2.3 会把依赖规则写成构建期断言。本节只提供骨架和验收口径,不提前展开层内细节。
机型矩阵的验证可以参考机型兼容矩阵测试 ,它属于建模项和真机实验的结合,不适合塞进 L1。跨书的分层测试思路可以参考分层测试 ,但本书的 L2 需要 mock Host,这是移动游戏多出来的一层。
小结
- mga-lab 不模拟设备,只把能在 Node 里验证的部分固定下来,把不能验证的部分标记清楚。
- 四层依赖只能向下;破例必须可见,不能靠「就这一次」消失在代码里。
- 验收分三档:L1 纯逻辑、L2 进程内端到端、建模项;建模项不计入通过率。
- 复用
Clock、Result、EventBus三个原语,让时间可注入、失败带原因、过程可订阅。 - 运行器只增加用例,不改变契约;后续章节在同一骨架上扩展。
阅读导航:上一节:1.2 移动端约束清单:内存、电量、发热、网络、机型 · 下一节:2.1 表现、玩法、系统、平台四层与依赖方向 。
常见错误与边界
- 把 L2 当成真机验证。进程内端到端只能证明接线正确,不能证明真机帧率、发热或原生桥接时序。
- 把建模项写成「已验证」。建模项的字段是假设、验证章节和观察指标,不是通过率;混进 L1/L2 会让报告失真。
- 用
setTimeout或Date.now()代替ManualClock。真实时间会让测试随机器负载漂移,运行器的耗时统计也失去可比性。 - 只断言「没有抛异常」。没有断言的测试会在逻辑悄悄改变时继续通过;至少要对输出、失败原因或事件顺序做一次精确断言。
- 让运行器吞掉异常。捕获异常是对的,但不记录原因就等于把失败变成静默;报告必须保留用例 id 和错误信息。
- 边界:mga-lab 不模拟渲染、原生桥接和传感器。这些能力在第 3 章用 Host 接口建模,在第 8 章用设备矩阵验证,不能指望 Node 进程给出答案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。