《Cocos 游戏开发实战》4.1 资源目录与 Bundle

资源管线决定首包多大、加载多快。本节讲清 assets/ 的目录分层、resources 目录的特殊性、Bundle 到底是什么(内置 main/resources/internal/start-scene 与自定义 Bundle 的区别)、怎么把目录配置成 bundle-levels 分包、Bundle 优先级与依赖,以及代码里怎么拿到 Bundle,最后给出《方块冲刺》首包与分包的具体划分。

4.1 资源目录与 Bundle

前三章我们把组件体系搭起来了,但组件引用的贴图、预制体、配置表还散落在 assets/ 里。资源管线决定三件事:首包多大、加载多快、能不能热更。这一节把 Cocos Creator 3.8 的资源组织方式讲清楚,它是「首包只含启动场景与主 UI,关卡资源进 bundle-levels 分包」这条约束的实现基础。

与站内 game/client/ 那批工程文不同,那边讲的是「资源放哪」,这里讲的是引擎凭什么知道资源在哪——答案藏在每个目录旁的 .meta 文件里,而 Bundle 只是 .meta 上的一个开关。

4.1.1 assets/ 目录分层

卷二从 1.2 就定下 assets/ 的分层,资源按用途进不同目录:

assets/
  scenes/      场景文件(main.scene、level-01.scene)
  prefabs/     预制体(player.prefab、coin.prefab、obstacle.prefab)
  textures/    图片与图集(player.png、coin.png、bg/)
  audio/       音频(jump.mp3、coin.wav、bgm.mp3)
  configs/     配置表(levels.json、items.json)
  scripts/     脚本(core/ game/ ui/ platform/)

分层的价值不在「整齐」,而在它能直接映射成 Bundle。configs/ 独立成目录,就是为了一步把它配成 bundle-levels;textures/ 再按 bg/ 细分,是为了让图集能按关卡打包。目录一乱,Bundle 就切不干净。

目录内容是否进首包
scenes/启动场景 main.scene是(主场景)
prefabs/通用预制体(玩家、金币、障碍)是(首关要用)
textures/UI 图、首关贴图部分(UI 进首包)
audio/音效、BGM否(进 bundle-levels)
configs/关卡与道具表否(进 bundle-levels)
scripts/全部脚本是(脚本单独打包)

脚本目录 scripts/ 在构建时的处理和其他资源不同:脚本会被合并进 index.js(或按 Bundle 分多个),不进资源清单。所以 assets/scripts/ 不要配成 Bundle——脚本的拆分由构建面板的脚本包选项决定,和资源 Bundle 是两套机制。卷二把 scripts/ 留在 main 里,靠 core/(纯逻辑)与 game/(玩法)在构建时一起打进首包。

另一个容易忽略的点:textures/ 与 audio/ 里的每个文件旁边都有一个同名 .meta,记录它的压缩参数、是否打图集、原始尺寸。移动资源要用编辑器拖,不要用系统文件管理器 mv——直接 mv 会让 .meta 里的 UUID 与资源对不上,所有引用它的预制体都会丢失贴图。这条在第 2 章讲 .meta 时展开过,放到资源管线的语境里再强调一次,因为它是资源重构时最高频的事故。

4.1.2 resources 目录的特殊性

assets/ 下有一个目录名是保留字:resources/。只有放在它下面的资源,才能用 resources.load('configs/levels') 这种不带 Bundle 名的路径动态加载。放在别处的资源,要么被场景/预制体静态引用(拖拽槽),要么必须先 loadBundle 再 bundle.load。

卷二刻意不用 resources/,原因有二:

  1. resources/ 下的所有资源会被无条件打进首包。放一张 2 MB 的背景图进去,首包就多 2 MB,哪怕游戏里根本没用。
  2. resources/ 破坏了 4.1.1 的分层——它把「动态加载」和「首包」绑死了。

替代方案是把要动态加载的资源全部放进自定义 Bundle,用 bundle.load 加载。代价是每次多一步 loadBundle,收益是首包可控。这是卷二的核心权衡之一。

4.1.3 Bundle 到底是什么

一个 Bundle 就是一组资源 + 一份索引。构建时 Cocos 把同一个 Bundle 里的所有资源打成一个包,生成一份 config.json(资源清单)和若干资源文件。运行时 loadBundle 加载的就是这个包的索引与资源。

引擎内置了四个 Bundle,名字固定:

内置 Bundle内容何时加载
main主场景、被主场景静态引用的资源启动时
resourcesassets/resources/ 下的全部资源启动时
internal引擎内置资源(默认字体、默认贴图)启动时
start-scene启动场景的独立包(可选)启动时

自定义 Bundle 则是开发者把某个目录「配置为 Bundle」后产生的,可以指定名字、优先级、是否远程。bundle-levels 就是这样一个自定义 Bundle。

构建之后,每个 Bundle 在输出目录里都是一个独立子目录,结构大致如下:

build/web-mobile/assets/
  main/
    index.js          # 该 Bundle 的脚本
    config.json       # 资源清单:uuid -> 路径映射
    0c/0c3f...png     # 资源文件(按 uuid 前两位分桶)
  bundle-levels/
    config.json
    import/...
  internal/
    config.json

config.json 是 Bundle 的索引:它把资源路径映射到 uuid 与文件位置。loadBundle 本质上就是下载并解析这份索引,之后 bundle.load('stages/level-01') 才能通过索引找到真正的文件。理解这一点,就能理解为什么跨 Bundle 的静态引用会撑大首包——被引用的资源必须跟着引用方一起进包,否则索引里找不到。

4.1.4 配置自定义 Bundle(需编辑器,不可命令行复现)

本节涉及 .meta 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 步骤:

  1. 资源管理器里选中 assets/configs/ 目录。
  2. 属性检查器勾选「配置为 Bundle」。
  3. 把 Bundle 名称 填成 bundle-levels。
  4. 优先级 填 1,压缩类型 选 合并依赖,配置远程 Bundle 不勾。
  5. 保存。

操作后 assets/configs.meta 里会出现 Bundle 配置:

{
  "ver": "1.2.0",
  "isBundle": true,
  "bundleName": "bundle-levels",
  "priority": 1,
  "compressionType": {},
  "optimizeHotUpdate": {},
  "isRemoteBundle": false
}

打开工程会看到什么:资源管理器里 configs 目录图标右下角多了一个小方块角标,表示它是 Bundle 根;同级 scenes/、prefabs/ 没有这个角标。构建后 build/<平台>/assets/ 下会多出 bundle-levels/ 目录,里面是 config.json 加打包好的资源。

isBundle: true 是开关,bundleName 是运行时 loadBundle 用的名字(不是目录名),priority 决定加载顺序,isRemoteBundle 为真时资源从远程服务器拉取(见 4.3 远程资源与热更 )。

4.1.5 优先级与依赖

多个 Bundle 同时加载时,priority 数值大的先加载。卷二里 bundle-levels 设 1,bundle-audio 设 0,于是关卡资源优先于音频就绪——因为关卡没加载完游戏根本进不去,音频晚半秒无伤大雅。

Bundle 之间还有依赖:bundle-levels 里的预制体引用了 textures/ 里的图集,如果图集不在同一个 Bundle,构建时会被提升到共享依赖里。依赖越跨 Bundle,首包越容易被意外撑大。所以卷二的原则是:一个 Bundle 尽量自包含,bundle-levels 里连它要用的贴图、音效一起放。

4.1.6 代码里拿到 Bundle

运行时第一步是 assetManager.loadBundle(name, callback),拿到 AssetManager.Bundle 实例后才能 load 里面的资源:

import { assetManager, AssetManager, Component, _decorator } from 'cc';
const { ccclass } = _decorator;

@ccclass('BundleLoader')
export class BundleLoader extends Component {
  private bundle: AssetManager.Bundle | null = null;

  onLoad(): void {
    assetManager.loadBundle('bundle-levels', (err, bundle) => {
      if (err) { console.error(err.message); return; }
      this.bundle = bundle;
      console.log('base:', bundle.base, 'deps:', bundle.deps.length);
    });
  }
}

已经加载过的 Bundle 用 assetManager.getBundle('bundle-levels') 同步取回,避免重复 loadBundle:

const cached = assetManager.getBundle('bundle-levels');
if (cached) {
  // 已经加载过,直接用
}

bundle.base 是该 Bundle 的资源根路径(本地是相对 URL,远程是服务器地址),bundle.deps 是它依赖的其他 Bundle 名列表。这两个字段在排查「资源 404」时特别有用——base 拼上资源路径就是最终请求 URL。

loadBundle 也支持带进度回调的重载,用来驱动加载条:

import { assetManager, Component, _decorator } from 'cc';
const { ccclass } = _decorator;

@ccclass('BootLoader')
export class BootLoader extends Component {
  loadWithProgress(): void {
    assetManager.loadBundle('bundle-levels', {
      onProgress: (finished: number, total: number) => {
        console.log(`bundle ${finished}/${total}`);
      },
    }, (err, bundle) => {
      if (err) { console.error(err.message); return; }
      console.log('loaded:', bundle.name);
    });
  }
}

注意 onProgress 的进度是「已完成的子任务数 / 总子任务数」,不是字节数——一个 Bundle 的索引加资源可能是几十个请求,进度会跳着走。要做平滑的加载条,得把「Bundle 加载」和「资源预加载」两段进度加权合并,第 5 章讲启动流程时再展开。

4.1.7 《方块冲刺》的分包方案

把上面的规则落到具体工程,卷二的分包方案如下:

包包含大小目标加载时机
main(首包)启动场景、主 UI、玩家预制体、UI 图集≤ 4 MB启动
bundle-levels关卡配置表、障碍与金币预制体、关卡贴图、关卡音效按需进关前
bundle-audioBGM、通用音效池按需主菜单后

首包只放「不加载就进不去游戏」的东西:启动场景、主 UI、玩家。关卡资源全部进 bundle-levels,进第一关前加载。这样即使有 50 关,首包也还是那 4 MB。具体的分包压缩配置与首包体积优化,第 15 章会展开。

进关时的加载逻辑集中在一个 LevelLoader 里,它先确保 Bundle 就绪,再读关卡配置:

import { assetManager, AssetManager, JsonAsset, Component, _decorator } from 'cc';
const { ccclass } = _decorator;

@ccclass('LevelLoader')
export class LevelLoader extends Component {
  private bundle: AssetManager.Bundle | null = null;

  async ensureBundle(): Promise<AssetManager.Bundle> {
    if (this.bundle) { return this.bundle; }
    const cached = assetManager.getBundle('bundle-levels');
    if (cached) { this.bundle = cached; return cached; }
    return new Promise((resolve, reject) => {
      assetManager.loadBundle('bundle-levels', (err, bundle) => {
        if (err) { reject(err); return; }
        this.bundle = bundle;
        resolve(bundle);
      });
    });
  }

  async loadLevelConfig(name: string): Promise<unknown> {
    const bundle = await this.ensureBundle();
    return new Promise((resolve, reject) => {
      bundle.load(`stages/${name}`, JsonAsset, (err, asset) => {
        if (err) { reject(err); return; }
        resolve(asset.json);
      });
    });
  }
}

这里把回调式 API 包成了 Promise,方便用 async / await 串起「加载 Bundle → 读配置 → 建关卡」三步。assetManager.getBundle 的同步短路避免了重复加载,这是 4.1.6 提到的「已经加载过就用缓存」的实际用法。

4.1.8 资源路径与 URL 规则

Bundle 内的资源路径是相对 Bundle 根的路径,不带扩展名。assets/configs/levels.json 在 bundle-levels 里的路径就是 levels;assets/configs/stages/level-01.json 是 stages/level-01。

场景写法
同 Bundle 内资源bundle.load('stages/level-01', JsonAsset, cb)
resources/ 内资源resources.load('configs/levels', JsonAsset, cb)
跨 Bundle 资源先 loadBundle('bundle-x') 再 bundle.load(...)
远程单文件assetManager.loadRemote<Texture2D>(url, cb)

三条规则:

  • 路径不带扩展名,引擎按类型找文件。写 'levels.json' 会找不到。
  • 路径大小写敏感。在 macOS 上开发(文件系统大小写不敏感)、在 Android/iOS 上打包(敏感)时最容易暴露,同一份代码在编辑器里能跑、上真机就 404。
  • Bundle 名与目录名解耦。目录叫 configs,Bundle 名叫 bundle-levels,loadBundle 用的是后者。改目录名不会影响运行时,改 bundleName 才会。

4.1.9 资源管线的常见错误清单

症状原因修法
loadBundle 回调 err 非空Bundle 名拼错,或目录没勾「配置为 Bundle」核对 .meta 的 bundleName
bundle.load 找不到资源路径带了扩展名,或大小写不符去扩展名,逐段核对目录
首包莫名变大资源放进了 resources/,或被主场景静态引用移入自定义 Bundle
预制体丢贴图用系统文件管理器移动了资源,.meta 失联用编辑器拖拽移动
远程 Bundle 404isRemoteBundle 为真但没配服务器地址见 4.3 远程资源与热更

下一节讲怎么把这些 Bundle 里的资源真正加载进内存,以及引用计数怎么决定它们什么时候被释放。

阅读导航:上一节:目录 · 下一节:4.2 动态加载与引用计数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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