《移动游戏产品的架构·客户端与移动运行时》1.3 mga-lab 的分层骨架与验收口径

架构判断如果不能回归,就会在下一个版本被推翻。本节给出 mga-lab 的四层骨架、依赖方向与 L1/L2/建模项三档验收口径,复用已有的 clock、result、events 三个原语,并用一个可运行的运行器把「通过、失败、待设备验证」分开记录。

为什么需要实验室,而不是一张架构图

前两节给出了两个判断:客户端架构不能按品类复制;移动端约束要写成可计算的清单。问题是,判断写在文章里,下一个版本就容易被另一张更漂亮的架构图覆盖。评审时最常见的争论不是「谁对」,而是「怎么证明上次的结论还成立」。

mga-lab 就是为这个问题建的。它的目标不是模拟引擎,也不是在 Node 里复刻一台手机,而是把能在本机验证的部分固定下来,把不能验证的部分明确标记出来。它有三条原则。

第一,能在纯逻辑层验证的,必须有断言。层顺序、依赖方向、预算换算、配置校验,这些都不依赖设备,应该在没有引擎、没有网络、没有渲染的 Node 进程里跑完。

第二,需要完整链路的,用进程内端到端。启动、事件分发、状态恢复这类流程,可以用 mock Host 和手动时钟在同一个进程里走一遍。它验证的是「接线是否正确」,不是「设备上是否流畅」。

第三,不能在本机复现的,登记为建模项。发热、功耗、原生桥接时序、崩溃采集,这些必须上真机;实验室只记录假设、验证章节和设备矩阵,绝不把它们算作「通过」。

这三条原则听起来朴素,却能避免最常见的两种自欺:把纯逻辑测试说成「端到端验证」,以及把建模项的假设说成「已验证」。

四层骨架与依赖方向

mga-lab 沿用第 2 章要展开的四层:platform、system、gameplay、presentation。本节先把骨架立起来,层内细节留给后续章节。

层负责允许依赖典型产物
platform引擎与原生壳、渠道 SDK、文件与网络、传感器无Host 适配器、平台能力接口
system账号、支付、配置、埋点、资源索引、存档platform能力客户端、配置快照
gameplay纯玩法状态机、规则、结算platform、system 的接口纯函数、reducer、状态机
presentationUI、场景、动画、输入表现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 进程给出答案。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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