本节目标:把「哪些资源进主包、哪些进分包」从拍脑袋变成一次可复算的规划,并用四条不变量守住它。
站内 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 从规划到加载管线
规划器给出的是「静态分包表」,运行时还需要一条加载管线把它执行出来:
- 启动只加载 main。main 加载完,玩家就能跑通一次核心循环;此时任何分包都还没下载。
- 按节点触发分包。玩家进入岛屿装扮界面时,才发起
sub_decor的加载;进入关卡选择时,才加载sub_levels。分包名就是加载调度的键。 - 依赖即加载顺序。
sub_decor_2的加载必须排在sub_decor之后——这不是调度器的约定,而是依赖表推出来的事实。把依赖关系带进运行时,加载管线就不用再单独维护一份顺序表。
三条合起来,分包表同时是加载计划:静态规划与运行时调度共享同一份依赖数据,不需要两处对齐。
小结
- 分包按加载时机切,不按类型切;判据是「依赖方向」与「体积预算」两条。
- D 层配置把资源清单与三条限额写成唯一真源;P 层内核按「core 闭包 → 首屏资源 → 其余归组」三步规划,单包超限就再开一个后缀包。
- 本机实跑:main 2960KB / 五个分包 / 总量 7870KB,四条不变量成立;预算收紧到 1500KB 时首屏资源正确回退。
- 与站内小游戏创作平台专题 的分工:那边规划的是平台的分包能力,这边规划的是一个项目自己的分包方案。
分包表定下来,最后一个问题是:这份方案在真实构建里占多少体积、离上限有多近、有多大概率越线。这是下一节的事。
阅读导航:上一节:项目结构与模块边界 · 下一节:构建产物与包体预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。