topfans/docs/specs/2026-07-31-cache-timeout-remediation.md

674 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 缓存清理超时问题修复方案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 文件上限降级(命中提前 breakwarn 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 TTLM2**——比 `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` 上限降到 500M0**——2000 文件 2-6s 太长500 文件 < 1.5s 4s 兜底内超出时 `truncated=true` + warnUI 显示 N+ 让用户知道有截断暂不实现 UI 提示 todo)。
5. **单 handler 超时从 8s 降到 4sM3**——500 文件上限后handler 正常 < 1.5s4s 兜底够用腾出 4s 给详情页 breakdown
6. **列表页总超时从 10s 降到 8sM3**——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 缓存 miss5min 过期 / 跨页面):
并发跑: handler.computeSize (含 sandbox-residual) + sandbox-residual.computeBreakdown
↑ plus.io 桥串行并发 = 最坏 8s+8s = 16s 撞 8s 兜底
```
### 3.2 关键瓶颈定位
| 瓶颈 | 当前值 | 阈值 | 影响 |
|------|--------|------|------|
| `_statDir` 单文件 `entry.file()` | 1-3ms/文件 | | 500 文件 = 0.5-1.5s2000 文件 = 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 M0ioPath 扫描合并(核心修复)
**目标** `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/<uid>/ 各分组
* 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 M1handler 改用合并 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 M2breakdown 缓存 + 详情页串行
**`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 2000500 |
| `frontend/utils/handlers/sandboxResidualHandler.js` | `computeSize` / `computeBreakdown` 改用 `scanSandboxResidualAll` |
| `frontend/utils/cacheManager.js` | 新增 `_breakdownCache` + `peekBreakdownCache` + `invalidateBreakdownCache``getCategoryBreakdown` 1min 缓存`deductFromCache` 同步失效 breakdown`HANDLER_TIMEOUT_MS` 80004000 |
| `frontend/pages/profile/cache-cleanup-detail.vue` | `Promise.all([info, breakdown])` 改串行 awaittimeout 80004000 |
| `frontend/pages/profile/cache-cleanup.vue` | 总超时 100008000 |
### 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-cleanupM4 手工回归
---
## 六、验证与回滚
### 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 抛错 仍能进清理动作
- [ ] 黑名单 keyaccess_token / user / star_id / cid / deviceFp 全部不被清理
- [ ] 跨账号A 登录写 share 退出 B 登录 列表页 sandbox-residual 显示其他用户 (A uid)」
- [ ] 跨账号B 清理其他用户 (A uid)」→ 返回列表页 "分享图与头像缓存" 行下降对应字节
- [ ] 缓存命中列表页第一次加载完成 进详情页再返回 列表页 spinner 不出现peekCache 命中
- [ ] breakdown 失效详情页清理 返回列表页 再次进详情页 数字已更新invalidateBreakdownCache 生效
- [ ] iOS 真机列表页加载时间 < 1sA15+
- [ ] Android 真机中端列表页加载 < 3s
- [ ] Android 真机低端列表页加载 < 4s 不超时
- [ ] 配额显示正确`uni.getStorageInfoSync().limitSize` × 1024 = quotaTotalBytes
- [ ] 未登录态分组页所有按钮置灰
- [ ] API 工程化handler 不直接写 SQL / IOservice 不直接碰 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 APIM2 缓存 + 串行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