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

30 KiB
Raw Blame History

缓存清理超时问题修复方案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),而 computeBreakdowncomputeSize 多走 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 缓存;详情页 getCacheInfogetCategoryBreakdown 顺序执行(避免 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.jsfrontend/utils/cacheManager.jsfrontend/utils/handlers/sandboxResidualHandler.jsfrontend/utils/handlers/sandboxTmpHandler.jsfrontend/pages/profile/cache-cleanup*.vue
  • 工作量估算2 人天M0-M3含回归测试
  • 前置版本:基线 commit 407ca10dfeat:添加内存缓存数据);未提交修改 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
scanSandboxResidualFilesPromise.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 同时返回:

/**
 * 一次 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),标注 @deprecated
  • scanSandboxResidualByUid 改为 scanSandboxResidualAll().then(r => r.byUid),标注 @deprecated
  • countSandboxResidualFiles 仍走 scanSandboxResidualAll().total.keyCount,无影响

4.2 M1handler 改用合并 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 M2breakdown 缓存 + 详情页串行

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 + invalidateBreakdownCachegetCategoryBreakdown 加 1min 缓存;deductFromCache 同步失效 breakdownHANDLER_TIMEOUT_MS 8000→4000
frontend/pages/profile/cache-cleanup-detail.vue Promise.all([info, breakdown]) 改串行 awaittimeout 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-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 单独回滚:删除 _breakdownCachegetCategoryBreakdown 改回无缓存
  • 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 改 APIM2 缓存 + 串行M3 超时收紧)
    • 文档 §3.1 复现路径 / §3.2 瓶颈表 / §3.3 调用栈 都覆盖到 §4 的具体函数行号
    • 既有 handler 的 clearSandboxResidualGlobal / clearSandboxShareByUid 不在修复范围(清理路径是单次 + 删除, 不卡)
    • 既有 cacheManager.cleanAllinvalidateCache 流程不变, 但需要同步失效 breakdown 缓存§4.3 已加)
    • 既有 cache-cleanup.vue 的 5min 缓存 + peekCache 流程不变
    • 不修改 unpackage/dist/
  • 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-residual
  • fix:总内存显示bug修复7667aadf—— 与本问题相关cache-cleanup 列表页"总内存" chip 修复
  • feat:增加实际使用量2f672920—— appUsedBytes 聚合引入 _storageSandbox 并发
  • feat:修改样式和去除多余的清理内存方式d9e3ff9e—— 最近的样式/清理逻辑改动
  • 草稿 key 改造迁移项见 2026-07-28-cache-cleanup-design.md §11