| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- import { game, native, sys } from 'cc';
- import { NATIVE } from 'cc/env';
- const DEFAULT_MANIFEST_PATH = 'project.manifest';
- const DEFAULT_STORAGE_DIRECTORY = 'hot-update';
- const SEARCH_PATHS_KEY = 'HotUpdateSearchPaths';
- const DEFAULT_MAX_RETRY_COUNT = 3;
- const DEFAULT_MAX_CONCURRENT_TASKS = 6;
- export type HotUpdateStage =
- | 'checking'
- | 'downloading'
- | 'retrying'
- | 'finished';
- export interface HotUpdateProgress {
- stage: HotUpdateStage;
- percent: number;
- percentByFile: number;
- downloadedBytes: number;
- totalBytes: number;
- downloadedFiles: number;
- totalFiles: number;
- message?: string;
- retryCount?: number;
- }
- export type HotUpdateProgressHandler = (progress: HotUpdateProgress) => void;
- export interface HotUpdateCheckResult {
- supported: boolean;
- updateAvailable: boolean;
- localVersion?: string;
- remoteVersion?: string;
- totalBytes: number;
- totalFiles: number;
- }
- export interface HotUpdateResult {
- updated: boolean;
- restartRequired: boolean;
- supported: boolean;
- localVersion?: string;
- remoteVersion?: string;
- }
- export interface HotUpdateServiceOptions {
- manifestPath?: string;
- storageDirectory?: string;
- maxRetryCount?: number;
- maxConcurrentTasks?: number;
- }
- interface ManifestJson {
- packageUrl?: string;
- remoteManifestUrl?: string;
- remoteVersionUrl?: string;
- version?: string;
- engineVersion?: string;
- assets?: Record<string, unknown>;
- searchPaths?: string[];
- [key: string]: unknown;
- }
- /**
- * Creator 原生 AssetsManager 热更新服务。
- *
- * remoteUrl 为空时直接使用本地 Manifest 内配置的远端地址;非空时读取
- * 本地 Manifest 的版本和资源列表,只覆盖三个远端地址后再执行检测。
- */
- export class HotUpdateService {
- private readonly manifestPath: string;
- private readonly storageDirectory: string;
- private readonly maxRetryCount: number;
- private readonly maxConcurrentTasks: number;
- private assetsManager: native.AssetsManager | null = null;
- private operationRunning = false;
- private retryCount = 0;
- private lastUpdateError = '';
- constructor(options: HotUpdateServiceOptions = {}) {
- this.manifestPath = options.manifestPath || DEFAULT_MANIFEST_PATH;
- this.storageDirectory = options.storageDirectory || DEFAULT_STORAGE_DIRECTORY;
- this.maxRetryCount = options.maxRetryCount ?? DEFAULT_MAX_RETRY_COUNT;
- this.maxConcurrentTasks = options.maxConcurrentTasks ?? DEFAULT_MAX_CONCURRENT_TASKS;
- }
- /** 仅检测是否有更新,不下载资源。 */
- async checkUpdate(remoteUrl = ''): Promise<HotUpdateCheckResult> {
- if (!NATIVE) {
- return this.unsupportedCheckResult();
- }
- this.ensureIdle();
- this.operationRunning = true;
- try {
- this.initializeAssetsManager(remoteUrl);
- return await this.runCheck();
- } finally {
- this.clearEventCallback();
- this.operationRunning = false;
- }
- }
- /** 检测并自动下载更新。 */
- async update(
- remoteUrl = '',
- onProgress?: HotUpdateProgressHandler,
- ): Promise<HotUpdateResult> {
- if (!NATIVE) {
- return {
- updated: false,
- restartRequired: false,
- supported: false,
- };
- }
- this.ensureIdle();
- this.operationRunning = true;
- try {
- this.initializeAssetsManager(remoteUrl);
- onProgress?.(this.createProgress('checking'));
- const checkResult = await this.runCheck();
- if (!checkResult.updateAvailable) {
- return {
- updated: false,
- restartRequired: false,
- supported: true,
- localVersion: checkResult.localVersion,
- remoteVersion: checkResult.remoteVersion,
- };
- }
- this.retryCount = 0;
- this.lastUpdateError = '';
- const result = await this.runUpdate(onProgress);
- this.prependUpdatedSearchPaths();
- onProgress?.(this.createProgress('finished', '资源更新完成'));
- return result;
- } finally {
- this.clearEventCallback();
- this.operationRunning = false;
- }
- }
- /** 更新完成后由启动状态调用,确保新脚本和 Asset Bundle 在干净环境中加载。 */
- restart(): void {
- if (NATIVE) {
- game.restart();
- }
- }
- dispose(): void {
- this.clearEventCallback();
- this.assetsManager = null;
- this.operationRunning = false;
- }
- private initializeAssetsManager(remoteUrl: string): void {
- this.clearEventCallback();
- const normalizedRemoteUrl = normalizeRemoteUrl(remoteUrl);
- const storagePath = this.createStoragePath();
- const manager = new native.AssetsManager('', storagePath, compareVersions);
- manager.setMaxConcurrentTask(this.maxConcurrentTasks);
- manager.setVerifyCallback(verifyDownloadedAsset);
- const manifestFilePath = native.fileUtils.fullPathForFilename(this.manifestPath);
- const loaded = normalizedRemoteUrl
- ? this.loadManifestWithRemoteUrl(manager, manifestFilePath, storagePath, normalizedRemoteUrl)
- : manager.loadLocalManifest(manifestFilePath);
- if (!loaded || !manager.getLocalManifest()?.isLoaded()) {
- manager.setEventCallback(null!);
- throw new Error(`[HotUpdateService] 无法加载本地 Manifest:${manifestFilePath}`);
- }
- this.assetsManager = manager;
- }
- private loadManifestWithRemoteUrl(manager: native.AssetsManager, manifestFilePath: string, storagePath: string, remoteUrl: string): boolean {
- const source = native.fileUtils.getStringFromFile(manifestFilePath);
- if (!source) {
- throw new Error(`[HotUpdateService] 本地 Manifest 为空:${manifestFilePath}`);
- }
- let manifestJson: ManifestJson;
- try {
- manifestJson = JSON.parse(source) as ManifestJson;
- } catch (error) {
- throw new Error(`[HotUpdateService] 本地 Manifest JSON 无效:${formatError(error)}`);
- }
- manifestJson.packageUrl = remoteUrl;
- manifestJson.remoteManifestUrl = `${remoteUrl}project.manifest`;
- manifestJson.remoteVersionUrl = `${remoteUrl}version.manifest`;
- const manifestRoot = native.fileUtils.getFileDir(manifestFilePath);
- const manifest = new native.Manifest(JSON.stringify(manifestJson), manifestRoot);
- if (!manifest.isLoaded()) {
- throw new Error('[HotUpdateService] URL 覆盖后的本地 Manifest 加载失败。');
- }
- return manager.loadLocalManifest(manifest, storagePath);
- }
- private runCheck(): Promise<HotUpdateCheckResult> {
- const manager = this.requireAssetsManager();
- return new Promise<HotUpdateCheckResult>((resolve, reject) => {
- manager.setEventCallback((event) => {
- const code = event.getEventCode();
- console.log(`[HotUpdate] CheckRes Code: ${code}`);
- switch (code) {
- case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
- this.clearEventCallback();
- reject(new Error('[HotUpdateService] 未找到本地 Manifest。'));
- break;
- case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
- case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
- this.clearEventCallback();
- reject(new Error(
- `[HotUpdateService] 下载或解析远端 Manifest 失败:${event.getMessage() || code}`,
- ));
- break;
- case native.EventAssetsManager.ALREADY_UP_TO_DATE:
- this.clearEventCallback();
- resolve(this.createCheckResult(false));
- break;
- case native.EventAssetsManager.NEW_VERSION_FOUND:
- this.clearEventCallback();
- resolve(this.createCheckResult(true));
- break;
- }
- });
- manager.checkUpdate();
- });
- }
- private runUpdate(onProgress?: HotUpdateProgressHandler): Promise<HotUpdateResult> {
- const manager = this.requireAssetsManager();
- return new Promise<HotUpdateResult>((resolve, reject) => {
- manager.setEventCallback((event) => {
- const code = event.getEventCode();
- console.log(`[HotUpdate] UpdateRes Code: ${code}`);
- switch (code) {
- case native.EventAssetsManager.UPDATE_PROGRESSION:
- onProgress?.(this.createProgress('downloading', event.getMessage(), event));
- break;
- case native.EventAssetsManager.UPDATE_FINISHED: {
- this.clearEventCallback();
- const localVersion = manager.getLocalManifest()?.getVersion();
- resolve({
- updated: true,
- restartRequired: true,
- supported: true,
- localVersion,
- remoteVersion: localVersion,
- });
- break;
- }
- case native.EventAssetsManager.ALREADY_UP_TO_DATE:
- this.clearEventCallback();
- resolve({
- updated: false,
- restartRequired: false,
- supported: true,
- localVersion: manager.getLocalManifest()?.getVersion(),
- remoteVersion: manager.getRemoteManifest()?.getVersion(),
- });
- break;
- case native.EventAssetsManager.UPDATE_FAILED:
- this.lastUpdateError = event.getMessage() || this.lastUpdateError;
- if (this.retryCount < this.maxRetryCount) {
- this.retryCount += 1;
- onProgress?.(this.createProgress(
- 'retrying',
- this.lastUpdateError || '重试下载失败的资源',
- event,
- ));
- manager.downloadFailedAssets();
- break;
- }
- this.clearEventCallback();
- reject(new Error(
- `[HotUpdateService] 更新失败,已重试 ${this.retryCount} 次:${this.lastUpdateError}`,
- ));
- break;
- case native.EventAssetsManager.ERROR_UPDATING:
- this.lastUpdateError = `资源 ${event.getAssetId()} 更新失败:${event.getMessage()}`;
- console.error(`[HotUpdateService] ${this.lastUpdateError}`);
- break;
- case native.EventAssetsManager.ERROR_DECOMPRESS:
- this.lastUpdateError = `资源解压失败:${event.getMessage()}`;
- console.error(`[HotUpdateService] ${this.lastUpdateError}`);
- break;
- case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
- case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
- case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
- this.clearEventCallback();
- reject(new Error(
- `[HotUpdateService] 热更新初始化失败:${event.getMessage() || code}`,
- ));
- break;
- }
- });
- manager.update();
- });
- }
- private prependUpdatedSearchPaths(): void {
- const manager = this.requireAssetsManager();
- const updatedPaths = manager.getLocalManifest()?.getSearchPaths() || [];
- const currentPaths = native.fileUtils.getSearchPaths();
- const mergedPaths = [
- ...updatedPaths,
- ...currentPaths.filter((path) => !updatedPaths.includes(path)),
- ];
- sys.localStorage.setItem(SEARCH_PATHS_KEY, JSON.stringify(mergedPaths));
- native.fileUtils.setSearchPaths(mergedPaths);
- }
- private createStoragePath(): string {
- const root = native.fileUtils.getWritablePath();
- return `${ensureTrailingSlash(root)}${this.storageDirectory}`;
- }
- private createCheckResult(updateAvailable: boolean): HotUpdateCheckResult {
- const manager = this.requireAssetsManager();
- return {
- supported: true,
- updateAvailable,
- localVersion: manager.getLocalManifest()?.getVersion(),
- remoteVersion: manager.getRemoteManifest()?.getVersion(),
- totalBytes: manager.getTotalBytes(),
- totalFiles: manager.getTotalFiles(),
- };
- }
- private createProgress(stage: HotUpdateStage, message?: string, event?: native.EventAssetsManager): HotUpdateProgress {
- const manager = this.assetsManager;
- return {
- stage,
- percent: normalizePercent(event?.getPercent() || 0),
- percentByFile: normalizePercent(event?.getPercentByFile() || 0),
- downloadedBytes: event?.getDownloadedBytes() || manager?.getDownloadedBytes() || 0,
- totalBytes: event?.getTotalBytes() || manager?.getTotalBytes() || 0,
- downloadedFiles: event?.getDownloadedFiles() || manager?.getDownloadedFiles() || 0,
- totalFiles: event?.getTotalFiles() || manager?.getTotalFiles() || 0,
- message,
- retryCount: stage === 'retrying' ? this.retryCount : undefined,
- };
- }
- private unsupportedCheckResult(): HotUpdateCheckResult {
- return {
- supported: false,
- updateAvailable: false,
- totalBytes: 0,
- totalFiles: 0,
- };
- }
- private requireAssetsManager(): native.AssetsManager {
- if (!this.assetsManager) {
- throw new Error('[HotUpdateService] AssetsManager 尚未初始化。');
- }
- return this.assetsManager;
- }
- private ensureIdle(): void {
- if (this.operationRunning) {
- throw new Error('[HotUpdateService] 已有检测或更新任务正在执行。');
- }
- }
- private clearEventCallback(): void {
- this.assetsManager?.setEventCallback(null!);
- }
- }
- function compareVersions(versionA: string, versionB: string): number {
- const a = versionA.split('.');
- const b = versionB.split('.');
- const length = Math.max(a.length, b.length);
- for (let index = 0; index < length; index += 1) {
- const partA = Number.parseInt(a[index] || '0', 10);
- const partB = Number.parseInt(b[index] || '0', 10);
- if (partA !== partB) {
- return partA - partB;
- }
- }
- return 0;
- }
- function verifyDownloadedAsset(filePath: string, asset: native.ManifestAsset): boolean {
- if (asset.compressed || !asset.size) {
- return true;
- }
- const actualSize = native.fileUtils.getFileSize(filePath);
- const valid = actualSize === asset.size;
- if (!valid) {
- console.error(
- `[HotUpdateService] 文件大小校验失败:${asset.path},expected=${asset.size}, actual=${actualSize}`,
- );
- }
- return valid;
- }
- function normalizeRemoteUrl(value: string): string {
- const normalized = String(value || '').trim();
- if (!normalized) {
- return '';
- }
- if (!/^https?:\/\//i.test(normalized)) {
- throw new Error(`[HotUpdateService] 热更新 URL 必须以 http:// 或 https:// 开头:${normalized}`);
- }
- return ensureTrailingSlash(normalized);
- }
- function ensureTrailingSlash(value: string): string {
- return value.endsWith('/') ? value : `${value}/`;
- }
- function normalizePercent(value: number): number {
- if (!Number.isFinite(value) || value <= 0) return 0;
- return Math.min(1, value > 1 ? value / 100 : value);
- }
- function formatError(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
- }
|