《Cocos 游戏开发实战》15.1 分包策略与首包体

微信小游戏给主包划了 4 MB 红线,首包多塞一张背景图就可能上不了架。本节把《方块冲刺》的资源按「首包只留启动场景与主 UI、关卡资源进 bundle-levels」切开,给出编辑器里配置 Bundle 的完整步骤与 .meta 内容,并写出一个能过 tsc 的分包加载管理器。

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.jsonbundle-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 编辑器打开,本节不可命令行复现:

  1. 在「资源管理器」中选中 assets/bundle-levels/ 目录(若不存在,右键 assets/ → 新建 → 文件夹,命名为 bundle-levels)。
  2. 看右侧「属性检查器」面板,勾选顶部的 「配置为 Bundle」 复选框。
  3. 勾选后面板会展开一组 Bundle 设置,按下面这张表填:
属性取值含义
Bundle 名称bundle-levels运行时 assetManager.loadBundle 用的名字
优先级1越小越先被合并处理;首包资源优先级为 0
压缩类型Merge Depended Assets与依赖合并压缩,体积最小
是否为远程包先不勾15.2 再改成远程
配置为 Bundle 的目录assets/bundle-levels自动带出
  1. 同样对 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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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