《Cocos 游戏开发入门》10.1 资源清单与依赖图

把散落的图片、音频、图集收进一份清单:定义 manifest 数据结构与 UUID 命名,用依赖闭包算出「加载一个资源要连带拉哪些」,再做拓扑排序得到可预加载顺序,最后给出 Cocos Creator 3.8 的 Bundle 与 resources 对照。

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.entriesBundle 内资源表AssetManager.Bundle
uuid资源 uuid(.meta 记录)Bundle.get(uuid)
路径加载bundle.load(path, type, cb)Bundle.load 重载
预置清单resourcesexport const resources: AssetManager.Bundle
动态加载清单assetManager.loadBundle(name, cb)AssetManager.loadBundle

差别在于:Cocos 的清单不是手写的,编辑器在导入资源时自动生成 uuid 与 .meta,构建时再算出 bundle 的依赖与顺序;我们手写清单,是为了把「依赖图」这件事本身讲透——理解了它,再看 bundle.load 就知道背后发生了什么。资源能列全、能度量、能排序之后,下一步就是真正把它们并发加载进来,并给用户一条诚实的进度条。下一节讲异步加载与进度。

阅读导航:上一节:目录 · 下一节:10.2 异步加载、进度与预加载 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画