CocosCreator2.4.3 AssetBundle热更新实战:大厅+子游戏方案解析
做这行的人大概都经历过那种时刻应用商店里挂着旧版本运营已经在群里被用户追着问“为什么还不更新”而代码仓库里那个导致闪退的坑早就填完了。如果项目正好锁在 CocosCreator 2.4.3又想要“大厅子游戏”这种常见的玩法框架那 AssetBundle 热更新差不多是绕不开的核心课题。这篇文章不打算贴官方文档也不准备搞什么玄学方案。我就按一个实际项目的落地过程来写从大厅和子游戏怎么拆 Bundle到版本清单怎么生成再到客户端比对、下载、加载的完整代码链路以及 2.4.3 上那些你不踩一次根本记不住的坑。已经跑过的同学可以直接对照检查正准备动手的可以当一份参考实现没概念的新手也能顺着思路把热更新这一环搭起来。1. 为什么 2.4.3 的项目特别需要“热更新 子游戏”这套组合拳先回想一下 2.4.x 这个特殊节点。和后面 3.x 的架构不同2.4.3 的 AssetBundle 体系已经相当成熟但官方对老版“热更新插件”的态度明显是逐步弱化。项目停留在 2.4.3 无非两个原因要么是历史包袱太重要么是团队评估后认为没必要为热更机制跨大版本。这时候自己接管“版本管理 远程 Bundle 加载”才是更可控的路线。大厅子游戏模式在这种背景下几乎是天然匹配。大厅本体更新频率低适合跟首包一起走子游戏/玩法模块更新频率高适合独立成 Bundle 做远程动态加载。这样拆的好处很直观首包体积只保留大厅和公共资源子游戏全部走远程加载单个玩法模块更新时不需要连着大厅一起发版运营活动玩法可以在不发原生包的前提下通过远端资源替换直接上线。实际工程里“大厅子游戏”往往还要处理账号、支付、公告、活动等模块但在热更新层面我们只需要关注一件事哪个 Bundle 在首包里哪个 Bundle 在远端以及谁负责把远端的新资源拉下来。2. 大厅与子游戏的边界先画清楚资源归属再谈 Bundle 配置我在不少项目里见过一种通病Bundle 切分全凭文件夹位置资源归属全凭开发时心情。等到要做热更新才发现子游戏场景引用了一个放在大厅目录下的公共字体大厅又动态加载了子游戏的预制体结果每次更新都不得不把一大片资源连坐打包。这一步想清楚后面能省掉很多麻烦。2.1 参与者的职责划分我建议在项目里明确三类目录大厅核心首包内置登录、用户中心、公告、聊天、主界面。这些都是用户进入游戏列表前必须展示的内容更新频率低、耦合度高放首包最稳。目录名直接取hall构建为本地 Bundle。子游戏远程热更每个玩法独立目录例如pk、chain、turntable。子游戏内部要有自己的场景、脚本、UI 和美术资源。不要跨目录引用大厅里的资源哪怕只是一个小图标。目录名就是 Bundle 名也是后续loadBundle的入参。公共资源包共享 Bundle像通用弹窗、音效管理、网络层依赖的配置归入common。这个包可以被多个子游戏共同引用但因为被依赖更新时要格外小心通常优先发布并保持向后兼容。2.2 目录和依赖的两个硬性约定在 CocosCreator 2.4.3 里Bundle 本质上是构建时打出来的一个资源集合。文件夹设为 Bundle 后里面的资源会按config.json描述的依赖关系打包。这里有两个容易踩坑的硬性约定子游戏 Bundle 内部必须自成闭环。尤其注意.prefab里拖拽引用的贴图、字体、图集是否都落在本 Bundle 内。跨 Bundle 引用不是不行但会让远端包加载时依赖大厅里的本地资源一旦大厅更新遗漏或改名子游戏加载就会出诡异问题。所有子游戏共享的东西不要塞进大厅也不要塞进某个子游戏。专门放commonBundle并且在编辑器里通过“配置为 Bundle”后的自动依赖提取机制来管理。简单说就是谁都能引但它自己只依赖引擎内置资源和原生包。3. Bundle 构建配置与产物结构拆解这个环节如果只是照着编辑器点几下很容易漏掉几个关键配置。我先给一份可以直接对照的检查清单再拆解构建产物里到底生成了什么。3.1 编辑器里的配置点在 2.4.3 中选中资源目录在属性检查器里可以做 Bundle 配置几个关键项我建议这样设配置项建议值原因Bundle 名称小写字母 下划线如subgame_pk避免大小写导致加载路径不一致压缩类型远程 Bundle 使用“无压缩”本地 Bundle 保持默认压缩场景在原生平台上有解压耗时远端包无压缩更稳远程服务器地址按构建平台配置 CDN URL每个平台可以配不同地址测试环境与正式环境分开配置为远程包子游戏勾选大厅不勾勾选后该 Bundle 不会进原生首包完全依赖远程加载“配置为远程包”是 2.4 里一个非常实用的开关。勾选之后构建产物会进入remote目录而不会混入原生包内。大厅不要勾否则用户首次安装都没法加载大厅。3.2 构建产物到底长什么样构建完成后你会在发布目录里看到类似这样的结构build/android/ ├── assets/ ├── src/ ├── remote/ │ ├── common/ │ │ ├── config.json │ │ └── import/ │ ├── hall/ │ ├── subgame_pk/ │ └── version.json └── ...remote目录下的每个子文件夹就是一个远端 Bundle。里面最重要的文件是config.json它声明了 Bundle 的 UUID 映射、场景列表、资源依赖关系。客户端loadBundle(subgame_pk)时本质就是先去拿这个config.json再按里面的映射加载资源。如果之前没见过这个目录可能被一个现象吓到首包里根本没找到subgame_pk相关文件。这是正常的因为它已经按“远程包”配置被排除了。这也说明只要服务器上放好remote目录客户端就可以在启动后从远程加载整个子游戏。4. 版本清单与差量包生成链路设计远程 Bundle 配置好了下一步就是“更新”本身。这里我不推荐在 2.4.3 上继续沿用老式project.manifest那套而是自己生成一份轻量版本清单。原因很简单可控、透明、不需要依赖旧插件而且能很自然地和子游戏粒度对齐。4.1 manifest 设计两级版本结构版本清单我拆成两层全局版本记录当前线上总版本号以及每个 Bundle 的版本号。文件级版本记录某个 Bundle 内每个文件的 MD5 和文件大小。全局版本可以是这样{ globalVersion: 31, bundles: { common: { version: 1.2.0, entry: https://cdn.example.com/hot/common/ }, subgame_pk: { version: 1.5.0, entry: https://cdn.example.com/hot/subgame_pk/ } } }每个 Bundle 再有一个自己的文件清单subgame_pk_manifest.json{ version: 1.5.0, files: { config.json: { md5: a3f8c2d1..., size: 18432 }, import/2e/2e4f4a2a-...json: { md5: 9f1c02a7..., size: 65536 } } }4.2 用脚本生成差量列表完整清单生成后关键一步是生成“差量包”。思路很简单拿上一版本的文件清单和当前版本做对比找出三种状态新增文件之前没有现在有修改文件MD5 变了删除文件客户端本地需要清理。理论上整份远程 Bundle 覆盖下载也不是不行但体量一大用户更新成本就高。差量更新需要客户端知道“从哪个版本开始升级”所以全局清单里建议带上历史版本信息或者直接给客户端下发“起点版本号”{ fromVersion: 1.4.0, toVersion: 1.5.0, updateFiles: [ { path: config.json, md5: a3f8c2d1..., url: https://cdn.example.com/hot/subgame_pk/config.json }, { path: import/2e/2e4f4a2a-...json, md5: 9f1c02a7..., url: https://cdn.example.com/hot/subgame_pk/import/2e/2e4f4a2a-...json } ], deleteFiles: [ import/old/old-file.json ] }我这个项目里这个差量清单是构建流水线里一个 Node 脚本自动生成的不经过人手工。发布流程是构建成都某一期的remote产物对产物目录计算 MD5生成*_manifest.json压缩需要更新的文件成 zip或直接以文件形式放 CDN更新服务器上的全局版本 JSON。4.3 全量包和差量包的选择策略差量更新并不永远优于全量更新。以下情况我会直接放弃差量、改成整包替换文件增量超过 Bundle 总量一半上一个版本间隔太久比如一个月没发版目录结构发生了大调整删除文件特别多测试环境无法准确复现旧版本。原因也很朴素差量文件多了以后客户端下载请求数会暴涨请求本身的花费比省下的流量还贵。直接整包拉取用一次下载换一次完整落盘反而更稳。5. 热更新核心模块比对、下载、校验、落地整个热更新模块我用一个有限状态机来驱动避免在异步流程里到处散布回调。状态就五条check、diff、download、apply、finish。每到一个状态做一件事失败就统一走fail。5.1 检查更新拿到全局版本并比对客户端的入口一般放在大厅启动场景里。先读本地缓存的全局版本号再从远程拉取全局版本 JSONconst UPDATE_STORAGE_KEY app_global_version; function getRemoteJson(url) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.timeout 10000; xhr.onload () { if (xhr.status 200) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error(json parse error)); } } else { reject(new Error(http ${xhr.status})); } }; xhr.onerror () reject(new Error(network error)); xhr.ontimeout () reject(new Error(timeout)); xhr.send(); }); } async function checkUpdate() { const remoteManifest await getRemoteJson(UPDATE_URL); const localVersion cc.sys.localStorage.getItem(UPDATE_STORAGE_KEY) || 0; if (remoteManifest.globalVersion ! localVersion) { return { needUpdate: true, remoteManifest: remoteManifest, oldVersion: localVersion }; } return { needUpdate: false }; }这里我故意没有把“有更新”和“下载”写成同一个函数。因为后续还要做更新弹窗、静默更新、强制更新等不同策略状态切分开更好扩展。5.2 获取差量清单拿到全局 manifest 后客户端需要知道自己到底缺哪些文件。如果服务端已经生成了差量清单直接用async function getDiffList(bundleName, fromVersion, toVersion) { const diffUrl ${CDN_BASE}/${bundleName}/diff_${fromVersion}_${toVersion}.json; return await getRemoteJson(diffUrl); }如果是本地端自己做差异比对就分别拉取旧版和最新的文件清单然后对比 MD5。我个人不推荐客户端做这份工作首先旧版本清单未必完整存在本地其次把流量都消耗在“计算差异”上不划算。生成差异应该发生在发布流水线而不是在玩家的手机上。5.3 下载与校验的细节差量清单拿到手以后就进入了最实在的下载环节。我在 2.4.3 的 JS 侧直接下载文件用XMLHttpRequest拿arraybuffer然后用引擎提供的文件接口落盘。代码如下function downloadFile(url) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.responseType arraybuffer; xhr.onload () { if (xhr.status 200) { resolve(new Uint8Array(xhr.response)); } else { reject(new Error(download failed ${xhr.status})); } }; xhr.onerror () reject(new Error(download error)); xhr.send(); }); } async function applyUpdate(diffList, bundleRootPath) { for (let i 0; i diffList.updateFiles.length; i) { const item diffList.updateFiles[i]; const buf await downloadFile(item.url); const md5 computeMd5(buf); if (md5 ! item.md5) { throw new Error(md5 mismatch: ${item.path}); } const destPath ${bundleRootPath}/${item.path}; writeFileAtomic(destPath, buf); updateProgress(i 1, diffList.updateFiles.length); } for (let i 0; i (diffList.deleteFiles || []).length; i) { const delPath ${bundleRootPath}/${diffList.deleteFiles[i]}; removeFileIfExists(delPath); } cc.sys.localStorage.setItem(UPDATE_STORAGE_KEY, toVersion); }这里的writeFileAtomic是我自己的封装先写临时文件写成功后重命名覆盖目标文件。别小看这个细节——如果下载到一半进程被杀直接写目标文件会留下半截资源轻则加载失败重则启动崩溃。原子写入能有效避免这种问题。5.4 把更新目录塞进搜索路径文件都落盘只是第一步关键要告诉引擎“到这里来找资源”。2.4.3 原生平台支持搜索路径机制。更新完成后需要把本地更新根目录加入到搜索路径里const updateBase getUpdateBasePath(); if (cc.sys.isNative) { jsb.fileUtils.addSearchPath(updateBase); } cc.assetManager.setBundleRoot(updateBase);特别注意setBundleRoot的调用时机。它会影响后续所有loadBundle的默认查找路径。所以正确的顺序是先完成所有热更文件的写入再设置根路径最后才去加载子游戏 Bundle。6. 子游戏动态加载与场景切换的完整代码热更文件落地只是“资源到位”真正让“大厅子游戏”跑起来的是动态加载逻辑。这部分我用一个实际案例来讲大厅列表点击某个玩法入口进入子游戏pk。6.1 加载子游戏 Bundlefunction enterSubGame() { const bundleName subgame_pk; const sceneName scenes/PkMain; cc.assetManager.loadBundle(bundleName, (err, bundle) { if (err) { showToast(加载玩法失败); return; } bundle.load(sceneName, cc.SceneAsset, (err2, sceneAsset) { if (err2) { showToast(场景加载失败); return; } const scene sceneAsset.scene; cc.director.runScene(scene); }); }); }从大厅进入子游戏就是这两层加载先loadBundle再bundle.load对应场景。我把 Bundle 的config.json理解成一个“索引文件”而bundle.load才是真正按索引拉资源。有个容易被忽视的点如果该 Bundle 配置为远程包那么loadBundle内部会先判断这个 Bundle 是否已经在本机不存在时会走下载逻辑。也就是说引擎本身也有一套“远程加载”机制。但我们自己写了热更模块就等于把“该下载什么文件”和“文件放哪”这两件事握在自己手里最后再通过搜索路径让引擎能找到。6.2 子游戏返回大厅与 Bundle 卸载返回大厅时不能直接runScene(hallScene)就完事。子游戏加载过的贴图、音效、预制体都还驻留在内存里。正确的做法是先把子游戏场景里的全局监听、定时器、常驻节点清掉再卸载 Bundlefunction backToHall() { // 1. 切回大厅场景保证没有子游戏场景的节点还在被引用 const hallBundle cc.assetManager.getBundle(hall); hallBundle.load(scenes/HallMain, cc.SceneAsset, (err, sceneAsset) { if (err) { return; } cc.director.runScene(sceneAsset.scene); }); // 2. 子游戏 Bundle 释放 scheduleOnce(() { const subBundle cc.assetManager.getBundle(subgame_pk); if (subBundle) { subBundle.releaseAll(); cc.assetManager.removeBundle(subBundle); } }, 0.1); }这里我刻意等了 0.1 秒再释放是为了让场景切换的引用计数在下一帧被正确回收。别在runScene的同一调用栈里立刻releaseAll否则某些还在队列里的加载请求会直接拿不到资源。6.3 子游戏之间的切换如果要在两个子游戏之间直接切更稳妥的做法是先回到大厅再进入目标子游戏。因为两个子游戏可能各自维护了一套 UI 栈和全局数据直接 A 切 B可能出现事件监听重叠、常驻节点互相引用的问题。如果产品上确实需要 A 直接切 B那就按“卸载再加载”的次序async function switchSubGame(fromBundle, toBundle, toScene) { // 1. 先释放当前 const cur cc.assetManager.getBundle(fromBundle); if (cur) { cur.releaseAll(); cc.assetManager.removeBundle(cur); } // 2. 延迟后半帧再加载新 await nextFrame(); cc.assetManager.loadBundle(toBundle, (err, bundle) { if (err) { return; } bundle.load(toScene, cc.SceneAsset, (err2, sceneAsset) { if (err2) { return; } cc.director.runScene(sceneAsset.scene); }); }); }需要特别注意的是脚本资源一旦被引擎加载通常不会因为releaseAll被卸载。这就是 JS 脚本和纯资源的不同。换句话说如果你改了子游戏的一个.ts脚本并热更新下去客户端最好还是通过重启场景或完全重进游戏来让新脚本生效。后续我会在坑位里专门讲这个。7. 2.4.3 实战中躲不过的坑与对策热更新方案跑通很容易跑到生产环境不崩才是考验。下面这些坑是我在 2.4.3 项目里真实见过的有些来自我自己的项目有些是别人踩完告诉我的都值得提前防护。7.1 脚本热更并不会“即时生效”CocosCreator 2.x 原生平台对脚本的处理和纯资源不同。资源图集、音频、预制体可以动态替换但已经执行的脚本类逻辑改起来非常麻烦。子游戏 Bundle 里的脚本如果发生改动用户必须中断当前会话重新启动游戏后才能让新脚本完整生效。这不是 2.4.3 的 bug而是引擎对脚本加载的设计机制决定的。所以在项目规范上我建议玩法逻辑尽量避免塞在只更新资源文件的“热更资源”里把经常调整的逻辑放到服务端配置或可动态解释的数据里。比如活动开关、数值表、文本文案优先走后端下发。真正要改代码逻辑时接受“重启生效”这一现实或者把它归到强更发版范畴。7.2 更新目录和包体目录都塞进搜索路径会互相干扰搜索路径不是越多越好。如果你既保留了包内资源路径又把更新目录加进去同一个文件可能会被引擎当成两个版本的文件。我遇到过的情况是旧首包里的某个图集和新下载图集重名结果加载到的内容时旧时新表现极不稳定。建议只保留一个“有效搜索路径入口”更新完成后把更新目录设置为最高优先级并主动监听资源加载是否命中预期 MD5。本地做不了复杂检查就简化部署策略更新小文件走差量大改版直接整体远程 Bundle 替换。7.3 存储路径在不同平台不一致Android 和 iOS 的应用沙箱路径、缓存目录路径都不同。热更文件如果放到tmp这种可能被系统清理的目录用户玩了几天后可能出现“资源莫名消失”。我目前的做法是统一放在沙箱的Library/Application Support或 Android 的filesDir下用一个自定义常量管理function getUpdateBasePath() { if (cc.sys.os cc.sys.OS_ANDROID) { return ${jsb.fileUtils.getWritablePath()}hotupdate/; } if (cc.sys.os cc.sys.OS_IOS) { return ${jsb.fileUtils.getWritablePath()}hotupdate/; } return hotupdate/; }这里做个提醒getWritablePath()得到的路径末尾一般带斜杠拼路径时别重复加。7.4 下载失败后的脏数据残留热更下载是最容易在生产环境翻车的环节。弱网环境下一个文件下载到一半网络断了你重试时又把它当“已存在”跳过那这个子游戏基本就废了。我的做法是三层校验下载前先确认本地没有同名文件有且 MD5 不一致则视为脏数据下载中采用临时文件方案完成后再校验 MD5下载结束后统一比对“本 Bundle 文件清单”和“服务器清单”缺一个文件都不允许进入加载流程。7.5 触屏版、模拟器上的兼容性问题有些 2.4.3 项目会顺带发一个触屏版或模拟器包。触屏版的远程 Bundle 行为与原生不同最明显的是XMLHttpRequest在跨域上的限制以及本地文件系统的写入能力。如果项目要求触屏版和原生共用一套热更方案建议单独抽一个平台适配层至少把“下载”和“落地”这两步抽象成可替换的接口。原生走文件系统方案触屏版走内存缓存代码逻辑不变只是平台实现不同。7.6 首包版本必须带一个“兜底更新入口”无论差量多精细总会遇到用户从很老的版本直接升级。或者用户在更新了一半时清掉了应用缓存。这时候稳妥的方案是大厅内置一个“全量更新子游戏”的兜底入口。检测到 Bundle 缺失或版本不符时直接走loadBundle让引擎自己去远程下载整包或者后端下发整包 zip客户端下载后解压覆盖。这个入口可能永远不会被点到但它能救回不少异常升级的用户。8. 最后再分享两个项目规范层面的小经验热更新这东西技术实现只是三分之一剩下三分之二是发布流程和团队规范。第一个经验是版本号必须“进代码”。别只在后台维护版本号客户端代码里要有一个兜底的BUILD_VERSION常量当远程清单连不上时客户端不会走“把本地资源当最新”的歧义逻辑。版本号规则统一用数字自增避免用1.2.3-beta这种让比较逻辑变复杂的格式。第二个经验是每次发布都清理一次过期的差量包。我在服务器上发现过几百个diff_xx_yy.json文件都是历史遗留。这些过期包不仅占空间万一客户端拿到旧差量包去升级还会把资源改坏。建议发布流水线里加一步只保留最近 N 个版本的差量包更早版本一律强制全量。热更新方案再完善也不可能代替专业的发版流程。2.4.3 上把 AssetBundle 和远程资源这层玩明白至少能让团队从“每次更新都要过应用商店”的焦虑里解脱出来把精力放到玩法迭代上。我现在的做法就是一套非常朴素的状态机代码量不大但线上跑了一年多基本没出过热更事故。