Cocos Creator热更新全攻略:从Manifest配置到断点续传实战
1. 项目概述
热更新,对于任何一款需要长期运营的Cocos Creator游戏来说,都是绕不开的核心功能。它意味着你可以在不重新提交应用商店审核的情况下,修复线上Bug、更新游戏内容,甚至是发布新活动。听起来很美好,但实际操作起来,从manifest文件的配置到断点续传的实现,每一步都暗藏玄机。我见过太多项目因为热更新没处理好,导致玩家更新失败、资源错乱,甚至游戏崩溃。这篇文章,就是把我这些年踩过的坑、总结的经验,从最基础的配置到高级的断点续传实战,系统地梳理给你。无论你是刚接触热更新的新手,还是想优化现有流程的老手,都能在这里找到答案。
2. 热更新核心机制深度解析
2.1 Manifest文件:热更新的“地图”
Manifest文件是热更新系统的灵魂,它是一份JSON格式的“资源地图”,精确描述了服务器上最新资源的版本、路径和校验信息。AssetsManager(热更新管理器)就是依靠对比本地和远程的Manifest文件,来决定需要下载哪些新资源。
一个标准的Manifest结构如下:
{ “packageUrl”: “http://your-cdn.com/remote-assets/“, “remoteManifestUrl”: “http://your-cdn.com/remote-assets/project.manifest”, “remoteVersionUrl”: “http://your-cdn.com/remote-assets/version.manifest”, “version”: “1.0.2”, “assets”: { “assets/main/background.jpg”: { “md5”: “a1b2c3d4e5f678901234567890123456”, “size”: 204800 }, “src/game.js”: { “md5”: “f0e1d2c3b4a596877869594837261514”, “compressed”: true } }, “searchPaths”: [] }关键字段解读与避坑指南:
packageUrl: 远程资源的根路径。这是所有资源下载的基准URL。最大的坑在于路径结尾的斜杠“/”。务必确保packageUrl以斜杠结尾,而assets中的路径是相对于此根路径的相对路径,且不能以斜杠开头。例如,packageUrl为“http://xx.com/remote/“,一个资源的key是“assets/bg.png”,那么完整的下载URL就是“http://xx.com/remote/assets/bg.png”。如果key错误地写成了“/assets/bg.png”,拼接后的URL就会变成“http://xx.com/remote//assets/bg.png”,可能导致404错误。assets与md5:assets对象里的每个key代表一个资源文件。md5字段是核心,用于校验文件完整性。这里有一个重要技巧:md5字段的内容不一定是真实的文件MD5值。系统只是用它来比较本地和远程资源是否“不同”。因此,你可以用版本号、时间戳甚至自增数字来填充这个字段,只要它能唯一标识一次文件变更即可。这对于那些生成MD5开销大或流程复杂的项目来说,是一个有效的优化点。compressed: 标记文件在服务器上是否是压缩的(目前仅支持zip)。如果为true,下载完成后会自动解压。注意:这要求服务器上的文件确实是zip格式,且解压后的文件名与key(不含扩展名)匹配。例如,key为“assets/bigTexture”,服务器上对应的文件应该是“bigTexture.zip”,解压后会得到“bigTexture.png”(或其他格式)。如果配置错误,会导致解压失败或找不到文件。searchPaths: 热更新成功后,会自动将本次更新的本地缓存路径加入FileUtils的搜索路径,并置于最前。这意味着,当游戏通过cc.resources.load或cc.assetManager加载资源时,会优先从热更新缓存中读取,从而覆盖包体内的旧资源。你通常不需要手动修改这里,除非有非常特殊的路径管理需求。
2.2 资源目录的“楚河汉界”:工程资源 vs 构建资源
这是新手最容易混淆的概念之一。在Cocos Creator项目中:
- 工程资源:位于项目根目录的
assets文件夹下。这是你在编辑器里看到和操作的资源。 - 构建资源:点击构建后,在
build目录(如build/web-mobile)下生成的assets和src文件夹。这才是最终会打包进游戏包体,以及需要上传到CDN进行热更新的资源。
核心原则:热更新针对的是构建后的资源,而非工程资源。你每次构建后,都需要将build目录下的assets、src以及生成的project.manifest和version.manifest文件,上传到packageUrl所指向的服务器目录。永远不要尝试直接更新工程里的assets目录。
2.3 更新流程全景图与断点续传原理
一次完整的热更新流程,可以概括为以下几步:
- 检查远程版本:通过
remoteVersionUrl获取简版的version.manifest,快速比对版本号。 - 获取远程资源清单:如果版本有更新,则下载完整的
remoteManifestUrl指向的project.manifest。 - 差异对比:将远程manifest与本地manifest进行对比,生成需要下载、更新或删除的资源列表。
- 下载差异资源:启动多任务(可配置并发数)下载资源文件,并支持断点续传。
- 校验与替换:下载完成后校验文件(如MD5),然后将临时文件夹中的新资源原子性地替换到本地缓存目录。
- 更新搜索路径:将新的缓存路径加入资源搜索路径,重启游戏或场景以生效。
断点续传是如何工作的?这是AssetsManager的一个强大特性。它同时支持文件级别和字节级别的续传。
- 文件级别:在下载过程中,系统会维护一个“临时manifest”,记录每个文件的下载状态(未开始、下载中、成功)。当更新意外中断,重启后,系统会读取这个临时manifest,跳过已下载成功的文件,只下载未完成或失败的文件。
- 字节级别:对于单个大文件,如果下载中途断开,再次请求时会通过HTTP Header中的
Range字段,告诉服务器从已下载的字节位置继续下载。这要求你的CDN或服务器必须支持Accept-Ranges,否则会从头开始下载整个文件。
3. 从零开始:配置与基础实战
3.1 生成Manifest文件
Cocos Creator编辑器在构建时,可以自动生成Manifest文件。在构建发布面板中,找到“MD5 Cache”和“生成manifest”选项,勾选它们。
- MD5 Cache:会为每个构建出的文件计算MD5并附加在文件名后(如
game.abcd1234.js),主要用于浏览器缓存刷新。对于热更新,我们更关心manifest里的md5字段。 - 生成manifest:构建完成后,会在构建目录下生成
project.manifest和version.manifest文件。
你也可以使用官方示例中的version_generator.js脚本,在构建后自定义生成流程,比如为md5字段注入自定义的版本信息。
3.2 初始化热更新管理器
在你的游戏启动脚本(通常是main.js或第一个场景的onLoad中)初始化热更新管理器。
// 假设你的热更新资源存放在 ‘resources/remote-assets’ 目录,构建后这个目录会被复制到包体内 const { native, sys } = cc; export default class HotUpdateManager { private _am: native.AssetsManager = null; private _storagePath: string = ‘’; private _localManifest: native.Manifest = null; private _remoteManifest: native.Manifest = null; init() { // 1. 确定热更新资源存储路径 this._storagePath = `${jsb.fileUtils.getWritablePath()}/remote-assets/`; // 在模拟器或某些平台,getWritablePath()可能为空,需要备用方案 if (!this._storagePath) { this._storagePath = ‘./remote-assets/’; } // 2. 本地Manifest的路径(初始时,使用包体内的manifest) const localManifestUrl = ‘remote-assets/project.manifest’; // 此路径相对于包体根目录 // 3. 创建热更新管理器 this._am = new native.AssetsManager(localManifestUrl, this._storagePath); // 4. 设置并发任务数量,避免网络拥堵 this._am.setMaxConcurrentTask(3); // 5. (可选但重要)设置自定义版本对比函数 this._am.setVersionCompareHandle((verA, verB) => { // 假设版本号格式为 x.y.z const parseVersion = (v) => v.split(‘.’).map(num => parseInt(num, 10)); const vA = parseVersion(verA); const vB = parseVersion(verB); for (let i = 0; i < 3; i++) { if (vA[i] !== vB[i]) { return vA[i] - vB[i]; } } return 0; }); // 6. 设置事件监听器 this._am.setEventCallback(this._onUpdateEvent.bind(this)); // 7. 加载本地Manifest this._localManifest = this._am.getLocalManifest(); if (!this._localManifest) { console.error(‘Failed to load local manifest.’); return; } // 8. 检查更新 this.checkUpdate(); } private _onUpdateEvent(event: native.EventAssetsManager) { const code = event.getEventCode(); switch (code) { case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST: console.error(‘本地Manifest文件加载失败’); break; case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST: case native.EventAssetsManager.ERROR_PARSE_MANIFEST: console.error(‘远程Manifest下载或解析失败’); break; case native.EventAssetsManager.ALREADY_UP_TO_DATE: console.log(‘已是最新版本’); this._onUpdateFinished(true); break; case native.EventAssetsManager.NEW_VERSION_FOUND: console.log(‘发现新版本’, this._am.getRemoteManifest().getVersion()); // 可以在这里提示用户是否开始更新 this.startUpdate(); break; case native.EventAssetsManager.UPDATE_PROGRESSION: const percent = event.getPercent(); const filePercent = event.getPercentByFile(); console.log(`总体进度: ${percent}%, 文件进度: ${filePercent}%`); // 更新UI进度条 this._updateProgressUI(percent, filePercent); break; case native.EventAssetsManager.ASSET_UPDATED: console.log(‘文件更新成功:’, event.getMessage()); break; case native.EventAssetsManager.UPDATE_FINISHED: console.log(‘热更新完成!’); this._onUpdateFinished(false); break; case native.EventAssetsManager.UPDATE_FAILED: console.error(‘热更新失败:’, event.getMessage()); // 失败后可以尝试重试下载失败的文件 // this._am.downloadFailedAssets(); break; case native.EventAssetsManager.ERROR_UPDATING: console.error(‘更新过程中发生错误:’, event.getMessage()); break; case native.EventAssetsManager.ERROR_DECOMPRESS: console.error(‘文件解压失败:’, event.getMessage()); break; } } checkUpdate() { this._am.checkUpdate(); } startUpdate() { // 开始更新前,确保网络状态等 this._am.update(); } private _onUpdateFinished(isUpToDate: boolean) { if (!isUpToDate) { // !!!关键步骤:更新搜索路径,让游戏加载新资源 this._updateSearchPaths(); } // 通知游戏主逻辑更新完成 // …… } private _updateSearchPaths() { // 获取热更新后的资源路径 const hotUpdateSearchPaths = this._am.getLocalManifest().getSearchPaths(); // 获取当前的搜索路径 const searchPaths = native.fileUtils.getSearchPaths(); // 将热更新路径插入到搜索路径的最前面 Array.prototype.unshift.apply(searchPaths, hotUpdateSearchPaths); // 设置新的搜索路径 native.fileUtils.setSearchPaths(searchPaths); console.log(‘资源搜索路径已更新:’, searchPaths); // 重要:需要重启游戏或重新加载关键场景,新的搜索路径和脚本才能完全生效 cc.director.loadScene(‘Main’); } }3.3 服务器端部署要点
- CDN或静态文件服务器:你需要一个可以通过HTTP/HTTPS访问的服务器来存放
project.manifest、version.manifest以及所有资源文件。 - 支持字节范围请求:为了启用断点续传,确保你的服务器正确配置了
Accept-Ranges: bytes这个HTTP响应头。对于Nginx,默认是支持的;对于某些对象存储服务,可能需要检查其配置。 - CORS配置(如果热更新地址与游戏域名不同):如果CDN域名和游戏部署域名不同,需要在CDN服务器上配置跨域资源共享(CORS)头,例如
Access-Control-Allow-Origin: *。 - 版本管理:建议建立清晰的目录结构,例如按版本号或日期划分文件夹。但注意,
packageUrl在manifest中是固定的,通常指向一个“latest”或当前版本的稳定目录,通过更新manifest内容本身来指向新资源。
4. 进阶实战:断点续传与失败处理
4.1 实现健壮的断点续传逻辑
虽然AssetsManager内置了断点续传,但我们需要在逻辑层做好配合,提供更好的用户体验。
export default class RobustHotUpdateManager extends HotUpdateManager { private _maxRetryCount = 3; private _retryMap: Map<string, number> = new Map(); // 记录文件重试次数 startUpdate() { // 在开始更新前,可以检查网络状态 if (!this._checkNetwork()) { this._onUpdateEvent(new cc.Event.EventAssetsManager(native.EventAssetsManager.ERROR_UPDATING, ‘网络不可用’)); return; } this._am.update(); } private _onUpdateEvent(event: native.EventAssetsManager) { super._onUpdateEvent(event); // 调用父类基础处理 switch (event.getEventCode()) { case native.EventAssetsManager.UPDATE_FAILED: const failedAsset = event.getAssetId(); const errorMessage = event.getMessage(); console.warn(`文件更新失败: ${failedAsset}, 原因: ${errorMessage}`); // 记录失败次数 const retryCount = this._retryMap.get(failedAsset) || 0; if (retryCount < this._maxRetryCount) { this._retryMap.set(failedAsset, retryCount + 1); console.log(`3秒后重试文件: ${failedAsset},第${retryCount + 1}次`); this.scheduleOnce(() => { // 重试下载失败的文件 this._am.downloadFailedAssets(); }, 3); } else { console.error(`文件${failedAsset}重试${this._maxRetryCount}次后仍失败,跳过。`); // 可以选择记录到日志,或强制让用户重新更新 this._retryMap.delete(failedAsset); // 继续更新其他文件(如果有) this._am.update(); } break; case native.EventAssetsManager.UPDATE_FINISHED: // 更新成功,清空重试记录 this._retryMap.clear(); this._onUpdateFinished(false); break; } } private _checkNetwork(): boolean { // 这里可以根据平台使用cc.sys、navigator.onLine等进行更精确的网络检查 return cc.sys.isBrowser ? navigator.onLine : true; // 原生平台需要更复杂的检测 } }4.2 大版本更新与本地缓存清理
当游戏进行跨越多个版本的大更新时,本地可能堆积了多个历史版本的缓存文件。虽然热更新机制会覆盖文件,但残留的旧文件可能占用不必要的空间。可以在检测到主版本号升级时进行清理。
private _cleanOldCacheIfNeeded() { const localManifest = this._am.getLocalManifest(); if (!localManifest) return; const localVersion = localManifest.getVersion(); const storedVersion = sys.localStorage.getItem(‘game_current_version’); // 简单的主版本号判断 (例如,从 1.x.x 到 2.x.x) const localMajorVer = parseInt(localVersion.split(‘.’)[0]); const storedMajorVer = storedVersion ? parseInt(storedVersion.split(‘.’)[0]) : localMajorVer; if (storedMajorVer < localMajorVer) { console.log(`检测到主版本升级 (${storedVersion} -> ${localVersion}),清理旧缓存。`); // 删除整个热更新存储目录 if (jsb.fileUtils.isDirectoryExist(this._storagePath)) { jsb.fileUtils.removeDirectory(this._storagePath); } // 重新创建目录 jsb.fileUtils.createDirectory(this._storagePath); } // 更新存储的版本号 sys.localStorage.setItem(‘game_current_version’, localVersion); } // 在初始化热更新管理器之前调用 this._cleanOldCacheIfNeeded(); this.init();4.3 自定义文件校验
除了使用MD5,你可能希望使用更快的校验方式(如文件大小)或与后端自定义的校验逻辑结合。
this._am.setVerifyCallback((filePath, asset) => { // 示例1:使用文件大小进行快速校验(不够安全,但速度快) const fileSize = fs.readFileSync(filePath).length; // 注意:原生平台需要使用对应的文件API if (asset.size && fileSize === asset.size) { return true; } // 示例2:使用自定义的简单校验和 // const customChecksum = this._calculateSimpleChecksum(filePath); // if (customChecksum === asset.customTag) { return true; } // 示例3:严格使用MD5校验(推荐) // const calculatedMD5 = this._calculateMD5(filePath); // if (calculatedMD5 === asset.md5) { return true; } console.warn(`文件校验失败: ${filePath}`); return false; });5. 常见问题排查与性能优化
5.1 问题排查清单
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 更新检查失败,无法获取远程版本 | 1.remoteVersionUrl地址错误或不可访问。2. 服务器未正确配置CORS。 3. 网络权限问题(原生平台)。 | 1. 在浏览器中直接访问remoteVersionUrl,看是否能下载到version.manifest文件。2. 检查浏览器控制台Network标签,查看请求是否被CORS策略阻塞。 3. 对于原生应用,确保已申请网络权限(Android Manifest, iOS Info.plist)。 |
| 能发现新版本,但下载进度始终为0% | 1.packageUrl路径错误,导致资源文件404。2. Manifest中 assets的key路径与packageUrl拼接后不正确。3. 服务器不支持并发请求或限制了请求频率。 | 1. 检查浏览器Network,查看具体哪个资源文件下载失败。 2. 核对 packageUrl和资源key的拼接规则,确保无误。3. 尝试调低 setMaxConcurrentTask,比如设为1,看是否可行。 |
| 更新完成后,游戏加载的还是旧资源 | 1._updateSearchPaths()方法未调用或调用时机不对。2. 搜索路径更新后,未重启游戏或重新加载场景。 3. 资源缓存问题。 | 1. 确保在UPDATE_FINISHED事件后调用了更新搜索路径的方法。2. 在更新搜索路径后,调用 cc.director.loadScene重启游戏。3. 对于Web平台,可能是浏览器缓存,尝试强制刷新或清理缓存。 |
| 断点续传无效,每次都是从头下载 | 1. 服务器未正确返回Accept-Ranges: bytes头。2. 临时Manifest文件在更新中断时损坏或未保存。 | 1. 使用curl或Postman测试资源URL的HEAD请求,检查响应头是否包含Accept-Ranges。2. 检查热更新存储目录的写入权限。 |
| iOS/Android原生平台更新失败 | 1. 存储路径getWritablePath()获取失败或不可写。2. 原生平台对HTTP请求的安全限制(iOS ATS)。 3. 文件系统路径大小写敏感性问题(Linux/Android)。 | 1. 打印出storagePath,检查路径是否有效且有写入权限。2. iOS确保Info.plist允许任意加载(或指定域名),Android注意网络权限。 3. 确保Manifest和资源文件名的大小写与实际文件完全一致。 |
| 热更新后游戏崩溃或脚本错误 | 1. 脚本(.js文件)热更新后,旧的JS上下文未清理。 2. 引擎版本不一致,热更新了资源但引擎底层接口不兼容。 | 1.脚本热更新后必须重启游戏,这是强制要求。在_updateSearchPaths()后调用cc.game.restart()。2. 避免通过热更新升级引擎(cc.js)。大版本引擎升级应通过应用商店渠道。 |
5.2 性能与体验优化建议
- 分步更新与懒加载:不要一次性更新所有资源。可以将资源分为“核心包”(启动必备)和“功能包”。游戏启动时只更新核心包,进入特定功能前再动态更新对应的功能包。这可以利用Asset Bundle机制配合热更新来实现。
- 差分更新:对于特别大的资源(如视频、大型图集),可以制作差分包。服务器端提供新旧版本间的差异文件,客户端下载差异文件后进行合并。这需要额外的工具链支持,但能极大减少流量消耗。Cocos Creator本身不直接提供此功能,需要自行实现或寻找第三方方案。
- 后台静默更新:在玩家非游戏核心时段(如登录后、匹配等待时),在后台进行小资源的预下载和更新,减少玩家在关键时刻的等待时间。
- 流量与电量考虑:在移动网络下,可以提示玩家是否下载更新,或者仅在Wi-Fi环境下自动进行大版本更新。可以通过
cc.sys.networkType获取网络类型。 - 完善的UI与反馈:提供清晰易懂的更新提示、进度条、暂停/继续按钮(对于大更新)以及更新失败后的友好重试选项。不要让玩家面对一个卡住或报错的黑屏。
热更新是连接开发运营与玩家的生命线。一个稳定、高效、用户体验良好的热更新系统,能让你在游戏运营中占据主动。希望这份指南能帮你避开我当年踩过的那些“坑”,让你的游戏更新之路更加顺畅。记住,多测试,尤其是弱网和中断测试,这是保证热更新可靠性的不二法门。
