CocosCreator大厅子游戏整合:Asset Bundle动态加载与避坑指南

发布时间:2026/10/7 12:49:22
CocosCreator大厅子游戏整合:Asset Bundle动态加载与避坑指南 简介这是一份围绕Cocos Creator打造“大厅多个子游戏”整合模式的实战笔记demo适合需要搭建多玩法入口、独立热更子游戏或模块化游戏项目的开发者参考。压缩包共64个文件以js脚本为主辅以json配置、meta资源描述、fire场景文件、png/jpg图片素材、ttf字体及bat工具脚本整体仅7.09MB便于快速下载与对照学习。已有943人学习下载。资源演示了大厅作为子游戏入口的设计思路覆盖场景切换、事件监听、资源动态加载、独立子游戏热更新、性能优化与打包发布等关键环节目录按大厅、子游戏及工具模块划分配合Readme阅读可更清晰理解整包集成与独立更新两种模式的区别。适合希望从零搭建或重构多游戏整合框架的Cocos Creator开发者作为梳理流程、调试排错和发布部署的参照。1. CocosCreator大厅子游戏整合先解决怎么把多个项目装进一个App做休闲游戏或互动内容集的人大概率会在某一天收到这样的需求手里已经有三五个独立跑好的小玩法突然要合成一个“大厅 子游戏”的 App或者你想做一个类似内置小游戏集的产品主界面是两排卡片点进去玩一局退出还能回到大厅。CocosCreator 里最省事的做法是把所有场景和资源塞进同一个主包但子游戏一多主场景越来越重首屏冷启动越来越慢任何一个子游戏的小改动都要全量发版成本很快失控。这篇笔记不是一个泛泛的教程而是顺着一个 CocosCreator 大厅子游戏 demo 的项目整合路径走一遍怎么按 Bundle 拆分大厅和多个子项目、运行时动态加载、退出卸载、事件通信以及不跑一遍根本想不到的坑。适合准备把现有单场景工程改造成多子游戏架构的开发者看。2. 架构选型为什么“大厅 子项目”不是把多个项目硬塞进一个包2.1 Asset Bundle 是整合的基石先搞清它是谁Asset Bundle 是 CocosCreator 做资源按需加载的单元。每个 Bundle 在被构建后会生成独立的资源清单和配置运行时通过assetManager.loadBundle加载。做大厅 子游戏的项目整合本质就是把子游戏相关的场景、预制体、贴图、声音、脚本代码全部收进各自独立的 Bundle 中大厅只保留最精简的主包资源。这样首包体积不会跟着子游戏数量涨进入子游戏时才拉取对应包体退出后还能释放内存。在工程里配置 Bundle 其实很简单对一个文件夹右键选择“配置为 Bundle”然后在属性检查器里给它起名。名字很关键代码里loadBundle(subgame1)用的就是这个名字。CocosCreator 在构建时会把这个文件夹里的资源按依赖关系抽离到独立的 Bundle 目录同时生成一份 Json 记录资源 uuid、路径和依赖表。这里有个容易忽略的点Bundle 不光是美术资源脚本代码也会跟着走。正因为 Bundle 连代码都能带你才能做到“子游戏的逻辑只在你进入子游戏时才加载”。CocosCreator 的 Bundle 和纯 C 或其他传统前端里的模块加载不太一样它有自己的一套依赖追踪和释放策略。你在编辑器里看到的了一个文件夹构建后可能被拆成多个依赖层级这也是为什么要强调“边界”这件事。提示Bundle 不是热更新专属机制。不配远程服务器它也能作为本地按需加载的模块使用。demo 阶段可以完全跑在本地后面接热更只需把 Bundle 的版本和远程地址配好即可。2.2 子游戏的边界哪些该进自己的 Bundle哪些必须共享一个干净的子游戏 Bundle 边界应该是自包含的子游戏专属的场景、脚本、UI、交互循环都放在自己的 Bundle 里公共资源比如大厅风格、通用按钮、工具函数库放在一个公共 Bundle 或主包。如果子游戏 A 的资源被子游戏 B 引用要么把这两段资源收进公共包要么接受重复打包别在运行时尝试去另一个 Bundle 里找资源那样会引入加载顺序依赖非常容易踩到“资源找不到”的问题。对于“大厅 子游戏”这种常见玩法我习惯这样划边界大厅本身一个常驻场景直属大厅的 UI、通用组件放到一个 Common Bundle或者直接保留在主包里。每个子游戏一个独立 Bundle入口是一个全屏 Prefab 或者一个独立子场景。前者更方便因为大厅节点不用被切换掉后者适合子游戏内部自带多个场景的玩法。子游戏依赖的公共数据、SDK 对接层、网络请求等抽到commonBundle 并让多个子游戏依赖。不要在每个子游戏里分别复制一份否则包体和代码量会翻倍。还有一条经验是越早约定“Bundle 不能跨引用”越好。编辑器里拖拽资源非常顺手大厅预制体上随手引了一个子游戏的贴图构建时这张贴图就会被算进主包依赖主包就被撑大了。这种错误构建时不报错只在后面看包体占比时才吓你一跳。后面避坑章会专门再讲。2.3 对比整包方案为项目整合付出架构成本值不值整包和 Bundle 方式最直观的区别可以用下面这个表来看对比维度整包集成大厅 子游戏 Bundle首包体积会随子游戏数量线性增大首包只包含大厅公共部分首次加载冷屏要等全部资源只等大厅子游戏进入时再加载发版效率改个子游戏也要全量发版子游戏 Bundle 可以独立更新内存压力所有子场景资源常驻退出子游戏时手动释放工程复杂度低需要处理加载顺序和生命周期结论很直接如果只是两三个休闲玩法的教学 demo整包来得省心但只要有“多个子项目后续还会继续加”的情况建议首次就把 Bundle 架构立起来。项目整合越晚做拆分成本越高。我见过很多团队一开始用整包顶着后来游戏多了想改大厅子游戏架构结果要动所有场景引用关系几乎没有后悔药。等架构定了我们再来看 demo 怎么落地。实际跑代码之前先把几种加载模式区分开大厅直接通过loadBundle按需拉包是最常规的做法如果子游戏之间有切换比如从子游戏 A 直接进入子游戏 B大厅要先把 A 的资源释放再加载 B。这个顺序控制好了整个项目整合就算成了八成。3. 搭一个最小 demo在大厅里动态加载子游戏3.1 demo 工程结构三块东西不要混在我写代码前先把工程分层摆出来。假设一个最简单的演示有大厅和两个子游戏assets/ hall/ // 大厅主场景和 UI scenes/ Hall.scene scripts/ HallManager.ts subgames/ sub1/ // 子游戏1这个 Bundle prefab/ SubGame1.prefab scripts/ SubGame1Root.ts res/ ... sub2/ // 子游戏2这个 Bundle prefab/ SubGame2.prefab scripts/ SubGame2Root.ts common/ scripts/ EventBus.ts GameDef.ts操作上在assets/subgames/sub1文件夹右键选择“配置为 Bundle”Bundle 名称填subgame1。sub2同理。两个文件夹里的资源在构建时会各自独立打包。hall文件夹不设成 Bundle所有资源直接进主包方便冷启动。这里有一个参数值得强调Bundle 配置面板里的“目标平台”可以单独控制比如你只想让subgame1打进微信小游戏包而subgame2只用于原生平台构建配置里可以分开勾选。demo 阶段不用管这个但要知道有这个能力因为大厅 多子项目的项目整合经常需要应对多端发布。每个子游戏最好都只有一个入口 Prefab这样大厅加载时只需面对一个统一路径。如果子游戏内部还有多个场景切换由子游戏自己管理大厅不感知。大厅负责的是“把子游戏的入口节点挂到自己的场景下”“退出时把它从场景移除”。把耦合限制在这两个动作上后期新增子游戏就只需要加一个 Bundle 配置和一个入口按钮映射。3.2 用 Bundle 动态加载子游戏最小代码先实现最直接的流程点击大厅列表里的按钮加载subgame1这个 Bundle然后从 Bundle 里取出SubGame1.prefab挂到大厅场景下。import { _decorator, Component, Node, Prefab, assetManager, instantiate } from cc; export default class HallManager extends Component { currentNode: Node | null null; enterSubGame(bundleName: string, prefabPath: string) { assetManager.loadBundle(bundleName, (err, bundle) { if (err) { console.error([Hall] loadBundle ${bundleName} failed:, err); return; } const prefab bundle.get(prefabPath, Prefab); if (!prefab) { console.error([Hall] prefab ${prefabPath} not found in bundle); return; } this.currentNode instantiate(prefab); this.currentNode.setParent(this.node.parent ?? this.node); }); } exitSubGame() { this.currentNode?.destroy(); this.currentNode null; } }逻辑很简单assetManager.loadBundle是入口回调成功表示 Bundle 已经加载bundle.get直接从已加载的 Bundle 里拿资源。注意instantiate(prefab)出来后挂到大厅 Node 的父节点下这样预制体内部的start会被触发。按钮的click事件绑定时传入两个字符串参数比如(subgame1, prefab/SubGame1)。参数说明loadBundle的bundleName必须和构建 Bundle 时的名字一致默认是文件夹名prefabPath是相对 Bundle 的路径不包含assets/前缀。bundle.get只适合 Bundle 已经确认加载完的情况如果加载过程还没结束get拿不到资源所以更稳妥的是用bundle.load。示例里没有做加载失败重试实际项目会封一层管理器下面继续。3.3 用统一的 SubGameManager 管理“进入/退出”状态只写上面的方法虽然能跑但大厅里同时多点几个入口或者重复进入同一个子游戏会出现多个节点叠在一起、事件互相干扰的情况。我习惯把入口逻辑收拢成一个管理类维护“当前是否有子游戏在运行”的状态import { _decorator, Component, Prefab, Node, assetManager, instantiate } from cc; type LoadDone (node: Node) void; export default class SubGameManager extends Component { public currNode: Node | null null; public currentBundleName: string ; enterGame(bundleName: string, prefabPath: string, onDone?: LoadDone) { if (this.currNode) { console.warn([SubGameManager] 已经有子游戏在运行先退出再进入); return; } assetManager.loadBundle(bundleName, (bundleErr, bundle) { if (bundleErr) { console.error(加载 ${bundleName} 失败, bundleErr); return; } bundle.load(prefabPath, Prefab, (loadErr, prefab) { if (loadErr) { console.error(加载 ${prefabPath} 失败, loadErr); return; } const node instantiate(prefab); node.setParent(this.node); this.currNode node; this.currentBundleName bundleName; onDone onDone(node); }); }); } exitGame() { if (!this.currNode) return; this.currNode.destroy(); this.currNode null; this.currentBundleName ; } }相比最小示例这里用currNode记录当前子游戏防止重复进入用bundle.load而不是bundle.get规避了“资源未加载完”的窗期。这个管理器挂在 Canvas 下的一个常驻空节点上子游戏 Prefab 都作为这个空节点的子节点这样即使大厅 UI 切换层级子游戏层和大厅层也能保持逻辑隔离。在enterGame里还有个细节成功回调里做node.setParent(this.node)this.node就是管理器节点的引用。因为this是管理器的组件实例this.node才是场景树上的节点。如果你把管理器挂在大厅根节点上那子游戏就是大厅根节点的直接子节点退出时要不要恢复层级要看你的设计。这里我选择独立挂载规避一层耦合。4. 项目整合中的通信与生命周期不处理好进入退出两三次就会出鬼4.1 用事件总线打通大厅与子游戏大厅和子游戏各自独立不能直接拿着对方的组件引用来调用方法否则会形成强耦合。常见做法是挂一个全局事件总线。子游戏要向大厅汇报分数发出事件大厅监听事件并更新 UI。一个极简事件总线可以这样写import { EventTarget } from cc; const bus new EventTarget(); export function onEvent(name: string, callback: (payload?: any) void) { bus.on(name, callback); return () { bus.off(name, callback); }; } export function emitEvent(name: string, payload?: any) { bus.emit(name, payload); }EventTarget是 CocosCreator 自带的简单事件目标不需要引第三方库也没有平台差异。用onEvent返回的 off 函数可以在子游戏销毁时解绑防止回调泄漏。如果你是第一次写这种跨模块通信建议把事件名定义成枚举放进common/scripts/GameDef.ts比如export enum GameEvent { SUBMIT_SCORE SUBMIT_SCORE, GAME_EXIT GAME_EXIT, }这样做的好处是子游戏内发事件时如果拼错字符串TypeScript 编译阶段就会报错而不是运行时静默丢掉。另一个隐藏收益是编辑器全局搜索方便重构事件名时可以快速定位所有引用处。事件总线的边界要克制。如果大厅和子游戏之间交互特别密集比如子游戏内有个进度条要实时同步到大厅那就不要把每个进度都发到全局总线否则总线会变成黑匣子。更好的做法是子游戏进入时大厅把可以用于同步的UIProxy传给子游戏根节点子游戏内部通过这个代理更新 UI而不是发事件。职责划分上总线适合一次性的“结果通知”代理适合持续性的“状态同步”。4.2 子游戏退出时只销毁节点是不够的子游戏节点被destroy()后它下面所有组件的onDestroy会执行但不会自动替你恢复大厅 UI 的状态。比如子游戏全屏 UI 把主厅 UI 盖住了退出时大厅停留在进入前的状态这没问题。问题在别处子游戏里的定时器、Tween、网络轮询、全局事件监听稍微不干净就会残留在大厅进程里。一个健壮的子游戏入口组件通常会显式做清理import { _decorator, Component, Node, Tween } from cc; import { offEvent } from ../common/EventBus; import { GameEvent } from ../common/GameDef; export default class SubGame1Root extends Component { private offFuncs: Array() void []; onLoad() { this.offFuncs.push(offEvent(GameEvent.SUBMIT_SCORE, this.onSubmitScore)); this.scheduleOnce(() { // 子游戏自己的开机初始化 }, 0.1); } onDestroy() { this.offFuncs.forEach(f f()); this.offFuncs.length 0; Tween.stopAllByTarget(this.node); } private onSubmitScore(score: number) { // 往大厅上报分数 } }关键点在于将所有需要解绑的监听器统一收进数组在onDestroy里一起解除。如果用EventTarget的on就必须持有off引用别在onDestroy里去写bus.off(SUBMIT_SCORE, 一个匿名函数)那会失效因为每次声明匿名函数都是新的引用。Tween.stopAllByTarget(this.node)能停掉针对该子游戏根节点的所有缓动防止动画残留在舞台上。setInterval这类全局定时器也必须记得清掉。你可以把setInterval返回的句柄存在组件的私有属性里在onDestroy里调用clearInterval。有些开发者觉得子游戏节点销毁了定时器就会自动消失但实际不是这样——定时器是全局的和节点生命周期没有绑定。跑一次进入退出循环如果你在 Console 里看到节点无法释放或内存增长先检查定时器。4.3 退出子游戏时清掉 Bundle 里的资源缓存但要小心公共资源恭喜你到了项目整合里最容易出对错的一个点。assetManager.loadBundle把 Bundle 加载进来之后它会对 Bundle 内的资源建立缓存bundle.releaseAll()能释放所有属于该 Bundle 的资源。理论上“退出子游戏并释放 Bundle”是最理想的内存回收方式。但是这里有个细节如果子游戏 Bundle 里引用了commonBundle 里的某个公共 SpriteFrame 或字体releaseAll()会把它引用到的公共资源也一起释放导致其他 Bundle 也使用该公共资源时出现“资源为空”或黑图。正确做法是先确认依赖关系。在 CocosCreator 里可以用bundle.getDependencies()查看依赖的资源 uuid再决定哪些该 release 哪些不该动。我一般用“引用计数”思维做不直接对 Bundle 调用releaseAll()而是把未共享的资源显式释放把公共资源留在公共 Bundle 里让它在整个生命周期内不因子游戏退出被错误销毁。demo 阶段最简单的是退出时只看节点销毁不做内存管理。等真正上生产再按下面的exitGame增强版处理exitGame() { if (!this.currNode) return; const bundle assetManager.getBundle(this.currentBundleName); this.currNode.destroy(); this.currNode null; if (bundle) { bundle.releaseAll(); assetManager.removeBundle(bundle); } }这行代码写起来很爽跑起来如果你没有公共资源引用也没问题。万一你遇到了子游戏退出后公共 UI 丢贴图就是它干的。真遇到那种情况把releaseAll改成只释放部分资源或者在关闭子游戏时暂时不removeBundle把它留在内存里供下一次快速进入。这个权衡需要结合你子游戏的数量和内存预算不是越倾向释放越好。5. 项目整合避坑这5个问题我踩过你最好提前知道5.1 加载了子游戏 Prefab但屏幕还是空的现象进入大厅点击按钮控制台没有任何报错或者只打了一个“加载成功”但是屏幕上看不到子游戏任何元素。原因这个坑十有八九是子游戏 Prefab 的父节点不对。如果你的大厅是一个全屏节点而子游戏 Prefab 挂到大厅这个节点底下并且子游戏内部坐标计算受父节点UIOpacity、Scale影响可能被缩放成 0 或透明也或者挂到了一个隐藏节点下面看起来就是“没出来”。解决先在大厅代码里给当前节点设置一个可视标记比如把currentNode.parent设成 Canvas 节点下的一个专门“子游戏层”别挂在大厅 UI 的某个按钮下面。另外检查 Prefab 根节点的active是否为 true。CocosCreator 的预制体实例默认 active 和编辑器里保持一致如果你在 Prefab 编辑器里挂了隐藏根节点怎么加载都不会显示。还想加一道保险就在SubGameManager.enterGame成功回调里强制node.active true。5.2 子游戏资源被打进主包Bundle 白设了现象本地跑的时候一切正常构建以后首包体比你预计的多打开构建产物也没见子游戏的贴图资源占比统计里把子游戏资源算到了主包。原因CocosCreator 的 Bundle 打包遵循“资源究竟从哪里被引用”。当大厅场景或大厅里的公共组件直接引用了子游戏 Prefab 的一部分资源比如一个公共图集、一个脚本常量构建器会认为这个资源是主包依赖于是把它默认复制到主包。这样子游戏内部确实能运行但主包已经被越界引用撑大了。解决严格检查跨包引用。建议在子游戏开发阶段就约定大厅脚本只通过 Bundle 加载子游戏所有子游戏内部资源都不要在编辑器中拖拽到大厅预制体的属性上。如果发现某个 SpriteFrame 被大厅的管理器引用到了把引用改成运行时通过 Bundle 加载或者把该资源转移到公共 Bundle。这个坑没有构建报错只有靠资源管理器的“依赖图表”来查。5.3 子游戏onLoad被调用两次或者没调用现象在子游戏脚本里打console.log(load)第一次进入打了两遍退出再进又不打了或者反过来第二次进入后子游戏的start一直不触发。原因很多是加载姿势不对。我们用bundle.load拿到同一个 Prefab 再instantiate每次 instantiate 出来的新节点都会跑onLoad但如果节点没有 active 地挂到 Canvas 下而是挂到了一个 inactive 节点下start不会执行onLoad可能延迟或完全不触发。另外如果同一个 Prefab 的引用被大厅的多个按钮同时持有点击时实例化两次就会看到两个onLoad。解决一套严格流程是确保 Prefab 节点最终的父节点是 active 的并且setParent之后不要立刻对父节点做destroy。想要防御在子游戏入口脚本的onLoad里加守卫判断一个全局单例是否存在避免重复初始化。比如if (SubGame1State.isLoaded) return; SubGame1State.isLoaded true;这个守卫还有一个作用当子游戏被异常重复加载时不至于同时跑两套游戏循环。5.4 大厅界面被子游戏遮挡退出后界面错位现象从子游戏回到大厅大厅原本的排行榜、头像或弹窗不见了或者坐标整体偏移。原因子游戏 Prefab 通常是全屏的会盖在大厅 UI 上面这没问题。问题出在进入子游戏前你手动修改过大厅主 UI 节点的active或层级退出时没有恢复。比如enterGame里顺手把大厅主 UIactive false但exitGame忘了把active true。解决状态保存与恢复要在进入和退出时成对写。我习惯维护一个SceneUIState对象记录进入前的节点active和setSiblingIndex退出时按记录恢复而不是写死“大厅 UI 一定存在”的假设。代码结构类似private savedState: { node: Node; active: boolean }[] []; enterGame() { this.savedState []; for (const uiNode of this.hallUIList) { this.savedState.push({ node: uiNode, active: uiNode.active }); uiNode.active false; } } exitGame() { for (const state of this.savedState) { state.node.active state.active; } this.savedState []; }这样做能避免“进入子游戏时隐藏大厅退出时又忘记显示”的低级错误。5.5 构建后 Bundle 找不到控制台报 2048现象构建出来的版本在真机上点击入口回调函数能执行但err非空错误码 2048或者提示Bundle not found编辑器里跑却正常。原因CocosCreator 的 Bundle 寻址依赖构建后的配置。代码里assetManager.loadBundle(subgame1)的名字需要和构建面板里的 Bundle 名称一致大小写也必须一致。如果没在构建配置里把该文件夹勾选为“Bundle”或者构建时没勾选对应平台都会导致运行时找不到。解决打开构建面板展开“Bundle 配置”确认subgame1和subgame2都被勾选。真机调试时如果用了远程资源服务器要确保服务器对应目录有config.json。别前端拿不到资源就猜代码先用浏览器 DevTools 或原生抓包看网络请求路径多半是路径少了斜杠或者域名不对。6. 进阶技巧给大厅到子游戏的加载过程加进度和预取进入子游戏如果全都是动态加载真机从磁盘读 Bundle 时用户会看到大厅按钮点击后卡一两秒体验很差。我最后的建议是把大加载过程拆成“进度条 预取”两步。先做一个简单加载面板弹出后开始加载export class LoadingView extends Component { progress: Label; show(bundleName: string, prefabPath: string, done: () void) { this.node.active true; assetManager.loadBundle(bundleName, (bundle) { bundle.load(prefabPath, Prefab, (err, prefab) { if (!err) { done(); this.node.active false; } }); }); } }上面代码只有回调没有进度。要显示进度可以在loadBundle的 options 里带onProgressassetManager.loadBundle(bundleName, { onProgress: (current: number, total: number) { this.progress.string 加载子游戏 ${Math.floor(current / total * 100)}%; } }, (err, bundle) { ... });不过要注意loadBundle的进度回调是整个 Bundle 资源的加载进度Bundle 内部还有一次loadprefab 的进度想要完整表现需要把两段进度做串连。实践中我更推荐另一套在大厅空闲时预取子游戏 Bundle但只加载配置不实例化。比如在首屏 UI 加载完成之后调一次assetManager.loadBundle(subgame1)趁用户在首页浏览时先把 bundle 拉进内存。等点击进入时直接从getBundle里取资源速度会快一个量级。预取代码// 大厅 onLoad 里预取 assetManager.loadBundle(subgame1, (err, bundle) { if (!err) { // 只是为了缓存 bundle节点先不实例化 console.log([Hall] subgame1 ready); } }); // 点击时 const bundle assetManager.getBundle(subgame1); if (bundle) { const prefab bundle.get(prefab/SubGame1, Prefab); this.instantiateSub(prefab); }预取要注意两点一是别在首屏onLoad里预取所有子游戏那样首屏还是会变慢建议只预取第一个入口或用户最常进的那一个。二是内存预算。预取占用的内存高峰期叠加如果你在低端机上跑需要根据占用状况主动释放不再需要的 Bundle。CocosCreator 提供assetManager.getDependencyStat可以打资源统计我上线前会通过它观察整包内存分配。说个我自己的习惯每次新增子游戏时都要反复确认“大厅入口、加载路径、退出清理”三件事能不能在 3 步内 trace 通。trace 不通就说明边界设计有问题真正多子游戏上线出问题的往往不是加载逻辑而是状态残留。项目整合做的是工程结构长期账别图一时方便而放弃结构上的清晰边界。希望这个笔记对你的 CocosCreator 大厅子游戏 demo 有些帮助踩过的坑你已经提前知道了。本文还有配套的精品资源点击获取