Cocos Creator热更新全攻略:从Manifest配置到断点续传实战

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: Mapstring, 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与反馈提供清晰易懂的更新提示、进度条、暂停/继续按钮对于大更新以及更新失败后的友好重试选项。不要让玩家面对一个卡住或报错的黑屏。热更新是连接开发运营与玩家的生命线。一个稳定、高效、用户体验良好的热更新系统能让你在游戏运营中占据主动。希望这份指南能帮你避开我当年踩过的那些“坑”让你的游戏更新之路更加顺畅。记住多测试尤其是弱网和中断测试这是保证热更新可靠性的不二法门。