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/,原因有二:
resources/下的所有资源会被无条件打进首包。放一张 2 MB 的背景图进去,首包就多 2 MB,哪怕游戏里根本没用。resources/破坏了 4.1.1 的分层——它把「动态加载」和「首包」绑死了。
替代方案是把要动态加载的资源全部放进自定义 Bundle,用 bundle.load 加载。代价是每次多一步 loadBundle,收益是首包可控。这是卷二的核心权衡之一。
4.1.3 Bundle 到底是什么
一个 Bundle 就是一组资源 + 一份索引。构建时 Cocos 把同一个 Bundle 里的所有资源打成一个包,生成一份 config.json(资源清单)和若干资源文件。运行时 loadBundle 加载的就是这个包的索引与资源。
引擎内置了四个 Bundle,名字固定:
| 内置 Bundle | 内容 | 何时加载 |
|---|---|---|
main | 主场景、被主场景静态引用的资源 | 启动时 |
resources | assets/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 编辑器打开,本节不可命令行复现。 步骤:
- 资源管理器里选中
assets/configs/目录。 - 属性检查器勾选「配置为 Bundle」。
- 把
Bundle 名称填成bundle-levels。 优先级填1,压缩类型选合并依赖,配置远程 Bundle不勾。- 保存。
操作后 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-audio | BGM、通用音效池 | 按需 | 主菜单后 |
首包只放「不加载就进不去游戏」的东西:启动场景、主 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 404 | isRemoteBundle 为真但没配服务器地址 | 见 4.3 远程资源与热更 |
下一节讲怎么把这些 Bundle 里的资源真正加载进内存,以及引用计数怎么决定它们什么时候被释放。
阅读导航:上一节:目录 · 下一节:4.2 动态加载与引用计数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。