《移动游戏产品的架构·网络、资源与热更新》13.3 卷二评审与进入卷三

卷二收口的最后一道门禁,把附录 C 的九组检查项写成可运行评审器:pass 无证据自动降级为 partial,partial 与 fail 都阻塞,空评审直接判失败。本节给出九组汇总、失败组与空白组的区别,以及进入商业化卷之前必须回答的四个问题与带病进入的登记方式。

「网络这块差不多了」

卷二读到最后一章,团队常会给出一个模糊结论:「网络这块差不多了,可以看商业化了。」

这句话在评审会上很难被反驳,因为没有人能立刻说出「差不多」差在哪。于是下一次事故来临时,复盘才会发现:协议兼容没测、回滚没演练、下载失败没有降级路径。

本节的评审器不接受「差不多」。它只认三种状态,并且把没有证据的「通过」直接降级。

九组检查项

卷二的范围对应九组:

组核心问题对应章节
协议与兼容新旧版本能不能互通第 2 章
连接与重连断了能不能回来第 3 章
离线与冲突离线操作会不会重复或丢失第 4 章
同步与预测客户端有没有越权第 5 章
战报与赛季结果能不能复盘第 6 章
流量与心跳连接成本是否可控第 7 章
资源与分包资源结构是否清晰第 8–9 章
热更与灰度更新能不能安全放量第 10–11 章
下载与 CDN资源能不能到达第 12 章

九组不是并列清单,而是卷二判断链的九个关节。任何一组全空,都说明那条链断了一节。

把门禁写成代码

评审器的规则只有三条:

  1. pass 必须有非空证据,否则降级为 partial;
  2. partial 与 fail 都是阻塞项;
  3. 空评审直接判失败,而不是「默认通过」。
// file: src/lab/review-gate-net.ts
// tier: L1
// verify: node --test test/lab/review-gate-net.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 openGroups: readonly string[];
  readonly reasons: readonly string[];
}

export const REVIEW_GROUPS = [
  "协议与兼容",
  "连接与重连",
  "离线与冲突",
  "同步与预测",
  "战报与赛季",
  "流量与心跳",
  "资源与分包",
  "热更与灰度",
  "下载与 CDN",
] 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");
  const openGroups = groups.filter((group) => group.pass === 0).map((group) => group.group);

  if (normalized.length === 0) {
    reasons.push("no review items");
  }
  if (counts.partial > 0) {
    reasons.push(`${counts.partial} partial item(s) block the gate`);
  }
  if (counts.fail > 0) {
    reasons.push(`${counts.fail} failed item(s) block the gate`);
  }

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

export function failingGroups(result: GateResult): readonly string[] {
  return result.groups.filter((group) => group.fail > 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: "protocol", group: "协议与兼容", status: "pass", evidence: "src/net/message-envelope.ts" },
  { id: "contract", group: "协议与兼容", status: "pass", evidence: "src/net/contract-check.ts" },
  { id: "reconnect", group: "连接与重连", status: "pass", evidence: "src/net/connection-fsm.ts" },
  { id: "backoff", group: "连接与重连", status: "pass", evidence: "src/net/backoff.ts" },
  { id: "offline", group: "离线与冲突", status: "pass", evidence: "src/net/offline-queue.ts" },
  { id: "sync", group: "同步与预测", status: "pass", evidence: "src/net/predict.ts" },
  { id: "report", group: "战报与赛季", status: "pass", evidence: "src/net/battle-report.ts" },
  { id: "traffic", group: "流量与心跳", status: "pass", evidence: "src/net/traffic-ledger.ts" },
  { id: "pack", group: "资源与分包", status: "pass", evidence: "src/resource/pack-plan.ts" },
  { id: "hotfix", group: "热更与灰度", status: "pass", evidence: "src/hotfix/rollout.ts" },
  { id: "cdn", group: "下载与 CDN", status: "pass", evidence: "src/cdn/downloader.ts" },
  { id: "telemetry", group: "下载与 CDN", status: "pass", evidence: "src/net/heartbeat.ts" },
];

evaluateGate 返回四类信息:是否通过、状态计数、按组汇总、阻塞项列表。另外两个派生视图很有用:

  • openGroups:一项都没通过的组,用来识别「完全没开始」;
  • failingGroups:存在 fail 的组,用来识别「做了但坏了」。

两个口径不能混。一个组可能全是 partial(做了没证据),也可能有 pass 有 fail(部分可用部分坏)。前者是没验证,后者是验证出问题,处理方式完全不同。

一次方舟的评审记录

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

项组状态证据处理
contract协议与兼容partial兼容用例只覆盖新增字段补删除字段与未知字段用例
hotfix热更与灰度fail回滚演练超时阻塞版本,先修回滚链路
其余 10 项—pass文件路径与测试输出保持

结果:passed: false,blockers 是这两项;openGroups 为空(每一组至少有一项通过),failingGroups 是「热更与灰度」。

这个区分决定了处理顺序:热更组有 fail,说明能力存在但坏了,优先级最高;协议组只有 partial,说明能力没验证完,可以排在其后。先修坏的,再补没验的。

与卷一评审器的关系

卷一的评审器与本节结构相似,但分组完全不同:

维度卷一卷二
分组分层、生命周期、配置、资源、UI、帧率、存档、区域化、可观测协议、连接、离线、同步、战报、流量、资源、热更、下载
检查对象客户端内部结构客户端与外部系统的契约
典型证据模块文件、状态机测试契约测试、补丁测试、演练记录
空白组含义内部能力缺失跨边界能力缺失

两卷的门禁都要求「证据可复现」,但卷二的证据更多来自演练与线上查询,而不是单测。这不是标准降低了,而是检查对象变了:协议兼容与回滚能力,本来就不是一个纯函数能证明的。

进入卷三之前

卷三讨论商业化与发行工程。进入之前,有四个问题必须能回答:

  1. 支付与广告的客户端边界在哪? 如果卷二还没界定「哪些状态归服务端权威」,卷三的订单与对账就无从谈起。
  2. 活动配置走哪条更新通道? 热更、配置下发还是商店版本,决定了活动能不能按时上线。
  3. 归因与埋点如何穿过网络层? 弱网下事件丢失、重试重复,都会污染归因数据。
  4. 发行渠道的包体差异谁负责? 渠道 SDK、分包策略与审核周期,需要在卷三的发行工程里统一。

四个问题里,前两个直接依赖卷二;后两个是卷二向卷三的接口。答不上来,说明卷二的热更与流量章节还没落地。

双案例的收口表

把九组门禁套到两款产品上,得到两张可复核的表:

项目消消方舟
协议与兼容passpass
连接与重连passpass
离线与冲突passpartial(离线仅云存档)
同步与预测不适用(回合制)pass
战报与赛季pass(轻量战报)pass
流量与心跳passpass
资源与分包passpass
热更与灰度passpass
下载与 CDNpasspass

「不适用」不是「通过」,评审时应记录为范围外,并在汇总里单列。把不适用算成 pass,会让通过率虚高,也会掩盖真正缺失的组。

带病进入卷三

如果只能回答其中两三个问题,也可以进入卷三,但必须把未答项登记为「带病进入」:

未答项影响修复版本
活动配置更新通道活动可能延迟上线卷三第 4 章前
归因事件去重买量数据偏高卷三第 6 章前

登记的意义不是免责,而是排期。允许带病进入,不允许假装没病——这句话在卷二同样适用。

与相邻章节的关系

本节依赖 13.1 的指标矩阵与 13.2 的交付流水线:前者提供数值证据,后者提供发布证据。附录 C 是本节检查项的来源,本节把清单变成可运行门禁。

读到这里,卷二的判断链已经闭合:从网络现实出发,经协议、连接、离线、同步、战报、流量,再到资源、分包、热更、灰度与下载,最后收口为指标与流水线两张表。卷三会在这条链上接入收入、渠道与发行。

小结

  • 卷二评审有九组:协议、连接、离线、同步、战报、流量、资源、热更、下载。
  • 门禁只认 pass/partial/fail,pass 无证据自动降级,空评审判失败。
  • openGroups 识别完全没开始的组,failingGroups 识别做了但坏了的组。
  • 先修 fail,再补 partial;两类问题的处理顺序不能反。
  • 「不适用」要单列,不能算成通过,否则通过率虚高。
  • 进入卷三前回答四个问题:支付边界、活动通道、归因链路、渠道包体。
  • 允许带病进入,但未答项必须登记并排期。

阅读导航:上一节:13.2 交付流水线与发布门禁 · 下一节:目录与学习路径 。

常见错误与边界

  1. 把评审做成打分。 平均分掩盖空白组;门禁只认通过与否,不做加权。
  2. 允许「部分通过」。 设计有、证据无,等于没验证;按规则直接降级。
  3. 证据写成「已确认」。 证据必须是路径、命令输出或线上查询,能被人复现。
  4. 把不适用算成通过。 消消没有强同步,不代表同步能力通过;应单列为范围外。
  5. 只看总分不看分组。 九组里一组全空,总分仍可能很高;openGroups 专门抓这个。
  6. 混淆空白组与失败组。 前者没开始,后者做坏了;处理优先级不同。
  7. 评审只做一次。 每个大版本重跑,保留历史,趋势比单次结论更有信息量。
  8. 把评审器当自动化测试。 它校验记录自洽,不替代契约测试、补丁测试与演练。
  9. 带病进入变成常态。 每个版本都带病,说明门禁没有约束力。
  10. 删除旧评审记录。 没有历史,就无法区分「一直如此」与「本版本退步」。
  11. 忽略演练证据。 回滚能力必须用演练证明,文档与流程图不算证据。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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