ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CocosCreator大厅+子游戏整合:Asset Bundle资源隔离与动态加载实践

CocosCreator大厅+子游戏整合:Asset Bundle资源隔离与动态加载实践 简介这是一个面向 Cocos Creator 开发者的「大厅子游戏」整合示例项目。它演示了如何以游戏大厅为统一入口承载多个独立子游戏并覆盖独立热更、模块化打包等常见设计形态适合正在开发多玩法合集、网游客户端或学习项目整合思路的开发者参考。资源包为 RAR 压缩格式共 64 个文件。核心类型包括 19 个 js 脚本、12 个 json 配置、12 个 meta 元数据、9 个 png 与 5 个 jpg 素材、3 个 ttf 字体、2 个 fire 场景另有说明文档与批处理工具压缩包整体约 7.09MB。项目结构清晰可对照源码理解大厅导航、场景切换、资源管理、热更新等关键环节。目前已有 941 人学习浏览。通过该 demo读者既能从整体上掌握“多子游戏模块拆分—按需加载—独立热更—整包整合”的完整流程也可利用现成场景和脚本直接改造扩展显著降低从零搭建多游戏集合项目的门槛。1. “大厅子游戏”看着只是多场景实际是资源与代码的隔离问题一开始接到“整合大厅和多个子项目”这个需求时大多数人的第一反应是把所有子游戏场景拖进一个 CocosCreator 工程编译出一个大 App完事。等子游戏数量超过五个、首包超过五十兆、任意一个子游戏改一行代码都要连带整包重出时才会意识到这个标题真正要解决的是什么——一个壳工程怎么优雅地装下多个彼此独立的游戏点到哪个才加载哪个退出时又能干净卸载。这种“大厅子游戏”的结构在休闲合集、运营活动聚合、多玩法客户端里到处都是CocosCreator 下最常见的落地方式就是用 Asset Bundle 做资源与代码隔离。标题里的“笔记 demo”本质上就是一份能跑起来的踩坑记录不是论文式的架构设计。2. 整合方案怎么选场景直切和 Bundle 动态加载差在首包、热更与内存2.1 两条主路线的取舍做“大厅子游戏”整合最朴素的做法是把所有子游戏场景都放在同一个工程里大厅按钮直接director.loadScene()切过去。这个方案在编辑器里点几下就能跑通demo 阶段特别快但它是把“整合”当成了“多场景打包”没有解决项目间隔离的问题。我见过一个小团队用这个方案扛了六个子游戏首包打出来接近 90MB每次改一个子游戏的数值都要整个 App 重新走渠道审核。更头疼的是脚本全部在一个全局作用域里两个子游戏里都定义了GameManager类构建不报错运行起来谁先加载谁生效模块之间互相覆盖查半天才发现是两个同名类撞了。Asset Bundle 方案是把每个子游戏独立成一个 Bundle大厅只负责加载和卸载。两者差异基本集中在三个数字上首包体积、热更粒度、内存峰值。对比维度场景直切方案Asset Bundle 方案首包体积所有子游戏资源全进首包线性增长首包只有大厅子游戏按需下载代码耦合脚本全部进全局作用域同名类互相覆盖各 Bundle 独立构建作用域隔离热更粒度改一个子游戏重出整包只更新对应 Bundle其余不动内存控制资源常驻无法按子游戏维度释放可 release 并 removeBundle内存能回到基线上手成本低编辑器里拖场景即可需要理解加载、挂载、释放的完整时序如果只是三五个轻量小游戏的内部工具原型场景直切完全够用。但凡是面向真实用户的聚合产品Bundle 基本是唯一体面的选择。原因不是性能数据好看而是它给了你“后悔药”子游戏崩了、资源多了、要下架了都只动一个包不用牵连大厅。2.2 为什么 Asset Bundle 成了事实标准CocosCreator 做 Bundle 的初衷就是把“按需加载”和“按需释放”这两个能力作为一等公民开放给开发者。一个工程里可以声明多个 Bundle构建时每个 Bundle 会独立产出资源目录和配置文件运行时大厅先启动子游戏 Bundle 只在用户点击入口时才拉下来。这里有个容易误解的点Bundle 不只是“把资源打个包”它还改变了构建粒度。每个 Bundle 内的脚本会被单独编译进对应的包Bundle 之间不能互相import对方的类只能通过消息或接口通信。这个限制初看很烦实际是帮你强制划清了边界——子游戏开发团队只要不碰大厅的代码就不存在“改坏了别人的模块”这种事情。远程 Bundle 是另一个加分项。把 Bundle 放到 CDN 上客户端启动时只加载大厅子游戏资源走到了才下这样首包可以控制到很小子游戏也能独立发版。这个能力在“多团队并行开发、各自提审”的场景下几乎是刚需。我一般建议第一次做整合的人先别急着上远程包本地工程里先把 Bundle 的加载和释放跑顺再考虑 CDN 和版本管理。远程包涉及的是另一套资源缓存与版本校验逻辑和本地 Bundle 不是一回事混在一起你会分不清是资源没打进去还是网络策略写错了。2.3 一个可复现的目录结构目录结构决定了后续所有代码怎么写。先给一个我常用的起步结构后面所有示例都基于它assets/ ├── bundle_hall/ # 主包大厅逻辑与UI │ ├── scenes/ │ │ └── Hall.scene │ ├── scripts/ │ │ ├── HallController.ts │ │ └── gameConfig.ts # 子游戏清单与Bundle映射 │ └── textures/ │ └── hall_bg.jpg ├── sub_1001/ # 子游戏1 │ ├── scenes/ │ │ └── GameMain.scene │ ├── scripts/ │ │ ├── GameEntry.ts │ │ └── GameConfig.ts │ └── textures/ │ └── game_bg.jpg └── sub_1002/ # 子游戏2 ├── scenes/ │ └── GameMain.scene ├── scripts/ │ └── GameEntry.ts └── textures/三个目录对应三个角色bundle_hall是主包永远跟着 App 走sub_1001和sub_1002分别配置成独立 Bundle。注意我没有把子游戏放到assets/resources下因为resources目录里的东西会全部打进主包那就等于没做隔离。gameConfig.ts是大厅和子游戏之间的映射表类似路由配置。它只存 Bundle 名、场景路径、子游戏 ID 这些元数据不 import 任何子游戏代码。后续每新增一个子游戏大厅只需要在这个文件里加一行配置不用改大厅逻辑这个约定能帮你省掉大量重复接入工作。3. 用 Asset Bundle 把第一个子项目挂进大厅配置、代码与参数3.1 编辑器里的三步配置勾选 Bundle、命名、构建选项CocosCreator 3.x 里配置 Bundle 不写代码纯编辑器操作。选中sub_1001文件夹在属性检查器里找到“配置为 Bundle”开关打开后下面会多出几个输入项Bundle 名称、优先级、压缩类型、是否为远程包。Bundle 名称如果不填默认用文件夹名sub_1001。我建议始终显式写上名字因为后续代码里assetManager.loadBundle()传的是这个名字而不是文件夹名。文件夹可以随便改Bundle 名改了就要同步改代码显式命名能减少一点隐式关联。压缩类型的三个选项“默认”“合并所有 JSON”“子包”对应的是脚本合并策略。起步阶段选“默认”就好它按脚本原本的模块结构产出排查问题最直观等子游戏多了、首包压力大了再切“合并所有 JSON”减小请求数这个属于构建优化不影响业务代码逻辑。优先级字段不调也能跑。它影响的是多个 Bundle 同时存在时的加载顺序单子游戏场景感知不到差异。等做到子游戏里再嵌子游戏的时候再回头研究这个参数现在不用管。构建时还会遇到一个“Bundle 配置”的树形面板里面能看到所有被标记的 Bundle 以及它们的压缩方式。这一步不需要额外操作构建后会自然地在输出目录里看到每个 Bundle 对应的独立文件夹。3.2 大厅侧动态加载子游戏场景的关键代码配置完成后大厅侧的核心代码其实只有两个动作加载 Bundle再加载 Bundle 里的场景。以下是我在项目里用的入口函数注释标明了每个参数的作用// bundle_hall/scripts/loadSubGame.ts import { assetManager, director, SceneAsset } from cc; /** * 进入某个子游戏 * param bundleName Bundle 名称与编辑器里配置的 Bundle Name 一致 * param scenePath 场景在 Bundle 内的相对路径如 scenes/GameMain * param onDone 进入成功后的回调 * param onError 加载失败后的回调不要把错误吞掉 */ export function enterSubGame( bundleName: string, scenePath: string, onDone?: () void, onError?: (msg: string) void ) { // 已加载过的 Bundle 不需要重新 load直接切场景 if (assetManager.getBundle(bundleName)) { director.loadScene(scenePath); onDone?.(); return; } assetManager.loadBundle(bundleName, (err, bundle) { if (err) { onError?.([enterSubGame] Bundle 加载失败: ${err}); return; } bundle.load(scenePath, SceneAsset, (err2, sceneAsset) { if (err2 || !sceneAsset) { onError?.([enterSubGame] 场景加载失败: ${err2}); return; } director.loadScene(sceneAsset.name); onDone?.(); }); }); }这段代码的逻辑是两层加载先assetManager.loadBundle()把 Bundle 的文件拉下来并注册到资源管理器中再用bundle.load()加载 Bundle 内的具体场景资源。第二步的SceneAsset类型参数很关键它告诉引擎你要加载的是场景引擎会连带解析场景里引用到的所有预制体、材质、贴图依赖。director.loadScene(sceneAsset.name)用加载到的场景资源名而不是手写字符串能避免拼写错误。有些场景资源名和文件名不完全一致直接用资源对象拿名字是最稳的。参数上有两个地方需要特别说明。第一个是bundleName它必须和编辑器里配置的 Bundle Name 完全一致大小写不敏感但推荐保持完全一致任何一端写错都会在加载回调里返回 err而且引擎不会提示你“配置里有没有这个 Bundle”只会给你一个笼统的加载失败。第二个是scenePath在 Bundle 内是相对路径别带assets/前缀构建后 Bundle 内部结构会被重排写assets/...反而会找不到。3.3 子游戏侧提供初始化和退出两个入口子游戏侧要做的事情更简单但很容易被忽略。每个子游戏都应该暴露两个纯函数初始化入口和退出清理入口。大厅只管调用不关心子游戏内部逻辑。// sub_1001/scripts/GameEntry.ts /** * 子游戏初始化入口 * param params 大厅传给子游戏的参数如用户ID、活动ID、房间号等 */ export function initSubGame(params: { userId: string; roomId?: string; fromHall?: boolean }) { console.log([SubGame:1001] init, userId${params.userId}, roomId${params.roomId}); // 在这里创建子游戏内的必要单例、监听全局事件、读取子游戏自己的配置 } /** * 子游戏退出清理入口 * 清除定时器、停止音频、移除事件监听把运行现场恢复干净 */ export function clearSubGame() { console.log([SubGame:1001] clear); // 停音频、清定时器、解绑事件监听 }这里有个很重要的约定大厅永远不会直接import子游戏目录下的任何类或函数而是通过一个全局注册表拿到子游戏暴露的接口。因为 Bundle 之间脚本是隔离开的你在大厅代码里import子游戏模块构建时会把子游戏代码一并打进大厅包整个隔离就失效了。我习惯的做法是子游戏在初始化时把自己注册到一个全局对象上比如globalThis.subGameRegistry[1001] { init, clear }大厅通过 ID 查找并调用。这个注册表本身是个约定写在工程公共类型定义里不在任何 Bundle 内部。调用时机上initSubGame放在场景加载完成后下一帧执行。因为场景切换后节点树刚建立立即操作节点可能拿到空的引用setTimeout(fn, 0)或者用director.once(Director.EVENT_AFTER_SCENE_LAUNCH)都能避开这个时序坑。4. 子游戏通信与生命周期事件总线、防连点与清理顺序4.1 用事件总线代替跨 Bundle 的类引用大厅和子游戏之间如果要传数据最常见也最可靠的做法是事件总线。CocosCreator 自带EventTarget可以直接拿来当全局消息中心。它足够轻不需要引入第三方库也可以完美处理“发送方和接收方不在同一个 Bundle”的问题——事件名是字符串天然无耦合。// bundle_hall/scripts/EventBus.ts import { EventTarget } from cc; /** * 全局事件总线 * 所有跨 Bundle 通信都走这里禁止直接引用另一个 Bundle 的类 */ export class EventBus { private static _instance: EventTarget | null null; static get instance(): EventTarget { if (!this._instance) { this._instance new EventTarget(); } return this._instance; } }使用上其实就是在EventTarget的on/off/emit外面包了一层单例。事件名建议带模块前缀比如sub_1001_enter_success、hall_goto_game这样每个子游戏之间不会互相踩事件。曾经有团队不加前缀两个子游戏都用game_start这个事件名大厅收到后调用了两套回调那个问题排查起来真的是玄学。事件总线有个常见误用是把复杂对象直接塞进事件参数里。跨 Bundle 传Node、Texture这类引擎对象会让资源持有关系变得复杂卸载 Bundle 时你会发现对象之间互相引用内存释放不掉。我的约定是事件里只传普通数据如用户 ID、关卡编号、分数这种可序列化的内容引擎对象一律不要跨 Bundle 传。4.2 子游戏挂载与卸载的标准时序挂载和卸载的时序直接决定你会不会翻车。挂载顺序我固定为加载 Bundle → 加载场景 → 切场景 → 场景启动 → 初始化子游戏模块。下面是一段完整流程同时加了防连点标记// bundle_hall/scripts/SubGameManager.ts import { assetManager, director, SceneAsset } from cc; import { EventBus } from ./EventBus; import { enterSubGame } from ./loadSubGame; class SubGameManager { private loadingSet new Setstring(); private currentRunning: string | null null; /** 进入子游戏bundleName 同时作为唯一标识 */ enter(bundleName: string, scenePath: string, params: object) { if (this.loadingSet.has(bundleName)) { console.warn([SubGameManager] ${bundleName} 正在加载中忽略重复点击); return; } this.loadingSet.add(bundleName); enterSubGame( bundleName, scenePath, () { this.loadingSet.delete(bundleName); this.currentRunning bundleName; // 通知子游戏模块执行初始化 EventBus.instance.emit(${bundleName}_init, params); }, (msg) { this.loadingSet.delete(bundleName); console.error([SubGameManager] enter failed: ${msg}); } ); } /** 退出子游戏按清理顺序释放 */ leave() { if (!this.currentRunning) return; const bundleName this.currentRunning; // 1. 通知子游戏做业务清理 EventBus.instance.emit(${bundleName}_clear); // 2. 释放 Bundle 中已加载的资源 const bundle assetManager.getBundle(bundleName); if (bundle) { bundle.releaseAll(); assetManager.removeBundle(bundle); } // 3. 回到大厅场景 this.currentRunning null; director.loadScene(Hall); } } export const subGameManager new SubGameManager();卸载的三步顺序是有讲究的先清理业务音频、定时器、事件监听再释放资源最后切场景。先切场景再释放资源会让子游戏场景里的节点在切换过程中仍然持有资源引用释放操作实际不生效如果先释放资源再切场景场景里正在引用的纹理可能已经被销毁切换瞬间会渲染异常。releaseAll()和removeBundle()是两个不同层级的操作。releaseAll()是释放 Bundle 内加载过的资源引用计数让它们可以被 GC 收集removeBundle()是从资产管理器的 Bundle 表中移除该 Bundle下次再进来会重新走下载或本地加载。只看内存的话两步都要执行只做一步都会留下残留。4.3 命名空间与管理约定防止两个子项目互相踩脚代码隔离不只靠 Bundle 机制物理隔开还得靠一套明确的约定否则子游戏之间还是会在公共区域打架。这里说的公共区域包括全局变量、事件名、节点命名、Prefab 名称。我常用的办法是强制每个子游戏使用独立前缀。比如子游戏 1001 的全局注册对象叫subGameRegistry[1001]它的节点名字都以g1001_开头事件名都以sub_1001_开头连编辑器里的资源目录都保持同名。这个习惯一开始有点繁琐但子游戏数量超过四个以后它帮你省下的排查时间远超投入成本。公共代码的抽取边界要格外小心。大厅里往往有网络请求、用户中心、日志上报这些公共模块子游戏也想要。一旦把公共模块复制到子游戏目录里就会出现两份网络层代码行为可能不一致。正确做法是公共模块只放在大厅包子游戏通过事件总线请求数据如果公共模块太庞大就单独拆一个commonBundle所有子游戏都依赖它但同样不直接 import 类而是通过注册表拿接口。5. 整合项目避坑实录五个高频问题从现象到根源5.1 卸载 Bundle 后内存纹丝不动甚至二次进入越来越卡现象调用releaseAll()和removeBundle()后在大厅里观察内存曲线数值没有回落回到子游戏再退出内存又涨一层两三次后明显卡顿。原因某个对象持有子游戏场景里的节点或资源引用最常见的是事件总线里的监听没移除。大厅侧在进入子游戏时往EventBus.instance挂了sub_1001_xxx的监听离开时漏了off这个回调闭包里抓到子游戏节点节点又引用贴图整条引用链全被保活Bundle 释放不掉。解决退出流程里把所有on对应的off成对清掉并且统一在leave()的第一行播发清理事件前执行。排查时可以打开浏览器 DevTools 的 Memory 面板在退出子游戏后打一个 Heap Snapshot用 CtrlF 搜索子游戏场景的类名或节点名如果还能搜到大量实例就顺着引用链往上找是谁持有它。这一步见得多了之后我都是先查事件监听命中率最高。5.2 本地运行正常构建后子游戏场景路径找不到现象编辑器里点“浏览器预览”一切正常打包后在真机上点击子游戏入口控制台报Scene asset is missing或者load failed代码没有任何改动。原因编辑器预览时资源路径按assets/目录映射构建后的 Bundle 内部结构被重新整理场景文件可能挪了层级如果你的scenePath写成assets/sub_1001/scenes/GameMain预览环境能解析构建后就解析不到。解决构建产物落地后直接去build/web-mobile/sub_1001/下看实际目录结构以构建产物里的相对路径为准改scenePath。同时不要依赖文件名大小写构建工具不会帮你纠正大小写不一致。踩过这个坑之后我在每次构建脚本里加了一步自动遍历 Bundle 目录输出所有场景文件的相对路径拿到手直接贴到配置里彻底告别手写。5.3 两个子游戏同时存在时资源互相锁死现象子游戏 A 和 B 都加载过后先退出 A内存下降不明显再退出 BA 的资源也开始释放B 又残留一部分。两个 Bundle 谁也没法独立回收。原因两个子游戏在场景里直接引用了同一个公共预制体或者预制体里的贴图来自彼此的 Bundle。构建时这两个资源被打进其中一个 Bundle另一个 Bundle 运行时跨包持有资源各自的 release 都受对方引用计数牵制。解决公共资源往上提放到大厅包或者单独的commonBundle 里子游戏目录里只放完全自己的资源。这里要补一句资源依赖不是静态代码能完全约束的预制体嵌套时容易悄悄引入跨包依赖。构建日志里有个“依赖图预览”在打包前过一眼凡是子游戏目录引用了另一个 Bundle 的资源的直接拉进重做名单这是个硬规矩商量余地越小越好。5.4 连点入口导致子游戏被加载两次场景闪现后重置现象玩家双击大厅里的子游戏入口子游戏场景闪一下又回到大厅或者场景里出现两份初始化数据。原因二次点击时第一个加载回调还没执行assetManager.getBundle返回为空于是又走了一次loadBundle。两次回调先后触发后一次director.loadScene顶替了前一次前一次的场景初始化也没执行完。解决用加载锁也就是第 4 章代码里的loadingSet。进enter时先检查 Set如果 Bundle 正在加载就直接return完成后从 Set 删除。这个防连点逻辑必须放在界面按钮的点击回调之外放在SubGameManager.enter的入口处否则每个界面的按钮都要单独写一遍总会漏一两个。5.5 返回大厅后子游戏音频还在循环现象子游戏里的背景音乐在返回大厅后继续播放几个子游戏切一圈后大厅里同时响着三四段 BGM。原因AudioSource 挂在一个不随子游戏场景卸载的节点上最常见的是挂在子游戏单例持有的常驻节点上或者用了resources里加载的音频资源但没在退出时调用stop。场景卸载不会主动停止音频播放只要节点不销毁声音就一直在。解决退出清理里显式调用音频停止接口不要指望场景销毁自动处理。子游戏入口脚本维护一个audioList每次播放都记录来源clear()时遍历stop并释放资源。这里我吃过一次亏想偷懒只停背景音乐没停音效结果一个短音效在循环播放查半天。血的教训清理音频必须枚举不能只处理你以为会响的那一路。6. 从 demo 到可交付验证脚本、内存检查与起步顺序6.1 用构建产物校验脚本确认每个 Bundle 没有漏配每次构建完成后我都会跑一遍校验脚本确认所有子游戏 Bundle 都正确产出。这个脚本不该等到测试阶段才介入应该挂在构建流程里失败就中断发版// 校验构建产物中每个 Bundle 的 config.json 是否存在且非空 const fs require(fs); const path require(path); const buildDir process.argv[2]; // 例如 build/web-mobile const bundleDirs fs.readdirSync(buildDir).filter(name { const p path.join(buildDir, name); return fs.statSync(p).isDirectory() name ! src name ! assets; }); if (bundleDirs.length 0) { console.error([FAIL] 未找到任何 Bundle 目录); process.exit(1); } for (const name of bundleDirs) { const configPath path.join(buildDir, name, config.json); if (!fs.existsSync(configPath)) { console.error([FAIL] ${name} 缺少 config.json); process.exitCode 1; continue; } const size fs.statSync(configPath).size; console.log([OK] ${name} config.json ${size} bytes); if (size 1024) { console.warn([WARN] ${name} config.json 偏小检查资源是否漏打进 bundle); } }buildDir通过命令行参数传入脚本只做存在性和体量检查不会误判资源内容。校验跑一次只要几秒钟但能拦住“忘了勾选 Bundle 配置”“文件夹重命名后构建产物路径漂移”这两类最基础的发布事故。6.2 内存验证三个快照看出资源有没有漏释放资源释放是否干净不能用“看起来不卡”来验证要用快照对比。做法是在浏览器运行工程打开 DevTools 的 Memory 面板打一个快照 A进入子游戏再退出打一个快照 B再次进入再退出打一个快照 C。对比 A 和 B如果 Texture、Scene、SpriteFrame 的实例数量基本一致说明第一次退出就把资源清干净了。对比 B 和 C如果数量明显高于第一次循环说明有累积泄漏回去查事件监听和常驻引用。这套验证必须在真机和浏览器各跑一遍浏览器和真机的资源回收策略有差异只在一端验证不够保险。CocosCreator 运行时自带的内存统计面板也能看到整体数值作为快速观察够用但定位具体对象还得靠快照搜索。我的习惯是每做一个新子游戏接入至少跑一遍这个三次快照流程通过后再进测试流程不然问题全堆给测试阶段定位成本翻倍。6.3 我的落地顺序从最小闭环开始别急着造框架最后说说我踩完这些坑之后的递进顺序。第一步只做大厅加一个子游戏Bundle 能加载能退出内存快照达标这一步不走完不开始第二个。第二步加入第二个子游戏验证事件总线的隔离性确认公共资源的归属。第三步才考虑远程包和热更版本管理这时候再去查 CDN 缓存策略、Bundle 版本号对不对都有前两步的基础兜底。框架层面的东西我从来一次不做满。事件总线、防连点、命名规范这些都是被实际需求逼着加出来的不是为了架构好看提前铺。原因很简单整合项目的复杂度来自子游戏之间的边界这个边界在第一个子游戏接入时根本不明显只有第二个、第三个进来你才看得到真正的冲突点过早抽象只会设计出无人使用的公共模块。早期我不信邪硬要在大厅里塞一套完整的微前端式管理框架结果子游戏接进来之前先写了两周框架代码子游戏接入后推翻了一半设计。后来学乖了demo 阶段能跑完全流程就够了结构跟着需求长。这个工作路径不一定最优但至少每一步的投入都落在能看见的产出上。希望帮到你。本文还有配套的精品资源点击获取
返回列表