/** * 小游戏 zip Bundle 存储补丁。 * * 背景:微信等小游戏本地缓存满时,引擎 unzip 失败后会调异步 clearLRU(每 500ms 删一个文件), * 空间未腾出前无法重试解压,导致 loadBundle 永久挂起;坏缓存写入 cacheList.json 后重启仍命中。 * * 本文件在 GameLaunch 最早阶段 monkey-patch 引擎 cacheManager: * 1. 新增 clearLRUImmediate — 同步删 LRU 缓存(无 setTimeout) * 2. 重写 unzipAndCacheBundle — 存储不足时同步清缓存并立即重试解压 * * 测试时在控制台过滤关键字:CacheManagerPatch * * @see Cocos 3.8.8 platforms/minigame/common/engine/cache-manager.js */ import { assetManager, path } from "cc"; declare global { interface Window { fsUtils?: MinigameFsUtils; } } /** 小游戏引擎适配层注入的文件系统工具(window.fsUtils) */ interface MinigameFsUtils { fs?: { unlinkSync?(filePath: string): void }; rmdirSync(dirPath: string, recursive?: boolean): Error | null; makeDirSync(dirPath: string, recursive?: boolean): Error | null; unzip(zipFilePath: string, targetPath: string, onComplete: (err: Error | null) => void): void; isOutOfStorage(errMsg: string): boolean; } /** 引擎 cacheManager 运行时扩展字段(TS 公开类型未暴露,运行时有) */ interface CacheManagerLike { cacheDir: string; autoClear: boolean; outOfStorage: boolean; cachedFiles: { forEach(cb: (val: { bundle: string; url: string; lastTime: number }, key: string) => void): void; add(key: string, val: { bundle: string; url: string; lastTime: string | number }): void; remove(key: string): unknown; }; _isZipFile(url: string): boolean; _write(): void; clearLRU(): void; clearLRUImmediate?(): void; unzipAndCacheBundle( id: string, zipFilePath: string, cacheBundleRoot: string, onComplete: (err: Error | null, targetPath?: string) => void, retryCount?: number ): void; } /** 日志前缀,控制台过滤:CacheManagerPatch */ const LOG_TAG = "[CacheManagerPatch]"; /** 存储不足时 unzip 最大重试次数(每次重试前同步清 1/3 LRU) */ const MAX_UNZIP_STORAGE_RETRIES = 5; /** 解压目标目录后缀,避免同毫秒多次解压路径冲突 */ let unzipPathSuffix = 0; function log(message: string, detail?: unknown): void { if (detail !== undefined) { console.log(LOG_TAG, message, detail); } else { console.log(LOG_TAG, message); } } function warn(message: string, detail?: unknown): void { if (detail !== undefined) { console.warn(LOG_TAG, message, detail); } else { console.warn(LOG_TAG, message); } } function logError(message: string, detail?: unknown): void { if (detail !== undefined) { console.error(LOG_TAG, message, detail); } else { console.error(LOG_TAG, message); } } /** 统计 cachedFiles 条目数 */ function countCachedFiles(cm: CacheManagerLike): number { let count = 0; cm.cachedFiles.forEach(() => { count++; }); return count; } /** * 获取小游戏平台 fsUtils。 * 仅在小游戏环境存在;Web 预览等环境为 null,补丁自动跳过。 */ function getFsUtils(): MinigameFsUtils | null { const fsUtils = (globalThis as typeof globalThis & { fsUtils?: MinigameFsUtils }).fsUtils; return fsUtils ?? null; } /** * 同步删除单条缓存对应的磁盘文件/目录。 * - zip bundle 解压后是目录 → rmdirSync * - 普通文件 / zip 包本身 → unlinkSync */ function removePathOrFileSync(cm: CacheManagerLike, originUrl: string, localPath: string): boolean { const fsUtils = getFsUtils(); if (!fsUtils) { warn("removePathOrFileSync: fsUtils missing", { originUrl, localPath }); return false; } if (cm._isZipFile(originUrl)) { if (cm._isZipFile(localPath)) { try { fsUtils.fs?.unlinkSync?.(localPath); log("removed file (zip)", { originUrl: path.basename(originUrl), localPath }); return true; } catch (e) { warn("unlinkSync zip failed", { localPath, error: e }); return false; } } const rmErr = fsUtils.rmdirSync(localPath, true); if (rmErr) { warn("rmdirSync failed", { localPath, error: rmErr.message }); return false; } log("removed dir (unzipped bundle)", { originUrl: path.basename(originUrl), localPath }); return true; } try { fsUtils.fs?.unlinkSync?.(localPath); log("removed file", { originUrl: path.basename(originUrl), localPath }); return true; } catch (e) { warn("unlinkSync failed", { localPath, error: e }); return false; } } /** * 按引擎 clearLRU 相同规则筛选待删缓存条目: * 1. 遍历 cachedFiles,跳过正在使用的 zip bundle * 2. 按 lastTime 升序(最久未用在前) * 3. 总数 ≥ 3 取最旧 1/3;< 3 则全取 */ function collectLruCandidates(cm: CacheManagerLike): Array<{ originUrl: string; url: string; lastTime: number }> { const allCandidates: Array<{ originUrl: string; url: string; lastTime: number }> = []; let skippedInUse = 0; cm.cachedFiles.forEach((val, key) => { let skip = false; if (cm._isZipFile(key)) { assetManager.bundles.forEach((bundle) => { if (bundle.base.indexOf(val.url) !== -1) { skip = true; } }); if (skip) { skippedInUse++; return; } } allCandidates.push({ originUrl: key, url: val.url, lastTime: val.lastTime }); }); allCandidates.sort((a, b) => a.lastTime - b.lastTime); const totalEligible = allCandidates.length; if (allCandidates.length >= 3) { allCandidates.length = Math.floor(allCandidates.length / 3); } log("collectLruCandidates", { totalInCache: countCachedFiles(cm), eligible: totalEligible, skippedInUseZipBundle: skippedInUse, toRemove: allCandidates.length, rule: totalEligible >= 3 ? "remove oldest 1/3" : "remove all eligible", }); return allCandidates; } /** * 给 cacheManager 挂载 clearLRUImmediate。 * 与引擎 clearLRU 筛选逻辑相同,但同步删除磁盘文件并立即写回 cacheList.json, * 不经过 setTimeout(deferredDelete, deleteInterval)。 */ function installClearLRUImmediate(cm: CacheManagerLike): void { cm.clearLRUImmediate = function clearLRUImmediate(this: CacheManagerLike): void { const beforeCount = countCachedFiles(this); warn("clearLRUImmediate start", { cacheDir: this.cacheDir, cachedCount: beforeCount, outOfStorage: this.outOfStorage, autoClear: this.autoClear, }); const caches = collectLruCandidates(this); if (caches.length === 0) { warn("clearLRUImmediate: nothing to remove"); return; } let diskOk = 0; let diskFail = 0; for (let i = 0, l = caches.length; i < l; i++) { const item = caches[i]; const cacheKey = `${assetManager.utils.getUuidFromURL(item.originUrl)}@native`; (assetManager as unknown as { files: { remove(key: string): void } }).files.remove(cacheKey); this.cachedFiles.remove(item.originUrl); if (removePathOrFileSync(this, item.originUrl, item.url)) { diskOk++; } else { diskFail++; } } this._write(); this.outOfStorage = false; const afterCount = countCachedFiles(this); warn("clearLRUImmediate done", { removedFromIndex: caches.length, diskOk, diskFail, cachedCountBefore: beforeCount, cachedCountAfter: afterCount, outOfStorage: this.outOfStorage, }); }; } /** * 重写引擎 unzipAndCacheBundle。 * 解压失败且报错为「存储空间不足」时:调 clearLRUImmediate 同步腾空间 → 立即递归重试; * 不再调引擎异步 clearLRU + 直接 onComplete(err) 让上层卡死。 */ function installUnzipAndCacheBundle(cm: CacheManagerLike, fsUtils: MinigameFsUtils): void { cm.unzipAndCacheBundle = function unzipAndCacheBundle( this: CacheManagerLike, id: string, zipFilePath: string, cacheBundleRoot: string, onComplete: (err: Error | null, targetPath?: string) => void, retryCount = 0 ): void { const time = Date.now().toString(); const targetPath = `${this.cacheDir}/${cacheBundleRoot}/${time}${unzipPathSuffix++}`; log("unzip start", { bundle: cacheBundleRoot, zipName: path.basename(id), retry: `${retryCount}/${MAX_UNZIP_STORAGE_RETRIES}`, zipFilePath, targetPath, cachedCount: countCachedFiles(this), outOfStorage: this.outOfStorage, }); const mkdirErr = fsUtils.makeDirSync(targetPath, true); if (mkdirErr) { logError("makeDirSync failed before unzip", { targetPath, error: mkdirErr.message }); } fsUtils.unzip(zipFilePath, targetPath, (err) => { if (err) { fsUtils.rmdirSync(targetPath, true); const outOfStorage = fsUtils.isOutOfStorage(err.message); warn("unzip failed", { bundle: cacheBundleRoot, zipName: path.basename(id), retry: retryCount, outOfStorage, message: err.message, }); if (outOfStorage && retryCount < MAX_UNZIP_STORAGE_RETRIES) { this.outOfStorage = true; if (this.autoClear !== false && this.clearLRUImmediate) { this.clearLRUImmediate(); } else { warn("skip clearLRUImmediate", { autoClear: this.autoClear, hasImmediate: !!this.clearLRUImmediate, }); } warn(`unzip out-of-storage → retry ${retryCount + 1}/${MAX_UNZIP_STORAGE_RETRIES}`, { bundle: cacheBundleRoot, zipName: path.basename(id), }); cm.unzipAndCacheBundle(id, zipFilePath, cacheBundleRoot, onComplete, retryCount + 1); return; } if (outOfStorage) { logError("unzip gave up: out-of-storage retries exhausted", { bundle: cacheBundleRoot, zipName: path.basename(id), maxRetries: MAX_UNZIP_STORAGE_RETRIES, message: err.message, }); } else { logError("unzip gave up: non-storage error (no retry)", { bundle: cacheBundleRoot, zipName: path.basename(id), message: err.message, }); } onComplete?.(err); return; } this.cachedFiles.add(id, { bundle: cacheBundleRoot, url: targetPath, lastTime: time }); this._write(); this.outOfStorage = false; log("unzip ok", { bundle: cacheBundleRoot, zipName: path.basename(id), retry: retryCount, targetPath, cachedCount: countCachedFiles(this), }); onComplete?.(null, targetPath); }); }; } /** * 入口:在 GameLaunch.start 最早阶段调用。 * 仅在小游戏环境(cacheManager + fsUtils 均存在)生效;已打过补丁则跳过(幂等)。 */ export function applyCacheManagerPatch(): void { const cm = assetManager.cacheManager as unknown as CacheManagerLike | null; const fsUtils = getFsUtils(); if (!cm && !fsUtils) { log("skip patch: not minigame env (cacheManager & fsUtils both missing)"); return; } if (!cm) { warn("skip patch: assetManager.cacheManager missing"); return; } if (!fsUtils) { warn("skip patch: window.fsUtils missing"); return; } if (typeof cm.clearLRUImmediate === "function") { log("skip patch: already applied"); return; } installClearLRUImmediate(cm); installUnzipAndCacheBundle(cm, fsUtils); log("patch applied", { cacheDir: cm.cacheDir, autoClear: cm.autoClear, cachedCount: countCachedFiles(cm), maxUnzipRetries: MAX_UNZIP_STORAGE_RETRIES, replaced: ["clearLRUImmediate (new)", "unzipAndCacheBundle (override)"], }); }