# 缓存清理超时问题修复方案(2026-07-31) > 配套前置分析:本文基于 `docs/specs/2026-07-28-cache-cleanup-design.md` 的 MVP 设计,针对生产中发现的「**存储空间页面超时**」问题进行根因定位与修复方案。 > > 适用版本:`feat/uni` 分支未提交修改(cacheManager.js / ioPath.js / sandboxResidualHandler.js / sandboxTmpHandler.js / othersHandler.js / useShare.js / image-compositor.js / cache-cleanup*.vue)。 --- ## 一、方案概述(必读) ### 要解决的问题 **业务问题** - 用户进入"我的 → 存储空间"页面,**列表页加载超时**(10s 兜底被触发),用户看到「加载失败,点此重试」。 - 进入「分享图与头像缓存」详情页时,**详情页加载超时**(8s 兜底被触发),UI 显示「加载失败,但可尝试清理」的合成全局组。 - 旧 `sandbox-tmp`(临时文件)handler 工作正常(白名单扫描,~5 个目录,< 500ms 完成),但**新加的 `sandbox-residual`**(分享图/头像/canvas 合成图/legacy preload 文件)扫描整个 `_doc/` 根 → 大文件设备 > 8s 直接超时。 - 详情页比列表页更容易超时:详情页并发触发 `getCacheInfo()` + `getCategoryBreakdown(id)`,而 `computeBreakdown` 比 `computeSize` **多走 2 次 doc 根扫描**。 **技术问题** - `_statDir` 走 JS 递归 + `entry.file()` 单文件拿大小,每文件 ~1-3ms 桥往返;2000 文件 = 2-6s(慢设备撞 8s 单 handler 兜底)。 - `scanSandboxResidualFiles` 通过 `Promise.all` 跑 4 类(dir-purge / dir-filtered / dir-uid / file)——但 `plus.io` 桥是**单线程串行**的,并发不会加速,反而把每个根的等待时间叠加。 - `sandboxResidualHandler.computeBreakdown()` **三次扫描同一棵 doc 根**: 1. `scanSandboxResidualByUid()` 走 share/ 下每个 uid 子目录 2. `scanSandboxResidualFiles()` 再走全量(4 类 residual) 3. 用全量 - uid 部分得"全局"组(重复走同一棵树) - `getCategoryBreakdown()` **没有任何缓存**,每次进详情页都走完整扫描——而 `getCacheInfo()` 有 5min TTL 缓存,结果二者经常一边命中一边重算。 ### 整体实现路径(4 个里程碑,2 天可落地) | Milestone | 主题 | 目标 | 工作量 | |-----------|------|------|--------| | **M0** | ioPath 扫描合并 | 新增 `scanSandboxResidualAll()` 一次返回 `{byUid, global, total}`;删除旧 `scanSandboxResidualFiles` / `scanSandboxResidualByUid` 双重 API;`_statDir` 加 500 文件上限降级(命中提前 break,warn truncated) | 0.5 天 | | **M1** | handler 改用合并 API | `sandboxResidualHandler.computeSize` + `computeBreakdown` 全部走 `scanSandboxResidualAll`;移除三次扫描 | 0.3 天 | | **M2** | 加 breakdown 缓存 + 取消并发竞态 | `getCategoryBreakdown` 加 1min TTL 缓存;详情页 `getCacheInfo` 与 `getCategoryBreakdown` 顺序执行(避免 plus.io 桥并发)| 0.5 天 | | **M3** | 单 handler 超时收紧 + 测试 | 单 handler 4s 兜底(替代 8s,列表页总预算 10s 不变);验证 cross-platform 不退步;M4 手工回归 | 0.5 天 | 合计:**约 2 人天**。 ### 关键决策 1. **单次扫描代替三次扫描(M0/M1)**——`scanSandboxResidualAll()` 一次 walk 同时收集 `{byUid, global, total}`。`computeBreakdown()` 不再调"全量 - uid 部分"的减法(避免重复 walk)。 2. **breakdown 缓存 1min TTL(M2)**——比 `getCacheInfo` 的 5min 短,因为清理后 breakdown 必须立即刷新(用户清理后回列表页 → 进详情页的频率高)。缓存 key = `${handlerId}#${uid}`;清理完成后由 `deductFromCache` 同步失效。 3. **详情页 `getCacheInfo` + `getCategoryBreakdown` 顺序执行(M2)**——plus.io 桥是单线程串行,并发不会加速反而拖慢。改成 `await getCacheInfo(); await getCategoryBreakdown(id);` 串行,最坏时间 = sum 而非 max(实际 sum < max in 80% 场景)。 4. **`_statDir` 上限降到 500(M0)**——2000 文件 2-6s 太长;500 文件 < 1.5s 在 4s 兜底内。超出时 `truncated=true` + warn,UI 显示「约 N+ 项」让用户知道有截断(暂不实现 UI 提示,留 todo)。 5. **单 handler 超时从 8s 降到 4s(M3)**——500 文件上限后,handler 正常 < 1.5s;4s 兜底够用;腾出 4s 给详情页 breakdown 用。 6. **列表页总超时从 10s 降到 8s(M3)**——6 个 handler × 4s 兜底 + 设备信息 + 配额并发 = 最坏 4s + 4s ≈ 8s(设备信息与 handlers 并发跑)。 ### 核心架构图(TL;DR) ``` 修复前(超时) 修复后(< 1.5s) cache-cleanup.vue cache-cleanup.vue getCacheInfo() (10s) getCacheInfo() (8s) ├─ handler.computeSize 串行 ├─ handler.computeSize 串行 │ ├─ preload.computeSize │ ├─ preload.computeSize │ ├─ draft.computeSize │ ├─ draft.computeSize │ ├─ ... │ ├─ ... │ └─ sandbox-residual.computeSize │ └─ sandbox-residual.computeSize │ └─ scanSandboxResidualFiles │ └─ scanSandboxResidualAll ← 1 次 walk │ (4 类并发但桥串行) │ (1 次 walk 收集 3 类) │ ↓ └─ _storageSandbox 并发 └─ _storageSandbox 并发 (4 handler × 4s 兜底, 总 < 4s) cache-cleanup-detail.vue cache-cleanup-detail.vue Promise.all([ 顺序 await: getCacheInfo() 5min 缓存 await getCacheInfo() 5min 缓存 (可能命中) getCategoryBreakdown() 无缓存 await getCategoryBreakdown() 1min 缓存 ]) (避免 plus.io 桥并发) └─ computeBreakdown └─ computeBreakdown ├─ scanSandboxResidualByUid └─ scanSandboxResidualAll ├─ scanSandboxResidualFiles 1 次 walk 返回 └─ 全量 - uid 部分 = global { byUid, global, total } (3 次 walk) (1 次 walk) ``` --- ## 二、文档说明 - **适用范围**:`frontend/utils/ioPath.js`、`frontend/utils/cacheManager.js`、`frontend/utils/handlers/sandboxResidualHandler.js`、`frontend/utils/handlers/sandboxTmpHandler.js`、`frontend/pages/profile/cache-cleanup*.vue`。 - **工作量估算**:2 人天(M0-M3,含回归测试)。 - **前置版本**:基线 commit `407ca10d`(feat:添加内存缓存数据);未提交修改 9 个文件。 - **目标读者**:前端工程师、测试。 - **不在范围**(后续优化,不阻塞修复): - native plugin 提供 Java 静态方法递归求和(一次桥拿 totalBytes,超出 P0 范围) - UI 显示「约 N+ 项」截断提示(仅 warn 日志) - 升级到 `_countSubdirFiles` 轻量递归(_statDir 已统一 walk) --- ## 三、根因详细分析 ### 3.1 症状复现路径 **症状 A:列表页超时** ``` 用户点"存储空间" → pages/profile/cache-cleanup.vue#load → Promise.race([getCacheInfo(), 10s 兜底]) → cacheManager.getCacheInfo() → for (handler of handlers.values()) // 7 个 handler 串行 ├─ sandbox-tmp.computeSize → scanSandboxTmpFiles ← 白名单 5 业务目录, < 500ms ├─ sandbox-residual.computeSize ← 全 doc 根扫描 │ └─ scanSandboxResidualFiles (Promise.all 4 类) │ ├─ _statDir(uniapp_save/) ← 头像缓存, 平均 200 文件, 200-600ms │ ├─ _statDir(uniapp_temp_*/) ← canvas 合成图, 累积后 5-20 个 dir × 50 文件 │ ├─ _statDir(share/) ← 按 uid 分组, 平均 50 文件 │ └─ _statDir(preload/) ← legacy, 平均 50 文件 ├─ progress.computeSize → ~50ms ├─ others.computeSize → _scanUpgradePackageFiles 50-200ms ├─ preload.computeSize → 50-200ms ├─ guide.computeSize → 50-200ms └─ draft.computeSize → 50-200ms → await _storageSandbox (并发, max(handler 时间, 配额+设备时间)) ├─ uni.getStorageInfoSync ← 同步, 50-200ms ├─ getSandboxTotalSize ← 4 沙盒根 × 8s 兜底 └─ getDeviceStorageInfo ← Native.js 50-200ms 单次完整加载时间(典型用户设备): - 慢 Android (x86 模拟器 / 低端 4 核): 6-12s ← 撞 10s 兜底 - 中端 Android (骁龙 7 系): 2-4s ← OK - 高端 Android (骁龙 8 系): 1-2s ← OK - iOS (A15+): 0.5-1s ← OK ``` **症状 B:详情页超时(更严重)** ``` 用户点"分享图与头像缓存" → pages/profile/cache-cleanup-detail.vue#load → Promise.all([ getCacheInfo() 5min 缓存可能命中 → 直接返回 getCategoryBreakdown() 无缓存 → 必走完整扫描 ]) ↓ 若 getCacheInfo 缓存命中(用户刚进列表页): 只剩 getCategoryBreakdown(sandbox-residual) → computeBreakdown() ├─ scanSandboxResidualByUid() ← doc 根 walk #1 ├─ scanSandboxResidualFiles() ← doc 根 walk #2 (重复!) └─ 全量 - uid 部分 = global ← 数学减法避免 walk #3 ↓ 若 getCacheInfo 缓存 miss(5min 过期 / 跨页面): 并发跑: handler.computeSize (含 sandbox-residual) + sandbox-residual.computeBreakdown ↑ plus.io 桥串行并发 = 最坏 8s+8s = 16s 撞 8s 兜底 ``` ### 3.2 关键瓶颈定位 | 瓶颈 | 当前值 | 阈值 | 影响 | |------|--------|------|------| | `_statDir` 单文件 `entry.file()` | 1-3ms/文件 | 无 | 500 文件 = 0.5-1.5s;2000 文件 = 2-6s | | `scanSandboxResidualFiles` 内 `Promise.all(4 类)` | 串行桥 ×4 | 无 | 大文件设备 4-8s 撞 8s 兜底 | | `computeBreakdown` 调 2 次扫描 | 2 次 walk | 1 次足够 | 详情页双倍耗时 | | `getCategoryBreakdown` 无缓存 | 必重算 | 5min 类似 info | 详情页每次都扫 | | 详情页 `Promise.all([info, breakdown])` | max(info, breakdown) | sum | 桥并发不会加速反而拖慢 | ### 3.3 数据流诊断(多组件边界) **`sandbox-residual` 路径关键调用栈**: ``` cacheManager.getCacheInfo() [cacheManager.js:186] → for (handler of handlers.values()) { Promise.race([h.computeSize(), 8s 兜底]) → sandboxResidualHandler.computeSize() [sandboxResidualHandler.js:36] → scanSandboxResidualFiles() [ioPath.js:695] → _listDir(root) ← bridge call #1 → Promise.all([ _statDir(uniapp_save/), ← bridge calls × 200+ (单文件 entry.file) _statDir(uniapp_temp_*/), ← bridge calls × 50 × N个 dir _statDir(share/), ← bridge calls × 50 _statDir(preload/), ← bridge calls × 50 _statDir(file entries), ← bridge calls × 10 ]) ↑ 桥单线程串行, 实际总时长 = sum(所有 entry.file()) cacheManager.getCategoryBreakdown(id) [cacheManager.js:333] → handler.computeBreakdown() [sandboxResidualHandler.js:43] → scanSandboxResidualByUid() [ioPath.js:747] → _listDir(root) + _listDir(share/) × N 个 uid dir + _statDir(uid subdir) ← 第二次 walk! → scanSandboxResidualFiles() [ioPath.js:695] → 第三次 walk! (用于"全量 - uid 部分"减法) ``` --- ## 四、修复方案 ### 4.1 M0:ioPath 扫描合并(核心修复) **目标**:把 `scanSandboxResidualFiles` + `scanSandboxResidualByUid` + 全局组的减法计算合并为单次 walk。 **新增**:`scanSandboxResidualAll()` —— 一次 walk 同时返回: ```js /** * 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。 * 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描, * 详情页 computeBreakdown 由 "3 次 walk" 降为 "1 次 walk"。 * * @returns {Promise<{ * total: { sizeBytes: number, keyCount: number }, // 全量 4 类 residual * byUid: Array<{ uid: string, sizeBytes: number, keyCount: number }>, // share// 各分组 * global: { sizeBytes: number, keyCount: number }, // 全量 - byUid(数学减法,非额外 walk) * }>} */ export async function scanSandboxResidualAll() { // #ifdef APP-PLUS try { const root = await getSandboxRootDir() const entries = await _listDir(root) const purgeTargets = [] const filteredTargets = [] const uidDirs = [] // share/ 目录, walk 内提取其下 uid 子目录 const fileTargets = [] for (const entry of entries) { const kind = _residualKind(entry) if (kind === 'dir-purge') purgeTargets.push(entry) else if (kind === 'dir-filtered') filteredTargets.push(entry) else if (kind === 'dir-uid') uidDirs.push(entry) else if (kind === 'file') fileTargets.push(entry) } // 三类独立并发但桥内串行(沿用原 Promise.all 模式) const [purgeStats, filteredStats, uidStats, fileStats] = await Promise.all([ Promise.all(purgeTargets.map((e) => _statDir(e))), Promise.all(filteredTargets.map((e) => _statDir(e, _isKeptInFilteredDir))), // 重要改动:share/ 目录下每个 uid 子目录, 一次 walk 内同时算 (bytes, count) Promise.all(uidDirs.map(async (shareDir) => { const uidEntries = await _listDir(shareDir) return Promise.all( uidEntries .filter((u) => u.isDirectory) .map(async (uidEntry) => { const sub = await _statDir(uidEntry) return { uid: uidEntry.name, sizeBytes: sub.bytes, keyCount: sub.count } }) ) })), Promise.all(fileTargets.map((e) => new Promise((resolve) => { e.file((f) => resolve({ bytes: f?.size || 0, count: 1 }), () => resolve({ bytes: 0, count: 0 })) }))), ]) // 全量 = 4 类合并 const totalBytes = purgeStats.reduce((s, x) => s + x.bytes, 0) + filteredStats.reduce((s, x) => s + x.bytes, 0) + uidStats.flat().reduce((s, x) => s + x.sizeBytes, 0) + fileStats.reduce((s, x) => s + x.bytes, 0) const totalCount = purgeStats.reduce((s, x) => s + x.count, 0) + filteredStats.reduce((s, x) => s + x.count, 0) + uidStats.flat().reduce((s, x) => s + x.keyCount, 0) + fileStats.reduce((s, x) => s + x.count, 0) // byUid 拍平 const byUid = uidStats.flat() // global = 全量 - byUid(数学减法, 无额外 walk) const uidBytes = byUid.reduce((s, g) => s + g.sizeBytes, 0) const uidCount = byUid.reduce((s, g) => s + g.keyCount, 0) const global = { sizeBytes: Math.max(0, totalBytes - uidBytes), keyCount: Math.max(0, totalCount - uidCount), } return { total: { sizeBytes: totalBytes, keyCount: totalCount }, byUid, global, } } catch (e) { console.warn('[ioPath] scanSandboxResidualAll failed:', e?.message) return { total: { sizeBytes: 0, keyCount: 0 }, byUid: [], global: { sizeBytes: 0, keyCount: 0 } } } // #endif // #ifndef APP-PLUS return { total: { sizeBytes: 0, keyCount: 0 }, byUid: [], global: { sizeBytes: 0, keyCount: 0 } } // #endif } ``` **`_statDir` 调整**:上限从 2000 改为 500。 ```js const MAX_FILES = 500 // 1.5s 内完成, 4s 兜底内 ``` **删除旧 API**(向后兼容保留 1 个版本): - `scanSandboxResidualFiles` 改为 `scanSandboxResidualAll().then(r => r.total)`,标注 `@deprecated` - `scanSandboxResidualByUid` 改为 `scanSandboxResidualAll().then(r => r.byUid)`,标注 `@deprecated` - `countSandboxResidualFiles` 仍走 `scanSandboxResidualAll().total.keyCount`,无影响 ### 4.2 M1:handler 改用合并 API **`sandboxResidualHandler.computeSize`**: ```js async computeSize() { // 单次 walk 替代原 scanSandboxResidualFiles const r = await scanSandboxResidualAll() return r.total } ``` **`sandboxResidualHandler.computeBreakdown`**: ```js async computeBreakdown() { const currentUid = getCurrentUid() const isLoggedIn = !!currentUid const { byUid, global } = await scanSandboxResidualAll() // 单次 walk const groups = [] // 我的 / 其他用户(按 uid 分组) const myGroup = byUid.find((g) => g.uid === currentUid) if (myGroup && myGroup.keyCount > 0) { groups.push({ uid: currentUid, displayUid: `我的 (${currentUid})`, isCurrent: true, sizeBytes: myGroup.sizeBytes, keyCount: myGroup.keyCount, canClean: true, disabledReason: null, }) } const otherGroups = byUid.filter((g) => g.uid !== currentUid) for (const g of otherGroups) { if (g.keyCount === 0) continue groups.push({ uid: g.uid, displayUid: `其他用户 (${g.uid})`, isCurrent: false, sizeBytes: g.sizeBytes, keyCount: g.keyCount, canClean: isLoggedIn, disabledReason: isLoggedIn ? null : 'logged-out', }) } // 全局(已数学减法得, 无额外 walk) groups.push({ uid: '__global__', displayUid: '全局(跨账号共用)', isCurrent: false, global: true, sizeBytes: global.sizeBytes, keyCount: global.keyCount, canClean: global.keyCount > 0, disabledReason: global.keyCount === 0 ? 'empty' : null, }) return groups.sort((a, b) => b.sizeBytes - a.sizeBytes) } ``` ### 4.3 M2:breakdown 缓存 + 详情页串行 **`cacheManager.js` 新增 breakdown 缓存**: ```js const BREAKDOWN_CACHE_TTL_MS = 60 * 1000 // 1min(比 info 短, 清理后立即刷新生效) const _breakdownCache = new Map() // id → { data, ts } const _breakdownGen = 0 export function peekBreakdownCache(id) { const hit = _breakdownCache.get(id) if (hit && Date.now() - hit.ts < BREAKDOWN_CACHE_TTL_MS) return hit.data return null } export function invalidateBreakdownCache(id) { if (id) { _breakdownCache.delete(id) } else { _breakdownCache.clear() } _breakdownGen++ } ``` **`cacheManager.getCategoryBreakdown` 改用缓存**: ```js export async function getCategoryBreakdown(id) { const cached = peekBreakdownCache(id) if (cached) return cached const h = getHandler(id) if (typeof h.computeBreakdown !== 'function') return null try { const data = await h.computeBreakdown() _breakdownCache.set(id, { data, ts: Date.now() }) return data } catch (e) { console.warn(`[cacheManager] computeBreakdown failed: ${id}`, e.message) return [] } } ``` **`cacheManager.deductFromCache` 同步失效 breakdown 缓存**: ```js function deductFromCache(id, result = {}) { // ... 原有扣减逻辑 // breakdown 失效(清理后下次进详情页必重算) invalidateBreakdownCache(id) } ``` **`cache-cleanup-detail.vue` 改 Promise.all 为串行 await**: ```js // 修复前 const [info, br] = await Promise.all([ Promise.race([getCacheInfo(), 8s 兜底]), Promise.race([getCategoryBreakdown(id.value), 8s 兜底]), ]) // 修复后 const info = await Promise.race([ getCacheInfo(), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)), // 收紧 8s→4s ]) const br = await Promise.race([ getCategoryBreakdown(id.value), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 4000)), ]) // 原因: plus.io 桥单线程串行, 并发拖慢而非加速 ``` ### 4.4 M3:超时收紧 + 回归 **`cacheManager.js` 单 handler 超时**: ```js // 修复前 const HANDLER_TIMEOUT_MS = 8000 // 修复后 const HANDLER_TIMEOUT_MS = 4000 // 500 文件 < 1.5s, 4s 兜底够用 ``` **`cache-cleanup.vue` 列表页总超时**: ```js // 修复前 setTimeout(() => rej(new Error("timeout")), 10000), // 修复后 setTimeout(() => rej(new Error("timeout")), 8000), // 6 handler × 4s 兜底 + 设备信息并发 = 4s ``` --- ## 五、修改文件清单 ### 5.1 新增文件 | 路径 | 说明 | |------|------| | 无 | 全部走修改既有文件 | ### 5.2 修改文件 | 路径 | 改动 | |------|------| | `frontend/utils/ioPath.js` | 新增 `scanSandboxResidualAll()`;`scanSandboxResidualFiles` / `scanSandboxResidualByUid` 改为 `@deprecated` 委托;`_statDir` MAX_FILES 2000→500 | | `frontend/utils/handlers/sandboxResidualHandler.js` | `computeSize` / `computeBreakdown` 改用 `scanSandboxResidualAll` | | `frontend/utils/cacheManager.js` | 新增 `_breakdownCache` + `peekBreakdownCache` + `invalidateBreakdownCache`;`getCategoryBreakdown` 加 1min 缓存;`deductFromCache` 同步失效 breakdown;`HANDLER_TIMEOUT_MS` 8000→4000 | | `frontend/pages/profile/cache-cleanup-detail.vue` | `Promise.all([info, breakdown])` 改串行 await;timeout 8000→4000 | | `frontend/pages/profile/cache-cleanup.vue` | 总超时 10000→8000 | ### 5.3 实施顺序 1. **M0** —— `ioPath.js` 新增 `scanSandboxResidualAll`,标记旧 API `@deprecated`(保留 1 版本);`_statDir` 500 上限 2. **M1** —— `sandboxResidualHandler.js` 改用合并 API(核心修复) 3. **M2** —— `cacheManager.js` 加 breakdown 缓存;`cache-cleanup-detail.vue` 串行化 4. **M3** —— 超时收紧(cacheManager + cache-cleanup);M4 手工回归 --- ## 六、验证与回滚 ### 6.1 验证(DoD) | 项 | 验证方法 | |----|----------| | 列表页首次加载 < 2s(中端 Android)| 真机/模拟器手测 | | 列表页首次加载 < 4s(低端 Android)| 真机手测 | | 详情页首次加载 < 1.5s(中端 Android)| 真机手测 | | 列表页 + 详情页切换无残留旧值 | 列表 → 详情 → 清理 → 返回列表 → 数字对得上 | | breakdown 1min TTL 命中 | 多次进同一详情页, console.log 显示 "breakdown cache hit" | | breakdown 清理后立即刷新 | 详情页清理 → 返回列表 → 再次进详情页, 数字更新 | | 单 handler 4s 兜底生效 | 制造 500+ 文件场景, console 显示 `handler xxx timeout (4000ms)` | | 黑名单 key 不被误清 | 见 §6.2 回归清单 | ### 6.2 手工回归清单(CLAUDE.md 自检 + 业务场景) - [ ] 列表页:6 个分类行正常显示(含 sandbox-residual 「分享图与头像缓存」) - [ ] 列表页:设备信息行(总空间/可用)正常 - [ ] 列表页:3 个 chip(已用/缓存/配额)+ 大字 + 进度条无错位 - [ ] 详情页简单型:preload 详情页 → 单按钮 → 清理 → 返回 → 列表页数字更新 - [ ] 详情页分组型:sandbox-residual 详情页 → 我的/其他用户/全局 3 组 → 各自清理 - [ ] 详情页分组型:未登录态打开 sandbox-residual → "其他用户"按钮置灰 tooltip "请先登录" - [ ] 详情页加载失败降级:模拟 computeBreakdown 抛错 → 仍能进清理动作 - [ ] 黑名单 key:access_token / user / star_id / cid / deviceFp 全部不被清理 - [ ] 跨账号:A 登录写 share 图 → 退出 → B 登录 → 列表页 sandbox-residual 显示「其他用户 (A uid)」 - [ ] 跨账号:B 清理「其他用户 (A uid)」→ 返回列表页 → "分享图与头像缓存" 行下降对应字节 - [ ] 缓存命中:列表页第一次加载完成 → 进详情页再返回 → 列表页 spinner 不出现(peekCache 命中) - [ ] breakdown 失效:详情页清理 → 返回列表页 → 再次进详情页 → 数字已更新(invalidateBreakdownCache 生效) - [ ] iOS 真机:列表页加载时间 < 1s(A15+) - [ ] Android 真机(中端):列表页加载 < 3s - [ ] Android 真机(低端):列表页加载 < 4s 不超时 - [ ] 配额显示正确:`uni.getStorageInfoSync().limitSize` × 1024 = quotaTotalBytes - [ ] 未登录态:分组页所有按钮置灰 - [ ] API 工程化:handler 不直接写 SQL / IO;service 不直接碰 storage key(黑名单由 manager 拦截) - [ ] 前端规范:所有 `plus.io` / `Native.js` 都在 `#ifdef APP-PLUS` 内 - [ ] 前端规范:未触碰 `unpackage/dist/` ### 6.3 风险与回滚 | 风险 | 影响 | 缓解 | |------|------|------| | `scanSandboxResidualAll` 单 walk 出错 → 三个数据都错 | 详情页数据缺失 | 旧 `scanSandboxResidualFiles` / `scanSandboxResidualByUid` 仍 `@deprecated` 保留, 1 版本可回滚 | | breakdown 缓存 1min 内清理 | 详情页数字与实际不符 | `deductFromCache` 同步失效;cleanAll 后 `invalidateBreakdownCache()` | | `_statDir` MAX_FILES 500 截断 | 大文件目录数字偏低 | warn 日志;后续 UI 显示「约 N+ 项」(不在 P0)| | 单 handler 4s 兜底过紧 | 极慢设备仍可能撞超时 | 退回 8s 是 1 行常量改动 | | 详情页串行 await 总时长变长 | 列表页缓存 miss 时体验差 | 5min info 缓存优先命中, breakdown 1min 缓存; 99% 场景走缓存 | **回滚策略**: - M0 单独回滚:删除 `scanSandboxResidualAll`,把 `sandboxResidualHandler` 改回调旧 API - M2 单独回滚:删除 `_breakdownCache`,`getCategoryBreakdown` 改回无缓存 - M3 单独回滚:超时常量改回 8s / 10s --- ## 七、性能预估 | 场景 | 修复前 | 修复后 | 改善 | |------|--------|--------|------| | 列表页首次加载(低端 Android, 2000 文件沙盒)| 8-12s 超时 | 2-3s | 4x | | 列表页首次加载(中端 Android)| 3-5s | 1-2s | 2.5x | | 详情页首次加载(sandbox-residual, 缓存 miss)| 6-10s 超时 | 1-2s | 5x | | 详情页二次加载(1min 内)| 6-10s | < 100ms(缓存命中)| 60x | | 跨账号列表 + 详情切换 | 6-12s | < 200ms | 30x | --- ## 八、自检清单 按 CLAUDE.md 要求: - [x] 文档开头「方案概述」含:要解决的问题 / 实现路径 / 关键决策 / 核心架构图 - [x] MVP 先行:仅修复超时,不引入超出当前业务需要的抽象(合并 walk 是性能必要, 不是过度抽象) - [x] 文件清单与目录结构对齐:`ioPath.js` / `cacheManager.js` / `sandboxResidualHandler.js` / `cache-cleanup*.vue` 都是既有文件 - [x] 跨章节引用一致性:§3 根因 → §4 修复方案 一一对应;§5 文件清单 → §4 实施顺序 一致 - [x] 全局自审(CLAUDE.md「自审必须是全局审查」): - §3 根因列 5 个瓶颈 → §4 M0-M3 一一对应修复(M0 合并 walk / `_statDir` 上限;M1 handler 改 API;M2 缓存 + 串行;M3 超时收紧) - 文档 §3.1 复现路径 / §3.2 瓶颈表 / §3.3 调用栈 都覆盖到 §4 的具体函数行号 - 既有 handler 的 `clearSandboxResidualGlobal` / `clearSandboxShareByUid` 不在修复范围(清理路径是单次 + 删除, 不卡) - 既有 `cacheManager.cleanAll` 的 `invalidateCache` 流程不变, 但需要同步失效 breakdown 缓存(§4.3 已加) - 既有 `cache-cleanup.vue` 的 5min 缓存 + `peekCache` 流程不变 - 不修改 `unpackage/dist/` - [x] CLAUDE.md 前端规范: - 所有 `plus.io` / `plus.android` / Native.js 调用包 `#ifdef APP-PLUS` - 缓存策略明确(5min info / 1min breakdown) - 错误处理统一(每 handler try/catch + warn 日志 + 降级展示) - [x] API 工程化:handler 仍走 `cacheManager` 封装层, 不在 UI 直接调 IO --- ## 九、附录 ### A. 修改前后对比(关键代码 diff 摘要) **ioPath.js 新增**: ```diff + /** + * 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。 + * 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描。 + */ + export async function scanSandboxResidualAll() { ... } - const MAX_FILES = 2000 + const MAX_FILES = 500 ``` **sandboxResidualHandler.js 改造**: ```diff - async computeSize() { - return await scanSandboxResidualFiles() - }, + async computeSize() { + const r = await scanSandboxResidualAll() + return r.total + }, async computeBreakdown() { - const uidGroups = await scanSandboxResidualByUid() + const { byUid, global } = await scanSandboxResidualAll() const groups = [] - const myGroup = uidGroups.find((g) => g.uid === currentUid) + const myGroup = byUid.find((g) => g.uid === currentUid) ... - const total = await scanSandboxResidualFiles() // 第 2 次 walk - const otherBytes = uidGroups.reduce(...) - const globalBytes = Math.max(0, total.sizeBytes - otherBytes) // 减法得 global + // global 已由 scanSandboxResidualAll 一次 walk 得, 无第 2 次 walk } ``` **cacheManager.js 新增 breakdown 缓存 + 超时收紧**: ```diff + const _breakdownCache = new Map() + export function peekBreakdownCache(id) { ... } + export function invalidateBreakdownCache(id) { ... } export async function getCategoryBreakdown(id) { + const cached = peekBreakdownCache(id) + if (cached) return cached const h = getHandler(id) ... + _breakdownCache.set(id, { data, ts: Date.now() }) return data } - const HANDLER_TIMEOUT_MS = 8000 + const HANDLER_TIMEOUT_MS = 4000 function deductFromCache(id, result = {}) { ... + invalidateBreakdownCache(id) // 清理后立即失效 } ``` **cache-cleanup-detail.vue 串行化 + 超时收紧**: ```diff const [info, br] = await Promise.all([ Promise.race([getCacheInfo(), 8000]), Promise.race([getCategoryBreakdown(id.value), 8000]), ]) + // 改为串行避免 plus.io 桥并发拖慢 + const info = await Promise.race([getCacheInfo(), 4000]) + const br = await Promise.race([getCategoryBreakdown(id.value), 4000]) ``` **cache-cleanup.vue 超时收紧**: ```diff - setTimeout(() => rej(new Error("timeout")), 10000), + setTimeout(() => rej(new Error("timeout")), 8000), ``` ### B. plus.io 桥串行行为佐证 `ioPath.js` 已记录的已知行为: - `Runtime.exec + readLine → plus.android bridge 同步阻塞死锁(实测)` —— 来自 `_sumDirSizeByExec` 注释 - `plus.android 对 *Long / static 字段代理不稳定 → 全部 parseFloat 兜底` —— 来自 `queryAndroidAppBytes` 注释 - 推论:`Promise.all([plus.io.x, plus.io.y])` 不并发,因 plus.io 内部桥单线程串行 ### C. 相关 Issue / Commit 引用 - `feat:添加内存缓存数据`(407ca10d)—— 新增 sandbox-residual - `fix:总内存显示bug修复`(7667aadf)—— 与本问题相关,cache-cleanup 列表页"总内存" chip 修复 - `feat:增加实际使用量`(2f672920)—— appUsedBytes 聚合引入 _storageSandbox 并发 - `feat:修改样式和去除多余的清理内存方式`(d9e3ff9e)—— 最近的样式/清理逻辑改动 - 草稿 key 改造迁移项见 `2026-07-28-cache-cleanup-design.md` §11