Cocos2.x热更新实战:从原理到部署的完整解决方案

📅 2026/7/8 17:12:11 👁️ 阅读次数 📝 编程学习
Cocos2.x热更新实战:从原理到部署的完整解决方案

1. 项目概述:为什么Cocos2.x项目必须掌握热更新?

如果你正在用Cocos Creator 2.x开发游戏,尤其是面向移动端的原生游戏,那么“热更新”这个词对你来说,绝对不是一个可选项,而是生存的必需品。想象一下这个场景:你的游戏上线后,玩家反馈了一个致命的脚本逻辑Bug,或者发现了一个可以无限刷金币的漏洞。按照传统方式,你需要重新打包整个游戏,提交到各个应用商店,然后经历漫长的审核(苹果App Store动辄几天),最后祈祷用户愿意主动更新。在这个过程中,流失的玩家和损失的口碑是无法估量的。热更新技术,就是为了解决这个痛点而生——它允许你在不重新提交应用商店安装包的情况下,通过网络动态更新游戏的脚本、资源、配置,甚至是整个场景。

Cocos Creator 2.x版本虽然官方没有在编辑器里提供一个“一键热更”的按钮,但其底层引擎(cocos2d-x)已经提供了强大且成熟的热更新模块AssetsManager。我们所要做的,就是理解这套机制,并把它与Cocos Creator的工作流顺畅地对接起来。这套方案的核心思路非常清晰:版本比对 -> 差异下载 -> 本地替换 -> 重启生效。本教程将为你彻底拆解这个过程,从原理到一行行代码的实现,再到实际部署中会遇到的“坑”,手把手带你构建一个稳定可靠的Cocos2.x热更新系统。无论你是独立开发者还是团队中的技术负责人,掌握这套流程,就等于给你的游戏上了“线上急救”的保险。

2. 热更新核心原理与架构设计

2.1 理解“版本差分”与“搜索路径”机制

Cocos2d-x热更新的核心是AssetsManager模块,它的设计哲学非常巧妙,不依赖于“增量补丁包”,而是基于完整的版本快照比对

为什么是完整快照,而不是增量包?假设本地版本是v1.0,线上最新版本是v1.2。传统增量更新需要生成v1.0->v1.1和v1.1->v1.2两个补丁包,流程复杂且容易出错。而Cocos的方案是,服务器上始终存放着每个版本完整的资源目录结构(assets/和src/)。AssetsManager会同时持有本地和远程的project.manifest(清单文件),这个文件记录了该版本下所有资源的“指纹”(通常是MD5值)。更新时,引擎会逐项比对两个清单中文件的指纹,只下载那些指纹不一致(即被修改过)的文件。对于客户端来说,它下载的确实是一个个独立的文件,但这些文件组合起来,就是完整的v1.2版本资源。这种方式天然支持跨版本更新(v1.0直接到v1.2),也避免了增量包合并可能带来的错误。

搜索路径(Search Paths)是如何工作的?这是热更新能生效的关键。Cocos2d-x引擎在加载资源时,有一个“搜索路径”数组。当你要加载一个图片textures/bg.png时,引擎会按顺序在这个数组中的路径里查找。热更新后,我们会把下载好的新版本资源放在一个特定目录(例如wxfile://usr/local/hotupdate/),并把这个目录插入到搜索路径数组的最前面。这样,引擎就会优先使用新目录下的bg.png,如果没找到,才会回退到原始的包内资源。这就实现了资源的无缝覆盖和回滚。

2.2 关键文件解析:Manifest与Version

整个热更新流程围绕着两个JSON格式的配置文件运转:

  1. project.manifest (清单文件):这是热更新的“总蓝图”。它包含了当前版本所有需要管理的文件列表及其唯一标识(MD5或CRC码)。一个典型的project.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.1.0", "assets": { "assets/textures/bg.png": { "md5": "a1b2c3d4e5f678901234567890123456" }, "src/scripts/main.js": { "md5": "f0e1d2c3b4a596877869594837261504" } }, "searchPaths": [] }
    • packageUrl: 远程资源包的根URL,所有需要下载的文件都基于这个路径。
    • remoteManifestUrl/remoteVersionUrl: 用于检查更新的远程配置文件的地址。
    • version: 当前清单对应的版本号。
    • assets: 一个字典,键是文件相对路径,值是包含md5等信息的对象。这是比对的依据。
  2. version.manifest (版本文件):可以看作是清单文件的“摘要”或“索引”。它只包含最核心的版本信息,体积很小。它的存在是为了优化体验——每次启动游戏检查更新时,先快速下载这个小文件,判断版本号是否变化。如果没变化,就无需下载庞大的project.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.1.0" }

实操心得:MD5的计算生成清单文件时,对每个文件计算MD5是必须的。你可以用Node.js的crypto模块,或者简单的命令行工具(如md5sumon Linux/Mac)。确保你的生成脚本遍历了assetsbuild/jsb-binary/scripts(JSB项目)或build/web-mobile/src(Web项目)目录下的所有文件。一个常见的“坑”是:如果文件内容没变,但修改时间变了,MD5不应改变。所以计算MD5必须基于文件二进制内容,而不是文件属性。

2.3 工作流程全景图

一次完整的热更新,其内部流程可以概括为以下步骤:

  1. 初始化:游戏启动,热更新模块加载本地project.manifest
  2. 检查更新:向remoteVersionUrl请求version.manifest,比对版本号。
  3. 获取更新清单:如果版本号不一致,向remoteManifestUrl请求远程project.manifest
  4. 计算差异:对比本地和远程清单的assets字段,生成需要下载/删除的文件列表。
  5. 下载差异文件:根据列表,从packageUrl拼接路径,逐个下载文件到临时目录。
  6. 验证与替换:下载完成后,验证每个文件的MD5。验证通过后,将临时目录整体移动到正式的搜索路径下,并更新本地project.manifest
  7. 重启游戏/重载场景:更新成功后,重启游戏或重载关键场景,使新资源和新脚本生效。

3. 实战:构建你的热更新系统

3.1 环境准备与项目设置

首先,你需要一个Cocos Creator 2.4.x的项目。确保你的项目已经可以正常构建发布到原生平台(Android/iOS)。

关键一步:关闭MD5 Cache在Cocos Creator的项目设置 -> 构建发布中,找到MD5 Cache选项,务必取消勾选。这个选项会给构建出的文件名加上MD5哈希值,虽然对Web平台缓存有益,但会彻底破坏原生平台热更新的文件路径比对逻辑,导致更新失效。

引入热更新插件为了方便生成清单文件,我们需要一个辅助脚本。你可以在项目根目录创建一个packages文件夹(如果不存在),然后将热更新编辑器插件(通常是一个包含main.jspackage.json的文件夹)放进去。重启Cocos Creator,你可能会在菜单栏看到相关功能。这个插件的作用主要是在每次构建完成后,自动为当前构建产物生成一份本地的project.manifestversion.manifest

3.2 编写热更新核心组件(HotUpdate.js)

这是整个热更新的大脑,我们将它放在assets/scripts/module/HotUpdate.js。这个组件需要处理状态机、UI反馈、错误处理等。

// HotUpdate.js cc.Class({ extends: cc.Component, properties: { // 用于显示进度和状态的Label节点 progressLabel: cc.Label, statusLabel: cc.Label, // 本地清单文件路径(相对于assets目录) localManifestUrl: 'project.manifest', // 更新提示面板 updatePanel: cc.Node }, onLoad () { // 只在原生平台进行热更新检查 if (!cc.sys.isNative) { this.statusLabel.string = '热更新仅支持原生平台'; return; } this.storageKey = 'HotUpdateSearchPaths'; // 本地存储的key this.tempSuffix = '_temp/'; // 临时目录后缀 this._am = null; // AssetsManager实例 this._checkListener = null; this._updateListener = null; this._failCount = 0; this.maxFailCount = 5; this.loadLocalManifest(); }, loadLocalManifest () { // 1. 加载本地清单文件 cc.loader.load(this.localManifestUrl, (err, data) => { if (err) { this.statusLabel.string = '加载本地清单失败: ' + err.message; cc.error(err); return; } // 2. 创建AssetsManager实例 // 注意:AssetsManager只在jsb环境下存在 if (typeof jsb === 'undefined') { this.statusLabel.string = '非原生环境,跳过热更新'; return; } this._am = new jsb.AssetsManager('', cc.writablePath); // 第一个参数是远程packageUrl,先置空,从manifest读取 this._am.setLocalManifest(data); // 设置本地清单 // 3. 恢复之前的搜索路径(如果之前更新过) this.restoreSearchPaths(); // 4. 开始检查更新 this.checkUpdate(); }); }, restoreSearchPaths () { // 从本地存储读取之前设置的热更新搜索路径 let savedPaths = cc.sys.localStorage.getItem(this.storageKey); if (savedPaths) { try { let paths = JSON.parse(savedPaths); // 将热更新路径插入到搜索路径的最前面 jsb.fileUtils.setSearchPaths(paths.concat(jsb.fileUtils.getSearchPaths())); cc.log('已恢复搜索路径:', paths); } catch (e) { cc.error('恢复搜索路径失败:', e); } } }, checkUpdate () { if (!this._am) return; this.statusLabel.string = '正在检查更新...'; // 设置监听器 this._checkListener = (event) => { switch (event.getEventCode()) { case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST: this.statusLabel.string = '本地清单加载失败'; break; case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST: case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST: this.statusLabel.string = '远程清单获取/解析失败'; break; case jsb.EventAssetsManager.ALREADY_UP_TO_DATE: this.statusLabel.string = '已是最新版本'; this.enterGame(); // 直接进入游戏 break; case jsb.EventAssetsManager.NEW_VERSION_FOUND: this.statusLabel.string = `发现新版本: ${event.getMessage()}`; // 询问用户是否更新,这里简单处理为直接开始更新 this.updatePanel.active = true; // 显示更新提示面板 // 在实际项目中,这里应该有一个确认弹窗 // this.startUpdate(); break; case jsb.EventAssetsManager.ERROR_UPDATING: this.statusLabel.string = '检查更新过程出错'; cc.error(event.getMessage()); break; case jsb.EventAssetsManager.ASSET_UPDATED: break; default: break; } }; this._am.setEventCallback(this._checkListener); // 开始检查更新 this._am.checkUpdate(); }, startUpdate () { this.updatePanel.active = false; if (!this._am) return; this.statusLabel.string = '开始下载更新...'; this.progressLabel.string = '0%'; // 移除检查监听器,设置更新监听器 this._am.setEventCallback(null); this._updateListener = (event) => { switch (event.getEventCode()) { case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST: case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST: case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST: this.statusLabel.string = `更新失败(清单错误): ${event.getMessage()}`; this._failCount++; if (this._failCount < this.maxFailCount) { setTimeout(() => this._am.downloadFailedAssets(), 1000); } break; case jsb.EventAssetsManager.ERROR_UPDATING: this.statusLabel.string = `更新失败: ${event.getAssetId()}, ${event.getMessage()}`; this._failCount++; if (this._failCount < this.maxFailCount) { setTimeout(() => this._am.downloadFailedAssets(), 1000); } break; case jsb.EventAssetsManager.UPDATE_PROGRESSION: let percent = event.getPercent(); let filePercent = event.getPercentByFile(); this.progressLabel.string = `${percent}% (文件: ${filePercent}%)`; let msg = event.getMessage(); if (msg) { this.statusLabel.string = `正在更新: ${msg}`; } break; case jsb.EventAssetsManager.UPDATE_FINISHED: this.statusLabel.string = '更新完成!'; this.progressLabel.string = '100%'; // 更新成功,保存新的搜索路径并重启游戏 this.onUpdateSuccess(); break; case jsb.EventAssetsManager.UPDATE_FAILED: this.statusLabel.string = '更新失败,即将重试...'; this._am.downloadFailedAssets(); break; case jsb.EventAssetsManager.ASSET_UPDATED: break; default: break; } }; this._am.setEventCallback(this._updateListener); // 开始更新 this._am.update(); }, onUpdateSuccess () { // 1. 获取更新后的本地清单 let localManifest = this._am.getLocalManifest(); if (!localManifest) { cc.error('更新后获取本地清单失败'); return; } // 2. 获取更新包存储的根路径 let storagePath = this._am.getStoragePath(); // 3. 将新的存储路径保存为最优先的搜索路径 let searchPaths = jsb.fileUtils.getSearchPaths(); // 确保新的路径在最前面 if (searchPaths.indexOf(storagePath) < 0) { searchPaths.unshift(storagePath); } // 保存到本地存储,供游戏下次启动时恢复 cc.sys.localStorage.setItem(this.storageKey, JSON.stringify([storagePath])); cc.log('热更新成功,新资源路径:', storagePath); cc.log('新搜索路径:', searchPaths); // 4. 重启游戏或重载场景 this.restartGame(); }, restartGame () { // 提示用户重启 this.statusLabel.string = '更新成功,即将重启游戏...'; // 原生平台重启需要完全重启进程,这里模拟一个短暂延迟后重载 setTimeout(() => { // 对于Cocos Creator,可以重新加载启动场景 cc.director.loadScene('Launch'); // 或者更彻底地,调用原生重启接口(需要原生层配合) // if (cc.sys.os === cc.sys.OS.ANDROID) { // jsb.reflection.callStaticMethod("org/cocos2dx/javascript/AppActivity", "restartGame", "()V"); // } else if (cc.sys.os === cc.sys.OS.IOS) { // // iOS调用Objective-C方法 // } }, 2000); }, enterGame () { // 没有更新或更新检查完毕,进入游戏主场景 cc.director.loadScene('Main'); }, onCancelUpdate () { // 用户取消更新,直接进入游戏(使用旧版本) this.updatePanel.active = false; this.statusLabel.string = '已取消更新,进入游戏'; this.enterGame(); } });

3.3 生成清单文件的自动化脚本

手动计算MD5并生成manifest文件是繁琐且易出错的。我们需要一个Node.js脚本version_generator.js来自动化这个过程。这个脚本应该在每次构建发布版本后执行。

// version_generator.js const fs = require('fs-extra'); const path = require('path'); const crypto = require('crypto'); // 命令行参数解析 const args = require('minimist')(process.argv.slice(2), { string: ['v', 'u', 's', 'd'], alias: { version: 'v', url: 'u', src: 's', dest: 'd' } }); if (!args.v || !args.u || !args.s || !args.d) { console.error('Usage: node version_generator.js -v <version> -u <remoteUrl> -s <srcDir> -d <destDir>'); console.error('Example: node version_generator.js -v 1.1.0 -u http://cdn.yourgame.com/v1.1/ -s ./build/jsb-binary/ -d ./assets/'); process.exit(1); } const version = args.v; const remoteUrl = args.u.endsWith('/') ? args.u : args.u + '/'; const srcDir = path.resolve(args.s); const destDir = path.resolve(args.d); // 确保目标目录存在 fs.ensureDirSync(destDir); // 需要遍历的目录(对应Cocos Creator构建输出) const assetDirs = [ 'assets', 'src' // 对于JSB项目,脚本在src下;对于Web项目,可能在build/web-mobile/src下,这里需要根据项目调整 ]; function calculateMD5(filePath) { const buffer = fs.readFileSync(filePath); const hash = crypto.createHash('md5'); hash.update(buffer); return hash.digest('hex'); } function generateManifest(basePath, manifestObj) { const assets = {}; let totalSize = 0; assetDirs.forEach(dir => { const fullDirPath = path.join(basePath, dir); if (!fs.existsSync(fullDirPath)) { console.warn(`目录不存在: ${fullDirPath}`); return; } // 递归遍历目录 const files = getAllFiles(fullDirPath); files.forEach(file => { const relativePath = path.relative(basePath, file).replace(/\\/g, '/'); // 统一为斜杠 const md5 = calculateMD5(file); const stat = fs.statSync(file); assets[relativePath] = { md5: md5, size: stat.size }; totalSize += stat.size; }); }); manifestObj.assets = assets; console.log(`找到 ${Object.keys(assets).length} 个资源文件,总大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`); return manifestObj; } function getAllFiles(dir, fileList = []) { const items = fs.readdirSync(dir); items.forEach(item => { const fullPath = path.join(dir, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { getAllFiles(fullPath, fileList); } else { // 忽略一些文件,比如.DS_Store, Thumbs.db, 以及manifest文件本身 if (!item.startsWith('.') && item !== 'project.manifest' && item !== 'version.manifest') { fileList.push(fullPath); } } }); return fileList; } // 生成 project.manifest const projectManifest = { packageUrl: remoteUrl, remoteManifestUrl: remoteUrl + 'project.manifest', remoteVersionUrl: remoteUrl + 'version.manifest', version: version, assets: {}, searchPaths: [] }; generateManifest(srcDir, projectManifest); const projectManifestPath = path.join(destDir, 'project.manifest'); fs.writeJsonSync(projectManifestPath, projectManifest, { spaces: 2 }); console.log(`已生成: ${projectManifestPath}`); // 生成 version.manifest (精简版) const versionManifest = { packageUrl: remoteUrl, remoteManifestUrl: remoteUrl + 'project.manifest', remoteVersionUrl: remoteUrl + 'version.manifest', version: version }; const versionManifestPath = path.join(destDir, 'version.manifest'); fs.writeJsonSync(versionManifestPath, versionManifest, { spaces: 2 }); console.log(`已生成: ${versionManifestPath}`); // 将生成的清单文件也复制到构建输出目录,作为本地初始清单 const buildManifestDest = path.join(srcDir, 'project.manifest'); fs.copySync(projectManifestPath, buildManifestDest); console.log(`已复制清单到构建目录: ${buildManifestDest}`);

使用方式:

# 假设你的构建输出在 build/jsb-binary/,远程资源地址是 http://cdn.xxx.com/v1.0/ node version_generator.js -v 1.0.0 -u http://cdn.xxx.com/v1.0/ -s ./build/jsb-binary/ -d ./assets/

这个脚本会做三件事:

  1. 遍历build/jsb-binary/下的assetssrc目录,计算所有文件的MD5。
  2. ./assets/目录下生成project.manifestversion.manifest
  3. 将生成的project.manifest复制回build/jsb-binary/,这样打出来的应用包就包含了初始版本的清单。

3.4 服务器端部署与资源管理

服务器端非常简单,你只需要一个静态文件服务器(如Nginx, Apache, 甚至是一个简单的HTTP服务器),将每次版本更新的完整资源目录(包含assets/, src/, project.manifest, version.manifest)放到一个独立的文件夹下即可。

目录结构示例:

你的CDN或服务器根目录 ├── v1.0.0/ (初始版本,与首发包内容一致) │ ├── assets/ │ ├── src/ │ ├── project.manifest │ └── version.manifest └── v1.1.0/ (热更新版本) ├── assets/ (可能只包含有变化的文件,但结构完整) ├── src/ (可能只包含有变化的脚本) ├── project.manifest └── version.manifest

关键点:

  • packageUrl在manifest中必须指向正确的目录,例如http://cdn.xxx.com/v1.1.0/
  • 确保服务器正确设置了MIME类型,尤其是对于.manifest文件,可以设置为application/jsontext/plain
  • 对于小团队或测试,你可以用Python快速启动一个本地服务器:python -m http.server 8000,然后将packageUrl改为http://你的本地IP:8000/v1.1.0/进行测试。

4. 原生启动流程的关键修补

这是很多教程会忽略,但至关重要的一步。热更新下载的文件,需要让游戏引擎在启动时就能加载到。这需要通过修改原生工程的启动代码来实现。

4.1 修改main.js文件

Cocos Creator在构建原生项目时,会生成一个main.js作为入口。我们需要在引擎初始化之前,就设置好搜索路径。通常,我们可以通过编辑器插件,在构建完成后自动修改这个文件。

手动修改示例(理解原理):找到build/jsb-binary/src/main.js文件,在文件最开头添加以下代码:

(function () { if (typeof window.jsb === 'object') { // 1. 读取本地存储的热更新搜索路径 var hotUpdateSearchPaths = localStorage.getItem('HotUpdateSearchPaths'); if (hotUpdateSearchPaths) { try { var paths = JSON.parse(hotUpdateSearchPaths); // 2. 设置到引擎文件系统的搜索路径中(插到最前面) jsb.fileUtils.setSearchPaths(paths.concat(jsb.fileUtils.getSearchPaths())); cc.log('热更新搜索路径已应用:', paths); // 3. 处理临时文件(确保更新过程的原子性) var storagePath = paths[0] || ''; var tempPath = storagePath + '_temp/'; var baseOffset = tempPath.length; // 如果存在临时目录,且没有标记文件,说明上次更新可能不完整,尝试恢复 if (jsb.fileUtils.isDirectoryExist(tempPath) && !jsb.fileUtils.isFileExist(tempPath + 'project.manifest.temp')) { var fileList = []; jsb.fileUtils.listFilesRecursively(tempPath, fileList); // 将临时目录下的文件移动到正式目录 for (var i = 0; i < fileList.length; ++i) { var srcPath = fileList[i]; var relativePath = srcPath.substr(baseOffset); var dstPath = storagePath + relativePath; if (srcPath[srcPath.length - 1] === '/') { jsb.fileUtils.createDirectory(dstPath); } else { if (jsb.fileUtils.isFileExist(dstPath)) { jsb.fileUtils.removeFile(dstPath); } jsb.fileUtils.renameFile(srcPath, dstPath); } } // 清理临时目录 jsb.fileUtils.removeDirectory(tempPath); cc.log('已恢复未完成的更新文件'); } } catch (e) { cc.error('应用热更新搜索路径失败:', e); } } } })();

注意事项:原子性操作上面的代码中,移动临时文件 (_temp/) 到正式目录的操作至关重要。AssetsManager在下载更新时,会先下载到[storagePath]_temp/目录,全部下载并验证成功后,才会进行移动。如果移动过程被中断(如游戏崩溃),下次启动时这段代码会尝试完成上次未完成的操作,保证了更新的原子性,避免出现“一半新一半旧”的损坏状态。

4.2 构建后自动化处理

手动修改main.js容易出错且麻烦。更优的做法是使用Cocos Creator的构建后脚本功能。你可以在项目package.json中配置,或者使用一个简单的编辑器插件,在构建过程的“生成后”事件中,自动将上述代码片段插入到生成的main.js文件头部。

5. 测试、调试与常见问题排查

5.1 完整的测试流程

  1. 构建初始版本 (v1.0)

    • 在Cocos Creator中构建原生项目(例如Android)。
    • 运行node version_generator.js ...生成v1.0的清单,并复制到构建输出目录。
    • 将整个构建输出目录(如jsb-binary/)上传到服务器的v1.0/目录下。
    • 打包安装到测试设备上。
  2. 制作更新版本 (v1.1)

    • 在项目中修改一些资源(如一张图片)或脚本。
    • 使用Debug模式重新构建非常重要:在测试阶段,务必使用Debug模式构建。因为Release模式会编译脚本为.jsc字节码,其优先级高于热更新下载的.js文件,导致脚本更新失效。
    • 运行生成脚本,生成v1.1的清单和资源。注意-u参数要指向服务器的v1.1/目录。
    • 将v1.1的assets/src/和两个清单文件上传到服务器的v1.1/目录。注意,这里只需上传变化了的文件以及完整的目录结构,未变化的文件可以不传(因为服务器上v1.0已有),但为了简单,首次测试可以上传全部。
  3. 测试更新

    • 在设备上运行v1.0的App。应该能看到检查更新、下载、重启的流程。
    • 更新后,检查修改的资源或功能是否生效。

5.2 常见问题与解决方案

问题现象可能原因排查步骤与解决方案
更新后资源/脚本未生效1. 搜索路径未正确设置。
2. Release模式构建,.jsc文件优先级高。
3. 清单文件中的packageUrl或文件路径错误。
1. 在main.js开头和HotUpdate.jsonUpdateSuccess中打印jsb.fileUtils.getSearchPaths(),确认热更新路径在最前。
2.测试时务必使用Debug模式构建
3. 检查手机日志,看下载的文件是否成功保存到预期路径。对比本地和远程project.manifestassets字段,确认MD5已改变。
一直提示“已是最新版本”1. 本地和远程的version.manifestversion字段相同。
2. 网络问题,未能成功获取远程version.manifest
3.remoteVersionUrl地址错误。
1. 确认服务器上的version.manifest版本号已递增。
2. 在HotUpdate.jscheckUpdate监听器中,添加网络失败的回调处理,并打印错误信息。
3. 用浏览器或抓包工具直接访问remoteVersionUrl,看是否能下载到正确的JSON文件。
更新过程中崩溃1. 下载的文件MD5校验失败。
2. 存储空间不足。
3. 临时文件移动过程出错。
1. 检查服务器文件是否完整,网络传输是否丢包。可以在生成清单时也输出文件大小,辅助比对。
2. 在更新开始前检查设备可用空间。
3. 确保main.js中的恢复代码能正确执行。检查_temp目录的权限。
iOS更新失败,Android正常iOS对文件路径和权限要求更严格。1. 确保使用的存储路径cc.writablePath在iOS上有读写权限。
2. 检查iOS项目是否正确引入了必要的框架(如AssetsLibrary,但通常Cocos已处理)。
3. 真机调试,查看Xcode控制台的具体错误信息。
“loader for [.manifest] not exists!”警告这是Cocos Creator加载器对.manifest后缀的警告,可以忽略。如果你想消除这个警告,可以在main.js或项目初始化的地方,通过cc.loader.addDownloadHandlers.manifest后缀注册一个空的下载处理器。

5.3 高级技巧与优化建议

  1. 差分更新:上述方案本质是“按文件差分”。对于大文件(如音频、视频),可以考虑将其分割成小 chunks,或者使用二进制差分算法(如bsdiff),但这需要更复杂的服务器端和客户端支持。对于大多数游戏,按文件差分已足够。
  2. 断点续传AssetsManager本身不支持断点续传。对于大文件,可以在失败重试时,通过记录已下载文件列表来实现简易续传。或者,对于超大单文件,可以考虑使用专门的下载库。
  3. 版本回退:保留之前版本的本地清单。当新版本更新后出现严重Bug,可以在客户端实现一个回退逻辑,将搜索路径改回旧版本,并删除新版本文件。这需要更精细的状态管理。
  4. 后台静默更新:可以在游戏主场景加载后,在后台线程中启动AssetsManager进行检查和下载,下次启动时生效。避免阻塞玩家进入游戏。
  5. 资源压缩与加密:上传到CDN前,可以对资源进行压缩(如gzip)和加密。客户端下载后先解密再使用。AssetsManager的监听器提供了文件下载完成的回调,可以在这里进行解密操作。
  6. 监控与统计:在更新流程的关键节点(开始检查、发现更新、开始下载、完成、失败)上报数据到你的游戏服务器,便于监控热更新的成功率和用户覆盖情况。

热更新是连接开发运营与线上用户的桥梁,一个稳定可靠的系统需要仔细的设计、充分的测试和持续的监控。希望这篇教程能帮你打下坚实的基础,让你能自信地应对游戏上线后的各种挑战。记住,核心在于理解“清单比对”和“搜索路径”这两个基本概念,其余的都是围绕它们展开的工程化实践。