CocosCreator 3.7.x 一步步给你的项目增加热更新
官方文档
官方文档主要是讲了原理,然后给了一个基于 3.3.2 版本的示例,都看完了之后感觉只是一知半解。
所以决定写一个 step by step 的教程,进一步以供大家参考。
(这里也有其它 帖子 可供参考)
环境准备
添加插件 hot-update
首先往你的项目中增加 hot-update插件 ,可以在文档中的示例代码中获取。(了解更多)
安装 spark-md5
打开命令行工具,在项目根目录执行 npm i spark-md5
。
用于计算、验证文件的 md5。
添加 version_generator.js
在项目根目录中添加 version_generator.js 文件,用于生成 热更版本manifest文件。
基于官方版本,但修改了计算文件 md5 的方式,改成了使用 spark-md5 来计算。
点击查看 version_generator.js 代码
var fs = require('fs');
var path = require('path');
// var crypto = require('crypto');
var SparkMD5 = require("spark-md5");
var manifest = {
packageUrl: 'http://localhost/tutorial-hot-update/remote-assets/',
remoteManifestUrl: 'http://localhost/tutorial-hot-update/remote-assets/project.manifest',
remoteVersionUrl: 'http://localhost/tutorial-hot-update/remote-assets/version.manifest',
version: '1.0.0',
assets: {},
searchPaths: []
};
var dest = './remote-assets/';
var src = './jsb/';
// Parse arguments
var i = 2;
while (i < process.argv.length) {
var arg = process.argv[i];
switch (arg) {
case '--url':
case '-u':
var url = process.argv[i + 1];
manifest.packageUrl = url;
manifest.remoteManifestUrl = url + 'project.manifest';
manifest.remoteVersionUrl = url + 'version.manifest';
i += 2;
break;
case '--version':
case '-v':
manifest.version = process.argv[i + 1];
i += 2;
break;
case '--src':
case '-s':
src = process.argv[i + 1];
i += 2;
break;
case '--dest':
case '-d':
dest = process.argv[i + 1];
i += 2;
break;
default:
i++;
break;
}
}
function readDir(dir, obj) {
try {
var stat = fs.statSync(dir);
if (!stat.isDirectory()) {
return;
}
var subpaths = fs.readdirSync(dir), subpath, size, md5, compressed, relative;
for (var i = 0; i < subpaths.length; ++i) {
if (subpaths[i][0] === '.') {
continue;
}
subpath = path.join(dir, subpaths[i]);
stat = fs.statSync(subpath);
if (stat.isDirectory()) {
readDir(subpath, obj);
}
else if (stat.isFile()) {
// Size in Bytes
size = stat['size'];
md5 = SparkMD5.ArrayBuffer.hash(fs.readFileSync(subpath))
// md5 = crypto.createHash('md5').update(fs.readFileSync(subpath)).digest('hex');
compressed = path.extname(subpath).toLowerCase() === '.zip';
relative = path.relative(src, subpath);
relative = relative.replace(/\\/g, '/');
relative = encodeURI(relative);
obj[relative] = {
'size': size,
'md5': md5
};
if (compressed) {
obj[relative].compressed = true;
}
}
}
} catch (err) {
console.error(err)
}
}
var mkdirSync = function (path) {
try {
fs.mkdirSync(path);
} catch (e) {
if (e.code != 'EEXIST') throw e;
}
}
// Iterate assets and src folder
readDir(path.join(src, 'src'), manifest.assets);
readDir(path.join(src, 'assets'), manifest.assets);
readDir(path.join(src, 'jsb-adapter'), manifest.assets);
var destManifest = path.join(dest, 'project.manifest');
var destVersion = path.join(dest, 'version.manifest');
mkdirSync(dest);
fs.writeFile(destManifest, JSON.stringify(manifest), (err) => {
if (err) throw err;
console.log('Manifest successfully generated');
});
delete manifest.assets;
delete manifest.searchPaths;
fs.writeFile(destVersion, JSON.stringify(manifest), (err) => {
if (err) throw err;
console.log('Version successfully generated');
});
TypeScript 部分
新建一个 CCCHotfix.ts 的文件,增加以下内容。
点击查看 CCCHotfix.ts 代码
import { Asset, EventTarget, native, resources } from "cc";
import { NATIVE } from "cc/env";
import * as SparkMD5 from "spark-md5";
enum HotfixCheckError {
Non = "",
NoLocalMainifest = "NoLocalMainifest",
FailToDownloadMainfest = "FailToDownloadMainfest",
}
type HotfixUpdateState = "progress" | "fail" | "success"
export default class CCCHotfix {
public static Event = {
ResVersionUpdate: "ResVersionUpdate",
Tip: "Tip",
}
private static _me: CCCHotfix;
public static get me(): CCCHotfix {
if (!this._me) {
this._me = new CCCHotfix()
}
return this._me;
}
private _working: boolean
private _checkUpdateRetryMaxTime: number;
private _checkUpdateRetryInterval: number;
private _maxUpdateFailRetryTime: number;
private _localManifestPath: string;
private _resVersion: string;
private _checkUpdateRetryTime: number;
private _updateFailRetryTime: number;
private _storagePath: string
private _e: EventTarget;
private _am: native.AssetsManager;
private _checkListener: (need: boolean, error: HotfixCheckError) => void;
private _updateListener: (state: HotfixUpdateState, progress?: number) => void;
public get e(): EventTarget {
return this._e;
}
/**
* 当前热更新资源版本号。
* - 对应有事件 Event.ResVersionUpdate
*/
public get resVersion(): string {
return this._resVersion;
}
public get localManifestPath(): string {
return this._localManifestPath;
}
public get working() {
return this._working && NATIVE && (native ?? false)
}
private constructor() {
this._working = false;
this._resVersion = ''
this._localManifestPath = ''
this._checkUpdateRetryTime = 0;
this._updateFailRetryTime = 0;
this._checkListener = null;
this._updateListener = null;
this._e = new EventTarget()
}
/**
* 进行初始化
* @param {boolean} work 改系统是否要工作
* @param {object} param [可选]参数
* @param {string} param.storagePath [可选]热更资源存储路径。默认为 "hotfix-assets"。
* @param {string} param.localManifestPath [可选]本地 mainfest 在 resource 中的路径,不包括拓展名。默认为 "project"(可对应 project.mainfest)。
* @param {number} param.checkUpdateRetryMaxTime - [可选]检查更新时如果网络错误,最多重试多少次。默认为1。
* @param {number} param.checkUpdateRetryInterval - [可选]检查更新时如果网络错误,间隔多少秒后重试。默认为3。
* @param {number} param.maxUpdateFailRetryTime - [可选]热更新时如果部分文件更新失败,将重试多少次。默认为1。
*/
public init(work: boolean, param?: {
storagePath?: string,
localManifestPath?: string,
checkUpdateRetryMaxTime?: number,
checkUpdateRetryInterval?: number,
maxUpdateFailRetryTime?: number,
}) {
this._working = work;
this._localManifestPath = param?.localManifestPath ?? "project";
this._checkUpdateRetryMaxTime = param?.checkUpdateRetryMaxTime ?? 1;
this._checkUpdateRetryInterval = param?.checkUpdateRetryInterval ?? 3;
this._maxUpdateFailRetryTime = param?.maxUpdateFailRetryTime ?? 1;
this._storagePath = (native.fileUtils ? native.fileUtils.getWritablePath() : '/') + (param?.storagePath ?? 'hotfix-assets');
console.log("storagePath " + this._storagePath);
}
/**
* 检查是否需要进行更新
* @return {Promise<{ needUpdate: boolean, error: any }>}
* needUpdate 是否需要进行更新;
* error 检查更新时的错误,如果没有错误则为 null
*/
public checkUpdate() {
return new Promise<{
needUpdate: boolean,
error: any,
}>((rso) => {
if (!this.working) {
rso({ needUpdate: false, error: null, })
return;
}
if (!this._am) {
this._loadLocalManifest().then((manifestUrl) => {
this._initAM(manifestUrl);
this.checkUpdate().then(ret => rso(ret))
}).catch(err => {
console.log("loadLocalManifest catch err");
rso({ needUpdate: false, error: err, })
})
} else {
this._internal_checkUpdate().then((needUpdate) => {
rso({ needUpdate: needUpdate, error: null })
}).catch(err => {
rso({ needUpdate: false, error: err })
})
}
})
}
/**
* 实际进行更新
* @param listener 更新进度回调:state 当前热更状态;progress 当前进度(0-100)
*/
public doUpdate(listener: (state: HotfixUpdateState, progress?: number) => void) {
if (this._am) {
this._updateListener = listener;
this._am.update();
}
}
// Setup your own version compare handler, versionA and B is versions in string
// if the return value greater than 0, versionA is greater than B,
// if the return value equals 0, versionA equals to B,
// if the return value smaller than 0, versionA is smaller than B.
private _versionCompareHandle(versionA: string, versionB: string) {
this._resVersion = versionA
this._e.emit(CCCHotfix.Event.ResVersionUpdate, versionA)
console.log("JS Custom Version Compare: version A is " + versionA + ', version B is ' + versionB);
var vA = versionA.split('.');
var vB = versionB.split('.');
for (var i = 0; i < vA.length; ++i) {
var a = parseInt(vA[i]);
var b = parseInt(vB[i] || '0');
if (a === b) {
continue;
}
else {
return a - b;
}
}
if (vB.length > vA.length) {
return -1;
}
else {
return 0;
}
}
// Setup the verification callback, but we don't have md5 check function yet, so only print some message
// Return true if the verification passed, otherwise return false
private _verifyCallback(path: string, asset: any) {
// When asset is compressed, we don't need to check its md5, because zip file have been deleted.
var compressed = asset.compressed;
// asset.path is relative path and path is absolute.
var relativePath = asset.path;
// // The size of asset file, but this value could be absent.
// var size = asset.size;
if (compressed) {
this._eTip("Verification passed : " + relativePath)
// panel.info.string = "Verification passed : " + relativePath;
return true;
} else {
// Retrieve the correct md5 value.
var expectedMD5 = asset.md5;
var filemd5 = SparkMD5['default'].ArrayBuffer.hash(native.fileUtils.getDataFromFile(path));
if (filemd5 == expectedMD5) {
this._eTip("Verification passed : " + relativePath + ' (' + expectedMD5 + ')')
return true
} else {
this._eTip("Verification fail : " + relativePath + ' (' + expectedMD5 + ' vs ' + filemd5 + ')')
return false;
}
}
}
private _retry() {
this._eTip('Retry failed Assets...')
this._am.downloadFailedAssets();
}
private _loadLocalManifest() {
return new Promise<string>((rso, rje) => {
this._eTip("load manifest from resource " + this._localManifestPath)
console.log("load manifest from resource " + this._localManifestPath)
// 读取本地的 localmanifest
resources.load(this._localManifestPath, Asset, (err, asset) => {
if (err) {
this._eTip("fail to load manifest")
console.error("fail to load manifest")
console.error(err);
rje(err);
} else {
this._eTip("success to load manifest, its nativeUrl: " + asset.nativeUrl)
console.log("success to load manifest, its nativeUrl: " + asset.nativeUrl)
rso(asset.nativeUrl);
}
})
})
}
private _initAM(manifestUrl: string) {
console.log('Storage path for remote asset : ' + this._storagePath);
this._am = new native.AssetsManager(manifestUrl, this._storagePath, this._versionCompareHandle.bind(this));
this._am.setVerifyCallback(this._verifyCallback.bind(this));
this._am.setEventCallback(this._eventCb.bind(this));
this._eTip('Hot update is ready, please check or directly update.')
}
private _internal_checkUpdate() {
return new Promise<boolean>((rso, rje) => {
if (!this._am.getLocalManifest() || !this._am.getLocalManifest().isLoaded()) {
this._eTip('Failed to load local manifest ...')
rje('Failed to load local manifest ...')
return
}
this._checkListener = (need, err) => {
if (need) {
rso(true)
} else {
if (err != HotfixCheckError.Non) {
if (err == HotfixCheckError.FailToDownloadMainfest) {
if (this._checkUpdateRetryMaxTime > this._checkUpdateRetryTime) {
setTimeout(() => {
this._checkUpdateRetryTime++;
console.log("fail to download manifest, retry check update, retryTime: " + this._checkUpdateRetryTime)
this._internal_checkUpdate()
.then((bol) => rso(bol))
.catch((err) => rje(err));
}, this._checkUpdateRetryInterval * 1000)
} else {
rje(err);
}
} else {
rje(err);
}
} else {
rso(false);
}
}
}
this._eTip('Cheking Update...')
console.log("HotFix AssetManager.checkUpdate")
this._am.checkUpdate();
})
}
private _eventCb(event: any) {
// console.log("HotFix AssetManager.EventCb " + event.getEventCode());
if (this._checkListener ?? false) {
this._checkCb(event)
} else if (this._updateListener ?? false) {
this._updateCb(event)
}
}
private _checkCb(event: any) {
const evtCode = event.getEventCode()
if (evtCode == native.EventAssetsManager.UPDATE_PROGRESSION) {
this._eTip('Cheking Update Progress...')
return;
}
const _checkListener = this._checkListener;
this._checkListener = null;
console.log('HotFix AssetManager.checkUpdate.Callback Code: ' + event.getEventCode());
switch (event.getEventCode()) {
case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
this._eTip('No local manifest file found, hot update skipped.')
_checkListener(false, HotfixCheckError.FailToDownloadMainfest)
break;
case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
this._eTip('Fail to download manifest file, hot update skipped.')
_checkListener(false, HotfixCheckError.FailToDownloadMainfest)
break;
case native.EventAssetsManager.ALREADY_UP_TO_DATE:
this._eTip('Already up to date with the latest remote version.')
_checkListener(false, HotfixCheckError.Non)
break;
case native.EventAssetsManager.UPDATE_FINISHED:
this._eTip('Update finished, seems not change...')
_checkListener(false, HotfixCheckError.Non)
break;
case native.EventAssetsManager.NEW_VERSION_FOUND:
this._eTip('New version found, please try to update. (' + Math.ceil(this._am.getTotalBytes() / 1024) + 'kb)')
_checkListener(true, HotfixCheckError.Non)
break;
default:
return;
}
}
private _updateCb(event: any) {
var needRestart = false;
var failed = false;
var retry = false
switch (event.getEventCode()) {
case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
this._eTip('No local manifest file found, hot update skipped.')
failed = true;
break;
case native.EventAssetsManager.UPDATE_PROGRESSION:
var msg = event.getMessage();
if (msg) {
// console.log(event.getPercent())
// console.log(event.getPercentByFile())
this._eTip('Updated file: ' + msg)
this._updateListener("progress", event.getPercentByFile())
}
break;
case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
this._eTip('Fail to download manifest file, hot update skipped.')
failed = true;
break;
case native.EventAssetsManager.ALREADY_UP_TO_DATE:
this._eTip('Already up to date with the latest remote version.')
failed = true;
break;
case native.EventAssetsManager.UPDATE_FINISHED:
this._eTip('Update finished. ' + event.getMessage());
needRestart = true;
break;
case native.EventAssetsManager.UPDATE_FAILED:
this._eTip('Update failed. ' + event.getMessage())
retry = true
break;
case native.EventAssetsManager.ERROR_UPDATING:
this._eTip('Asset update error: ' + event.getAssetId() + ', ' + event.getMessage());
break;
case native.EventAssetsManager.ERROR_DECOMPRESS:
this._eTip(event.getMessage())
break;
default:
break;
}
if (retry) {
if (this._updateFailRetryTime < this._maxUpdateFailRetryTime) {
this._updateFailRetryTime++;
this._retry()
} else {
failed = true;
}
}
if (failed) {
this._am.setEventCallback(null!);
this._updateListener("fail")
this._updateListener = null;
}
if (needRestart) {
this._am.setEventCallback(null!);
// Prepend the manifest's search path
var searchPaths = native.fileUtils.getSearchPaths();
var newPaths = this._am.getLocalManifest().getSearchPaths();
console.log(JSON.stringify(newPaths));
Array.prototype.unshift.apply(searchPaths, newPaths);
// This value will be retrieved and appended to the default search path during game startup,
// please refer to samples/js-tests/main.js for detailed usage.
// !!! Re-add the search paths in main.js is very important, otherwise, new scripts won't take effect.
localStorage.setItem('HotUpdateSearchPaths', JSON.stringify(searchPaths));
native.fileUtils.setSearchPaths(searchPaths);
this._updateListener("success")
this._updateListener = null;
}
}
private _eTip(tip: string) {
this._e.emit(CCCHotfix.Event.Tip, tip)
}
}
使用
初始化
在使用前需要使用 init 接口进行初始化。
/**
* 进行初始化
* @param {boolean} work 改系统是否要工作
* @param {object} param [可选]参数
* @param {string} param.storagePath [可选]热更资源存储路径。默认为 "hotfix-assets"。
* @param {string} param.localManifestPath [可选]本地 mainfest 在 resource 中的路径,不包括拓展名。默认为 "project"(可对应 project.mainfest)。
* @param {number} param.checkUpdateRetryMaxTime - [可选]检查更新时如果网络错误,最多重试多少次。默认为1。
* @param {number} param.checkUpdateRetryInterval - [可选]检查更新时如果网络错误,间隔多少秒后重试。默认为3。
* @param {number} param.maxUpdateFailRetryTime - [可选]热更新时如果部分文件更新失败,将重试多少次。默认为1。
*/
public init(work: boolean, param?: {
storagePath?: string,
localManifestPath?: string,
checkUpdateRetryMaxTime?: number,
checkUpdateRetryInterval?: number,
maxUpdateFailRetryTime?: number,
})
使用时,通常来说直接使用即可。
CCCHotfix.me.init(true);
如果需要不需要进行热更则
CCCHotfix.me.init(false);
进行热更
进行热更,用到了 checkUpdate接口 以及 doUpdate接口。
// 接收最新的热更进度提示信息。
CCCHotfix.me.e.on(CCCHotfix.Event.Tip, (tip) => {
console.log(tip);
// // 在对应的 UI 中显示提示
// LoaderDlg.me.onTip(tip)
})
// 检查更新
CCCHotfix.me.checkUpdate().then(({ needUpdate, error }) => {
// 响应检查更新回调
console.log("checkUpdateRet " + needUpdate + " " + error)
if (needUpdate) {
// 需要更新
console.log("Start to update");
// 开始更新
CCCHotfix.me.doUpdate((state, progress) => {
// 响应更新回调
if (state == "progress") {
// 进度更新
// progress 范围为 0 ~ 100
// LoaderDlg.me.onProgress(progress);
console.log('版本更新进度: ' + progress);
return;
}
if (state == "fail") {
console.log('版本更新失败...')
// 补充版本更新失败逻辑...
} else if (state == "success") {
console.log("版本更新成功")
// LoaderDlg.me.onProgress(100);
// LoaderDlg.me.onTip(`版本更新成功!即将软重启。`)
setTimeout(()=>{
game.restart();
}, 1666)
}
})
} else if (error) {
// 检查更新时碰到错误
console.error('版本更新检查失败', error)
// 补充版本检查更新失败逻辑...
} else {
console.log("已是最新版本!")
// 补充游戏后续逻辑
}
})
其它接口
当前是否能进行热更
CCCHotfix.me.working
当前热更新资源版本号。需要搭配 Event.ResVersionUpdate 来使用。
CCCHotfix.me.resVersion
// ......
// ......
// ......
this._verTxt.text = `${CCCHotfix.me.resVersion}`
CCCHotfix.me.e.on(CCCHotfix.Event.ResVersionUpdate, ()=>{
this._verTxt.text = `${CCCHotfix.me.resVersion}`
})
构建发布
打母包
用 CocosCreator 的构建功能按照需要打 Android/IOS/Windows/... 平台的包。
下面以下列假设为例子进行构建:
- Android 平台。
- BuildPath 为 build/android。
- manifest 放在 resources。
- 远程资源放在 https://www.foo.bar/game/ 上。
... 等待构建完毕。
创建对应 manifest.bat
在项目根目录中添加 android-manifest.bat 文件,方便后续生成对应版本号的 manifest 文件。
其中 remote、respath 变量需要自行修改。
执行后会在 remote-assets 文件夹中生成新的 version.manifest 和 project.manifest。
chcp 65001
@echo off
set /p version="请输入版本号(以 1.3.2 为格式):"
@REM 这里修改一下你放热更资源的远程地址
set remote=https://www.foo.bar/game/
@REM 这里修改一下热更资源路径
set respath=build\android\data
call node version_generator.js -v %version% -u %remote% -s %respath%
(执行后)
更新母包 manifest
将上一步创建的 project.manifest 和 version.manifest 放到 项目Assets 的 resources 目录下:
对应的 TS 代码中,初始化 CCCHotfix
时需要:
CCCHotfix.me.init(true, {
...
localManifestPath: "project",
...
});
上传 manifest 和 版本资源 到远程地址
先将 project.manifest 和 version.manifest 上传到 https://www.foo.bar/game/ 。
然后将 BuildPath 下的 data 文件夹中的 assets、jsb-adapter、src 也都上传到 https://www.foo.bar/game/ 。(题外话:因为本版本已经打进包里,其实本步骤可忽略。)
再次构建
完成 manifest 和 版本资源 的上传之后,再次打开 CocosCreator 的构建面板进行构建。
然后就按照你喜欢的方式打包成 apk 吧,这个 apk 就是可以发布的、并且发布后能正常进行热更的母包啦!
热更
在母包发布后,我们收集了一些需求,经过一段时间的实现后,现在需要更新到用户手上了!
我们可以通过如下步骤来完成。
构建
为了得到新版本的 版本资源 文件,我们需要 构建 一下。
生成对应版本的 manifest
假设现在线上的热更新资源版本是 0.0.1,那我们就升一个版本,执行之前写好的 manifest.bat 生成 0.0.2 的 manifest 文件。
上传 manifest 和 版本资源 到远程地址
先将 project.manifest 和 version.manifest 上传到 https://www.foo.bar/game/ 。
然后将 BuildPath 下的 data 文件夹中的 assets、jsb-adapter、src 也都上传到 https://www.foo.bar/game/ 。