2.2 分包与资源加载管线

分包不是把文件挪个位置,而是把「玩家什么时候会用到它」翻译成加载顺序。本节给出一份带依赖与体积的资源清单,再用一段 P 层 TypeScript 内核按「依赖闭包 + 体积预算」自动规划 main 与分包,并用四条不变量断言规划结果,让分包线由约束反推而不是拍脑袋。

本节目标:把「哪些资源进主包、哪些进分包」从拍脑袋变成一次可复算的规划,并用四条不变量守住它。

站内 24 篇的小游戏创作平台专题 是做平台的口径(编辑器架构、开发者生态);本节继续做《合成小岛》这一个项目,处理它的分包与资源加载管线。

上一节把模块边界钉死了,但边界只回答了「谁依赖谁」。真正决定首屏能装下多少东西的,是分包:哪些资源必须跟着主包一起到位,哪些可以等玩家玩到那一步再拉。分包切错了,要么首屏塞不下(撞主包上限),要么首屏缺东西(玩家进来点不动)。

2.2 分包与资源加载管线

2.2.1 分包不是挪文件,是排加载顺序

一个常见的错误做法,是做完资源之后按「类型」分:图集一包、音频一包、配置一包。这么分看起来整齐,但它回答不了一个关键问题——玩家什么时候需要它。

正确的切法是按加载时机分,而加载时机由两件事决定:

  • 依赖方向。如果资源 A 依赖资源 B(比如「合成特效」要用到「合成物图集」),那么 B 必须比 A 先可用。主包最先加载,所以主包里的资源不能依赖任何分包资源——否则首屏就会出现「要用一个还没下载的东西」。
  • 体积预算。主包有上限,单分包也有上限。装不下就得往外挪,挪多了又可能撞上分包数量上限。

把这两条合起来,分包规划就变成了一个带预算约束的装箱问题:在「依赖先于被依赖者」的偏序下,把资源塞进 main 和若干分包,让每个包都不越界。这正是本节要写的那段内核。

2.2.2 资源清单与限额(D 层)

下面这份是 D 层配置:十四条资源,每条记下体积、依赖、是否核心、是否首屏必需、以及它属于哪个逻辑分组;外加三条限额。它是规划器唯一的输入。

{
  "project": "merge-island",
  "rule": "main 内的资源不得依赖任何分包资源;分包资源只能依赖 main 或更早加载的分包",
  "limitsKB": {
    "mainMaxKB": 4096,
    "subSingleMaxKB": 2048,
    "subCountMax": 20
  },
  "assets": [
    {"id": "code_core", "kb": 320, "deps": [], "core": true, "firstScreen": true, "group": "boot"},
    {"id": "boot_ui", "kb": 260, "deps": ["code_core"], "core": true, "firstScreen": true, "group": "boot"},
    {"id": "ui_atlas", "kb": 480, "deps": [], "core": true, "firstScreen": true, "group": "ui"},
    {"id": "i18n_zh", "kb": 40, "deps": [], "core": true, "firstScreen": true, "group": "ui"},
    {"id": "merge_tiles", "kb": 1240, "deps": ["ui_atlas"], "core": false, "firstScreen": true, "group": "merge"},
    {"id": "bgm_main", "kb": 620, "deps": [], "core": false, "firstScreen": true, "group": "audio"},
    {"id": "merge_fx", "kb": 700, "deps": ["merge_tiles"], "core": false, "firstScreen": false, "group": "merge"},
    {"id": "merge_sfx", "kb": 220, "deps": ["merge_fx"], "core": false, "firstScreen": false, "group": "merge"},
    {"id": "decor_atlas", "kb": 1380, "deps": ["ui_atlas"], "core": false, "firstScreen": false, "group": "decor"},
    {"id": "decor_extra", "kb": 820, "deps": ["decor_atlas"], "core": false, "firstScreen": false, "group": "decor"},
    {"id": "level_config", "kb": 90, "deps": ["code_core"], "core": false, "firstScreen": false, "group": "levels"},
    {"id": "level_thumbs", "kb": 640, "deps": ["level_config"], "core": false, "firstScreen": false, "group": "levels"},
    {"id": "island_bgm", "kb": 720, "deps": [], "core": false, "firstScreen": false, "group": "audio"},
    {"id": "sfx_pack", "kb": 340, "deps": [], "core": false, "firstScreen": false, "group": "audio"}
  ]
}

三条限额(主包 4096KB、单分包 2048KB、分包数量 20)是公开可观察的平台机制,观察时点 2026-10,全部写成可配置参数,具体数值以平台最新文档为准。清单里两个布尔字段是规划的抓手:core 表示「不做任何裁剪都必须进主包」,firstScreen 表示「首屏能看到它更好,但装不下可以退让」。

2.2.3 分包规划内核(P 层)

下面这段是 P 层内核。它按三步走:先把 core 资源与它们的依赖闭包放进 main;再把首屏资源按「依赖已就位且不超主包上限」尽量留在 main;剩下的按 group 归入分包,某个分包装满了就再开一个同组的后缀包。全程整数运算。

// file: core/src/e02/subpackage.ts
// tier: P
// verify: node --test 'core/test/e02_*.test.ts'
//
// 分包规划内核:按「依赖闭包 + 体积预算」把资源装进 main 与若干分包。
// 规则:main 只放 core 闭包与首屏资源;其余按 group 归入分包,单包超限就再开一个。
// 全整数运算(KB),除法一律地板除,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface AssetSpec { readonly id: string; readonly kb: number; readonly deps: readonly string[]; readonly core: boolean; readonly firstScreen: boolean; readonly group: string }
export interface SubLimits { readonly mainMaxKB: number; readonly subSingleMaxKB: number; readonly subCountMax: number }
export interface SubPackage { name: string; group: string; assets: string[]; kb: number }
export interface PlanResult {
  readonly main: string[]; readonly mainKB: number; readonly subs: SubPackage[];
  readonly subCount: number; readonly totalKB: number; readonly mainSharePerMille: number;
  readonly mainHeadroomKB: number; readonly violations: string[]; readonly ok: boolean;
}

export function buildIndex(assets: readonly AssetSpec[]): Map<string, AssetSpec> {
  const idx = new Map<string, AssetSpec>();
  for (const a of assets) idx.set(a.id, a);
  return idx;
}

/** 传递依赖闭包(不含自身)。 */
export function closure(id: string, idx: Map<string, AssetSpec>): Set<string> {
  const seen = new Set<string>();
  const stack = [...(idx.get(id)?.deps ?? [])];
  while (stack.length > 0) {
    const d = stack.pop()!;
    if (seen.has(d) || !idx.has(d)) continue;
    seen.add(d);
    for (const dd of idx.get(d)!.deps) stack.push(dd);
  }
  return seen;
}

/** 子集拓扑排序:子集外的依赖视为已满足;就绪项按 (kb 降序, id 升序) 取。 */
export function orderByDeps(ids: readonly string[], idx: Map<string, AssetSpec>): string[] {
  const inSet = new Set(ids);
  const indeg = new Map<string, number>();
  const adj = new Map<string, string[]>();
  for (const id of ids) { indeg.set(id, 0); adj.set(id, []); }
  for (const id of ids) for (const d of idx.get(id)!.deps) {
    if (!inSet.has(d)) continue;
    adj.get(d)!.push(id);
    indeg.set(id, indeg.get(id)! + 1);
  }
  const ready = ids.filter((id) => indeg.get(id) === 0);
  const order: string[] = [];
  while (ready.length > 0) {
    ready.sort((a, b) => (idx.get(b)!.kb - idx.get(a)!.kb) || (a < b ? -1 : 1));
    const n = ready.shift()!;
    order.push(n);
    for (const nx of adj.get(n)!) {
      indeg.set(nx, indeg.get(nx)! - 1);
      if (indeg.get(nx) === 0) ready.push(nx);
    }
  }
  return order;
}

export function plan(assets: readonly AssetSpec[], limits: SubLimits): PlanResult {
  const idx = buildIndex(assets);
  // 1) main = core 资源 ∪ 依赖闭包
  const main = new Set<string>();
  for (const a of assets) if (a.core) { main.add(a.id); for (const d of closure(a.id, idx)) main.add(d); }
  let mainKB = 0;
  for (const id of main) mainKB += idx.get(id)!.kb;

  // 2) 首屏资源尽量留在 main(依赖须已在 main,且不超 mainMaxKB)
  const eager = assets.filter((a) => a.firstScreen && !main.has(a.id)).map((a) => a.id);
  for (const id of orderByDeps(eager, idx)) {
    const a = idx.get(id)!;
    if (a.deps.every((d) => !idx.has(d) || main.has(d)) && mainKB + a.kb <= limits.mainMaxKB) { main.add(id); mainKB += a.kb; }
  }

  // 3) 其余按 group 归入分包,单包超限就再开一个
  const rest = assets.filter((a) => !main.has(a.id)).map((a) => a.id);
  const subs: SubPackage[] = [];
  const groupCount = new Map<string, number>();
  for (const id of orderByDeps(rest, idx)) {
    const a = idx.get(id)!;
    let target = subs.filter((s) => s.group === a.group).pop();
    if (target && target.kb + a.kb > limits.subSingleMaxKB) target = undefined;
    if (!target) {
      const seen = groupCount.get(a.group) ?? 0;
      groupCount.set(a.group, seen + 1);
      target = { name: seen === 0 ? `sub_${a.group}` : `sub_${a.group}_${seen + 1}`, group: a.group, assets: [], kb: 0 };
      subs.push(target);
    }
    target.assets.push(id);
    target.kb += a.kb;
  }

  const totalKB = mainKB + subs.reduce((s, p) => s + p.kb, 0);
  const violations: string[] = [];
  if (mainKB > limits.mainMaxKB) violations.push(`main ${mainKB}KB 超过上限 ${limits.mainMaxKB}KB`);
  for (const p of subs) if (p.kb > limits.subSingleMaxKB) violations.push(`分包 ${p.name} ${p.kb}KB 超过单包上限 ${limits.subSingleMaxKB}KB`);
  if (subs.length > limits.subCountMax) violations.push(`分包数量 ${subs.length} 超过上限 ${limits.subCountMax}`);
  for (const id of main) for (const d of idx.get(id)!.deps) if (idx.has(d) && !main.has(d)) violations.push(`main 资源 ${id} 依赖分包资源 ${d}`);

  return {
    main: [...main].sort(), mainKB, subs, subCount: subs.length, totalKB,
    mainSharePerMille: Math.floor((mainKB * 1000) / Math.max(1, totalKB)),
    mainHeadroomKB: limits.mainMaxKB - mainKB, violations, ok: violations.length === 0,
  };
}

两个细节值得点出:orderByDeps 的就绪集按「体积降序、id 升序」取,所以同一个清单永远得到同一个规划——规划必须可复现;分包命名用 sub_<group>,同组第二次开包才加后缀 _2,名字本身就说明了加载顺序。

2.2.4 断言:四条不变量(P 层测试)

规划器输出的是「一份方案」,方案对不对得靠断言。下面这段 P 层测试除了锁定确定性的数字,还守住四条不变量:main 不依赖分包、单包不超限、资源不重不漏、预算收紧时首屏资源会回退。

// file: core/test/e02_subpackage.test.ts
// tier: P
// verify: node --test 'core/test/e02_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { plan, closure, orderByDeps, buildIndex } from "../src/e02/subpackage.ts";
import type { AssetSpec, SubLimits } from "../src/e02/subpackage.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e02_assets.json"), "utf-8")) as
  { assets: AssetSpec[]; limitsKB: SubLimits };
const { assets, limitsKB: limits } = cfg;
const idx = buildIndex(assets);

test("依赖闭包:boot_ui 含 code_core,merge_sfx 含 merge_fx 与 merge_tiles", () => {
  assert.ok(closure("boot_ui", idx).has("code_core"));
  const c = closure("merge_sfx", idx);
  assert.ok(c.has("merge_fx") && c.has("merge_tiles"));
});

test("归组顺序:依赖先于被依赖者", () => {
  const pos = new Map(orderByDeps(["decor_extra", "decor_atlas", "merge_sfx", "merge_fx"], idx).map((n, i) => [n, i]));
  assert.ok(pos.get("decor_atlas")! < pos.get("decor_extra")! && pos.get("merge_fx")! < pos.get("merge_sfx")!);
});

test("规划确定:main 六项 2960KB、五个分包、四条限额全过", () => {
  const r = plan(assets, limits);
  assert.deepEqual(r.main, ["bgm_main", "boot_ui", "code_core", "i18n_zh", "merge_tiles", "ui_atlas"]);
  assert.deepEqual(r.subs.map((s) => [s.name, s.kb]), [
    ["sub_decor", 1380], ["sub_decor_2", 820], ["sub_audio", 1060], ["sub_merge", 920], ["sub_levels", 730],
  ]);
  assert.deepEqual([r.mainKB, r.subCount, r.totalKB, r.mainSharePerMille, r.mainHeadroomKB], [2960, 5, 7870, 376, 1136]);
  assert.equal(r.ok, true);
  assert.deepEqual(r.violations, []);
});

test("不变量:main 不依赖分包、单包不超限、资源不重不漏", () => {
  const r = plan(assets, limits);
  const mainSet = new Set(r.main);
  for (const id of r.main) for (const d of idx.get(id)!.deps) assert.ok(mainSet.has(d), `${id} 依赖分包 ${d}`);
  for (const s of r.subs) assert.ok(s.kb <= limits.subSingleMaxKB, `${s.name} 超限`);
  assert.deepEqual([...r.main, ...r.subs.flatMap((s) => s.assets)].sort(), assets.map((a) => a.id).sort());
});

test("预算收紧:main 上限降到 1500KB 时首屏资源回退到分包", () => {
  const r = plan(assets, { ...limits, mainMaxKB: 1500 });
  assert.equal(r.mainKB, 1100);
  assert.equal(r.main.includes("merge_tiles") || r.main.includes("bgm_main"), false);
  assert.equal(r.ok, true);
});

本机实跑输出(node --test 'core/test/e02_*.test.ts'):

✔ 依赖闭包:boot_ui 含 code_core,merge_sfx 含 merge_fx 与 merge_tiles
✔ 归组顺序:依赖先于被依赖者
✔ 规划确定:main 六项 2960KB、五个分包、四条限额全过
✔ 不变量:main 不依赖分包、单包不超限、资源不重不漏
✔ 预算收紧:main 上限降到 1500KB 时首屏资源回退到分包
ℹ tests 5
ℹ pass 5
ℹ fail 0

读这份结果:

  • main = 6 项、2960KB,占总包 7870KB 的 376‰,距主包上限还有 1136KB 余量。这六项是 code_core、boot_ui、ui_atlas、i18n_zh、merge_tiles、bgm_main——四个 core 加两个首屏资源。
  • 五个分包:sub_decor(1380) 与 sub_decor_2(820) 是同一组被单包上限切开的,sub_audio(1060)、sub_merge(920)、sub_levels(730)。注意 sub_decor_2 里是 decor_extra,它依赖 decor_atlas(在 sub_decor),所以先加载 sub_decor 再加载 sub_decor_2,依赖方向仍然成立。
  • 四条不变量全部成立:main 不依赖任何分包、每个分包都不超 2048KB、十四条资源不重不漏。
  • 预算收紧到 1500KB 时,merge_tiles 与 bgm_main 被挤出 main,main 缩到 1100KB(只剩 core 闭包)——首屏资源是可退让的,这就是 firstScreen 与 core 分开的意义。

这些数字是同机相对量级,绝对数值不可移植;可移植的是这套流程——清单是输入,规划与四条不变量是输出。

2.2.5 从规划到加载管线

规划器给出的是「静态分包表」,运行时还需要一条加载管线把它执行出来:

  1. 启动只加载 main。main 加载完,玩家就能跑通一次核心循环;此时任何分包都还没下载。
  2. 按节点触发分包。玩家进入岛屿装扮界面时,才发起 sub_decor 的加载;进入关卡选择时,才加载 sub_levels。分包名就是加载调度的键。
  3. 依赖即加载顺序。sub_decor_2 的加载必须排在 sub_decor 之后——这不是调度器的约定,而是依赖表推出来的事实。把依赖关系带进运行时,加载管线就不用再单独维护一份顺序表。

三条合起来,分包表同时是加载计划:静态规划与运行时调度共享同一份依赖数据,不需要两处对齐。

小结

  • 分包按加载时机切,不按类型切;判据是「依赖方向」与「体积预算」两条。
  • D 层配置把资源清单与三条限额写成唯一真源;P 层内核按「core 闭包 → 首屏资源 → 其余归组」三步规划,单包超限就再开一个后缀包。
  • 本机实跑:main 2960KB / 五个分包 / 总量 7870KB,四条不变量成立;预算收紧到 1500KB 时首屏资源正确回退。
  • 与站内小游戏创作平台专题 的分工:那边规划的是平台的分包能力,这边规划的是一个项目自己的分包方案。

分包表定下来,最后一个问题是:这份方案在真实构建里占多少体积、离上限有多近、有多大概率越线。这是下一节的事。

阅读导航:上一节:项目结构与模块边界 · 下一节:构建产物与包体预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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