8.3 上线检查清单
前八章各自收了口:第 1 章管包体、第 2 章管测试、第 3 章管性能、第 4 章管跨端、第 5 章管裁剪、第 6 章管调试、第 7 章把游戏做完、8.1 与 8.2 管预算与交付。但上线前没人会一章一章翻回去手动重跑——所以这一节把所有验收点收敛成一份清单,再写一个自检器,把能自动跑的条目真正跑一遍。
清单的价值不在「列得全」,而在每一条都能被验证。一条「确认没有内存泄漏」是空话,一条 node --expose-gc tools/budget-gate.mjs 才是检查项。本节给每条都配上命令、章节来源与层级,然后把清单本身变成可执行的产物。
这一节的形态和前两节不同。8.1 与 8.2 各给出一个具体产物(预算报告、构建清单),8.3 给出的是一张覆盖面——它把前面所有产物和检查串成一条放行流程。所以本节的重点不是再发明一个新工具,而是证明「前面每一章的验收点,都能被一个统一的入口重新跑一遍」。
换句话说,8.3 是这一章、也是这一卷工程线的索引。前面每一节都产出了自己的可运行产物,但那些产物散落在不同的命令里;清单做的事,是把它们收进一个入口、配上章节出处,让「这一版能不能发」这个问题有一个唯一的答案入口。
清单为什么必须可执行
手工清单有一个致命弱点:它会过期。半年前写的「跑一下那个脚本」,半年后脚本改了名、参数变了、甚至删了,清单还在那儿打勾。要让清单不过期,唯一可靠的办法是让它自己跑——跑不动的条目会立刻报错,而不是静默地留在那里骗人。
第二个理由是边界要显式。不是所有验收点都能在命令行复现:黄金图像比对要 headless 浏览器(L2),真机帧率要真机与平台 SDK(L4)。可执行的清单必须把这两类显式分出来,而不是让「没跑」和「跑过了」在表里长得一样。一个把 L4 项伪装成 L1 的清单,比没有清单更危险。
所以本节的自检器分两组:CHECKS 是能 spawn 的自动项,逐条判退出码;MANUAL 是需要人工的项,打印出来提醒放行,但不计入成败。
第三个理由是可核对性。一份清单如果只是文字,谁都可以声称「我跑过了」;一旦它是一条能跑的脚本,跑没跑过、过没过,就有客观证据。把清单从「文档」升级成「脚本」,本质上是给放行这件事装了一个可审计的开关——它不阻止你上线,但它让你无法假装检查过了。
第四个理由是可传递性。清单写在人的脑子里,只对那个人有效;写成脚本,任何接手的人都能跑。工程交付里最贵的成本往往是「只有某个人知道怎么验证」,而脚本化正是把这部分知识从人身上搬到仓库里。这也是本卷反复出现的一条主线:把隐性知识变成可执行的东西。
可运行产物:清单自检器
下面是 tools/launch-check.mjs,L1,Node 直跑。它把八章的验收点写成一个数组,每条带章节号、描述与命令。
// file: tools/launch-check.mjs
// tier: L1
// verify: node tools/launch-check.mjs
//
// 《微光岛》上线检查清单的可执行版:把前八章的验收点收敛成一张表,
// 能自动跑的逐条 spawn 并判退出码;需真机 / 浏览器的显式列为 MANUAL。
// 自动项全过才退出 0;MANUAL 项不计入成败,只提醒人工放行。
import { spawnSync } from 'node:child_process';
const CHECKS = [
{ id: 'C1', chapter: '2.1', desc: '内核单测(引擎假件)', cmd: 'npx vitest run src/core/minimal-engine.test.ts src/core/frame-timer.test.ts src/core/memory-tags.test.ts src/perf/budget.test.ts' },
{ id: 'C2', chapter: '5.1', desc: '全量类型检查(模块边界)', cmd: 'npx tsc --noEmit' },
{ id: 'C3', chapter: '2.3', desc: '确定性回放:两次运行逐位相同', cmd: 'node src/apps/glimmer-isle-demo.ts' },
{ id: 'C4', chapter: '8.1', desc: '性能预算门禁(B 层,软告警)', cmd: 'node --expose-gc tools/budget-gate.mjs' },
{ id: 'C5', chapter: '8.2', desc: '生产构建产物', cmd: 'node tools/build-prod.mjs' },
{ id: 'C6', chapter: '8.2', desc: '产物清单校验(sha256)', cmd: 'node tools/verify-dist.mjs' },
{ id: 'C7', chapter: '8.2', desc: '静态服务冒烟', cmd: 'node tools/serve-dist.mjs' },
];
const MANUAL = [
{ id: 'M1', chapter: '2.2', tier: 'L2', desc: 'headless 黄金图像比对', why: '需 headless Chromium + 本地 http' },
{ id: 'M2', chapter: '4.1', tier: 'L2', desc: 'WebGPU / WebGL2 能力探测与降级', why: '需浏览器安全上下文' },
{ id: 'M3', chapter: '3.3', tier: 'L4', desc: 'GPU 计时与真机帧率', why: '需真机 / 平台 SDK' },
{ id: 'M4', chapter: '8.2', tier: 'L4', desc: '上传静态托管 / 缓存头 / HTTPS', why: '需托管平台账号与 DNS' },
];
let fail = 0;
for (const c of CHECKS) {
const r = spawnSync(c.cmd, { shell: true, encoding: 'utf8' });
const ok = r.status === 0;
if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${c.id} ch${c.chapter} ${c.desc}`);
}
for (const m of MANUAL) console.log(`MANUAL ${m.id} [${m.tier}] ch${m.chapter} ${m.desc} (${m.why})`);
console.log(`\n自动项 ${CHECKS.length},通过 ${CHECKS.length - fail},失败 ${fail};手动项 ${MANUAL.length}`);
console.log(fail === 0 ? '=== launch-check: 自动项全过,人工项待放行 ===' : `=== launch-check: ${fail} 项失败 ===`);
process.exit(fail === 0 ? 0 : 1);
自检器的写法刻意保持朴素:一个 CHECKS 数组、一个 MANUAL 数组、两个循环。没有并发、没有缓存、没有配置文件解析——这些「工程化」的加法在这里都是负担。清单的读者需要在半夜三点一眼看懂它做了什么,朴素就是最好的可读性。
跑 node tools/launch-check.mjs,本机结果如下。
PASS C1 ch2.1 内核单测(引擎假件)
PASS C2 ch5.1 全量类型检查(模块边界)
PASS C3 ch2.3 确定性回放:两次运行逐位相同
PASS C4 ch8.1 性能预算门禁(B 层,软告警)
PASS C5 ch8.2 生产构建产物
PASS C6 ch8.2 产物清单校验(sha256)
PASS C7 ch8.2 静态服务冒烟
MANUAL M1 [L2] ch2.2 headless 黄金图像比对 (需 headless Chromium + 本地 http)
MANUAL M2 [L2] ch4.1 WebGPU / WebGL2 能力探测与降级 (需浏览器安全上下文)
MANUAL M3 [L4] ch3.3 GPU 计时与真机帧率 (需真机 / 平台 SDK)
MANUAL M4 [L4] ch8.2 上传静态托管 / 缓存头 / HTTPS (需托管平台账号与 DNS)
自动项 7,通过 7,失败 0;手动项 4
=== launch-check: 自动项全过,人工项待放行 ===
七项自动检查全部 PASS,四项 MANUAL 按预期只提示不判定,退出码 0。注意自检器不吞输出——spawnSync 的命令输出直接透传到终端,任何一项失败都能立刻看到是哪个命令挂了,而不是只看到一个红叉。
清单全表:八章收敛
把自检器的 CHECKS 与 MANUAL 展开成一张人类可读的表。层级一列沿用全书的 L1/L2/L4/B 口径,「本机」一列是实跑结果。
| 检查项 | 章节 | 层级 | 验收命令 | 本机 |
|---|---|---|---|---|
| 内核单测(引擎假件) | 2.1 | L1 | npx vitest run src/core/*.test.ts | PASS |
| 全量类型检查 | 5.1 | L1 | npx tsc --noEmit | PASS |
| 确定性回放 | 2.3 | L1 | node src/apps/glimmer-isle-demo.ts | PASS |
| 帧预算剖析 | 3.1 | L1 | node src/render/app/render-profiling.ts | PASS |
| 合批绘制调用 | 3.3 | L1 | node src/render/sort/batch.ts | PASS |
| 性能预算门禁 | 8.1 | B | node --expose-gc tools/budget-gate.mjs | PASS(1 WARN) |
| 生产构建产物 | 8.2 | L1 | node tools/build-prod.mjs | PASS |
| 产物清单校验 | 8.2 | L1 | node tools/verify-dist.mjs | PASS |
| 静态服务冒烟 | 8.2 | L1 | node tools/serve-dist.mjs | PASS |
| headless 黄金图像比对 | 2.2 | L2 | 手动(headless Chromium) | 待人工 |
| 能力探测与降级 | 4.1 | L2 | 手动(浏览器安全上下文) | 待人工 |
| GPU 计时与真机帧率 | 3.3 | L4 | 手动(真机 / 平台 SDK) | 待人工 |
| 上传托管 / 缓存 / HTTPS | 8.2 | L4 | 手动(平台账号 + DNS) | 待人工 |
表里有几点要说明。「PASS(1 WARN)」是刻意的:8.1 的预算门禁里有一项「未合批基线」故意标 WARN,自检器只看退出码,所以整体仍是 PASS——这正符合 B 基准层「软告警不硬失败」的定位。第 3.1 与 3.3 行虽然标 L1,但它们只是「能跑」,不代表量出来的 GPU 耗时可信;真正的 GPU 计时在第 3.3 节里被标为 L4,要真机。第 1 章与第 6、7 章没有单独成行,因为它们的验收点已经被吸收进别处:包体进了 8.1 的预算,玩法链的确定性进了 2.3 的回放,调试工具的断言进了 2.1 的单测。
这张表还暴露了一个容易被忽略的事实:前七章的验收点,最后几乎都收敛到了 L1。包体、性能、确定性、类型,全都能在命令行里跑。真正落到 L2/L4 的只有四类:需要 GPU 的渲染结果、需要真机的性能、需要浏览器的能力探测、需要平台的部署。这不是巧合,而是「工程化」这个词的具体含义——把尽可能多的验收从「人工看」变成「机器判」。
表太密,把自动项展开成逐条。每条给出「命令—期望—失败时先看哪」。
- C1 内核单测:
npx vitest run src/core/minimal-engine.test.ts src/core/frame-timer.test.ts src/core/memory-tags.test.ts src/perf/budget.test.ts。期望:4 个文件全绿。失败先看是哪个断言炸了,多半是引擎假件(fake)与真实子系统的接口漂移。 - C2 全量类型检查:
npx tsc --noEmit。期望:No errors found。失败先看是不是新引入的文件没走erasableSyntaxOnly(比如误用了enum)。 - C3 确定性回放:
node src/apps/glimmer-isle-demo.ts。期望:末行PASS: 两次运行逐位相同。失败说明有非确定性来源混进来了(真实时钟、随机数、遍历顺序)。 - C4 性能预算门禁:
node --expose-gc tools/budget-gate.mjs。期望:退出码 0,报告里允许有 WARN。失败先看是不是构建配置被改动导致包体口径变了。 - C5 生产构建:
node tools/build-prod.mjs。期望:打印入口名与 31 个文件、total=51881 B。失败先看shaders/是不是软链而没加dereference。 - C6 产物校验:
node tools/verify-dist.mjs。期望:ALL PASS。失败先看是不是dist/里有手工改过、没重新构建的文件。 - C7 静态服务:
node tools/serve-dist.mjs。期望:三行都返回 200。失败先看manifest.entry指向的文件是否真的在dist/里。
这几条的期望输出都写死了,所以自检器不需要解析输出,只看退出码就够了。写死期望值有个副作用:一旦某条命令的输出格式变了,退出码可能还是 0,但期望值已经不对——所以这些期望值应该和命令本身一起维护,改命令就顺手改期望。
还有一条经验:退出码是契约,输出是给人看的。凡是进 CHECKS 的命令,都必须「成功退出 0、失败退出非 0」——这是它能不能当自动检查的前提。一个总是退出 0 的命令(比如某些只打印不报错的脚本)不能直接进清单,得先给它补上正确的退出码语义。
手工项与放行:自动与人工的边界
自动项覆盖不到的四条里,两条是 L2(浏览器),两条是 L4(真机 / 平台)。L2 其实有办法在本地跑,只是不能像 L1 那样「一条命令完事」——它需要一个 headless 浏览器加一个带安全上下文的本地 http 服务。
以 headless 黄金图像比对(2.2)为例,最小 harness 的形状是:起一个 loopback http 服务(必须带 Cross-Origin-Opener-Policy 与 Cross-Origin-Embedder-Policy 头,否则 navigator.gpu 不可用),用 Playwright 打开页面、跑渲染、回读像素、与黄金图像逐像素比对。本卷的冒烟门禁 gates/smoke_l2.mjs 就是这个形状:它验证 WebGPU compute 回读、渲染通道回读、WGSL 编译诊断与 WebGL2 readPixels 四件事,任一项不过则整套 L2 口径不成立。
gates/smoke_l2.mjs 的验收口径(需 headless Chromium,本机需 Playwright)
compute.f32x2 数组每元素乘 2,首/中/末三点精确断言
render.center=green 渲染通道中心像素 = (0,255,0,255)
render.corner=bg 角落像素 = 清屏色
wgsl.good=0error 合法 WGSL 编译 0 error
wgsl.bad=diagnosed 非法 WGSL 能被 getCompilationInfo 诊断出错误
webgl2.readPixels WebGL2 清屏后 readPixels 取回预期色
这段是 L2,本节需在真实浏览器中手动验证,命令行不可复现——它依赖本机装有 Playwright 与 headless Chromium,且 navigator.gpu 只在安全上下文可用。本节把它列为 MANUAL 而不是假装跑过,正是因为它在当前环境里跑不起来。L4 的两条(真机帧率、托管平台)同理,甚至更强:它们依赖的是本机根本不存在的硬件与账号。
有人会说,既然 L2 能在本地跑,为什么不干脆把它也接进 CHECKS?答案是依赖成本。L1 的七条检查只依赖 Node 与仓库里的源码,任何一台开发机都能跑;一旦把 Playwright 拉进来,清单就多了一个「本机得先装浏览器」的前置条件,失败原因也从「代码坏了」变成「环境没配好」。清单的门槛越高,被跳过的概率越大。所以本节宁可把 L2 单列成人工项,也不为了「看起来更自动」而抬高整份清单的依赖。
清单的维护:加一条、为什么留、跑多勤
清单要长期有效,就得让「加一条检查」变得便宜。自检器的结构特意做成一个纯数组,加检查就是往数组里塞一个对象——不用改循环、不用改输出逻辑。
比如某天线上发现「存档跨版本迁移后坐标漂移」,回来自检器里加一条:
// snippet: 往 CHECKS 里追加一条(示例,非完整文件)
{ id: 'C8', chapter: '7.1', desc: '存档迁移往返不漂移', cmd: 'npx vitest run src/core/migration.test.ts' },
加完这条,下次放行它就会自动跑。这就是清单能活下来的机制:故障 → 加检查 → 永不再犯。如果每次加检查都要动框架代码,没人愿意加,清单就会退化成一份过期的文档。
反过来,删一条检查也要有纪律。只有当某条检查对应的功能彻底不存在了(不是「暂时跳过」),才删它。把一条长期失败的检查「注释掉」,等于把清单的可信度打一个洞——下一次它就敢再打一个。
这条纪律在实践里最容易破。一个检查挂了,修起来要半小时,放行又催得紧,最省事的做法就是先把它 skip 掉。skip 一次没事,skip 两次就成了习惯,第三次它就永远躺在那儿没人管。防止这种滑坡的办法是给 skip 加成本:要么当场修,要么在 issue 里记一笔并注明原因与期限,不许默默跳过。
清单不是平均分布的,每一条背后都有一个具体的、曾经或很可能发生的失败模式。把这张对照写下来,是为了让后来者知道「这条为什么要留」:
| 检查 | 防的失败模式 |
|---|---|
| 内核单测(2.1) | 子系统接口漂移,改动一处炸三处 |
| 类型检查(5.1) | 模块边界被悄悄跨过,包体回涨 |
| 确定性回放(2.3) | 非确定性来源混入,回放对不上 |
| 帧预算剖析(3.1) | 逻辑或装配悄悄变慢 |
| 合批绘制调用(3.3) | 有人关掉合批,绘制调用暴涨 30 倍 |
| 性能预算门禁(8.1) | 四项预算中任一项越线 |
| 生产构建(8.2) | 资源没打进去、软链没解引用 |
| 产物校验(8.2) | 发布物被换过、哈希对不上 |
| 静态服务(8.2) | 入口引用的文件其实不存在 |
九条自动检查,九个失败模式,没有一条是「为了凑数」。这也是判断清单是否健康的简单标准:删掉任意一条,你能不能立刻说出它会漏掉什么?说不出来的,就是冗余项。
清单的边界:层级口径与不该进清单的事
清单里每一行都带一个层级标签,这个标签不是装饰,它直接决定「这条能不能进自动流水线」。全书的层级口径在本节收敛如下。
| 层级 | 含义 | 能不能自动跑 | 清单里的例子 |
|---|---|---|---|
| L1 | 纯 TS / Node 可直跑 | 能,一条命令 | 单测、类型检查、构建、校验 |
| L2 | 需 GPU / 浏览器 | 半自动,需 headless 环境 | 黄金图像比对、能力探测 |
| L3 | 仅类型(配置草图) | 不跑,只做类型检查 | 桌面打包配置 |
| L4 | 命令行不可复现 | 不能,人工 | 真机帧率、托管平台 |
| B | 基准(帧预算、GC、GPU 计时) | 能跑,但只告警 | 性能预算门禁 |
把这张表和上面的检查表对照看:九条自动项里八条是 L1、一条是 B;四条人工项里两条 L2、两条 L4;L3 在本节的清单里没有条目,因为它是「类型草图」,本来就不是运行期检查。层级不是难度,而是可复现性——L4 的东西可能比 L1 简单(比如「把文件传上去」),但它在命令行里复现不了,所以只能人工。
这也解释了一个常见困惑:为什么「部署」这么重要的事,却排在 L4 人工项里,而「跑单测」这么小的事反而自动?因为重要性决定它该不该检查,可复现性决定它能不能自动检查。两件事分开,清单才不会被「重要性」和「可行性」搅在一起。
回到本节的四个 MANUAL 项:它们之所以人工,不是因为「暂时懒得自动化」,而是因为在当前环境里没有可复现的自动化路径。一旦某天项目接进了 CI、装好了 headless 浏览器、配好了托管账号,M1 与 M4 就可能降级成自动项,M3 仍会留在人工——真机帧率永远需要一个真机,这是物理限制,不是工程懒。
清单跑一次要花多久,也决定它能不能被高频使用。本机实测,node tools/launch-check.mjs 跑完七条自动项大约几十秒,其中大头是 npx tsc --noEmit 与 npx vitest 的启动开销;build-prod 与 verify-dist 都不到一秒。这个量级意味着它可以每次提交都跑,而不只是放行前跑一次。
但并非所有条目都该每次跑。一个务实的频率分层:
- 每次提交:C2 类型检查、C3 确定性回放——最快,且最容易在改动中被破坏。
- 每次合并:C1 单测、C4 预算门禁——比提交慢,但仍是秒级。
- 放行前:C5–C7 构建与产物校验——它们改的是交付物本身,只在要发布时才有意义。
- 人工放行:M1–M4——频率由发布节奏决定,不由代码改动决定。
这套分层的意义是:让最便宜的检查跑得最勤。类型检查一秒能跑完,就没理由等到放行才跑;真机帧率要半小时,就不该指望每次提交都做。把检查按成本排序、按成本分配频率,清单才不会因为「太重」而被整体跳过。
有一点要补充:本节的 launch-check.mjs 把七条自动项一次跑完,是为了「放行」这个场景服务的——放行时你要的是「全绿」这个总体结论。日常开发里,更合理的用法是只跑其中一两条(比如只跑类型检查),把完整清单留给合并与放行。同一个脚本,按场景取子集,不必每次全跑。
一份好清单的边界,和它包含什么一样重要。以下几类事故意没有进本清单。
第一类是一次性决策。选不选某个渲染后端、用不用某个物理库,这些是架构决策,不是每次放行都要重跑的东西。它们的验收点是「决策记录还在不在」,属于文档,不属于清单。
第二类是主观质量。「手感好不好」「画面美不美」这类判断,没有任何命令能替代人。把它们塞进清单只会制造假精确——写一条 手感 PASS 毫无意义。这些交给 playtest。
第三类是已被上游覆盖的重复项。比如「包体没超预算」在 8.1 的预算门禁里已经查过,就不在 8.3 里再查一遍。清单要避免重复,否则一处失败会在多个地方报同一个错,反而看不清根因。
第四类是长期未复现的偶发问题。一个只在某台特定机器上偶发的问题,加进清单只会带来 flaky。正确的做法是记录到 issue 跟踪里、等它能稳定复现了再加检查。
把这四类排除掉,清单剩下的就是「稳定、重要、可验证」的检查——这正是它能被信任的原因。清单的可信度来自克制,不来自覆盖面。
一个直观的检验方法:把清单打印出来贴在墙上,让一个没参与过这个项目的人读一遍。如果他读完能说出「这台机器上现在能不能发布」,清单就是合格的;如果他只能说「哦,检查了很多东西」,那清单就太长了。清单的读者不是写它的人,而是半夜被叫起来做放行的人——他需要的是「哪条没过」,不是「一共有多少条」。
清单跑完,会得到两个数:自动项通过数、人工项待办数。这两个数不能相加,因为它们回答的是不同问题。自动项通过,回答的是「交付物本身没坏」;人工项通过,回答的是「交付物在真实环境里能用」。前者可以每天跑,后者只能在放行那一刻做一次。
放行的正确姿势是先自动、后人工:自检器全绿之前,人工项连看的必要都没有;自检器一红,人工项看了也是白看。反过来,如果自动项全绿就以为可以上线了,那大概率会在「真机上跑不满帧」或「缓存头配错」上翻车——这两件事恰好都在人工项里。
还有一个顺序上的细节:先跑最便宜的。自检器里 C2 类型检查一秒出结果,C5 构建也很快,但 M3 真机帧率要专门安排设备。所以实际放行时应该先让自动项全绿,再安排人工项;如果自动项在最后一步才报红,之前的人工验证就全白做了。把检查按成本从低到高排,是让放行流程最短的排法。
还有一条纪律:清单是活的。每当修一个线上问题,就回来自检器里加一条对应的检查。清单越长不代表越好,但每一条都应该是「曾经真的出过问题」或「一旦出问题代价很大」的点。一个从没触发过的检查项,要么说明它不重要,要么说明它写错了。
最后要说清这一节没有做的事:它没有给出一个 CI 配置文件,也没有给出一个部署脚本。原因是本机没有 CI 平台、没有 Docker、没有托管账号——写一个跑不起来的 CI 配置,比不写更容易误导人。本节只交付「本机能真正跑通的七条自动检查 + 四条明确的人工项」,把 CI 接线与部署放行留给拥有那些环境的团队。能跑的先跑到底,跑不了的显式留白,这是本卷贯穿始终的诚实原则。
一次完整的放行流程长这样:
- 跑
node tools/launch-check.mjs,确认七条自动项全绿。 - 逐条走四个 MANUAL 项:在 headless 浏览器里跑 L2、在真机上跑 L4。
- 自动项与人工项都过,才认为「可以发布」。
- 发布后立刻把清单再跑一遍——确认发布出去的那份产物,和清单里记的那份一致。
第 4 步经常被省略,但它才是闭环的收尾:发布前的检查证明「准备发布的产物是对的」,发布后的检查证明「真正发布出去的产物也是对的」。两者之间的差异,往往来自「构建用的产物」和「部署用的产物」不是同一份——这正是 8.2 的哈希清单要防的事。
这套流程看似繁琐,但它把「上线」从一个凭感觉的动作,变成了一串有证据的步骤。每一步都能说清「我凭什么认为它没问题」——这正是工程化交付和「本地跑通就发」之间的根本区别。
到这一节为止,工程交付这条线收束了:预算(8.1)、构建(8.2)、清单(8.3)三件事把前三卷的引擎从「能跑」推到了「能交付」。下一节起进入全书的收官部分——把 helio 与其它引擎放在一条坐标轴上做对照,先说清对照的口径。
阅读导航:上一节:8.2 构建与部署 · 下一节:9.1 单一坐标轴对照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。