热更新与版本灰度

4.1 热更新到底解决什么

先把一件事说清楚:Cocos Creator 的资源热更新只适用于原生发布版本。官方文档写得很直接,Web 版本可以通过服务器直接更新版本,所以资源热更新只适用于原生发布版本,AssetsManager 类也只在 native 命名空间下。

这一点跟大多数人的预期不一样。我一开始以为热更新是「不改包就能推新内容」的通用能力,在做微信小游戏时才反应过来:小游戏的主包 4M 限制和每次更新都要走审核,这才是它真正的痛点。小游戏那边解法不是热更新,而是远程 Bundle,资源放 CDN,主包里只留代码和启动必需的部分。

所以这一章我讲两条线:原生端用官方 AssetsManager 做资源热更新,配合版本号与灰度流程;小游戏端用远程 Bundle + MD5 版本控制做「无审核的资源更新」,工程量最小、风险最低。

热更新解决的核心问题是审核周期和包体限制。 原生应用走应用商店,每次改一行文案都要等几天到几周审核。热更新让你能绕过商店,把资源推到 CDN 上,玩家下次启动就拿到新版本。但它救不了原生代码,改了 C++ 或者改了引擎行为,包体里的二进制没变,热更新帮不上忙。

4.2 Manifest 与版本号策略

热更新的核心机制是 Manifest 文件。服务端和本地都保存一份完整版本的游戏资源,更新时对比两边的差异,决定下载哪些文件。

Manifest 里包含这几样东西:远程资源包的根路径(packageUrl)、远程 Manifest 地址(remoteManifestUrl)、远程 Version 文件地址(remoteVersionUrl,非必需)、主版本号、资源列表(以文件路径索引,每个文件带版本信息,一般用 md5)、搜索路径列表。

Version 文件是 Manifest 的一部分,不含文件列表。 Manifest 可能比较大,每次检查更新都完整下载会影响体验,所以额外提供一个很小的 Version 文件。AssetsManager 先读 Version 文件里的主版本号,判断要不要继续下载 Manifest。版本号策略我用四段:主版本.次版本.修订号.构建次数。

段位 什么时候变 客户端要做什么
主版本 有不兼容改动 清空本地热更缓存,回到安装包版本
次版本 有资源改动 走完整热更新流程
修订号 小修复 走完整热更新流程
构建次数 同版本内的重复构建 用于区分灰度包,不触发更新

版本对比用引擎内置的四段式比较函数(x.x.x.x 格式,纯数字)。如果你的版本号不是这个格式,它会退化成字符串比较,那顺序可能跟你预期不一样。要完全控制,可以调 assetsManager.setVersionCompareHandle(fn) 传一个自己的比较函数,接收两个版本号字符串,返回大于零表示前者更新。

md5 信息不一定是文件的 md5,可以是任意版本标识,本地和远程对比时只要两边不一样就认为这个文件有改动。你也可以用递增的构建号当版本标识,这样更直观。

生成 Manifest 用官方范例工程里的 version_generator.js:

node version_generator.js -v 1.0.3 \
  -u http://your-server-address/courier/remote-assets/ \
  -s build/android/assets -d assets/

-v 是主版本号,-u 是远程包地址(这个地址必须和最初发布版本里 Manifest 的远程包地址一致,否则检测不到更新),-s 是本地原生打包版本的目录相对路径,比如 build/android/assets,-d 是保存 Manifest 的相对路径。

4.3 远程 Bundle:小游戏端的「轻量热更」

原生端的热更新 machinery 重,小游戏端我用的是一个更简单的路子:把 Asset Bundle 配成远程包,配合 MD5 缓存。

配置分三步。在资源管理器里把要远程化的文件夹配置为 Bundle,勾上「配置为远程包」,目标平台选对应平台。构建面板里勾上 MD5 Cache,填 资源服务器地址,然后构建。构建完把发布包目录下的 remote 文件夹整个传到服务器上,然后删掉本地这份。

原理在于:开启 MD5 Cache 后,文件的 URL 会随文件内容变化而变化。新版本资源的 URL 变了,客户端不会命中旧缓存,自然拉新的。

加载远程 bundle 有两种方式。常规方式和本地一样,调 assetManager.loadBundle('city_levels', cb),回调里拿到 bundle 实例再 bundle.loadScene('level_03', cb)。要实现「检查到新版本就拉」,可以在第二个参数传 { version: 'fbc07' },引擎会绕过缓存里的旧版本文件重新下载;不传的话引擎会自己去 settings.json 里查对应的 Hash 值。

这里有个构建时必须注意的坑:配置为远程包的 Bundle,构建时要在构建面板填「资源服务器地址」,不然构建出来的包是坏的。官方文档专门为这条写了提醒。

另外记住微信小游戏的硬限制:不能从远程下载脚本文件。所以带脚本的 Bundle 走远程时,Creator 会把代码拷贝到 src/bundle-scripts 目录下保证能加载。这也是为什么远程 Bundle 最好只放纯资源,脚本留在主包。还有一条:Bundle 里的脚本不要跨 Bundle 互相引用,否则运行时可能找不到对应脚本。需要共享的类或变量,暴露到一个自己的全局命名空间里。

4.4 灰度发布:先小流量,再全量

灰度的思路很简单:先让一小部分用户拿到新版本,观察数据没问题,再放量。

我在 Skyline Courier 上用的流程是这样。

第一阶段,内部灰度(1 天)。 构建一个 wechatgame-gray 任务名的包,AppID 用测试号或体验版,只发给团队成员和几个种子玩家。验证功能正确性和崩溃率,不看数据。

第二阶段,小流量(2~3 天)。 提交审核通过后,先在 MP 后台把版本设为「体验版」给几十个真实用户跑。这一阶段看三个指标:崩溃率有没有上升、留存有没有掉、关键路径(进关 → 送包裹 → 结算)有没有报错。 第三阶段,全量(1 天)。 数据没问题再放量。

第四阶段,回滚窗口(3 天)。 全量之后保留旧版本的资源一份在 CDN 上。三天内发现问题,把 manifest 版本号改回旧的,客户端下次启动就自动回到旧版本。

版本号怎么管。 我的规则是:每次构建都必须递增版本号,哪怕只改了一张图。这个纪律能省掉无穷无尽的麻烦,这个包是哪个版本、线上跑的是哪次构建 这种问题一旦答不上来,后面所有的排查都会卡住。

具体到工程实践,我在 git 上用这套规则:主分支 main 只放随时可发布的代码;release/1.0.x 是当前要发布的分支;每次发版前从 release 分支切一个 build/20261112-01 这样的 tag,对应一次构建。资源上传 CDN 时目录结构是 remote-assets/1.0.3/ 这样带版本号,manifest 里指到具体版本目录。这样任何时候想回滚,把 manifest 的版本号改回去就行。

这里顺带说清楚 3.8 的工程根目录结构,因为版本管理绕不开它:根目录下是 assets、build、library、local、profiles、settings、temp、extensions 和 package.json。没有 packages 目录,那是 3.7 以前的写法。热更新相关的编辑器插件装在 extensions/ 里,构建结束时它会自动给 main.js 附加搜索路径设置逻辑和更新中断修复代码。入库规则也简单:assets/、extensions/、settings/、package.json 进版本控制,build/、library/、local/、temp/ 是构建和缓存产物,排除掉。

4.5 更新失败怎么办,以及什么不该用热更

更新失败了怎么办。 引擎层面提供了断点续传和失败重试。断点续传在文件级和字节级都支持,v3.6 起 iOS 和 Android 平台也支持了。前提是服务器支持 Accept-Ranges,否则会从头下载。

下载过程中出异常(下载失败、解压失败、校验失败)会触发 UPDATE_FAILED 事件,失败的资源列表记在热更新管理器里,可以调 assetsManager.downloadFailedAssets() 只重下这部分。文件校验用 assetsManager.setVerifyCallback(fn) 挂一个自己的函数,算出 md5 跟 asset.md5 比对,返回 true 表示文件正常。

我的兜底策略是三层的。 第一层,下载失败自动重试两次,间隔递增。第二层,重试仍失败就提示「更新失败,请检查网络后重试」,让玩家手动点,不要卡死在 loading。第三层,最坏情况,校验连续失败,直接清空本地缓存让玩家回到安装包版本。

清缓存这招在原始资源丢失时特别管用。Skyline Courier 有一次资源误传到 CDN 上传错了,覆盖了同名文件,客户端缓存校验一直过不去。清了 assetManager.cacheManager.clearCache() 之后重新拉才对。

热更之后必须重启游戏。 两个原因:更新后的脚本需要干净的 JS 环境,否则新旧版本的类和对象会混在一起;资源配置需要 Asset Bundle 被重新加载并被 Asset Manager 重新读取。相关代码必须放在 main.js 里 require 其他脚本之前执行。

引擎升级不要走热更新。 包体里的 C++ 引擎版本和 src/cocos-js/cc.js 的版本如果不一致,可能导致严重问题甚至完全无法运行。引擎升级应该走应用商店发新包。改了原生代码也一样,热更帮不上忙。

资源校验要做。 别只靠 URL 变了来判断新版本,配一个 setVerifyCallback 算 md5,能挡住 CDN 传错文件、传输截断这类问题。

最后是一张发布 checklist,每次发布照着走一遍。

  • [ ] 版本号已递增,四段式,且 version 与 manifest 一致
  • [ ] 改动已分类:纯资源 / 逻辑脚本 / 原生代码(原生代码不走热更)
  • [ ] 资源已上传 CDN,目录带版本号,旧的仍保留
  • [ ] 远程包地址与首发版本一致
  • [ ] 构建时未勾选 MD5 Cache(原生热更场景下会失效)
  • [ ] 客户端与服务端 manifest 已生成且版本号匹配
  • [ ] 校验回调已挂,失败重试逻辑已挂
  • [ ] 兜底可用:清缓存可回到安装包版本
  • [ ] 灰度比例符合预期,崩溃率、留存、关键路径无异常
  • [ ] 回滚明确:改 manifest 版本号或把 CDN 目录指回旧版
  • [ ] git tag 已打,release 分支已合并回 main

4.6 相关阅读