《移动游戏产品的架构·客户端与移动运行时》13.3 卷一评审与进入卷二

卷末评审不是打分,而是一道门禁:结构、数值、证据三项全过才能进入卷二。本节把附录 C 的清单写成可运行评审器,明确「部分通过按未过处理」「无证据的通过自动降级」,用九组汇总识别结构性缺失,并给出进入网络与资源卷之前必须回答的四个问题,以及带病进入的登记方式。

「我们算读完了吗」

读完 13.2,团队通常会问一个问题:卷一这些结论,我们到底做到了几条?

这个问题不能用「大概七成」回答。架构评审最常见的失败模式,就是把清单变成打分表:每项 0 到 5 分,最后算一个平均分,看起来 4.2 分很健康,实际上几项关键能力根本没有证据。更糟的是「部分完成」——设计做了,没验证,被算成半分。

本节的评审器只认三种状态,并且把「部分」直接当成「未过」。

三道门禁

卷一收口有三道门禁,分别检查不同性质的东西:

门禁检查对象通过标准对应产物
结构门禁模块、层、依赖方向problems 为空,四层无缺13.1 的矩阵审计
数值门禁帧预算、内存水位、机型分档每条基线规则通过,跨产品比较口径成立13.2 的基线校验
证据门禁附录 C 的九组检查项没有 partial 与 fail,且每条 pass 都有证据本节评审器

三道门禁是「与」的关系,不是加权平均。结构错了,数值再漂亮也不能发;数值达标但证据缺失,同样不能算过。

把评审写成代码

评审器的核心只有一条规则:pass 必须有非空证据,否则降级为 partial;partial 与 fail 都是阻塞项。

// file: src/lab/review-gate.ts
// tier: L1
// verify: node --test test/lab/review-gate.test.ts

export type ReviewStatus = "pass" | "partial" | "fail";

export interface ReviewItem {
  readonly id: string;
  readonly group: string;
  readonly status: ReviewStatus;
  readonly evidence: string;
}

export interface GroupSummary {
  readonly group: string;
  readonly pass: number;
  readonly partial: number;
  readonly fail: number;
}

export interface GateResult {
  readonly passed: boolean;
  readonly total: number;
  readonly counts: Readonly<Record<ReviewStatus, number>>;
  readonly groups: readonly GroupSummary[];
  readonly blockers: readonly ReviewItem[];
  readonly reasons: readonly string[];
}

export const REVIEW_GROUPS = [
  "分层与依赖",
  "生命周期与恢复",
  "配置",
  "资源与内存",
  "UI 与输入",
  "帧率与功耗",
  "存档",
  "区域化",
  "可观测性",
] as const;

export function evaluateGate(items: readonly ReviewItem[]): GateResult {
  const reasons: string[] = [];
  const normalized: ReviewItem[] = [];

  for (const item of items) {
    if (item.status === "pass" && item.evidence.trim() === "") {
      reasons.push(`${item.id}: pass without evidence counts as partial`);
      normalized.push({ ...item, status: "partial" });
      continue;
    }
    normalized.push(item);
  }

  const counts: Record<ReviewStatus, number> = { pass: 0, partial: 0, fail: 0 };
  for (const item of normalized) {
    counts[item.status] += 1;
  }

  const groups = REVIEW_GROUPS.map((group) => {
    const scoped = normalized.filter((item) => item.group === group);
    return {
      group,
      pass: scoped.filter((item) => item.status === "pass").length,
      partial: scoped.filter((item) => item.status === "partial").length,
      fail: scoped.filter((item) => item.status === "fail").length,
    };
  });

  const blockers = normalized.filter((item) => item.status !== "pass");
  if (normalized.length === 0) {
    reasons.push("no review items");
  }

  return {
    passed: normalized.length > 0 && blockers.length === 0,
    total: normalized.length,
    counts,
    groups,
    blockers,
    reasons,
  };
}

export function openGroups(result: GateResult): readonly string[] {
  return result.groups.filter((group) => group.pass === 0).map((group) => group.group);
}

export function formatGate(result: GateResult): string {
  const header = "group\tpass\tpartial\tfail";
  const rows = result.groups.map(
    (group) => `${group.group}\t${group.pass}\t${group.partial}\t${group.fail}`,
  );
  return [header, ...rows].join("\n");
}

export const DEFAULT_REVIEW: readonly ReviewItem[] = [
  { id: "layers", group: "分层与依赖", status: "pass", evidence: "src/core/layers.ts" },
  { id: "boundaries", group: "分层与依赖", status: "pass", evidence: "src/core/boundaries.ts" },
  { id: "lifecycle", group: "生命周期与恢复", status: "pass", evidence: "src/runtime/lifecycle.ts" },
  { id: "recovery", group: "生命周期与恢复", status: "pass", evidence: "src/runtime/recovery.ts" },
  { id: "config", group: "配置", status: "pass", evidence: "src/config/schema.ts" },
  { id: "memory", group: "资源与内存", status: "pass", evidence: "src/resource/memory-budget.ts" },
  { id: "ui", group: "UI 与输入", status: "pass", evidence: "src/ui/popup-arbiter.ts" },
  { id: "frame", group: "帧率与功耗", status: "pass", evidence: "src/runtime/frame-budget.ts" },
  { id: "save", group: "存档", status: "pass", evidence: "src/save/merge.ts" },
  { id: "i18n", group: "区域化", status: "pass", evidence: "src/i18n/timezone.ts" },
  { id: "crash", group: "可观测性", status: "pass", evidence: "src/obs/crash-model.ts" },
  { id: "sampling", group: "可观测性", status: "pass", evidence: "src/obs/sampling.ts" },
];

默认评审有 12 项,覆盖附录 C 的九组。evaluateGate 输出四个可直接用于会议的信息:是否通过、各状态计数、按组汇总、阻塞项列表。formatGate 把分组汇总打印成一张九行表,正好对应评审 PPT 的一页。

注意 openGroups 的判断:它返回的是「一项都没通过」的组,而不是「有阻塞项」的组。前者用于识别完全没开始的领域,后者已经在 blockers 里。两个口径混用,会把「做了一半」误报成「完全没做」。

三种典型评审结果

评审结果通常落在三种形态,处理方式完全不同:

形态特征处理方式
全过passed: true,blockers 为空记录基线,进入下一个版本
局部阻塞少数 partial,集中在某一组补齐证据或实现,不阻塞其它组
结构性缺失某个组 pass 为 0,或 total 为 0停下来补设计,不要用其它组的得分掩盖

第三种最危险。它经常表现为「可观测性 0 项」「存档 0 项」,但在总分里只占一小块,评审容易带着「总体不错」的结论过关。评审器的 openGroups 就是为了把这类空白单独拎出来。

还有一个常见争议:某项确实做了,但证据是「口头确认」。按本节规则,口头确认不算证据,必须落到文件、测试输出或线上查询。evidence 字段填的是路径或查询语句,不是「已确认」。

一次评审记录

假设方舟在赛季前跑一次评审,12 项里有 2 项没过:

项组状态证据处理
save存档partial合并用例只覆盖单端冲突补双端冲突用例,下个迭代
i18n区域化fail新区域字体未覆盖阻塞出海版本,先补字体子集
其余 10 项—pass文件路径与测试输出保持

evaluateGate 返回 passed: false,blockers 是这两项,openGroups 包含「区域化」但不包含「存档」——因为存档组还有其它项通过。这个区分很实用:区域化是「完全没开始」,存档是「做了一半」,处理优先级不同。

这份记录下一次评审时应该保留,与新的结果对比。评审的价值一半在当次结论,一半在两次之间的变化。

双案例的收口基线

把三道门禁套到双案例上,得到两份可复核的收口表:

项目消消方舟
模块矩阵层分布2/6/2/12/6/2/1(玩法模块 id 不同)
共享能力5 项5 项(同一中台)
目标帧率30fps60fps
低端机稳态内存420MB1,150MB
水位余量24%26%
Jank 率2.1%1.4%(不同口径,不横比)
证据项12/1212/12

这张表的意义不在于数字本身,而在于每一行都能指回一个产物或一份报告。行数不多,但每一行都可验证;这比一张二十页、处处「约」和「左右」的架构文档更有用。

进入卷二之前

卷二讨论网络、资源与热更新。进入之前,有四个问题必须已经能回答,否则卷二的方法会落空:

  1. 哪些状态归服务端权威? 如果连货币、奖励、进度归谁都没定,重连与离线队列就没有判断依据。
  2. 资源包怎么分? 消消 480MB、方舟 1.8GB,分包策略不同;不知道包怎么分,首包与热更都无从谈起。
  3. 热更的政策边界在哪? 商店允许更新什么、不允许更新什么,决定了灰度与回滚的设计。
  4. 客户端版本如何标识? 版本号、渠道号、资源版本三者如何组合,决定了服务端如何做兼容与灰度。

四个问题都答不上来,说明卷一的生命周期、配置与资源章节还没真正落地;先回去补,不要直接读卷二。

如果只能回答其中两三个,也可以进入卷二,但要把未答的问题记成「带病进入」项,在卷二对应章节里优先补。**允许带病进入,不允许假装没病。**这句话同样适用于后面的每一卷。

与相邻章节的关系

本节依赖 13.1 的结构矩阵与 13.2 的数值基线,二者分别提供「结构证据」与「数值证据」。附录 C 是本节评审项的来源,本节则把清单变成可运行的门禁。

读到这里,卷一的判断链已经闭合:从双案例与约束出发,经分层、运行时、资源、交互、计量,最后收口为结构与数值两张基线表。卷二会在这条链上接入网络与资源分发。

小结

  • 评审是门禁,不是打分;partial 与 fail 都阻塞,不做加权平均。
  • pass 必须附证据,口头确认自动降级为 partial。
  • 用 openGroups 识别「一项都没通过」的领域,避免结构性缺失被总分掩盖。
  • 双案例收口表的每一行都要能指回一个产物或报告,否则不算收口。
  • 进入卷二前先回答四个问题:状态权威、资源分包、热更边界、版本标识。
  • 允许带病进入下一卷,但未答的问题必须登记为「带病进入」项并优先补。
  • 评审记录要保留历史,两次结果之间的变化比单次结论更有信息量。

阅读导航:上一节:13.2 性能预算与机型分档基线 · 下一节:目录与学习路径 。

常见错误与边界

  1. 把评审做成打分。 平均分掩盖结构性缺失;九组里有一组全空,总分仍可能「及格」。门禁只认通过与否。
  2. 允许「部分通过」。 设计有、证据无,等于没验证。按本节规则直接降级,不给中间态。
  3. 证据写成「已确认」。 证据必须是文件路径、测试命令输出或线上查询语句,能被人复现。
  4. 用总分决定是否进入卷二。 进入下一卷的前提是四个具体问题能回答,不是一个百分比。
  5. 只评审一次。 门禁应在每个大版本重跑;一次通过不代表下个版本仍然通过。
  6. 把评审器当自动化测试。 它校验的是评审记录的自洽性,不替代依赖测试、性能测试与真机验收。
  7. 忽略 total 为 0 的情况。 空评审会返回 passed: false 和 no review items,这是有意设计:没有记录不等于没有问题。
  8. 把「带病进入」变成常态。 允许带病进入卷二,是给一次性取舍留空间;如果每个版本都带病,说明门禁没有约束力。
  9. 只保留最新一次评审。 历史记录是判断趋势的唯一依据;删掉旧记录,就无法区分「一直如此」与「本版本退步」。
  10. 用评审器替代真实验收。 evaluateGate 检查的是记录自洽,不执行依赖测试、性能测试或真机验收;两者必须同时存在。
  11. 把九组当成固定不变。 九组来自附录 C 的卷一范围;产品阶段变化时可以增删,但增删必须写进评审记录。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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