资源加载与 bundle 瘦身
3.1 为什么 3.x 不能随手 load
我第一版把所有资源塞进主 bundle,构建完主包 3.6 MB,加两个场景直接爆了。微信小游戏主包不能超过 4 MB,超了就要走分包或者远程包,第二版我把资源按场景拆开,主包降到 1.1 MB。
先说清 2.x 到 3.x 变了什么。2.x 用 cc.resources.load 和 cc.loader,2.4 引入了 assetManager 替代 loader,3.x 把 loader 这套彻底去掉了。现在能用的入口是 assetManager 这个全局对象,函数都挂在它上面,类型和枚举在 AssetManager 命名空间下。resources 没有消失,它现在是内置的一个 Asset Bundle,只是加载入口从 cc.resources 变成了从 cc 导入的 resources。
这就是我要说的重点:3.x 里加载资源必须先明确"从哪个 bundle 来"。resources.load 只能拿到 resources 目录里的东西,其他任何动态加载都要走 bundle 的 load 系列。
| 需求 | 3.x 入口 | 说明 |
|---|---|---|
| 加载 resources 目录资源 | resources.load(path, Type, cb) |
内置 bundle,路径相对 resources 目录 |
| 加载 bundle 内资源 | bundle.load(path, Type, cb) |
路径相对 bundle 根目录 |
| 批量加载目录 | resources.loadDir / bundle.loadDir |
路径里只能用斜杠 |
| 加载场景 | bundle.loadScene(name, cb) |
只加载不运行,还要 director.runScene |
| 预加载 | resources.preload / bundle.preload |
只下载不反序列化,消耗小 |
| 远程资源 | assetManager.loadRemote(url, opts, cb) |
不走 bundle 体系 |
resources 目录有它的特殊性:它需要在 assets 根目录下手动创建,目录里的所有资源以及它们关联的依赖资源都会被导出。而且文档特别提醒,如果一份资源只是被 resources 里其他资源依赖、并不需要被 resources.load 直接调用,就不要放在 resources 里,这会增大 config.json,还会让那些资源无法在构建时被自动剔除,同时破坏 JSON 的自动合并策略。
这个坑我踩过。有一批纯装饰用的图标只是被订单条目预制体引用着,我顺手放进了 resources,结果 config.json 多出几十 KB,而且构建日志里一直有"未被引用资源"的提示。挪出去之后干净了。
3.2 bundle 的划分原则
自定义 bundle 是以文件夹为单位配置的,在资源管理器里选中文件夹,属性检查器勾「配置为 Bundle」。
我踩过的最大一个坑是划分维度。刚开始我按资源类型分:textures/、prefabs/、audio/ 各一个 bundle。看起来整齐,实际用起来难受,加载一个订单条目预制体,得同时准备 textures 和 prefabs 两个 bundle,依赖关系被切碎了。
改按业务模块分之后顺畅得多:gameplay、level、ui、audio 四个。加载关卡只需要 level 这一个 bundle,它内部的贴图、预制体、场景全都在里面。
划分原则我的理解是这样:bundle 的边界要落在"运行时一起被需要的资源集合"上,而不是"资源长得像"。跨 bundle 引用会被记录成依赖,运行时得保证被依赖的那个先加载。内置 bundle 的优先级就是这么用的,main 是 7,resources 是 8,start-scene 是 20,internal 是 21,构建时按优先级从大到小依次构建。同一个资源被不同优先级的 bundle 引用时,资源优先放在优先级高的 bundle 里,低优先级的只存一条记录,同时依赖高优先级的那个。官方建议自定义 bundle 的优先级不要高于内置的,尽量让共享资源落在内置 bundle 里,这样包体最小。
还有一条硬规则:Asset Bundle 不支持嵌套。A 文件夹里不能再套一个配了 bundle 的 B 文件夹。
bundle 里的脚本会合并成一个 js 文件,运行时加载 bundle 时会加载这个 js。文档提醒不同 bundle 的脚本最好不要互相引用,否则运行时可能找不到对应脚本。真要共享,把类和变量挂到自己的全局命名空间上。
3.3 远程 bundle 与热更新的关系
勾了「配置为远程包」的 bundle,构建后会跑到发布包的 remote 文件夹里,需要自己把整个 remote 目录传到服务器上,然后在构建发布面板填「资源服务器地址」。
微信小游戏这里有个特殊约束:主包体积不能超过 4 MB,包含所有代码和资源,额外的资源必须通过网络请求下载。官方分包加载文档给的完整限制是这么一套数字:
| 限制项 | 上限 |
|---|---|
| 主包 | 4M |
| 主包 + 所有分包总和 | 30M |
| 单个普通分包 | 不限大小 |
| 单个独立分包 | 4M |
普通分包和独立分包的差别在依赖关系上。普通分包加载前主包必须已经就绪,独立分包可以脱离主包单独启动,但它自己也卡在 4M 这个线以内。
Creator 这边的对应物是 Asset Bundle 的「压缩类型」设成「小游戏分包」。构建后这个 bundle 会落到发布包的 subpackages 目录,同时自动写进微信发布包的 game.json 里。有个限制:小游戏分包只能放本地,不能配置为远程包,所以「配置为远程包」这一项是锁住的。
除了包体限制,还有两条环境相关的约束值得记:包内资源在小游戏环境里不是按需加载的,是一次性加载所有包内资源再启动页面;以及不可以从远程服务器下载脚本文件,文档提到 Creator 会把 Asset Bundle 的代码拷贝到 src/bundle-scripts 目录下保证正常加载。
主包放远程有个副作用:首场景加载变慢。解法是勾「初始场景分包」,勾上之后首场景及其依赖资源会被构建到 assets/start-scene 这个内置 bundle 里,这个 bundle 留在本地,引擎启动时自动从本地包内加载。
关于热更新和 bundle 的关系,我得承认这块我没吃透。官方热更新教程的思路是:服务端和本地都保存完整版本资源,靠 Manifest 文件比对差异决定下载哪些文件。它依赖引擎的 AssetsManager 模块,而这个模块只在 native 命名空间下,Web 版本因为能直接从服务器拿文件所以不走这套。3.8 提供了"仅构建 Bundle"功能,可以单独重建某个 bundle 输出到指定路径,设计意图之一就是给热更新用的。
我现在的做法比较笨:远程 bundle 配 MD5 Cache,靠 Creator 的 settings.json 里记录的 Hash 自动对齐版本。如果要手动指定版本 hash,可以这样:
import { assetManager } from 'cc';
assetManager.loadBundle('level', { version: 'fbc07' }, (err, bundle) => {
if (err) {
console.error(err);
return;
}
console.log('load bundle successfully.');
});
传了 version 之后,资源系统会比对版本号和本地缓存,一致就直接用缓存,不一致就重新下载。
3.4 加载失败与"加载了但显示不出来"
先说加载失败。assetManager.loadBundle 的回调第一个参数是 err,非空就是失败。我踩过的失败原因有三个:一是 bundle 名字写错(注意不能叫 internal、resources、main、start-scene 这四个内置名);二是远程包的服务器地址没配,或者 remote 目录没上传;三是路径写法问题,路径里只能用斜杠,反斜杠会直接失效,而且结尾不能带扩展名。
bundle 加载之后资源路径是相对 bundle 根目录的,不是相对 bundle 文件夹。比如 bundle 里有个 textures/background 图集,代码里写 bundle.load('textures/background', SpriteAtlas, cb)。
再说我花时间最久的那类问题:资源明明加载成功了,节点上就是看不见。排查路径我固定走这几步。
第一步,看 err。bundle.load 的 err 参数不检查等于没调试,加载失败的资源是 undefined,后面赋值给 spriteFrame 就是一片空白。
第二步,看类型对不对。图片导入后生成的是 ImageAsset,直接 bundle.load('image') 拿到的是 ImageAsset,不是 SpriteFrame。要拿 SpriteFrame 必须指到子资源路径 image/spriteFrame。同一路径下有多个同名资源时,还得声明类型参数。
第三步,看路径层级。image 是 ImageAsset,image/spriteFrame 是 SpriteFrame,image/texture 是 Texture2D。这三层别搞混。
第四步,看节点有没有被渲染。Canvas 是 2D 渲染的收集入口,所有 2D 渲染元素必须是 RenderRoot2D 的子节点才会被渲染。UI 节点挂在普通 Node 底下,屏幕上什么都没有。
第五步,看尺寸。Sprite 的 size mode 如果是 CUSTOM 而节点尺寸是 0,也是看不见。加载完贴图记得同时给 SpriteFrame 和 Sprite 组件都赋值。
我写了个小工具函数专门干这件事:
import { Bundle, SpriteFrame, Sprite } from 'cc';
export function loadFrame(bundle: Bundle, path: string, sprite: Sprite) {
bundle.load(path, SpriteFrame, (err, frame) => {
if (err) {
console.error('load fail:', path, err);
return;
}
sprite.spriteFrame = frame;
console.log('loaded:', path, frame.originalSize);
});
}
这里补一句我自己踩过的坑:Bundle 是类型,导入时要直接写在 import 语句里。我一开始写成 const { Bundle } = AssetManager; 再拿它当类型注解用,TypeScript 立刻报"Bundle 不能作为类型使用"。类型和运行时对象是两回事,类型只存在于编译期,得用 import type 或者普通 import 拿。
3.5 首包体积控制与加载重试
4 MB 这个数字逼着我重新想资源放在哪。主包里必须有的是:引擎运行时、启动场景和它依赖的东西、以及所有要动态加载的入口资源。其余全部往后放。
我做的一个具体动作是把关卡做成 bundle,而不是全部塞进 main。第一关玩不到的资源不进首包,玩家点了"下一关"才去 load 对应 bundle。这个设计有个前提:bundle 里的场景资源要能被 bundle.loadScene 加载,而且场景里不能引用其他 bundle 的资源,否则运行时加载顺序会出问题。
另一个动作是把 resources 目录清空,只留真正需要 resources.load 的东西,比如结算时读的 JSON 配置和玩家头像占位图。
加载失败我一律加重试。远程包第一次加载失败很常见,网络抖动、CDN 节点刚上线、微信环境首次请求要建连接。直接白屏给玩家看是不能接受的。
import { assetManager, Bundle } from 'cc';
function loadBundleWithRetry(name: string, retries = 2): Promise<Bundle> {
return new Promise((resolve, reject) => {
const attempt = (left: number) => {
assetManager.loadBundle(name, (err, bundle) => {
if (!err) { resolve(bundle!); return; }
if (left <= 0) { reject(err); return; }
console.warn(`load ${name} failed, retry ${left}`);
attempt(left - 1);
});
};
attempt(retries);
});
}
重试次数我定 2 次,也就是最多试 3 遍。再多就没意义了,那是网络彻底不通,用户该去设置里重开。失败之后弹一个"资源加载失败,请检查网络后重试"的按钮,点一下重新走流程。
顺带记住两个跟释放有关的坑。第一,assetManager.removeBundle(bundle) 移除 bundle 时不会释放里面已加载的资源,得先 bundle.releaseAll() 或 bundle.release(path, Type)。第二,release 系列接口不检查引用计数,直接释放,而 addRef/decRef 是自动释放机制。动态加载出来的资源如果打算长期持有,记得 addRef(),不用的时候 decRef() 并把引用置 null。我曾经在场景切换时释放了一批还在用的贴图,结果屏幕上贴图变黑还报 GL 错误。
