10.1 资源清单与依赖图
前面九章,《方块冲刺》的所有素材都是「想用就现取」:loadImage('/blocks.png')、new Audio('/bgm.mp3')。小工程这样没问题,但一旦素材上百、还要分场景按需加载,散装路径就会失控——你不知道关卡里到底引用了哪些图,也没法回答「首屏最少要拉多少字节」。这一节先把资源收进一份清单(manifest),再用它算出依赖闭包与预加载顺序。末节给出 Cocos Creator 3.8 的对照。
10.1.1 清单要解决三个问题
在动手写结构之前,先明确清单存在的理由,否则很容易退化成一个没人维护的 JSON:
- 可枚举:给定一个关卡,能列全它用到的每一份素材,而不是靠 grep 源码碰运气。
- 可度量:能算出某个集合的总字节数,用来定「首屏预算」。
- 可排序:图集依赖纹理、Prefab 依赖 SpriteFrame,加载必须有先后,清单要能表达这层关系。
站内 content/posts/game/ 有一千多篇客户端与发行文章,讲的多是引擎里的现成方案;本套书的区别是这份清单由我们自己定义并真跑测试。
10.1.2 清单的数据结构
清单是一张以 UUID 为主键的表,每条记录描述一份资源:
// src/assets/manifest.ts
export type AssetKind = 'image' | 'audio' | 'json' | 'atlas' | 'font';
export interface AssetEntry {
/** 稳定标识,不随目录搬迁而变 */
uuid: string;
/** 人类可读路径,仅用于调试与降级 */
path: string;
kind: AssetKind;
/** 字节数,用于进度加权与预算核算 */
size: number;
/** 直接依赖的 UUID 列表 */
deps: string[];
}
export interface Manifest {
version: number;
entries: Record<string, AssetEntry>;
}
size 与 deps 是这张表的价值所在:前者让进度条能按字节加权(10.2 会用到),后者让加载器知道「拉了 atlas:blocks 就必须连 image:blocks 一起拉」。下面是一份真实的小清单:
{
"version": 1,
"entries": {
"img:blocks": { "uuid": "img:blocks", "path": "textures/blocks.png", "kind": "image", "size": 24576, "deps": [] },
"atlas:blocks":{ "uuid": "atlas:blocks","path": "atlas/blocks.json", "kind": "atlas", "size": 512, "deps": ["img:blocks"] },
"aud:bgm": { "uuid": "aud:bgm", "path": "audio/bgm.mp3", "kind": "audio", "size": 786432,"deps": [] },
"lvl:1": { "uuid": "lvl:1", "path": "levels/level-1.json","kind": "json", "size": 2048, "deps": ["atlas:blocks", "aud:bgm"] }
}
}
10.1.3 路径与 UUID 命名
为什么主键不直接用 path?因为路径会变。策划把 textures/blocks.png 挪到 textures/tiles/blocks.png,如果路径就是主键,所有引用点全部失效。UUID 稳定、路径易变,所以引用一律指向 UUID,路径只作为调试线索与降级兜底。
命名上我们约定前缀式 UUID:img: / aud: / lvl: / atlas:。这样一眼能看出种类,日志里也好筛。真实引擎的 UUID 是 22 位 base64 串(Cocos 的 .meta 里就是这种),可读性差但能防碰撞;我们手工维护,可读性优先,等到第 17 章构建管线再讨论自动生成。
命名还有一条纪律:同一份资源只能有一个 UUID。如果 blocks.png 既被登记为 img:blocks 又被登记为 img:blocks-v2,依赖闭包会把它算两遍,进度条会虚高。构建时应加一条去重校验。
10.1.4 依赖闭包
有了 deps,「加载关卡 1」就变成一个图遍历问题:从根节点出发,收集所有可达节点。这就是依赖闭包:
export function closure(manifest: Manifest, roots: string[]): Set<string> {
const seen = new Set<string>();
const stack = [...roots];
while (stack.length > 0) {
const uuid = stack.pop() as string;
if (seen.has(uuid)) continue;
const entry = manifest.entries[uuid];
if (!entry) {
console.warn(`[mini2d] 清单缺少条目:${uuid}`);
continue;
}
seen.add(uuid);
for (const dep of entry.deps) stack.push(dep);
}
return seen;
}
用迭代加显式栈而不是递归,是为了避免深依赖链把调用栈压爆——素材图深起来几十层并不罕见。函数对缺失条目只告警不抛错:一份过期的清单不该让整个游戏起不来。
算出闭包后,总字节数就是一次求和,这就是「首屏预算」的分子:
export function totalBytes(manifest: Manifest, uuids: Iterable<string>): number {
let sum = 0;
for (const uuid of uuids) {
sum += manifest.entries[uuid]?.size ?? 0;
}
return sum;
}
10.1.5 可预加载集合与拓扑序
闭包告诉你「要拉哪些」,但没告诉你「先拉哪个」。图集依赖纹理,纹理没到位时解析图集只是白等。所以还需要一个拓扑序:被依赖的排在前面。深度优先后序遍历天然给出这个顺序:
export function topoOrder(manifest: Manifest, roots: string[]): string[] {
const need = closure(manifest, roots);
const state = new Map<string, number>(); // 0 未访问 1 访问中 2 已完成
const order: string[] = [];
const visit = (uuid: string): void => {
const s = state.get(uuid) ?? 0;
if (s === 2) return;
if (s === 1) throw new Error(`[mini2d] 检测到依赖环:${uuid}`);
state.set(uuid, 1);
const entry = manifest.entries[uuid];
for (const dep of entry?.deps ?? []) {
if (need.has(dep)) visit(dep);
}
state.set(uuid, 2);
order.push(uuid);
};
for (const root of roots) visit(root);
return order;
}
state 三态是环检测的关键:若访问到「访问中」的节点,说明存在循环依赖(比如 A 依赖 B、B 又依赖 A),直接抛错比默默死循环好。清单里出现环几乎总是人工登记失误,构建期就该拦下来。
有了拓扑序,「可预加载集合」就是拓扑序里 kind === 'image' | 'audio' 的那些——它们没有依赖,可以尽早并发发起;atlas 与 json 必须等依赖到位后再解析:
export function preloadable(order: string[], manifest: Manifest): string[] {
return order.filter((uuid) => {
const kind = manifest.entries[uuid]?.kind;
return kind === 'image' || kind === 'audio' || kind === 'font';
});
}
10.1.6 构建时静态分析
手工维护清单容易漏。第 17 章的构建管线会加一步静态分析:扫源码里所有 loadAsset('img:xxx') 形式的调用,把出现过的 UUID 抽出来,与清单求差集,报「代码引用了清单里没有的资源」和「清单里有但没人用的资源」。前者是死链级错误,后者是体积浪费,两类都值得在 CI 里报警。
这套「UUID 引用 + 构建期对账」和 Cocos 的做法是同构的:Cocos 的 .meta 记录每个资源的 uuid,编辑器在构建时遍历场景与脚本依赖,自动生成 bundle 的内容清单。
10.1.7 清单校验与单测
清单是数据,数据就该有校验。三条最容易出错的规则,写成一次遍历就能查完:
export function validate(manifest: Manifest): string[] {
const problems: string[] = [];
const paths = new Map<string, string>();
for (const [key, entry] of Object.entries(manifest.entries)) {
// 1. 主键必须与 uuid 一致,否则按 uuid 取会落空
if (key !== entry.uuid) problems.push(`${key}: 主键与 uuid 不一致`);
// 2. 路径不得重复,重复意味着同一份资源被登记了两次
const dup = paths.get(entry.path);
if (dup) problems.push(`${entry.path}: 被 ${dup} 与 ${key} 重复登记`);
paths.set(entry.path, key);
// 3. 依赖必须存在
for (const dep of entry.deps) {
if (!manifest.entries[dep]) problems.push(`${key}: 依赖 ${dep} 不存在`);
}
}
return problems;
}
这三条分别对应三类真实事故:主键写错导致「资源明明在清单里却取不到」、路径重复导致进度虚高、依赖缺失导致加载时才报错。把它们放进 CI,比等到线上崩了再查便宜得多。
再补一个单测,把「闭包 + 拓扑序」这对核心函数钉死。用 vitest(第 16 章会系统讲测试,这里先给最小用例):
import { describe, expect, it } from 'vitest';
import { closure, topoOrder, type Manifest } from '../src/assets/manifest';
const m: Manifest = {
version: 1,
entries: {
img: { uuid: 'img', path: 'a.png', kind: 'image', size: 10, deps: [] },
atlas: { uuid: 'atlas', path: 'a.json', kind: 'atlas', size: 1, deps: ['img'] },
lvl: { uuid: 'lvl', path: 'l.json', kind: 'json', size: 1, deps: ['atlas'] },
},
};
describe('依赖图', () => {
it('闭包包含全部传递依赖', () => {
expect([...closure(m, ['lvl'])].sort()).toEqual(['atlas', 'img', 'lvl']);
});
it('拓扑序里依赖先于被依赖者', () => {
const order = topoOrder(m, ['lvl']);
expect(order.indexOf('img')).toBeLessThan(order.indexOf('atlas'));
expect(order.indexOf('atlas')).toBeLessThan(order.indexOf('lvl'));
});
it('依赖成环时抛错', () => {
const cyclic: Manifest = {
version: 1,
entries: {
a: { uuid: 'a', path: 'a', kind: 'json', size: 1, deps: ['b'] },
b: { uuid: 'b', path: 'b', kind: 'json', size: 1, deps: ['a'] },
},
};
expect(() => topoOrder(cyclic, ['a'])).toThrow(/依赖环/);
});
});
三条断言覆盖了三种失败模式:漏依赖、顺序错、成环。这类纯数据结构的测试不需要浏览器,node 环境跑几毫秒,属于「写了就几乎不会失效」的高性价比测试。
10.1.8 Cocos 对照:Bundle 与 resources
Cocos Creator 3.8 里,这套清单由编辑器生成、由 Bundle 承载。cc.d.ts 中 assetManager 是 AssetManager 实例,resources 是一个特殊的预置 Bundle:
import { assetManager, resources, Asset, SpriteFrame } from 'cc';
// 加载一个自定义 Bundle(对应我们按关卡划分的清单)
assetManager.loadBundle('level-1', (err, bundle) => {
if (err) {
console.error(err);
return;
}
// 从 Bundle 内按路径取资源,等价于在清单里按 UUID 取
bundle.load('textures/coin/spriteFrame', SpriteFrame, (e, sf) => {
if (e) return;
// sf 即已就绪的 SpriteFrame
});
});
// resources 是随包发布的默认 Bundle,用法完全一致
resources.load('atlas/blocks', Asset, (err, asset) => {
if (err) return;
console.log(asset.name);
});
对照表:
| 我们的概念 | Cocos 3.8 对应 | cc.d.ts 证据 |
|---|---|---|
Manifest.entries | Bundle 内资源表 | AssetManager.Bundle |
uuid | 资源 uuid(.meta 记录) | Bundle.get(uuid) |
| 路径加载 | bundle.load(path, type, cb) | Bundle.load 重载 |
| 预置清单 | resources | export const resources: AssetManager.Bundle |
| 动态加载清单 | assetManager.loadBundle(name, cb) | AssetManager.loadBundle |
差别在于:Cocos 的清单不是手写的,编辑器在导入资源时自动生成 uuid 与 .meta,构建时再算出 bundle 的依赖与顺序;我们手写清单,是为了把「依赖图」这件事本身讲透——理解了它,再看 bundle.load 就知道背后发生了什么。资源能列全、能度量、能排序之后,下一步就是真正把它们并发加载进来,并给用户一条诚实的进度条。下一节讲异步加载与进度。
阅读导航:上一节:目录 · 下一节:10.2 异步加载、进度与预加载 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。