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; 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 { 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 { 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 { const manager = this.requireAssetsManager(); return new Promise((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 { const manager = this.requireAssetsManager(); return new Promise((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); }