Cocos Creator热更新实战:基于Manifest的版本控制与增量更新方案

📅 2026/8/2 20:49:42 👁️ 阅读次数 📝 编程学习
Cocos Creator热更新实战:基于Manifest的版本控制与增量更新方案

1. 项目概述:为什么我们需要基于Manifest的版本控制?

在移动游戏开发,尤其是使用Cocos Creator的项目中,资源热更新几乎是每个项目上线后都必须面对的核心运维需求。想象一下,你的游戏上线后,发现了一个UI贴图错误,或者某个关卡的数值配置需要紧急调整。如果每次修复都要求用户重新下载整个几百兆甚至上G的安装包,流失率将是灾难性的。热更新的本质,就是让用户在不重新安装App的前提下,仅下载并替换有问题的或新增的资源文件。

而“基于Manifest的版本控制”,正是实现这一过程高效、可靠、可管理的基石。它不是一个简单的文件替换,而是一套完整的资源版本管理、差异比对、安全校验和回滚保障的体系。很多团队在初期可能会尝试一些“土办法”,比如直接对比服务器文件列表和本地文件列表,或者简单粗暴地用版本号判断全量更新。这些方法在小规模时或许能跑起来,但随着资源数量膨胀、版本迭代频繁,很快就会暴露出效率低下、容易出错、无法应对复杂网络环境等问题。

我经历过不止一个项目,因为热更新逻辑不严谨,导致玩家更新后资源错乱、白屏甚至客户端崩溃。所以,今天我想结合实战,深入拆解Cocos Creator官方推荐的这套基于Manifest的热更新方案。它不仅适用于Cocos Creator 2.x,其核心思想在3.x版本中同样重要,只是部分API和细节有所调整。我们会从原理设计、实操步骤、到避坑经验,完整地走一遍,目标是让你看完就能在自己的项目中搭建一套健壮的热更新系统。

2. 核心原理与设计思路拆解

2.1 Manifest文件:资源世界的“户籍管理系统”

你可以把Manifest文件理解为你游戏资源世界的“户籍管理系统”或“资产清单”。它不是一个神秘的东西,本质上就是一个JSON格式的配置文件。这个文件里,详细登记了当前版本所有需要被管理的资源文件的“身份信息”。

一份标准的Manifest(通常命名为project.manifest)会包含以下核心信息:

  • 版本信息:包含一个主版本号(如1.0.0)和一个资源版本号(通常是一个自增的整数或时间戳)。主版本号用于大版本标识,资源版本号才是热更新决策的关键。
  • 引擎版本:记录生成该Manifest时使用的Cocos Creator引擎版本,用于兼容性检查。
  • 资源列表:这是核心部分。一个字典(Object),以资源在项目中的路径为Key,其Value则包含了该资源的“指纹”信息。
    • MD5哈希值:计算该文件内容得到的唯一MD5值。只要文件内容有一个字节的改动,这个值就会变化。这是判断文件是否需要更新的黄金标准。
    • 文件大小:文件的字节数,用于下载前的预估和校验。
    • 压缩信息:如果资源在发布时被压缩(如.zip),这里会记录压缩包的MD5和大小。

为什么需要MD5?因为只靠文件名和文件大小是靠不住的。我踩过坑:如果开发者不小心上传了一个同名但内容错误的文件,或者文件在传输过程中损坏,仅凭文件名和大小无法发现问题,会导致客户端更新到一个坏的文件。而MD5校验能在下载后确保文件的完整性,从源头杜绝“脏数据”。

2.2 热更新流程:像“快递比对发货单”一样工作

整个热更新流程,可以类比为一次高效的快递收发过程:

  1. 本地持有“旧清单”:玩家手机里已经安装的游戏,包含一个project.manifest文件,记录了安装时所有资源的版本和MD5。
  2. 访问“中央仓库”:游戏启动时,客户端会向你们预设的更新服务器(CDN或自有服务器)请求一个最新的project.manifest文件。
  3. “清单”比对:客户端将本地Manifest和服务器Manifest进行逐项比对。这个过程不是简单比较版本号,而是深入到资源列表,对比每个文件的MD5值。
  4. 生成“采购单”:找出所有MD5值不一致的文件,以及服务器Manifest中存在而本地没有的文件(新增资源)。这些文件构成了本次需要更新的“差异包”。
  5. “下载并验货”:根据生成的列表,逐个或批量下载这些文件。每下载完一个,立即计算其MD5,与服务器Manifest中记录的值进行比对,确保文件下载无误。
  6. “替换上架”:所有文件校验通过后,将它们移动到本地游戏的可写路径下(如wx.env.USER_DATA_PATHjsb.fileUtils.getWritablePath()),并覆盖旧的资源文件。同时,用新的Manifest替换旧的Manifest。
  7. “重启生效”:热更新通常需要重启游戏模块或整个游戏,以加载新的资源。

这个流程的精妙之处在于增量更新。假设你的游戏有1000个资源文件,本次版本只修改了1个UI图片。那么玩家只需要下载这1个图片文件和最新的Manifest文件,而不是整个资源包,更新体验极佳。

2.3 版本控制策略:何时触发更新?

基于Manifest,我们可以设计灵活的更新策略:

  • 强制更新:当服务器Manifest中的engineVersionversion(主版本)高于本地,且不兼容时,可以引导用户前往应用商店下载全新安装包。这通常用于引擎大版本升级或游戏核心框架改动。
  • 静默热更:当只有assetsVersion(资源版本)或文件MD5发生变化时,在游戏启动过程中自动、无感地完成资源下载和替换。这是最常用的方式。
  • 可选热更:对于一些非紧急的大资源包(如新的语音包、高清素材),可以提示用户,允许其选择在Wi-Fi环境下更新或暂不更新。

关键在于,这个决策逻辑是客户端根据两个Manifest文件比对后自主做出的,服务器只需要提供最新的Manifest和资源文件存放服务(通常是静态CDN),无需复杂的业务逻辑交互,架构简单清晰。

3. 实战步骤:从零搭建热更新系统

3.1 环境准备与项目设置

首先,确保你使用的是Cocos Creator 2.4.15或以上版本(3.x版本流程类似,但界面和部分API不同)。我们以一个简单的项目为例。

  1. 构建发布:在Cocos Creator编辑器中,完成你的项目开发。进入项目 -> 构建发布面板。
  2. 关键构建选项
    • 发布平台:选择你的目标平台,如AndroidiOS
    • MD5 Cache这个选项必须勾选!它会在构建时,为所有资源文件名附加其MD5值(如bg.png变成bg.03abc.png),并生成对应的project.manifestversion.manifest文件。这是实现增量更新的前提。
    • 主包压缩类型:根据需求选择,小游戏平台常用小游戏分包
    • 构建路径:选择一个本地目录,如./build

点击构建,等待完成。构建结束后,在构建输出目录(如build/android)下,你会看到除了常见的assetssrc等目录外,还有两个关键文件:project.manifestversion.manifestversion.manifest可以看作是project.manifest的一个轻量级摘要,通常只包含版本号等基本信息,用于更新前的快速检查。

3.2 服务器端部署:让资源可访问

热更新不需要复杂的后端程序,但需要有一个可以通过HTTP/HTTPS访问的静态文件服务器或对象存储服务(如阿里云OSS、腾讯云COS、AWS S3,或自建的Nginx服务器)。

  1. 创建资源目录:在你的服务器或CDN上,为每个游戏版本创建一个独立的目录。一种推荐的目录结构是:

    https://your-cdn.com/your-game/ ├── v1.0.0/ # 版本目录,以主版本命名 │ ├── project.manifest │ ├── version.manifest │ └── assets/ # 存放所有资源文件 │ ├── main.03abc.js │ ├── bg.8f2de.png │ └── ... └── v1.1.0/ # 新版本目录 ├── project.manifest └── assets/ └── ...

    这种结构清晰,且可以同时保留多个历史版本,便于管理和回滚。

  2. 上传文件:将构建输出的project.manifestversion.manifest以及assets目录下的所有文件(注意是assets目录下的内容,而不是assets目录本身),上传到对应版本的服务端目录中。确保文件的网络访问路径与Manifest中记录的路径能对应上。

注意:很多新手会直接把整个构建输出目录(包含assetssrc等文件夹)上传到服务器根目录,然后在代码里拼接路径,这很容易导致路径错误。正确做法是,确保服务器上project.manifest文件同级或子目录下,能找到它里面记录的所有文件。

3.3 客户端代码实现:编写更新逻辑

这是核心部分。我们需要在游戏的启动场景(如Loading场景)中,编写热更新检查与执行的代码。

// 假设在Loading场景的某个脚本中,例如 HotUpdateManager.js cc.Class({ extends: cc.Component, properties: { // 可以关联进度条、提示文本等UI组件 progressBar: cc.ProgressBar, tipLabel: cc.Label, }, onLoad() { // 1. 设置热更新存储路径 // 不同平台的可写路径不同,Cocos提供了接口获取 this.storagePath = cc.sys.isNative ? jsb.fileUtils.getWritablePath() + ‘hotupdate/’ : ‘’; // Native平台 // 如果是小游戏平台,可能是 wx.env.USER_DATA_PATH if (cc.sys.platform === cc.sys.WECHAT_GAME) { this.storagePath = wx.env.USER_DATA_PATH + ‘/hotupdate/’; } // 创建目录(如果不存在) this._ensureStoragePath(); // 2. 定义服务器Manifest的URL // 通常version.manifest放在一个固定的URL,用于快速检查是否需要更新 this.remoteManifestUrl = ‘https://your-cdn.com/your-game/version.manifest‘; // project.manifest的URL可能需要根据version.manifest解析出来,这里先假设一个模式 this.remoteProjectManifestUrl = ‘https://your-cdn.com/your-game/v{version}/project.manifest‘; // 3. 开始热更新流程 this.startUpdate(); }, startUpdate() { // 先尝试检查是否有新版本 this.checkUpdate(); }, checkUpdate() { this.tipLabel.string = ‘正在检查更新...‘; // 创建AssetsManager对象,这是Cocos提供的热更新核心类 this.am = new jsb.AssetsManager(‘‘, this.storagePath); // 设置验证回调,用于文件下载后的MD5校验 this.am.setVerifyCallback(this._verifyCallback.bind(this)); // 先下载轻量的version.manifest进行快速检查 let versionChecker = new jsb.Manifest(this.remoteManifestUrl); this.am.setVersionCompareHandle(function (versionA, versionB) { // 自定义版本比较逻辑,这里简单比较资源版本号字符串 return versionB > versionA; }); this.am.loadLocalManifest(this._getLocalManifestPath()); // 先加载本地Manifest this.am.loadRemoteManifest(versionChecker, (event) => { if (event.getEventCode() === jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST) { // 首次启动,没有本地Manifest,直接进入游戏或触发完整包更新 cc.log(‘No local manifest, maybe first launch.‘); this.enterGame(); } else if (event.getEventCode() === jsb.EventAssetsManager.ALREADY_UP_TO_DATE) { cc.log(‘Already up-to-date.‘); this.enterGame(); } else if (event.getEventCode() === jsb.EventAssetsManager.NEW_VERSION_FOUND) { cc.log(‘New version found, start updating...‘); // 发现新版本,开始下载完整的project.manifest并进行差异更新 this.startHotUpdate(); } else { cc.error(‘Check update failed:‘, event.getMessage()); // 网络错误等情况,可以重试或直接进入游戏 this.enterGame(); } }); }, startHotUpdate() { this.tipLabel.string = ‘开始下载更新...‘; // 加载远程的project.manifest (这里需要根据版本号拼出正确的URL) let remoteProjectManifestUrl = this._getRemoteProjectManifestUrl(); // 一个根据版本信息生成URL的方法 let remoteManifest = new jsb.Manifest(remoteProjectManifestUrl); this.am.loadRemoteManifest(remoteManifest, (event) => { if (event.getEventCode() !== jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST && event.getEventCode() !== jsb.EventAssetsManager.ALREADY_UP_TO_DATE) { // 设置事件监听器,处理下载过程中的各种事件 this.am.setEventCallback(this._updateCallback.bind(this)); // 开始更新 this.am.update(); } }); }, _updateCallback(event) { let manager = event.getAssetsManager(); switch (event.getEventCode()) { case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST: cc.log(‘No local manifest error.‘); break; case jsb.EventAssetsManager.UPDATE_PROGRESSION: // 更新进度 let percent = event.getPercent(); let filePercent = event.getPercentByFile(); this.progressBar.progress = percent / 100; this.tipLabel.string = `正在下载更新: ${percent.toFixed(2)}% (当前文件: ${filePercent.toFixed(2)}%)`; cc.log(`Total: ${percent}%, File: ${filePercent}%`); break; case jsb.EventAssetsManager.ASSET_UPDATED: cc.log(`File updated: ${event.getAssetId()}`); break; case jsb.EventAssetsManager.ERROR_UPDATING: cc.log(`Error updating file: ${event.getAssetId()}, message: ${event.getMessage()}`); // 单个文件更新失败,可以根据策略重试或忽略 break; case jsb.EventAssetsManager.UPDATE_FINISHED: cc.log(‘Update finished.‘); this.tipLabel.string = ‘更新完成,重启游戏生效‘; // 更新完成,通常需要重启游戏或重新加载场景以应用新资源 this.scheduleOnce(() => { this.restartGame(); }, 1.0); break; case jsb.EventAssetsManager.UPDATE_FAILED: cc.log(‘Update failed. ‘ + event.getMessage()); this.tipLabel.string = ‘更新失败: ‘ + event.getMessage(); // 更新失败,可以提示用户重试或跳过 break; case jsb.EventAssetsManager.ERROR_DECOMPRESS: cc.log(`Error decompressing file: ${event.getMessage()}`); break; } }, _verifyCallback(path, asset) { // 这里是自定义的校验函数,AssetsManager内部会调用 // 你可以在这里加入额外的校验逻辑,比如用xxtea解密后再校验等 // 默认情况下,它会使用manifest中记录的md5进行校验 // 返回 true 表示校验通过,false 表示失败 return true; }, _getLocalManifestPath() { // 返回本地Manifest文件的完整路径 // 首次启动时,这个文件来自安装包内的原始位置 // 热更新后,这个文件位于可写路径下 let local = this.storagePath + ‘project.manifest‘; if (jsb.fileUtils.isFileExist(local)) { return local; } // 如果可写路径没有,则返回包内原始路径(Native平台) return cc.url.raw(‘project.manifest‘); }, _ensureStoragePath() { if (cc.sys.isNative && !jsb.fileUtils.isDirectoryExist(this.storagePath)) { jsb.fileUtils.createDirectory(this.storagePath); } }, restartGame() { // 重启游戏的方法,Native平台和小游戏平台不同 if (cc.sys.isNative) { // 对于原生平台,一种常见做法是退出当前进程,由启动器重新启动 // 或者重新加载启动场景,并确保AssetsManager使用新的搜索路径 cc.assetManager.loadBundle(‘main‘, (err, bundle) => { if (err) { return console.error(err); } bundle.loadScene(‘Loading‘, (err, scene) => { if (err) { return console.error(err); } cc.director.runSceneImmediate(scene); }); }); } else { // 小游戏平台,可以重新加载页面或重启游戏逻辑 cc.game.restart(); } }, enterGame() { // 无需更新或更新完成后,进入游戏主场景 cc.director.loadScene(‘Main‘); } });

这段代码是一个高度简化的框架,实际项目中你需要处理更多的边界情况,比如网络重试、磁盘空间检查、更新失败后的降级策略等。

3.4 原生平台(Android/iOS)的特殊处理

对于原生平台,构建出的assetssrc目录会被打包到安装包内,默认是只读的。热更新的资源必须下载到应用的可写目录(getWritablePath())。因此,在游戏启动时,你需要修改资源的搜索路径,让引擎优先从可写目录加载资源,如果找不到,再回退到安装包内加载。

通常在更新完成后,你需要调用类似下面的代码来更新搜索路径:

if (cc.sys.isNative) { let searchPaths = jsb.fileUtils.getSearchPaths(); let writablePath = jsb.fileUtils.getWritablePath(); let newPath = writablePath + ‘hotupdate/‘; // 将热更新路径插入到搜索路径的最前面 searchPaths.unshift(newPath); jsb.fileUtils.setSearchPaths(searchPaths); }

这样,当cc.loadercc.assetManager尝试加载一个资源(比如bg.png)时,会先在hotupdate目录下找,找到了就用更新后的版本,找不到再去安装包里找原始版本。

4. 常见问题、排查技巧与避坑指南

在实际操作中,你会遇到各种各样的问题。下面是我总结的一些典型场景和解决方案。

4.1 更新失败:Manifest加载或解析错误

  • 问题现象:控制台报错Error: Manifest parse errorFailed to load manifest
  • 排查思路
    1. 检查URL:首先确保你在代码中填写的remoteManifestUrl是绝对正确且可公开访问的。直接在浏览器里打开这个URL,看是否能下载到一个正确的JSON文件。
    2. 检查CORS:如果你的资源放在CDN或另一个域名下,而游戏是Web发布或小游戏平台,可能会遇到跨域问题。浏览器控制台会提示CORS错误。解决方案是在服务器端为Manifest文件(以及资源文件)的HTTP响应头加上Access-Control-Allow-Origin: *
    3. 检查JSON格式:下载下来的Manifest文件,用文本编辑器或JSON校验工具检查其格式是否正确。特别注意末尾不能有多余的逗号。
    4. 检查文件编码:确保Manifest文件是UTF-8 without BOM编码。某些Windows编辑器保存的UTF-8带BOM头,可能会导致解析失败。

4.2 文件下载成功但校验失败(MD5不匹配)

  • 问题现象:更新进度到某个文件时卡住,日志报错Asset MD5 mismatch
  • 排查思路
    1. 服务器文件与Manifest记录不一致:这是最常见的原因。你修改了资源(比如icon.png),重新构建了项目,生成了新的Manifest。但是上传到服务器时,只上传了新的Manifest,却忘记上传新的icon.xxxxx.png文件(注意文件名已变),或者上传的文件在中途损坏了。务必确保服务器上文件的MD5值与它对应的Manifest里记录的MD5值完全一致。可以写一个部署脚本,在上传后自动校验。
    2. 本地缓存问题:某些CDN或服务器可能有缓存,导致客户端下载到的是旧文件。在上传新文件后,记得刷新CDN缓存。
    3. 自定义校验函数出错:如果你重写了_verifyCallback函数,请检查你的校验逻辑是否正确。

4.3 更新后资源没有生效(白屏或显示旧资源)

  • 问题现象:更新流程显示成功,重启游戏后,看到的还是旧的UI或资源。
  • 排查思路
    1. 搜索路径未更新:尤其是在原生平台,更新文件下载到hotupdate目录后,没有调用setSearchPaths将可写路径添加到搜索路径首位。引擎仍然从安装包内加载旧资源。
    2. 资源引用问题:Cocos Creator中,资源是通过UUID引用的。如果你在代码中动态加载一个资源,使用的是resources.load(‘bg’),那么它加载的是resources目录下的bg。热更新通常更新的是assets目录下的构建后资源。确保你更新的资源路径,与代码中加载的路径能对应上。对于动态加载的资源,最好也使用MD5 Cache后的完整路径(或通过某种映射关系获取)。
    3. 未清理旧缓存:在某些平台(如小游戏),引擎可能有自己的资源缓存机制。在应用新资源前,可能需要先清理旧的缓存,例如调用cc.assetManager.cacheManager.clearCache()(注意:这会清空所有缓存,请谨慎使用)。

4.4 版本回滚与降级处理

这是一个高级但至关重要的主题。假设你发布了一个热更新版本v1.1,但里面有个严重Bug,你需要让用户回退到v1.0。

  • 设计思路:你的更新逻辑不能是“单向”的。客户端在更新时,除了下载新文件,还应该备份当前的Manifest和可能被覆盖的关键文件。
  • 简易实现:在AssetsManager开始更新前,将本地的project.manifest复制一份,命名为project.manifest.backup。如果更新后验证失败(比如游戏无法启动),可以提供一个“修复”或“重试”按钮,点击后将备份的Manifest恢复,并清理掉新下载的文件。
  • 更健壮的方案:维护一个本地更新历史记录。每次成功更新后,将当前的版本号和Manifest内容记录在一个本地配置文件中。当需要回滚时,根据历史记录,主动从服务器下载指定旧版本的文件进行替换。这需要服务器端保留多个历史版本的文件。

4.5 大文件更新与断点续传

  • 问题:对于几十兆甚至上百兆的资源包(如一个高清视频或语音包),在移动网络环境下下载容易中断,且重新开始体验极差。
  • 解决方案:原生的jsb.AssetsManager在部分平台可能不支持断点续传。对于这种需求,通常有两条路:
    1. 使用原生插件:寻找或自己开发支持断点续传的原生下载模块(Android用OkHttp,iOS用NSURLSession),下载完文件后再交给热更新管理器校验和安装。
    2. 分包与懒更新:这是更推荐的做法。将大资源包设计为独立的“分包”,在游戏内提供一个专门的下载界面。使用更强大的下载库(如axios配合Blob,或小游戏的wx.downloadFile)来实现带进度和暂停续传的下载。下载完成后,将其作为普通资源放入热更新目录,并更新搜索路径。Cocos Creator的Asset Bundle(资源包)功能非常适合这种场景。

5. 进阶优化与工程化实践

当项目规模变大,热更新就不再是一个简单的功能,而需要工程化的管理。

5.1 自动化构建与部署流水线

手动构建、比对、上传文件极易出错。你应该将这个过程自动化。

  1. 使用CI/CD工具:如Jenkins、GitLab CI、GitHub Actions。
  2. 编写构建脚本:脚本应自动完成以下步骤:
    • 拉取指定版本代码。
    • 安装Cocos Creator命令行环境(npm install -g cocos-creator-cli)。
    • 使用命令行构建项目(cocos creator --project [path] --build [options])。
    • 读取构建生成的project.manifest,根据其内容计算出需要上传的差异文件(对比服务器上最新版本的Manifest)。
    • 将差异文件和新的Manifest上传至CDN的对应版本目录。
    • 可选:更新一个全局的“最新版本号”配置文件,供客户端查询。

5.2 灰度发布与A/B测试

你不能让所有用户同时更新到一个可能存在风险的版本。

  • 实现思路:在更新服务器上,不止存放一份version.manifest。你可以根据用户ID、设备ID、渠道号或随机比例,返回不同版本的Manifest URL。
  • 简单示例:客户端首次请求一个固定的配置接口,该接口根据用户ID哈希值返回一个版本号,如v1.1.0_gray_20。客户端随后去请求https://cdn.com/game/v1.1.0_gray_20/project.manifest。这样,你可以控制只有20%的用户收到这个灰度版本的更新。观察这部分用户的崩溃率和反馈,没问题再全量发布。

5.3 监控与数据分析

热更新系统上线后,你需要知道它的运行状况。

  • 关键指标
    • 更新成功率:有多少用户成功完成了更新。
    • 更新耗时分布:用户平均花费多长时间更新。
    • 失败原因分布:是网络超时、MD5校验失败,还是磁盘空间不足。
    • 版本渗透率:各个资源版本在用户端的分布情况。
  • 如何收集:在客户端更新流程的关键节点(开始检查、发现更新、下载进度、更新成功、更新失败)埋点,将数据上报到你的数据分析平台。在更新失败时,尽可能将错误码和原因一并上报。

5.4 与Git版本控制的协同

项目代码使用Git管理,资源热更新管理文件版本,两者需要协同。

  • 最佳实践:将构建生成的project.manifestversion.manifest也纳入Git管理(可以放在项目根目录或一个特定目录)。这样,任何一个提交对应的资源版本都是明确的。当你需要回溯历史版本进行修复或排查问题时,可以轻松地找到对应的Manifest文件,并重新构建出完全一致的资源包。

最后,关于Cocos Creator 3.x,其热更新核心原理与2.4.x一致,但API和部分流程发生了变化。3.x版本更推荐使用cc.assetManagerBundle体系。AssetsManager类可能被标记为废弃。在3.x中,你需要关注cc.assetManager.downloadercc.assetManager.loadBundle以及Bundle的versiononUpdate等机制。官方文档和示例项目是迁移的最佳参考,但本文所阐述的基于Manifest、MD5校验、差异更新的设计思想,是完全通用的。