15.1 分包策略与首包体
第 14 章把《方块冲刺》构建成了 H5、微信小游戏和原生三个包。H5 端还能忍受一个几 MB 的 index.js,微信小游戏不行:它对主包有 4 MB 的硬上限,一旦超了,构建面板会直接报错,包也传不上去。首包体积因此从一个「优化项」变成了「发布前置条件」。
与站内 game/publish/ 那 167 篇的关系:那些文章讲的是 Steam 发行流程(商店页、愿望单、打包上传);本节讲的是小游戏平台的主包红线与 Bundle 分包,这是站内几乎没人写过的工程侧话题。
本节要回答两个问题:哪些资源必须留在首包、哪些必须踢出去,以及怎么用 Cocos 的 Bundle 机制把它们切开。切完之后,首包只负责「让用户尽快看到第一屏」,其余关卡资源在进入游戏后再按需拉取。
15.1.1 首包红线从哪里来
微信小游戏的限制不是「建议」,而是平台侧的加载模型决定的:用户点开小游戏时,微信先把主包下载并解压,再启动 JS 引擎。主包越大,这段「点开之后什么都看不到」的等待就越长。平台的现行口径是:
| 项 | 上限(参考值,以微信官方文档当前版本为准) | 说明 |
|---|---|---|
| 主包 | 4 MB | 冷启动必须先下载完的部分 |
| 单个分包 | 4 MB | 一个 Bundle 的上限 |
| 分包总量 | 20 MB | 所有分包之和 |
| 首屏可接受等待 | ≤ 1.2 秒(NFR-02) | 这是我们的自定预算,比平台宽松 |
所以「首包体」不是一个可以妥协的数字,而是必须在构建面板里被量出来、并卡在红线以下的硬指标。1.1 里 NFR-03 定的「主包 ≤ 4 MB」,就是这张表的第一行。
15.1.2 划分原则:什么必须进首包
划分的判据只有一条:「从冷启动到玩家看到主菜单,这条路径上必需的资源,才进首包」。其余一律出去。按这条判据把《方块冲刺》的资产过一遍:
| 资源 | 去处 | 理由 |
|---|---|---|
启动场景 main.scene | 首包 | 冷启动第一帧就要用 |
| 主菜单 / 设置 / 结算 UI 图集 | 首包 | 首屏与主路径都要 |
| 中文字体子集(常用 500 字) | 首包 | UI 文案渲染必需,整字库太大 |
| 通用音效(点击、跳跃) | 首包 | 首次交互就可能触发 |
| 关卡障碍物 / 金币贴图 | bundle-levels | 进游戏才用,按关卡懒加载 |
| 背景视差图(多套主题) | bundle-levels | 大图,且只有部分关卡用到 |
关卡配置 levels.json | bundle-levels | 与关卡资源同生命周期 |
| 背景音乐 / 长音效 | bundle-audio | 体积大、可延迟加载 |
一个常见误区是「把整张图集拆开」。图集(Atlas)的价值在于合批:一张图集里的贴图能一次 Draw Call 画完,拆成散图反而增加 Draw Call。所以正确的做法不是拆图,而是按「首包 / 分包」把图集本身分组:首包图集只放主菜单用得到的图,关卡图集整张进 bundle-levels。13.1 讲的合批原则在这里必须被尊重,否则省下的体积会用帧率还回去。
把这条判据落成一句可执行的规则,团队里每个人都该背下来:
- 冷启动到主菜单之间用不到的贴图,一律进 Bundle。
- 冷启动到主菜单之间用不到的音频,一律进 Bundle。
- 脚本永远留在首包,Bundle 之间不互相 import。
- 新增资源先问「首屏用不用」,答不上来就默认进 Bundle。
这四条没有例外。实践里最常见的返工,就是美术同学把一张 2 MB 的背景图直接拖进 assets/textures/,构建时才被发现主包超限——返工成本远高于一开始就丢进 bundle-levels/level-1/textures/。
15.1.3 在编辑器里把目录配成 Bundle
Cocos 的 Bundle 是目录级概念:你把某个目录标记为 Bundle,构建时引擎就把它打成独立分包。下面这几步在编辑器里完成。
以下操作需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
- 在「资源管理器」中选中
assets/bundle-levels/目录(若不存在,右键assets/→ 新建 → 文件夹,命名为bundle-levels)。 - 看右侧「属性检查器」面板,勾选顶部的 「配置为 Bundle」 复选框。
- 勾选后面板会展开一组 Bundle 设置,按下面这张表填:
| 属性 | 取值 | 含义 |
|---|---|---|
| Bundle 名称 | bundle-levels | 运行时 assetManager.loadBundle 用的名字 |
| 优先级 | 1 | 越小越先被合并处理;首包资源优先级为 0 |
| 压缩类型 | Merge Depended Assets | 与依赖合并压缩,体积最小 |
| 是否为远程包 | 先不勾 | 15.2 再改成远程 |
| 配置为 Bundle 的目录 | assets/bundle-levels | 自动带出 |
- 同样对
assets/bundle-audio/重复一次,Bundle 名填bundle-audio。
设置完成后,编辑器会在该目录旁生成 / 更新一个 .meta 文件。下面这段内容需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现——.meta 由编辑器在导入资源时写入,你只能在编辑器里间接看到它:
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "a1b2c3d4-0000-4e5f-8a9b-0c1d2e3f4a5b",
"files": [],
"subMetas": {},
"userData": {
"isBundle": true,
"bundleName": "bundle-levels",
"priority": 1,
"compressionType": "merge_depended_assets",
"isRemoteBundle": false,
"verificationType": "auto"
}
}
userData 里那五个键就是面板上那五个设置项。不要手改 .meta——编辑器在导入时会用自己的内存状态覆盖它,手改的键会在下一次刷新时被冲掉;正确姿势永远是在面板上点。
在编辑器里打开这个工程,你会看到:资源管理器里 bundle-levels 目录图标右下角多了一个小包裹标记;把光标停在目录名上,提示会显示「Bundle: bundle-levels」。这就是「它已经是分包了」的唯一视觉证据。
15.1.4 代码侧:一个可编译的分包加载器
Bundle 配好之后,运行时要靠 assetManager.loadBundle 把它加载进来。下面这个组件把「按名字加载、缓存句柄、释放」封成一个可复用的管理器。这段脚本是 L2(import ... from 'cc'),已对 cc.d.ts 校验,可过 tsc --noEmit:
import { _decorator, Component, assetManager, AssetManager, error, warn } from 'cc';
const { ccclass } = _decorator;
export type BundleName = 'bundle-levels' | 'bundle-audio';
@ccclass('BundleLoader')
export class BundleLoader extends Component {
private readonly loaded = new Map<BundleName, AssetManager.Bundle>();
loadBundle(name: BundleName, onProgress?: (ratio: number) => void): Promise<AssetManager.Bundle> {
const cached = this.loaded.get(name);
if (cached) {
onProgress?.(1);
return Promise.resolve(cached);
}
return new Promise<AssetManager.Bundle>((resolve, reject) => {
assetManager.loadBundle(name, (err, bundle) => {
if (err) {
error(`[block-dash] loadBundle ${name} failed: ${err.message}`);
reject(err);
return;
}
this.loaded.set(name, bundle);
onProgress?.(1);
resolve(bundle);
});
});
}
isLoaded(name: BundleName): boolean {
return this.loaded.has(name);
}
releaseBundle(name: BundleName): void {
const bundle = this.loaded.get(name);
if (!bundle) {
warn(`[block-dash] releaseBundle ${name}: not loaded`);
return;
}
bundle.releaseAll();
assetManager.removeBundle(bundle);
this.loaded.delete(name);
}
}
三个细节值得停一下。第一,loadBundle 的回调签名是 (err: Error | null, data: AssetManager.Bundle),所以 err 判空后 bundle 一定是有效对象,不需要再判一次。第二,AssetManager.Bundle 是命名空间下的类,类型写作 AssetManager.Bundle,不能简写成 Bundle——Bundle 不是 cc 的顶层导出。第三,bundle.releaseAll() 释放包内所有资源,assetManager.removeBundle(bundle) 把包从缓存里摘掉;两个都要调,只调前者会留下一个空壳句柄。
15.1.5 加载关卡资源与进度
有了 Bundle 句柄,加载一个关卡的资源就变成「先拿包、再按相对路径 load」。路径是相对 Bundle 目录、且不带扩展名的:
import { _decorator, Component, assetManager, AssetManager, SpriteFrame, JsonAsset } from 'cc';
const { ccclass } = _decorator;
@ccclass('LevelAssetLoader')
export class LevelAssetLoader extends Component {
async loadLevel(levelId: number): Promise<void> {
const bundle = assetManager.getBundle('bundle-levels');
if (!bundle) {
console.warn('[block-dash] bundle-levels 尚未加载');
return;
}
const dir = `level-${levelId}`;
const [frames, goalX] = await Promise.all([
this.loadFrames(bundle, `${dir}/textures`),
this.loadGoalX(bundle, `${dir}/level`),
]);
console.log(`[block-dash] level ${levelId}: ${frames.length} frames, goalX=${goalX}`);
}
private loadFrames(bundle: AssetManager.Bundle, path: string): Promise<SpriteFrame[]> {
return new Promise((resolve, reject) => {
bundle.loadDir(path, SpriteFrame, (err, assets) => (err ? reject(err) : resolve(assets)));
});
}
private loadGoalX(bundle: AssetManager.Bundle, path: string): Promise<number> {
return new Promise((resolve, reject) => {
bundle.load(path, JsonAsset, (err, asset) => {
if (err) {
reject(err);
return;
}
const raw = asset.json;
const value = raw ? raw['goalX'] : 0;
resolve(typeof value === 'number' ? value : 0);
});
});
}
}
bundle.loadDir(path, type, onComplete) 一次把目录下的同类资源全拉进来,bundle.load(path, type, onComplete) 拉单个。assetManager.getBundle(name) 从缓存里取已加载的 Bundle,取不到返回 null——所以它必须与 15.1.4 的 BundleLoader.loadBundle 配合使用,不能单靠它自己。JsonAsset 的 json 字段是解析后的对象,直接当关卡配置用。8.2 讲过 levels.json 的校验,这里的 loadGoalX 就是它的运行时入口——校验逻辑放在纯 TS 侧,加载放在引擎侧,两边用同一个类型约束。
15.1.6 构建后怎么核对首包体积
Bundle 配完不算完,必须在构建产物上量一遍。H5 与小游戏构建后,产物目录里会出现一个 assets/ 子目录,Bundle 各自成文件夹:
# 微信小游戏构建产物(参考结构,实际以你的构建版本为准)
ls -lh build/wechatgame/
# assets/main/ 首包资源
# assets/bundle-levels/ 分包资源
# assets/bundle-audio/ 分包资源
# cocos-js/ 引擎与项目脚本
# game.js / game.json 小游戏入口
du -sh build/wechatgame/assets/main
du -sh build/wechatgame/assets/bundle-levels
对照 NFR-03 核对:assets/main 加 cocos-js 加入口文件,就是主包体积。这条命令的输出要写进构建日志,作为 14.1 里「每次构建都要记录主包体积」的落点。如果超了 4 MB,优先查三处:有没有美术资源误拖进 assets/ 根目录(会进首包)、字体是不是整字库(应该做子集)、有没有大图没进 Bundle。
15.1.7 三类最常见错误
| 现象 | 原因 | 对策 |
|---|---|---|
| 构建报「主包超限」 | 大图 / 整字库留在首包 | 移进 Bundle,字体做子集 |
| 运行时报「Bundle not found」 | 名字拼错,或目录没勾「配置为 Bundle」 | 名字与 .meta 的 bundleName 逐字对齐 |
| 加载分包时报循环依赖 | Bundle A 的资源引用了 Bundle B 的脚本 | Bundle 之间不允许脚本互相引用,抽到首包 scripts/ |
第三条最容易踩:Bundle 可以互相引用资源,但脚本必须都在首包(或至少在启动时可解析)。BundleLoader 这种通用逻辑必须放 assets/scripts/platform/,而不是放进某个 Bundle。
15.1.8 工程视角的收口
这一节做完,工程里多了三样东西:assets/bundle-levels/ 与 assets/bundle-audio/ 两个被标记为 Bundle 的目录(各带一份 isBundle: true 的 .meta)、一个可编译的 BundleLoader 组件,以及一条「构建后量主包体积」的核对动作。配置项方面,构建面板里的**「主包压缩类型」和「MD5 Cache」**两项会在 15.2 用到,现在先保持默认。
分包把资源从首包里挪走了,但它没解决另一个问题:分包本身也要从某处下载。如果分包跟着主包一起打进小游戏包体,那只是把 4 MB 的红线换成了 20 MB 的总量红线——用户还是得等。真正让首包变小的关键动作,是把分包资源托管到 CDN、让它们「按需、从远端」拉取。下一节就讲远程资源与 CDN 怎么配。
阅读导航:上一节:目录 · 下一节:15.2 远程资源与 CDN 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。