HotUpdateService.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import { game, native, sys } from 'cc';
  2. import { NATIVE } from 'cc/env';
  3. const DEFAULT_MANIFEST_PATH = 'project.manifest';
  4. const DEFAULT_STORAGE_DIRECTORY = 'hot-update';
  5. const SEARCH_PATHS_KEY = 'HotUpdateSearchPaths';
  6. const DEFAULT_MAX_RETRY_COUNT = 3;
  7. const DEFAULT_MAX_CONCURRENT_TASKS = 6;
  8. export type HotUpdateStage =
  9. | 'checking'
  10. | 'downloading'
  11. | 'retrying'
  12. | 'finished';
  13. export interface HotUpdateProgress {
  14. stage: HotUpdateStage;
  15. percent: number;
  16. percentByFile: number;
  17. downloadedBytes: number;
  18. totalBytes: number;
  19. downloadedFiles: number;
  20. totalFiles: number;
  21. message?: string;
  22. retryCount?: number;
  23. }
  24. export type HotUpdateProgressHandler = (progress: HotUpdateProgress) => void;
  25. export interface HotUpdateCheckResult {
  26. supported: boolean;
  27. updateAvailable: boolean;
  28. localVersion?: string;
  29. remoteVersion?: string;
  30. totalBytes: number;
  31. totalFiles: number;
  32. }
  33. export interface HotUpdateResult {
  34. updated: boolean;
  35. restartRequired: boolean;
  36. supported: boolean;
  37. localVersion?: string;
  38. remoteVersion?: string;
  39. }
  40. export interface HotUpdateServiceOptions {
  41. manifestPath?: string;
  42. storageDirectory?: string;
  43. maxRetryCount?: number;
  44. maxConcurrentTasks?: number;
  45. }
  46. interface ManifestJson {
  47. packageUrl?: string;
  48. remoteManifestUrl?: string;
  49. remoteVersionUrl?: string;
  50. version?: string;
  51. engineVersion?: string;
  52. assets?: Record<string, unknown>;
  53. searchPaths?: string[];
  54. [key: string]: unknown;
  55. }
  56. /**
  57. * Creator 原生 AssetsManager 热更新服务。
  58. *
  59. * remoteUrl 为空时直接使用本地 Manifest 内配置的远端地址;非空时读取
  60. * 本地 Manifest 的版本和资源列表,只覆盖三个远端地址后再执行检测。
  61. */
  62. export class HotUpdateService {
  63. private readonly manifestPath: string;
  64. private readonly storageDirectory: string;
  65. private readonly maxRetryCount: number;
  66. private readonly maxConcurrentTasks: number;
  67. private assetsManager: native.AssetsManager | null = null;
  68. private operationRunning = false;
  69. private retryCount = 0;
  70. private lastUpdateError = '';
  71. constructor(options: HotUpdateServiceOptions = {}) {
  72. this.manifestPath = options.manifestPath || DEFAULT_MANIFEST_PATH;
  73. this.storageDirectory = options.storageDirectory || DEFAULT_STORAGE_DIRECTORY;
  74. this.maxRetryCount = options.maxRetryCount ?? DEFAULT_MAX_RETRY_COUNT;
  75. this.maxConcurrentTasks = options.maxConcurrentTasks ?? DEFAULT_MAX_CONCURRENT_TASKS;
  76. }
  77. /** 仅检测是否有更新,不下载资源。 */
  78. async checkUpdate(remoteUrl = ''): Promise<HotUpdateCheckResult> {
  79. if (!NATIVE) {
  80. return this.unsupportedCheckResult();
  81. }
  82. this.ensureIdle();
  83. this.operationRunning = true;
  84. try {
  85. this.initializeAssetsManager(remoteUrl);
  86. return await this.runCheck();
  87. } finally {
  88. this.clearEventCallback();
  89. this.operationRunning = false;
  90. }
  91. }
  92. /** 检测并自动下载更新。 */
  93. async update(
  94. remoteUrl = '',
  95. onProgress?: HotUpdateProgressHandler,
  96. ): Promise<HotUpdateResult> {
  97. if (!NATIVE) {
  98. return {
  99. updated: false,
  100. restartRequired: false,
  101. supported: false,
  102. };
  103. }
  104. this.ensureIdle();
  105. this.operationRunning = true;
  106. try {
  107. this.initializeAssetsManager(remoteUrl);
  108. onProgress?.(this.createProgress('checking'));
  109. const checkResult = await this.runCheck();
  110. if (!checkResult.updateAvailable) {
  111. return {
  112. updated: false,
  113. restartRequired: false,
  114. supported: true,
  115. localVersion: checkResult.localVersion,
  116. remoteVersion: checkResult.remoteVersion,
  117. };
  118. }
  119. this.retryCount = 0;
  120. this.lastUpdateError = '';
  121. const result = await this.runUpdate(onProgress);
  122. this.prependUpdatedSearchPaths();
  123. onProgress?.(this.createProgress('finished', '资源更新完成'));
  124. return result;
  125. } finally {
  126. this.clearEventCallback();
  127. this.operationRunning = false;
  128. }
  129. }
  130. /** 更新完成后由启动状态调用,确保新脚本和 Asset Bundle 在干净环境中加载。 */
  131. restart(): void {
  132. if (NATIVE) {
  133. game.restart();
  134. }
  135. }
  136. dispose(): void {
  137. this.clearEventCallback();
  138. this.assetsManager = null;
  139. this.operationRunning = false;
  140. }
  141. private initializeAssetsManager(remoteUrl: string): void {
  142. this.clearEventCallback();
  143. const normalizedRemoteUrl = normalizeRemoteUrl(remoteUrl);
  144. const storagePath = this.createStoragePath();
  145. const manager = new native.AssetsManager('', storagePath, compareVersions);
  146. manager.setMaxConcurrentTask(this.maxConcurrentTasks);
  147. manager.setVerifyCallback(verifyDownloadedAsset);
  148. const manifestFilePath = native.fileUtils.fullPathForFilename(this.manifestPath);
  149. const loaded = normalizedRemoteUrl
  150. ? this.loadManifestWithRemoteUrl(manager, manifestFilePath, storagePath, normalizedRemoteUrl)
  151. : manager.loadLocalManifest(manifestFilePath);
  152. if (!loaded || !manager.getLocalManifest()?.isLoaded()) {
  153. manager.setEventCallback(null!);
  154. throw new Error(`[HotUpdateService] 无法加载本地 Manifest:${manifestFilePath}`);
  155. }
  156. this.assetsManager = manager;
  157. }
  158. private loadManifestWithRemoteUrl(manager: native.AssetsManager, manifestFilePath: string, storagePath: string, remoteUrl: string): boolean {
  159. const source = native.fileUtils.getStringFromFile(manifestFilePath);
  160. if (!source) {
  161. throw new Error(`[HotUpdateService] 本地 Manifest 为空:${manifestFilePath}`);
  162. }
  163. let manifestJson: ManifestJson;
  164. try {
  165. manifestJson = JSON.parse(source) as ManifestJson;
  166. } catch (error) {
  167. throw new Error(`[HotUpdateService] 本地 Manifest JSON 无效:${formatError(error)}`);
  168. }
  169. manifestJson.packageUrl = remoteUrl;
  170. manifestJson.remoteManifestUrl = `${remoteUrl}project.manifest`;
  171. manifestJson.remoteVersionUrl = `${remoteUrl}version.manifest`;
  172. const manifestRoot = native.fileUtils.getFileDir(manifestFilePath);
  173. const manifest = new native.Manifest(JSON.stringify(manifestJson), manifestRoot);
  174. if (!manifest.isLoaded()) {
  175. throw new Error('[HotUpdateService] URL 覆盖后的本地 Manifest 加载失败。');
  176. }
  177. return manager.loadLocalManifest(manifest, storagePath);
  178. }
  179. private runCheck(): Promise<HotUpdateCheckResult> {
  180. const manager = this.requireAssetsManager();
  181. return new Promise<HotUpdateCheckResult>((resolve, reject) => {
  182. manager.setEventCallback((event) => {
  183. const code = event.getEventCode();
  184. console.log(`[HotUpdate] CheckRes Code: ${code}`);
  185. switch (code) {
  186. case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
  187. this.clearEventCallback();
  188. reject(new Error('[HotUpdateService] 未找到本地 Manifest。'));
  189. break;
  190. case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
  191. case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
  192. this.clearEventCallback();
  193. reject(new Error(
  194. `[HotUpdateService] 下载或解析远端 Manifest 失败:${event.getMessage() || code}`,
  195. ));
  196. break;
  197. case native.EventAssetsManager.ALREADY_UP_TO_DATE:
  198. this.clearEventCallback();
  199. resolve(this.createCheckResult(false));
  200. break;
  201. case native.EventAssetsManager.NEW_VERSION_FOUND:
  202. this.clearEventCallback();
  203. resolve(this.createCheckResult(true));
  204. break;
  205. }
  206. });
  207. manager.checkUpdate();
  208. });
  209. }
  210. private runUpdate(onProgress?: HotUpdateProgressHandler): Promise<HotUpdateResult> {
  211. const manager = this.requireAssetsManager();
  212. return new Promise<HotUpdateResult>((resolve, reject) => {
  213. manager.setEventCallback((event) => {
  214. const code = event.getEventCode();
  215. console.log(`[HotUpdate] UpdateRes Code: ${code}`);
  216. switch (code) {
  217. case native.EventAssetsManager.UPDATE_PROGRESSION:
  218. onProgress?.(this.createProgress('downloading', event.getMessage(), event));
  219. break;
  220. case native.EventAssetsManager.UPDATE_FINISHED: {
  221. this.clearEventCallback();
  222. const localVersion = manager.getLocalManifest()?.getVersion();
  223. resolve({
  224. updated: true,
  225. restartRequired: true,
  226. supported: true,
  227. localVersion,
  228. remoteVersion: localVersion,
  229. });
  230. break;
  231. }
  232. case native.EventAssetsManager.ALREADY_UP_TO_DATE:
  233. this.clearEventCallback();
  234. resolve({
  235. updated: false,
  236. restartRequired: false,
  237. supported: true,
  238. localVersion: manager.getLocalManifest()?.getVersion(),
  239. remoteVersion: manager.getRemoteManifest()?.getVersion(),
  240. });
  241. break;
  242. case native.EventAssetsManager.UPDATE_FAILED:
  243. this.lastUpdateError = event.getMessage() || this.lastUpdateError;
  244. if (this.retryCount < this.maxRetryCount) {
  245. this.retryCount += 1;
  246. onProgress?.(this.createProgress(
  247. 'retrying',
  248. this.lastUpdateError || '重试下载失败的资源',
  249. event,
  250. ));
  251. manager.downloadFailedAssets();
  252. break;
  253. }
  254. this.clearEventCallback();
  255. reject(new Error(
  256. `[HotUpdateService] 更新失败,已重试 ${this.retryCount} 次:${this.lastUpdateError}`,
  257. ));
  258. break;
  259. case native.EventAssetsManager.ERROR_UPDATING:
  260. this.lastUpdateError = `资源 ${event.getAssetId()} 更新失败:${event.getMessage()}`;
  261. console.error(`[HotUpdateService] ${this.lastUpdateError}`);
  262. break;
  263. case native.EventAssetsManager.ERROR_DECOMPRESS:
  264. this.lastUpdateError = `资源解压失败:${event.getMessage()}`;
  265. console.error(`[HotUpdateService] ${this.lastUpdateError}`);
  266. break;
  267. case native.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
  268. case native.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
  269. case native.EventAssetsManager.ERROR_PARSE_MANIFEST:
  270. this.clearEventCallback();
  271. reject(new Error(
  272. `[HotUpdateService] 热更新初始化失败:${event.getMessage() || code}`,
  273. ));
  274. break;
  275. }
  276. });
  277. manager.update();
  278. });
  279. }
  280. private prependUpdatedSearchPaths(): void {
  281. const manager = this.requireAssetsManager();
  282. const updatedPaths = manager.getLocalManifest()?.getSearchPaths() || [];
  283. const currentPaths = native.fileUtils.getSearchPaths();
  284. const mergedPaths = [
  285. ...updatedPaths,
  286. ...currentPaths.filter((path) => !updatedPaths.includes(path)),
  287. ];
  288. sys.localStorage.setItem(SEARCH_PATHS_KEY, JSON.stringify(mergedPaths));
  289. native.fileUtils.setSearchPaths(mergedPaths);
  290. }
  291. private createStoragePath(): string {
  292. const root = native.fileUtils.getWritablePath();
  293. return `${ensureTrailingSlash(root)}${this.storageDirectory}`;
  294. }
  295. private createCheckResult(updateAvailable: boolean): HotUpdateCheckResult {
  296. const manager = this.requireAssetsManager();
  297. return {
  298. supported: true,
  299. updateAvailable,
  300. localVersion: manager.getLocalManifest()?.getVersion(),
  301. remoteVersion: manager.getRemoteManifest()?.getVersion(),
  302. totalBytes: manager.getTotalBytes(),
  303. totalFiles: manager.getTotalFiles(),
  304. };
  305. }
  306. private createProgress(stage: HotUpdateStage, message?: string, event?: native.EventAssetsManager): HotUpdateProgress {
  307. const manager = this.assetsManager;
  308. return {
  309. stage,
  310. percent: normalizePercent(event?.getPercent() || 0),
  311. percentByFile: normalizePercent(event?.getPercentByFile() || 0),
  312. downloadedBytes: event?.getDownloadedBytes() || manager?.getDownloadedBytes() || 0,
  313. totalBytes: event?.getTotalBytes() || manager?.getTotalBytes() || 0,
  314. downloadedFiles: event?.getDownloadedFiles() || manager?.getDownloadedFiles() || 0,
  315. totalFiles: event?.getTotalFiles() || manager?.getTotalFiles() || 0,
  316. message,
  317. retryCount: stage === 'retrying' ? this.retryCount : undefined,
  318. };
  319. }
  320. private unsupportedCheckResult(): HotUpdateCheckResult {
  321. return {
  322. supported: false,
  323. updateAvailable: false,
  324. totalBytes: 0,
  325. totalFiles: 0,
  326. };
  327. }
  328. private requireAssetsManager(): native.AssetsManager {
  329. if (!this.assetsManager) {
  330. throw new Error('[HotUpdateService] AssetsManager 尚未初始化。');
  331. }
  332. return this.assetsManager;
  333. }
  334. private ensureIdle(): void {
  335. if (this.operationRunning) {
  336. throw new Error('[HotUpdateService] 已有检测或更新任务正在执行。');
  337. }
  338. }
  339. private clearEventCallback(): void {
  340. this.assetsManager?.setEventCallback(null!);
  341. }
  342. }
  343. function compareVersions(versionA: string, versionB: string): number {
  344. const a = versionA.split('.');
  345. const b = versionB.split('.');
  346. const length = Math.max(a.length, b.length);
  347. for (let index = 0; index < length; index += 1) {
  348. const partA = Number.parseInt(a[index] || '0', 10);
  349. const partB = Number.parseInt(b[index] || '0', 10);
  350. if (partA !== partB) {
  351. return -1;//partA - partB;
  352. }
  353. }
  354. return 0;
  355. }
  356. function verifyDownloadedAsset(filePath: string, asset: native.ManifestAsset): boolean {
  357. if (asset.compressed || !asset.size) {
  358. return true;
  359. }
  360. const actualSize = native.fileUtils.getFileSize(filePath);
  361. const valid = actualSize === asset.size;
  362. if (!valid) {
  363. console.error(
  364. `[HotUpdateService] 文件大小校验失败:${asset.path},expected=${asset.size}, actual=${actualSize}`,
  365. );
  366. }
  367. return valid;
  368. }
  369. function normalizeRemoteUrl(value: string): string {
  370. const normalized = String(value || '').trim();
  371. if (!normalized) {
  372. return '';
  373. }
  374. if (!/^https?:\/\//i.test(normalized)) {
  375. throw new Error(`[HotUpdateService] 热更新 URL 必须以 http:// 或 https:// 开头:${normalized}`);
  376. }
  377. return ensureTrailingSlash(normalized);
  378. }
  379. function ensureTrailingSlash(value: string): string {
  380. return value.endsWith('/') ? value : `${value}/`;
  381. }
  382. function normalizePercent(value: number): number {
  383. if (!Number.isFinite(value) || value <= 0) return 0;
  384. return Math.min(1, value > 1 ? value / 100 : value);
  385. }
  386. function formatError(error: unknown): string {
  387. return error instanceof Error ? error.message : String(error);
  388. }