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 热更新流程像“快递比对发货单”一样工作整个热更新流程可以类比为一次高效的快递收发过程本地持有“旧清单”玩家手机里已经安装的游戏包含一个project.manifest文件记录了安装时所有资源的版本和MD5。访问“中央仓库”游戏启动时客户端会向你们预设的更新服务器CDN或自有服务器请求一个最新的project.manifest文件。“清单”比对客户端将本地Manifest和服务器Manifest进行逐项比对。这个过程不是简单比较版本号而是深入到资源列表对比每个文件的MD5值。生成“采购单”找出所有MD5值不一致的文件以及服务器Manifest中存在而本地没有的文件新增资源。这些文件构成了本次需要更新的“差异包”。“下载并验货”根据生成的列表逐个或批量下载这些文件。每下载完一个立即计算其MD5与服务器Manifest中记录的值进行比对确保文件下载无误。“替换上架”所有文件校验通过后将它们移动到本地游戏的可写路径下如wx.env.USER_DATA_PATH或jsb.fileUtils.getWritablePath()并覆盖旧的资源文件。同时用新的Manifest替换旧的Manifest。“重启生效”热更新通常需要重启游戏模块或整个游戏以加载新的资源。这个流程的精妙之处在于增量更新。假设你的游戏有1000个资源文件本次版本只修改了1个UI图片。那么玩家只需要下载这1个图片文件和最新的Manifest文件而不是整个资源包更新体验极佳。2.3 版本控制策略何时触发更新基于Manifest我们可以设计灵活的更新策略强制更新当服务器Manifest中的engineVersion或version主版本高于本地且不兼容时可以引导用户前往应用商店下载全新安装包。这通常用于引擎大版本升级或游戏核心框架改动。静默热更当只有assetsVersion资源版本或文件MD5发生变化时在游戏启动过程中自动、无感地完成资源下载和替换。这是最常用的方式。可选热更对于一些非紧急的大资源包如新的语音包、高清素材可以提示用户允许其选择在Wi-Fi环境下更新或暂不更新。关键在于这个决策逻辑是客户端根据两个Manifest文件比对后自主做出的服务器只需要提供最新的Manifest和资源文件存放服务通常是静态CDN无需复杂的业务逻辑交互架构简单清晰。3. 实战步骤从零搭建热更新系统3.1 环境准备与项目设置首先确保你使用的是Cocos Creator 2.4.15或以上版本3.x版本流程类似但界面和部分API不同。我们以一个简单的项目为例。构建发布在Cocos Creator编辑器中完成你的项目开发。进入项目 - 构建发布面板。关键构建选项发布平台选择你的目标平台如Android或iOS。MD5 Cache这个选项必须勾选它会在构建时为所有资源文件名附加其MD5值如bg.png变成bg.03abc.png并生成对应的project.manifest和version.manifest文件。这是实现增量更新的前提。主包压缩类型根据需求选择小游戏平台常用小游戏分包。构建路径选择一个本地目录如./build。点击构建等待完成。构建结束后在构建输出目录如build/android下你会看到除了常见的assets、src等目录外还有两个关键文件project.manifest和version.manifest。version.manifest可以看作是project.manifest的一个轻量级摘要通常只包含版本号等基本信息用于更新前的快速检查。3.2 服务器端部署让资源可访问热更新不需要复杂的后端程序但需要有一个可以通过HTTP/HTTPS访问的静态文件服务器或对象存储服务如阿里云OSS、腾讯云COS、AWS S3或自建的Nginx服务器。创建资源目录在你的服务器或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/ └── ...这种结构清晰且可以同时保留多个历史版本便于管理和回滚。上传文件将构建输出的project.manifest、version.manifest以及assets目录下的所有文件注意是assets目录下的内容而不是assets目录本身上传到对应版本的服务端目录中。确保文件的网络访问路径与Manifest中记录的路径能对应上。注意很多新手会直接把整个构建输出目录包含assets、src等文件夹上传到服务器根目录然后在代码里拼接路径这很容易导致路径错误。正确做法是确保服务器上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的特殊处理对于原生平台构建出的assets和src目录会被打包到安装包内默认是只读的。热更新的资源必须下载到应用的可写目录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.loader或cc.assetManager尝试加载一个资源比如bg.png时会先在hotupdate目录下找找到了就用更新后的版本找不到再去安装包里找原始版本。4. 常见问题、排查技巧与避坑指南在实际操作中你会遇到各种各样的问题。下面是我总结的一些典型场景和解决方案。4.1 更新失败Manifest加载或解析错误问题现象控制台报错Error: Manifest parse error或Failed to load manifest。排查思路检查URL首先确保你在代码中填写的remoteManifestUrl是绝对正确且可公开访问的。直接在浏览器里打开这个URL看是否能下载到一个正确的JSON文件。检查CORS如果你的资源放在CDN或另一个域名下而游戏是Web发布或小游戏平台可能会遇到跨域问题。浏览器控制台会提示CORS错误。解决方案是在服务器端为Manifest文件以及资源文件的HTTP响应头加上Access-Control-Allow-Origin: *。检查JSON格式下载下来的Manifest文件用文本编辑器或JSON校验工具检查其格式是否正确。特别注意末尾不能有多余的逗号。检查文件编码确保Manifest文件是UTF-8 without BOM编码。某些Windows编辑器保存的UTF-8带BOM头可能会导致解析失败。4.2 文件下载成功但校验失败MD5不匹配问题现象更新进度到某个文件时卡住日志报错Asset MD5 mismatch。排查思路服务器文件与Manifest记录不一致这是最常见的原因。你修改了资源比如icon.png重新构建了项目生成了新的Manifest。但是上传到服务器时只上传了新的Manifest却忘记上传新的icon.xxxxx.png文件注意文件名已变或者上传的文件在中途损坏了。务必确保服务器上文件的MD5值与它对应的Manifest里记录的MD5值完全一致。可以写一个部署脚本在上传后自动校验。本地缓存问题某些CDN或服务器可能有缓存导致客户端下载到的是旧文件。在上传新文件后记得刷新CDN缓存。自定义校验函数出错如果你重写了_verifyCallback函数请检查你的校验逻辑是否正确。4.3 更新后资源没有生效白屏或显示旧资源问题现象更新流程显示成功重启游戏后看到的还是旧的UI或资源。排查思路搜索路径未更新尤其是在原生平台更新文件下载到hotupdate目录后没有调用setSearchPaths将可写路径添加到搜索路径首位。引擎仍然从安装包内加载旧资源。资源引用问题Cocos Creator中资源是通过UUID引用的。如果你在代码中动态加载一个资源使用的是resources.load(‘bg’)那么它加载的是resources目录下的bg。热更新通常更新的是assets目录下的构建后资源。确保你更新的资源路径与代码中加载的路径能对应上。对于动态加载的资源最好也使用MD5 Cache后的完整路径或通过某种映射关系获取。未清理旧缓存在某些平台如小游戏引擎可能有自己的资源缓存机制。在应用新资源前可能需要先清理旧的缓存例如调用cc.assetManager.cacheManager.clearCache()注意这会清空所有缓存请谨慎使用。4.4 版本回滚与降级处理这是一个高级但至关重要的主题。假设你发布了一个热更新版本v1.1但里面有个严重Bug你需要让用户回退到v1.0。设计思路你的更新逻辑不能是“单向”的。客户端在更新时除了下载新文件还应该备份当前的Manifest和可能被覆盖的关键文件。简易实现在AssetsManager开始更新前将本地的project.manifest复制一份命名为project.manifest.backup。如果更新后验证失败比如游戏无法启动可以提供一个“修复”或“重试”按钮点击后将备份的Manifest恢复并清理掉新下载的文件。更健壮的方案维护一个本地更新历史记录。每次成功更新后将当前的版本号和Manifest内容记录在一个本地配置文件中。当需要回滚时根据历史记录主动从服务器下载指定旧版本的文件进行替换。这需要服务器端保留多个历史版本的文件。4.5 大文件更新与断点续传问题对于几十兆甚至上百兆的资源包如一个高清视频或语音包在移动网络环境下下载容易中断且重新开始体验极差。解决方案原生的jsb.AssetsManager在部分平台可能不支持断点续传。对于这种需求通常有两条路使用原生插件寻找或自己开发支持断点续传的原生下载模块Android用OkHttpiOS用NSURLSession下载完文件后再交给热更新管理器校验和安装。分包与懒更新这是更推荐的做法。将大资源包设计为独立的“分包”在游戏内提供一个专门的下载界面。使用更强大的下载库如axios配合Blob或小游戏的wx.downloadFile来实现带进度和暂停续传的下载。下载完成后将其作为普通资源放入热更新目录并更新搜索路径。Cocos Creator的Asset Bundle资源包功能非常适合这种场景。5. 进阶优化与工程化实践当项目规模变大热更新就不再是一个简单的功能而需要工程化的管理。5.1 自动化构建与部署流水线手动构建、比对、上传文件极易出错。你应该将这个过程自动化。使用CI/CD工具如Jenkins、GitLab CI、GitHub Actions。编写构建脚本脚本应自动完成以下步骤拉取指定版本代码。安装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.manifest和version.manifest也纳入Git管理可以放在项目根目录或一个特定目录。这样任何一个提交对应的资源版本都是明确的。当你需要回溯历史版本进行修复或排查问题时可以轻松地找到对应的Manifest文件并重新构建出完全一致的资源包。最后关于Cocos Creator 3.x其热更新核心原理与2.4.x一致但API和部分流程发生了变化。3.x版本更推荐使用cc.assetManager和Bundle体系。AssetsManager类可能被标记为废弃。在3.x中你需要关注cc.assetManager.downloader、cc.assetManager.loadBundle以及Bundle的version和onUpdate等机制。官方文档和示例项目是迁移的最佳参考但本文所阐述的基于Manifest、MD5校验、差异更新的设计思想是完全通用的。