「我们算读完了吗」
读完 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/1 | 2/6/2/1(玩法模块 id 不同) |
| 共享能力 | 5 项 | 5 项(同一中台) |
| 目标帧率 | 30fps | 60fps |
| 低端机稳态内存 | 420MB | 1,150MB |
| 水位余量 | 24% | 26% |
| Jank 率 | 2.1% | 1.4%(不同口径,不横比) |
| 证据项 | 12/12 | 12/12 |
这张表的意义不在于数字本身,而在于每一行都能指回一个产物或一份报告。行数不多,但每一行都可验证;这比一张二十页、处处「约」和「左右」的架构文档更有用。
进入卷二之前
卷二讨论网络、资源与热更新。进入之前,有四个问题必须已经能回答,否则卷二的方法会落空:
- 哪些状态归服务端权威? 如果连货币、奖励、进度归谁都没定,重连与离线队列就没有判断依据。
- 资源包怎么分? 消消 480MB、方舟 1.8GB,分包策略不同;不知道包怎么分,首包与热更都无从谈起。
- 热更的政策边界在哪? 商店允许更新什么、不允许更新什么,决定了灰度与回滚的设计。
- 客户端版本如何标识? 版本号、渠道号、资源版本三者如何组合,决定了服务端如何做兼容与灰度。
四个问题都答不上来,说明卷一的生命周期、配置与资源章节还没真正落地;先回去补,不要直接读卷二。
如果只能回答其中两三个,也可以进入卷二,但要把未答的问题记成「带病进入」项,在卷二对应章节里优先补。**允许带病进入,不允许假装没病。**这句话同样适用于后面的每一卷。
与相邻章节的关系
本节依赖 13.1 的结构矩阵与 13.2 的数值基线,二者分别提供「结构证据」与「数值证据」。附录 C 是本节评审项的来源,本节则把清单变成可运行的门禁。
读到这里,卷一的判断链已经闭合:从双案例与约束出发,经分层、运行时、资源、交互、计量,最后收口为结构与数值两张基线表。卷二会在这条链上接入网络与资源分发。
小结
- 评审是门禁,不是打分;
partial与fail都阻塞,不做加权平均。 pass必须附证据,口头确认自动降级为partial。- 用
openGroups识别「一项都没通过」的领域,避免结构性缺失被总分掩盖。 - 双案例收口表的每一行都要能指回一个产物或报告,否则不算收口。
- 进入卷二前先回答四个问题:状态权威、资源分包、热更边界、版本标识。
- 允许带病进入下一卷,但未答的问题必须登记为「带病进入」项并优先补。
- 评审记录要保留历史,两次结果之间的变化比单次结论更有信息量。
阅读导航:上一节:13.2 性能预算与机型分档基线 · 下一节:目录与学习路径 。
常见错误与边界
- 把评审做成打分。 平均分掩盖结构性缺失;九组里有一组全空,总分仍可能「及格」。门禁只认通过与否。
- 允许「部分通过」。 设计有、证据无,等于没验证。按本节规则直接降级,不给中间态。
- 证据写成「已确认」。 证据必须是文件路径、测试命令输出或线上查询语句,能被人复现。
- 用总分决定是否进入卷二。 进入下一卷的前提是四个具体问题能回答,不是一个百分比。
- 只评审一次。 门禁应在每个大版本重跑;一次通过不代表下个版本仍然通过。
- 把评审器当自动化测试。 它校验的是评审记录的自洽性,不替代依赖测试、性能测试与真机验收。
- 忽略
total为 0 的情况。 空评审会返回passed: false和no review items,这是有意设计:没有记录不等于没有问题。 - 把「带病进入」变成常态。 允许带病进入卷二,是给一次性取舍留空间;如果每个版本都带病,说明门禁没有约束力。
- 只保留最新一次评审。 历史记录是判断趋势的唯一依据;删掉旧记录,就无法区分「一直如此」与「本版本退步」。
- 用评审器替代真实验收。
evaluateGate检查的是记录自洽,不执行依赖测试、性能测试或真机验收;两者必须同时存在。 - 把九组当成固定不变。 九组来自附录 C 的卷一范围;产品阶段变化时可以增删,但增删必须写进评审记录。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。