From dd46b361fc7d978aa8385a3c65a4e0436f4a84a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=AE=AB=E6=AC=A3=E6=B5=B7?= <gongxinhai@kunpo.cc>
Date: Thu, 20 Mar 2025 16:47:19 +0800
Subject: [PATCH] =?UTF-8?q?=E7=83=AD=E6=9B=B4=E6=96=B0=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/hotupdate/HotUpdateManager.ts | 267 ++++++++++++++++++++++++++++++
 src/kunpocc.ts                    |   2 +
 2 files changed, 269 insertions(+)
 create mode 100644 src/hotupdate/HotUpdateManager.ts

diff --git a/src/hotupdate/HotUpdateManager.ts b/src/hotupdate/HotUpdateManager.ts
new file mode 100644
index 0000000..20f25b9
--- /dev/null
+++ b/src/hotupdate/HotUpdateManager.ts
@@ -0,0 +1,267 @@
+/**
+ * @Author: Gongxh
+ * @Date: 2025-03-20
+ * @Description: 热更新管理器
+ */
+
+import { Asset, game, native, sys } from "cc";
+import { Platform } from "../global/Platform";
+import { log, warn } from "../tool/log";
+
+const TAG = "hotupdate:";
+
+export class HotUpdateManager {
+    private static instance: HotUpdateManager;
+    public static getInstance(): HotUpdateManager {
+        if (!HotUpdateManager.instance) {
+            HotUpdateManager.instance = new HotUpdateManager();
+        }
+        return HotUpdateManager.instance;
+    }
+
+    /** 版本号 */
+    private _version: string = '';
+    /** 可写路径 */
+    private _writablePath: string = '';
+    /** 资源管理器 */
+    private _am: native.AssetsManager = null;
+    /** 是否正在更新 或者 正在检查更新 */
+    private _updating: boolean = false;
+
+    /** 检查更新的回调 */
+    private _checkSucceed: (need: boolean, size: number) => void = null;
+    private _checkFail: (code: number, message: string) => void = null;
+
+    /** 更新回调 */
+    private _updateProgress: (kb: number, total: number) => void = null;
+    private _updateFail: (code: number, message: string) => void = null;
+    private _updateError: (code: number, message: string) => void = null;
+    /**
+     * 1. 初始化热更新管理器
+     * @param manifest 传入manifest文件
+     * @param version 传入游戏版本号 eg: 1.0.0
+     */
+    public init(manifest: Asset, version: string): void {
+        if (!Platform.isNativeMobile) {
+            return;
+        }
+        if (this._am) {
+            warn(`${TAG}请勿重复初始化`);
+            return;
+        }
+        this._version = version;
+
+        let writablePath = native?.fileUtils?.getWritablePath() || "";
+        if (!writablePath.endsWith("/")) {
+            writablePath += "/";
+        }
+        this._writablePath = `${writablePath}hot-update/${version}/`;
+        log(`${TAG}可写路径:${this._writablePath}`);
+
+        // 创建 am 对象
+        this._am = native.AssetsManager.create("", this._writablePath);
+        this._am.setVersionCompareHandle(this._versionCompareHandle);
+        this._am.setVerifyCallback(this._verifyCallback);
+        // 加载本地的 manifest
+        log(`${TAG} 加载本地的 manifest:${manifest.nativeUrl}`);
+        this._am.loadLocalManifest(manifest.nativeUrl);
+    }
+
+    /** 
+     * 2. 检查是否有新的热更版本
+     * @param res.succeed.need 是否需要更新
+     * @param res.succeed.size 需要更新的资源大小 (KB)
+     * 
+     * @param res.fail 检查失败的回调 
+     * @param res.fail.code
+     * -1000: 未初始化 
+     * -1001: 正在更新或者正在检查更新
+     * -1002: 本地manifest文件错误
+     * -1004: 解析远程manifest文件失败
+     */
+    public checkUpdate(res: { succeed: (need: boolean, size: number) => void, fail: (code: number, message: string) => void }): void {
+        this._checkSucceed = res.succeed;
+        this._checkFail = res.fail;
+        if (this._updating) {
+            res.fail(-1001, "正在更新或者正在检查更新");
+            return;
+        }
+        if (!Platform.isNativeMobile) {
+            res.succeed(false, 0);
+            return;
+        }
+        if (!this._am) {
+            res.fail(-1000, "未初始化, 需要先调用init方法");
+            return;
+        }
+        this._updating = true;
+        // 设置回调
+        this._am.setEventCallback(this._checkCb.bind(this));
+        // 检查更新
+        this._am.checkUpdate();
+    }
+
+    /**
+     * 3. 开始热更新
+     * @param res.progress 更新进度回调 kb: 已下载的资源大小, total: 总资源大小 (kb)
+     * @param res.fail 更新失败 可以重试
+     * @param res.fail.code 更新失败错误码
+     * -10001: 更新失败 需要重试
+     * @param res.error 更新错误 无法重试
+     * @param res.error.code 更新错误错误码
+     * -1000: 未初始化
+     * -1001: 正在更新或者正在检查更新
+     * -10002: 资源更新错误
+     * -10003: 解压错误
+     */
+    public startUpdate(res: {
+        progress: (kb: number, total: number) => void,
+        fail: (code: number, message: string) => void,
+        error: (code: number, message: string) => void
+    }): void {
+        this._updateProgress = res.progress;
+        this._updateFail = res.fail;
+        this._updateError = res.error;
+
+        log(`${TAG} 开始热更新`);
+        if (this._updating) {
+            res.error(-1001, "正在更新或者正在检查更新");
+            return;
+        }
+        if (!this._am) {
+            res.error(-1000, "未初始化, 需要先调用init方法");
+            return;
+        }
+        this._updating = true;
+        this._am.setEventCallback(this._updateCb.bind(this));
+        this._am.update();
+    }
+
+    /** 重试失败的资源 */
+    public retryUpdate(): void {
+        this._am.downloadFailedAssets();
+    }
+
+    /** 检查更新的回调 */
+    private _checkCb(event: native.EventAssetsManager) {
+        let eventCode = event.getEventCode();
+        log(`${TAG} 检查更新回调code:${eventCode}`);
+        this._updating = false;
+        switch (eventCode) {
+            case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
+                this._checkFail(-1002, "本地没有manifest文件");
+                break;
+            case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
+                // this._checkFail(-1003, "下载manifest文件失败");
+                this._checkSucceed(false, 0);
+                break;
+            case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
+                this._checkFail(-1004, "解析远程manifest文件失败");
+                break;
+            case native.EventAssetsManager.ALREADY_UP_TO_DATE:
+                this._checkSucceed(false, 0);
+                break;
+            case native.EventAssetsManager.NEW_VERSION_FOUND:
+                // 发现新版本
+                this._checkSucceed(true, this._am.getTotalBytes() / 1024);
+                break;
+            default:
+                return;
+        }
+        this._am.setEventCallback(null);
+    }
+
+    /** 更新的回调 */
+    private _updateCb(event: native.EventAssetsManager) {
+        let eventCode = event.getEventCode();
+        log(`${TAG} 更新回调code:${eventCode}`);
+        let needRestart = false;
+        switch (eventCode) {
+            case native.EventAssetsManager.UPDATE_PROGRESSION:
+                let bytes = event.getDownloadedBytes() / 1024;
+                let total = event.getTotalBytes() / 1024;
+                this._updateProgress(bytes, total);
+                break;
+            case native.EventAssetsManager.UPDATE_FINISHED:
+                // 更新完成 自动重启
+                needRestart = true;
+                break;
+            case native.EventAssetsManager.UPDATE_FAILED:
+                this._updating = false;
+                this._updateFail(-10001, event.getMessage());
+                break;
+            case native.EventAssetsManager.ERROR_UPDATING:
+                this._updating = false;
+                this._updateError(-10002, event.getMessage());
+                break;
+            case native.EventAssetsManager.ERROR_DECOMPRESS:
+                this._updating = false;
+                this._updateError(-10003, event.getMessage());
+                break;
+            default:
+                break;
+        }
+        if (needRestart) {
+            this._am.setEventCallback(null);
+
+            // Prepend the manifest's search path
+            let searchPaths = native.fileUtils.getSearchPaths();
+            log(`${TAG} 当前搜索路径:${JSON.stringify(searchPaths)}`);
+
+            let newPaths = this._am.getLocalManifest().getSearchPaths();
+            log(`${TAG} 新搜索路径:${JSON.stringify(newPaths)}`);
+
+            Array.prototype.unshift.apply(searchPaths, newPaths);
+            sys.localStorage.setItem('hotupdate::version', this._version);
+            sys.localStorage.setItem('hotupdate::searchpaths', JSON.stringify(searchPaths));
+            native.fileUtils.setSearchPaths(searchPaths);
+
+            // 重启游戏
+            setTimeout(() => {
+                game.restart()
+            }, 500);
+        }
+    }
+
+    /**
+     * 版本号比较
+     * @param version1 本地版本号
+     * @param version2 远程版本号
+     * 如果返回值大于0,则version1大于version2
+     * 如果返回值等于0,则version1等于version2
+     * 如果返回值小于0,则version1小于version2
+     */
+    private _versionCompareHandle(version1: string, version2: string): number {
+        log(`${TAG}本地资源版本号:${version1} 远程资源版本号:${version2}`);
+        let v1 = version1.split('.');
+        let v2 = version2.split('.');
+        for (let i = 0; i < v1.length; ++i) {
+            let a = parseInt(v1[i]);
+            let b = parseInt(v2[i] || '0');
+            if (a === b) {
+                continue;
+            } else {
+                return a - b;
+            }
+        }
+        if (v2.length > v1.length) {
+            return -1;
+        }
+        return 0;
+    }
+
+    private _verifyCallback(path: string, asset: native.ManifestAsset): boolean {
+        // 资源是否被压缩, 如果压缩我们不需要检查它的md5值
+        let compressed = asset.compressed;
+        if (compressed) {
+            return true;
+        }
+        // 预期的md5
+        let expectedMD5 = asset.md5;
+        // 资源大小
+        let size = asset.size;
+        // 验证资源md5
+        log(`${TAG} 记录的md5:${expectedMD5} 文件大小:${size} 文件相对路径:${asset.path} 绝对路径:${path}`);
+        return true;
+    }
+}
diff --git a/src/kunpocc.ts b/src/kunpocc.ts
index c7a9fc4..a6b668b 100644
--- a/src/kunpocc.ts
+++ b/src/kunpocc.ts
@@ -74,3 +74,5 @@ export { ConditionAllNode } from "./condition/node/ConditionAllNode";
 export { ConditionAnyNode } from "./condition/node/ConditionAnyNode";
 export { ConditionBase } from "./condition/node/ConditionBase";
 
+/** 热更新 */
+export { HotUpdateManager } from "./hotupdate/HotUpdateManager";