30 KiB
缓存清理超时问题修复方案(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 根:scanSandboxResidualByUid()走 share/ 下每个 uid 子目录scanSandboxResidualFiles()再走全量(4 类 residual)- 用全量 - 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 人天。
关键决策
- 单次扫描代替三次扫描(M0/M1)——
scanSandboxResidualAll()一次 walk 同时收集{byUid, global, total}。computeBreakdown()不再调"全量 - uid 部分"的减法(避免重复 walk)。 - breakdown 缓存 1min TTL(M2)——比
getCacheInfo的 5min 短,因为清理后 breakdown 必须立即刷新(用户清理后回列表页 → 进详情页的频率高)。缓存 key =${handlerId}#${uid};清理完成后由deductFromCache同步失效。 - 详情页
getCacheInfo+getCategoryBreakdown顺序执行(M2)——plus.io 桥是单线程串行,并发不会加速反而拖慢。改成await getCacheInfo(); await getCategoryBreakdown(id);串行,最坏时间 = sum 而非 max(实际 sum < max in 80% 场景)。 _statDir上限降到 500(M0)——2000 文件 2-6s 太长;500 文件 < 1.5s 在 4s 兜底内。超出时truncated=true+ warn,UI 显示「约 N+ 项」让用户知道有截断(暂不实现 UI 提示,留 todo)。- 单 handler 超时从 8s 降到 4s(M3)——500 文件上限后,handler 正常 < 1.5s;4s 兜底够用;腾出 4s 给详情页 breakdown 用。
- 列表页总超时从 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 同时返回:
/**
* 一次 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。
const MAX_FILES = 500 // 1.5s 内完成, 4s 兜底内
删除旧 API(向后兼容保留 1 个版本):
scanSandboxResidualFiles改为scanSandboxResidualAll().then(r => r.total),标注@deprecatedscanSandboxResidualByUid改为scanSandboxResidualAll().then(r => r.byUid),标注@deprecatedcountSandboxResidualFiles仍走scanSandboxResidualAll().total.keyCount,无影响
4.2 M1:handler 改用合并 API
sandboxResidualHandler.computeSize:
async computeSize() {
// 单次 walk 替代原 scanSandboxResidualFiles
const r = await scanSandboxResidualAll()
return r.total
}
sandboxResidualHandler.computeBreakdown:
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 缓存:
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 改用缓存:
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 缓存:
function deductFromCache(id, result = {}) {
// ... 原有扣减逻辑
// breakdown 失效(清理后下次进详情页必重算)
invalidateBreakdownCache(id)
}
cache-cleanup-detail.vue 改 Promise.all 为串行 await:
// 修复前
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 超时:
// 修复前
const HANDLER_TIMEOUT_MS = 8000
// 修复后
const HANDLER_TIMEOUT_MS = 4000 // 500 文件 < 1.5s, 4s 兜底够用
cache-cleanup.vue 列表页总超时:
// 修复前
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 实施顺序
- M0 ——
ioPath.js新增scanSandboxResidualAll,标记旧 API@deprecated(保留 1 版本);_statDir500 上限 - M1 ——
sandboxResidualHandler.js改用合并 API(核心修复) - M2 ——
cacheManager.js加 breakdown 缓存;cache-cleanup-detail.vue串行化 - 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 要求:
- 文档开头「方案概述」含:要解决的问题 / 实现路径 / 关键决策 / 核心架构图
- MVP 先行:仅修复超时,不引入超出当前业务需要的抽象(合并 walk 是性能必要, 不是过度抽象)
- 文件清单与目录结构对齐:
ioPath.js/cacheManager.js/sandboxResidualHandler.js/cache-cleanup*.vue都是既有文件 - 跨章节引用一致性:§3 根因 → §4 修复方案 一一对应;§5 文件清单 → §4 实施顺序 一致
- 全局自审(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/
- §3 根因列 5 个瓶颈 → §4 M0-M3 一一对应修复(M0 合并 walk /
- CLAUDE.md 前端规范:
- 所有
plus.io/plus.android/ Native.js 调用包#ifdef APP-PLUS - 缓存策略明确(5min info / 1min breakdown)
- 错误处理统一(每 handler try/catch + warn 日志 + 降级展示)
- 所有
- API 工程化:handler 仍走
cacheManager封装层, 不在 UI 直接调 IO
九、附录
A. 修改前后对比(关键代码 diff 摘要)
ioPath.js 新增:
+ /**
+ * 一次 walk 同时收集 doc 根残留的 (全量, uid分组, 全局) 三类信息。
+ * 替代旧 scanSandboxResidualFiles + scanSandboxResidualByUid 双扫描。
+ */
+ export async function scanSandboxResidualAll() { ... }
- const MAX_FILES = 2000
+ const MAX_FILES = 500
sandboxResidualHandler.js 改造:
- 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 缓存 + 超时收紧:
+ 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 串行化 + 超时收紧:
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 超时收紧:
- 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-residualfix:总内存显示bug修复(7667aadf)—— 与本问题相关,cache-cleanup 列表页"总内存" chip 修复feat:增加实际使用量(2f672920)—— appUsedBytes 聚合引入 _storageSandbox 并发feat:修改样式和去除多余的清理内存方式(d9e3ff9e)—— 最近的样式/清理逻辑改动- 草稿 key 改造迁移项见
2026-07-28-cache-cleanup-design.md§11