CocosCreator 3.7.x 一步步给你的项目增加热更新

官方文档

1. 热更新范例教程 · Cocos Creator

2. 热更新管理器 · Cocos Creator

官方文档主要是讲了原理,然后给了一个基于 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/... 平台的包。
下面以下列假设为例子进行构建:

  1. Android 平台。
  2. BuildPath 为 build/android。
  3. manifest 放在 resources。
  4. 远程资源放在 https://www.foo.bar/game/ 上。

image

... 等待构建完毕。

创建对应 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%

(执行后)
image

更新母包 manifest

将上一步创建的 project.manifest 和 version.manifest 放到 项目Assets 的 resources 目录下:

image

对应的 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/ 。(题外话:因为本版本已经打进包里,其实本步骤可忽略。)

image

再次构建

完成 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/

image

posted @ 2023-06-29 08:38  bakabird1998  阅读(604)  评论(0编辑  收藏  举报