674 lines
30 KiB
Markdown
674 lines
30 KiB
Markdown
# 缓存清理超时问题修复方案(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/<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 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 |