卷二「构建产物与包体预算」 讲的是单个产物的预算怎么定:首包多大、分包怎么切、超了怎么砍。本节往前一步——当你要同时出四个渠道 × 两种变体 × 两档资源时,这些产物怎么组织成一张确定性构建矩阵,怎么保证每一个都不超预算,又怎么用一份可复现的清单哈希把它们钉死。这和卷二「CI 发布流水线」 是上下游:那节讲流水线怎么跑,本节讲流水线该产出什么。
一个包,为什么会变成十六个包
最朴素的做法是「打一个包,四个平台共用」。但四个渠道的差异是结构性的:微信要带微信 SDK、抖音要带抖音 SDK、Poki 和 CrazyGames 只跑在浏览器里,它们的包体上限、渠道增量、资源策略都不同。如果硬塞一个包,结果一定是按最宽松的平台定尺寸,让最严格的平台超限。
正确的做法是把包当成矩阵的产物:渠道 × 变体 × 资源档。三个轴各自独立:
| 轴 | 取值 | 影响什么 |
|---|---|---|
| 渠道 | wechat / douyin / poki / crazygames | 渠道 SDK 增量、平台预算上限 |
| 变体 | release / debug | 是否 minify、是否带调试信息 |
| 资源档 | lite / full | 是否加载全量美术资源 |
三个轴相乘,4 × 2 × 2 = 16 个产物。关键认知:这 16 个产物来自同一份源码,只是构建参数不同——所以它们必须能被确定性枚举,而不是靠人手维护一张 Excel。手维护的矩阵一定会漏:接了第五个渠道忘了补两行,或者某个资源档没跑。
产物大小与预算:两条整数公式
每个产物的大小由四段相加,预算取两处约束的较小值:
sizeKb = baseSizeKb + 渠道 overheadKb + 变体 sizeDeltaKb + 资源档 sizeDeltaKb
budgetKb = min(渠道 budgetKb, 变体 budgetKb)
status = sizeKb <= budgetKb ? "ok" : "over"
为什么预算要取小?因为渠道上限和变体上限是两个独立的硬约束——小游戏平台限制首包体积,调试版又要塞调试符号。两个都不能破,所以取更严的那个。这条公式没有任何浮点,全整数可比对。
D 层:矩阵配置
矩阵配置放在 config/g03_matrix.json。baseSizeKb 是逻辑代码的体积,渠道、变体、资源档各自带一个整数增量。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测包体。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "构建矩阵与产物预算表。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测包体。产物大小 = baseSizeKb + 渠道 overheadKb + 变体 sizeDeltaKb + 资源档 sizeDeltaKb;预算 = min(渠道 budgetKb, 变体 budgetKb);status = size<=budget ? ok : over。全整数,无浮点。真实平台包体上限以各家开发者文档为准,命令行不可复现(X)。",
"baseSizeKb": 820,
"variants": [
{ "id": "release", "name": "发布版", "minify": 1, "debugInfo": 0, "sizeDeltaKb": 0, "budgetKb": 4096 },
{ "id": "debug", "name": "调试版", "minify": 0, "debugInfo": 1, "sizeDeltaKb": 260, "budgetKb": 4096 }
],
"assetTiers": [
{ "id": "lite", "name": "轻量资源", "sizeDeltaKb": 0 },
{ "id": "full", "name": "全量资源", "sizeDeltaKb": 900 }
],
"channels": [
{ "id": "wechat", "name": "微信小游戏", "overheadKb": 120, "budgetKb": 4096 },
{ "id": "douyin", "name": "抖音小游戏", "overheadKb": 150, "budgetKb": 4096 },
{ "id": "poki", "name": "Poki(Web)", "overheadKb": 40, "budgetKb": 1536 },
{ "id": "crazygames", "name": "CrazyGames(Web)", "overheadKb": 55, "budgetKb": 1536 }
]
}
S 层:把矩阵展开并标出超限
下面这段是 S 层仿真脚本 sim/g03_build_matrix.py。它读上面那份 config,把矩阵展开、算大小与预算、标出 over 项,并对按 id 升序的产物清单做 FNV-1a 32 位哈希——这个哈希就是产物的「指纹」,第 3.3 节要用它做跨平台一致性比对。
#!/usr/bin/env python3
# file: sim/g03_build_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g03_build_matrix.py --config config/ --out out/
"""构建矩阵与产物管理仿真:读 config/g03_matrix.json,把「渠道 × 变体 × 资源档」
展开成一张确定性构建矩阵,算每个产物的大小、预算与超限状态,并给出清单摘要。
产物大小 = baseSizeKb + 渠道 overheadKb + 变体 sizeDeltaKb + 资源档 sizeDeltaKb
预算 = min(渠道 budgetKb, 变体 budgetKb)
状态 = sizeKb <= budgetKb ? "ok" : "over"
manifest = 按产物 id 升序,对「id:sizeKb:status」逐行做 FNV-1a 32 位哈希
口径:包体上限是「公开可观察机制」的建模假设,真实上限以各家开发者文档为准,
命令行不可复现(X)。产出 JSON + CSV + SVG,全整数,无浮点。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET
FNV_OFFSET, FNV_PRIME, MASK32 = 2166136261, 16777619, 0xFFFFFFFF
CSV_COLS = ("id", "channel", "variant", "assetTier", "sizeKb", "budgetKb", "status")
def fnv1a32(s: str) -> int:
h = FNV_OFFSET
for b in s.encode("utf-8"):
h ^= b
h = (h * FNV_PRIME) & MASK32
return h
def build_matrix(cfg: dict) -> list:
rows = []
for ch in cfg["channels"]:
for v in cfg["variants"]:
for t in cfg["assetTiers"]:
size = cfg["baseSizeKb"] + ch["overheadKb"] + v["sizeDeltaKb"] + t["sizeDeltaKb"]
budget = min(ch["budgetKb"], v["budgetKb"])
rows.append({"id": f'{ch["id"]}-{v["id"]}-{t["id"]}', "channel": ch["id"],
"variant": v["id"], "assetTier": t["id"], "sizeKb": size,
"budgetKb": budget, "status": "ok" if size <= budget else "over"})
rows.sort(key=lambda r: r["id"])
return rows
def draw_svg(path: str, rows: list, channels: list) -> int:
"""纯 Python SVG:每渠道两根横条——ok 产物与 over 产物(按预算标线)。"""
W, H, PAD, ROW = 780, 70 + 56 * len(channels), 170, 56
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "各渠道产物:预算内(ok) vs 超限(over)"
span = W - PAD - 40
hi = max([r["sizeKb"] for r in rows] + [1])
for i, cid in enumerate(channels):
sub = [r for r in rows if r["channel"] == cid]
y = 52 + i * ROW
ok = sum(1 for r in sub if r["status"] == "ok")
over = len(sub) - ok
w_ok = span * ok // len(sub)
w_over = span * over // len(sub)
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = cid
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w_ok), "height": "20",
"rx": "4", "fill": "#16a34a"})
ET.SubElement(root, "rect", {"x": str(PAD + w_ok), "y": str(y + 4), "width": str(w_over),
"height": "20", "rx": "4", "fill": "#dc2626"})
ET.SubElement(root, "text", {"x": str(PAD + w_ok + w_over + 8), "y": str(y + 18), "font-size": "11",
"fill": "#6b7280"}).text = f"ok {ok} · over {over}"
mx = max(r["sizeKb"] for r in sub)
ET.SubElement(root, "text", {"x": "16", "y": str(y + 42), "font-size": "11",
"fill": "#9ca3af"}).text = f"最大 {mx} KB / 峰值 {hi} KB"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
with open(os.path.join(a.config, "g03_matrix.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = build_matrix(cfg)
over = sum(1 for r in rows if r["status"] == "over")
total_kb = sum(r["sizeKb"] for r in rows)
manifest_src = "\n".join(f'{r["id"]}:{r["sizeKb"]}:{r["status"]}' for r in rows)
manifest = fnv1a32(manifest_src)
digest = f"artifacts={len(rows)},over={over},totalKb={total_kb},manifest={manifest}"
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "g03_build_matrix.svg"), rows, [c["id"] for c in cfg["channels"]])
with open(os.path.join(a.out, "g03_build_matrix.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(CSV_COLS)
for r in rows:
w.writerow([r[k] for k in CSV_COLS])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"artifactCount": len(rows), "over": over, "totalKb": total_kb, "manifest": manifest,
"digest": digest, "artifacts": rows,
"svg": os.path.join(a.out, "g03_build_matrix.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "g03_build_matrix.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "artifactCount": len(rows), "over": over,
"overIds": [r["id"] for r in rows if r["status"] == "over"]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g03_build_matrix.py --config config/ --out out/,16 个产物里恰好 4 个超限(同机相对量级,绝对数值不可移植):
| 渠道 | 产物数 | 超限数 | 最大产物(KB) | 渠道预算(KB) |
|---|---|---|---|---|
| 微信小游戏 | 4 | 0 | 2100 | 4096 |
| 抖音小游戏 | 4 | 0 | 2130 | 4096 |
| Poki(Web) | 4 | 2 | 2020 | 1536 |
| CrazyGames(Web) | 4 | 2 | 2035 | 1536 |
超限的四个全是 *-full(poki-release-full、poki-debug-full、crazygames-release-full、crazygames-debug-full)。原因一目了然:Web 平台的预算只有 1536 KB,而全量资源要额外 900 KB,一加就破 2000 KB。这不是 bug,是决策——它直接告诉你:Web 渠道必须走轻量资源档,或者把全量资源改成按需懒加载。小游戏平台预算宽裕(4096 KB),全量资源装得下。矩阵的价值就在这:它把「哪个平台放不下什么」在构建前就算清楚了,而不是等提审被驳回才发现。
P 层内核:同一张矩阵,跨语言对拍
下面这段是 P 层 TypeScript 内核 core/src/g03/buildmatrix.ts:它复算同一张矩阵与同一个清单哈希。哈希用 BigInt 做 32 位 FNV-1a,与 Python 的 & 0xFFFFFFFF 逐位等价。
// file: core/src/g03/buildmatrix.ts
// tier: P
// verify: node --test 'core/test/g03_*.test.ts'
//
// 构建矩阵与产物管理内核:一份 config/g03_matrix.json 展开出「渠道 × 变体 × 资源档」
// 的确定性构建矩阵,算每个产物的大小 / 预算 / 超限状态,并给出可复现的清单摘要。
// 与 sim/g03_build_matrix.py 逐位等价的 TypeScript 对偶实现。
// 全整数、无浮点、无 Math.random;FNV-1a 用 BigInt 防溢出(只用可擦除语法)。
export interface Variant {
readonly id: string; readonly name: string;
readonly minify: number; readonly debugInfo: number;
readonly sizeDeltaKb: number; readonly budgetKb: number;
}
export interface AssetTier { readonly id: string; readonly name: string; readonly sizeDeltaKb: number; }
export interface BuildChannel { readonly id: string; readonly name: string; readonly overheadKb: number; readonly budgetKb: number; }
export interface MatrixConfig {
readonly version: number; readonly game: string; readonly baseSizeKb: number;
readonly variants: readonly Variant[]; readonly assetTiers: readonly AssetTier[];
readonly channels: readonly BuildChannel[];
}
export interface Artifact {
readonly id: string; readonly channel: string; readonly variant: string; readonly assetTier: string;
readonly sizeKb: number; readonly budgetKb: number; readonly status: string;
}
/** 产物大小:base + 渠道 overhead + 变体增量 + 资源档增量。 */
export function artifactSizeKb(cfg: MatrixConfig, ch: BuildChannel, v: Variant, t: AssetTier): number {
return cfg.baseSizeKb + ch.overheadKb + v.sizeDeltaKb + t.sizeDeltaKb;
}
/** 展开构建矩阵;产物按 id 升序(清单哈希要求确定性顺序)。 */
export function buildMatrix(cfg: MatrixConfig): Artifact[] {
const rows: Artifact[] = [];
for (const ch of cfg.channels) {
for (const v of cfg.variants) {
for (const t of cfg.assetTiers) {
const sizeKb = artifactSizeKb(cfg, ch, v, t);
const budgetKb = Math.min(ch.budgetKb, v.budgetKb);
rows.push({ id: `${ch.id}-${v.id}-${t.id}`, channel: ch.id, variant: v.id, assetTier: t.id,
sizeKb, budgetKb, status: sizeKb <= budgetKb ? "ok" : "over" });
}
}
}
rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return rows;
}
export const FNV_OFFSET = 2166136261n;
export const FNV_PRIME = 16777619n;
export const MASK32 = 4294967295n;
/** FNV-1a 32 位哈希(ASCII 串逐字节);与 Python 侧 fnv1a32 逐位一致。 */
export function fnv1a32(s: string): number {
let h = FNV_OFFSET;
for (let i = 0; i < s.length; i += 1) {
h = h ^ BigInt(s.charCodeAt(i));
h = (h * FNV_PRIME) & MASK32;
}
return Number(h);
}
/** 产物清单哈希:对「id:sizeKb:status」逐行做 FNV-1a。 */
export function manifestHash(rows: readonly Artifact[]): number {
return fnv1a32(rows.map((r) => `${r.id}:${r.sizeKb}:${r.status}`).join("\n"));
}
/** 跨语言对拍摘要:与 sim/g03_build_matrix.py 的 digest 逐字符一致。 */
export function digest(rows: readonly Artifact[]): string {
let over = 0; let totalKb = 0;
for (const r of rows) { if (r.status === "over") over += 1; totalKb += r.sizeKb; }
return `artifacts=${rows.length},over=${over},totalKb=${totalKb},manifest=${manifestHash(rows)}`;
}
配套测试 core/test/g03_buildmatrix.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验矩阵规模、预算取小、超限判定与哈希稳定性。在本机跑 node --test 'core/test/g03_*.test.ts',本节 5 个用例全过;两套独立实现给出同一个摘要:
digest = artifacts=16,over=4,totalKb=23860,manifest=3216319739
清单哈希 3216319739 是整个矩阵的指纹:渠道、变体、资源档、每一段增量、预算规则,任何一处改动都会让这个数变。把它写进构建产物的元数据,就得到了一份可核验的产物身份——这正是下一节「跨平台版本一致性」要用的东西。
X 层:真实包体上限与渠道 SDK 不可复现
必须划清边界。上面算的是模型侧:给定各段增量与预算,产物是 ok 还是 over。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的平台包体上限(各平台对首包、总包、单文件大小的限制会变,以开发者文档为准);
- 真实的渠道 SDK 增量(微信 / 抖音 SDK 的实际体积随版本变化,且和你的打包方式有关);
- 真实的压缩率(同一份代码在不同平台的 gzip / brotli 效果不同,本节的
sizeKb是未压缩建模值)。
工程上正确的做法是:把可复现的矩阵与不可复现的体积事实分开——矩阵结构进单测(本节),真实体积在每次构建后反标定回 config/(把实测体积填回去,矩阵立刻反映现实)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 矩阵可枚举 | 产物由「渠道 × 变体 × 资源档」确定性展开,不靠人手维护 |
| 预算取小 | 渠道上限与变体上限两个硬约束同时满足 |
| 超限可定位 | 超限产物能精确列出,并给出可执行的应对(换档 / 懒加载) |
| 清单可核验 | 产物清单有稳定哈希,可写进产物元数据 |
| 跨语言一致 | Python 与 TS 两套实现的摘要逐字符相等 |
| 边界清晰 | 真实包体上限与 SDK 体积标 X,不写成「实测」 |
构建矩阵最容易被忽略的价值,不是「少打几个包」,而是在构建前就暴露「哪个平台装不下什么」。等到提审被驳回再回头砍资源,代价是几天甚至一轮审核周期。下一节我们把矩阵的产物指纹接上——怎么验证多个平台的产物真的来自同一份源码,以及当某个平台跑崩时怎么安全回滚。
阅读导航:上一节:3.1 平台抽象层与渠道差异化配置 · 下一节:3.3 多平台版本一致性与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。