安卓端热更新

只支持热更Cocos内部资源,安卓原生层面的修改(调用手机相册)必须重新安装应用


大致流程

打母包 -> 生成版本控制文件 -> 二次打包 -> 热更包上传到服务器


项目中安装插件

需要先引入热更新插件,实现每次打包后自动对main.js 中注入函数,该函数控制热更新缓存路径

在项目根目录新建extensions文件夹用于存放插件,在extensions中新建builder文件夹和package.json文件,在builder中新建hook.jsindex.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.batversion_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文件里填的服务器的地址中