3.2 构建矩阵与产物管理

同一份代码要产出微信、抖音、Poki、CrazyGames 四套包,还要区分发布版与调试版、轻量与全量资源。本节把「渠道 × 变体 × 资源档」展开成一张确定性构建矩阵,算每个产物的大小与预算、标出超限项,并给出可复现的产物清单哈希,配 Python 仿真与 TypeScript 内核跨语言对拍。

卷二「构建产物与包体预算」 讲的是单个产物的预算怎么定:首包多大、分包怎么切、超了怎么砍。本节往前一步——当你要同时出四个渠道 × 两种变体 × 两档资源时,这些产物怎么组织成一张确定性构建矩阵,怎么保证每一个都不超预算,又怎么用一份可复现的清单哈希把它们钉死。这和卷二「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)
微信小游戏4021004096
抖音小游戏4021304096
Poki(Web)4220201536
CrazyGames(Web)4220351536

超限的四个全是 *-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 多平台版本一致性与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制