Cocos - 热更新
安卓端热更新
只支持热更Cocos内部资源,安卓原生层面的修改(调用手机相册)必须重新安装应用
大致流程
打母包 -> 生成版本控制文件 -> 二次打包 -> 热更包上传到服务器
项目中安装插件
需要先引入热更新插件,实现每次打包后自动对main.js 中注入函数,该函数控制热更新缓存路径
在项目根目录新建extensions文件夹用于存放插件,在extensions中新建builder文件夹和package.json文件,在builder中新建hook.js和index.js 文件
//package.json
{
"name": "hot-update",
"version": "0.0.2",
"package_version": 2,
"description": "用于热更新插件 (3.0.0)",
"contributions": {
"builder": "./builder"
}
}//hook.js
'use strict';
var Fs = require("fs");
var Path = require("path");
var inject_script = `
(function () {
if (typeof window.jsb === 'object') {
var hotUpdateSearchPaths = localStorage.getItem('HotUpdateSearchPaths');
if (hotUpdateSearchPaths) {
var paths = JSON.parse(hotUpdateSearchPaths);
jsb.fileUtils.setSearchPaths(paths);
var fileList = [];
var storagePath = paths[0] || '';
var tempPath = storagePath + '_temp/';
var baseOffset = tempPath.length;
if (jsb.fileUtils.isDirectoryExist(tempPath) && !jsb.fileUtils.isFileExist(tempPath + 'project.manifest.temp')) {
jsb.fileUtils.listFilesRecursively(tempPath, fileList);
fileList.forEach(srcPath => {
var relativePath = srcPath.substr(baseOffset);
var dstPath = storagePath + relativePath;
if (srcPath[srcPath.length] == '/') {
jsb.fileUtils.createDirectory(dstPath)
}
else {
if (jsb.fileUtils.isFileExist(dstPath)) {
jsb.fileUtils.removeFile(dstPath)
}
jsb.fileUtils.renameFile(srcPath, dstPath);
}
})
jsb.fileUtils.removeDirectory(tempPath);
}
}
}
})();
`;
exports.onAfterBuild = function (options, result) {
var url = Path.join(result.dest, 'data', 'main.js');
if (!Fs.existsSync(url)) {
url = Path.join(result.dest, 'assets', 'main.js');
}
Fs.readFile(url, "utf8", function (err, data) {
if (err) {
throw err;
}
var newStr = inject_script + data;
Fs.writeFile(url, newStr, function (error) {
if (err) {
throw err;
}
console.warn("SearchPath updated in built main.js for hot update");
});
});
}//index.js
exports.configs = {
'*': {
hooks: './builder/hook.js'
},
};最后在编辑器界面左上角 扩展 -> 扩展管理器 -> 已安装扩展 中刷新并启用hot-update扩展
版本控制文件工具
在项目根目录中使用npm引入第三方库,用于生成文件MD5
npm i spark-md5
在项目根目录中新建android-manifest.bat与version_generator.js 文件
chcp 65001
@echo off
set /p version="请输入版本号(以 1.3.2 为格式):"
@REM 这里修改一下你放热更资源的远程地址
set remote=https://wangzhing.wang.bjzhiyukeji.cn/app/remote/
@REM 这里修改一下构建出来的安卓包资源路径
set respath=build\oow\data
call node version_generator.js -v %version% -u %remote% -s %respath%
set /p var=Done...//version_generator.js
var fs = require('fs');
var path = require('path');
// var crypto = require('crypto');
var SparkMD5 = require("spark-md5");
var manifest = {
packageUrl: 'https://wangzhing.wang.bjzhiyukeji.cn/app/remote/',
remoteManifestUrl: 'https://wangzhing.wang.bjzhiyukeji.cn/app/remote/project.manifest',
remoteVersionUrl: 'https://wangzhing.wang.bjzhiyukeji.cn/app/remote/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];
console.log("url: ", url);
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];
console.log("src: ", src);
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');
});
项目中创建热更新类
在项目里放脚本的地方新建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?: string) => 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?: string) => 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("校验资源: " + 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("校验资源: " + relativePath + ' (' + expectedMD5 + ')')
return true
} else {
this._eTip("校验资源失败: " + relativePath + ' (' + expectedMD5 + ' vs ' + filemd5 + ')')
return false;
}
}
}
private _retry() {
this._eTip('尝试重新下载...')
this._am.downloadFailedAssets();
}
private _loadLocalManifest() {
return new Promise<string>((rso, rje) => {
this._eTip("读取本地版本信息: " + this._localManifestPath)
// 读取本地的 localmanifest
resources.load(this._localManifestPath, Asset, (err, asset) => {
if (err) {
this._eTip("读取本地版本信息失败: ")
console.error("读取本地版本信息失败: ")
console.error(err);
rje(err);
} else {
this._eTip("本地版本信息路径: " + 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('热更新初始化完成');
}
private _internal_checkUpdate() {
return new Promise<boolean>((rso, rje) => {
if (!this._am.getLocalManifest() || !this._am.getLocalManifest().isLoaded()) {
this._eTip('加载本地版本信息失败');
rje('加载本地版本信息失败');
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...')
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('未获取到本地版本信息,热更新失败')
_checkListener(false, HotfixCheckError.FailToDownloadMainfest)
break;
case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
this._eTip('未获取到远程版本信息,热更新失败')
_checkListener(false, HotfixCheckError.FailToDownloadMainfest)
break;
case native.EventAssetsManager.ALREADY_UP_TO_DATE:
this._eTip('当前已是最新版本')
_checkListener(false, HotfixCheckError.Non)
break;
case native.EventAssetsManager.UPDATE_FINISHED:
this._eTip('热更新完成')
_checkListener(false, HotfixCheckError.Non)
break;
case native.EventAssetsManager.NEW_VERSION_FOUND:
this._eTip('发现新版本,准备热更新(' + 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('未获取到本地版本信息,热更新失败')
failed = true;
break;
case native.EventAssetsManager.UPDATE_PROGRESSION:
var msg = event.getMessage();
let progressInfo = {};
progressInfo["byte_percent"] = event.getPercent() / 100;
progressInfo["file_percent"] = event.getPercentByFile() / 100;
progressInfo["total_files"] = event.getTotalFiles();
progressInfo["downloaded_files"] = event.getDownloadedFiles();
progressInfo["total_bytes"] = event.getTotalBytes();
progressInfo["downloaded_bytes"] = event.getDownloadedBytes();
this._updateListener("progress", JSON.stringify(progressInfo));
if (msg) {
this._eTip('Updated file: ' + msg);
}
break;
case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
this._eTip('未获取到远程版本信息,热更新失败')
failed = true;
break;
case native.EventAssetsManager.ALREADY_UP_TO_DATE:
this._eTip('当前已是最新版本')
failed = true;
break;
case native.EventAssetsManager.UPDATE_FINISHED:
this._eTip('热更新完成 ' + event.getMessage());
needRestart = true;
break;
case native.EventAssetsManager.UPDATE_FAILED:
this._eTip('热更新失败 ' + event.getMessage())
retry = true
break;
case native.EventAssetsManager.ERROR_UPDATING:
this._eTip('热更新失败 ' + 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)
}
}
使用时直接引入这个类
//CCCHotfix类内部是单例模式
CCCHotfix.me;//获取类对象
CCCHotfix.e;//获取类中的事件对象
//先初始化
CCCHotfix.me.init(true);//true启用热更 false关闭热更
//监听类中传出的热更事件
CCCHotfix.me.e.on(CCCHotfix.Event.Tip, (tip) => {
//提示当前热更进度
console.log(tip);
});
//检查更新
CCCHotfix.me.checkUpdate().then(({needUpdate, error}) => {
//根据needUpdate判断当前是否需要更新
if(needUpdate) {
//需要更新
CCCHotfix.me.doUpdate((state, progress) => {
if(state == "progress") {
//更新进程回传进度 progress是内部传出的进度字符串
let progressObj = JSON.parse(progress);
progressObj["byte_percent"];//当前下载字节百分比
progressObj["file_percent"];//当前下载文件百分比
progressObj["total_files"];//总文件数量
progressObj["downloaded_files"];//当前已下载文件数量
progressObj["total_bytes"];//总字节大小
progressObj["downloaded_bytes"];//当前已下载字节大小
return;
}
if(state == "fail") {
//更新失败的逻辑
setTimeout(() => {
game.restart();
}, 2000);
}else if(state == "success") {
//更新完成的逻辑
setTimeout(() => {
game.restart();
}, 2000);
}
})
}else if(error) {
//检查更新失败的逻辑
console.log("检查更新失败", error);
setTimeout(() => {
game.restart();
}, 2000);
}else {
//不需要更新的逻辑
diretor.loadScene("main");
}
});打母包
直接构建打包,第一次打包用于生成版本控制文件
生成版本控制文件
修改.bat 批处理文件中的服务器文件地址与构建出来的安卓包的资源路径
修改完后直接运行.bat文件,输入版本号,会在项目根目录中生成remote-assets文件夹,文件夹中包含两个版本控制文件
复制这两个版本控制文件到项目内resources文件夹中,用于热更时与服务器版本比对
打发布包
项目中加入版本控制文件后,第二次打包
去安卓那边构建出APK上传到服务器
热更包上传到服务器
将两个版本控制文件上传到之前填的服务器的地址中,此时APP版本与服务器版本一致,不会进行热更新
进行热更新
项目进行了一些修改后,想使用热更新部署到APP中,直接编辑器构建安卓包,然后运行.bat,输入比当前版本更高的版本号,然后将两个版本控制文件与项目根目录/build/android/data/中的所有文件夹一起上传到.bat文件里填的服务器的地址中