fix:修复存储空间已用大小虚高(对齐系统存储口径)

- cacheManager:移除 sandboxOnlyBytes,避免与 getSandboxTotalSize 完整 data 目录扫描双计(345.8MB vs 系统 413MB 根因)
- ioPath:StorageStatsManager 改 getApkBytes+getDataBytes+getCacheBytes 三部分求和,规避 proxy 把 getAppBytes 误解析成仅 base.apk(21MB)
- ioPath:native 返回值 <30MB 判不可信 → 完整 data 目录扫描降级(_scanFullAppUsage)
- ioPath:4 根扫描 PRIVATE_DOC 只扫顶层(防双计)、PRIVATE_WWW 递归数全;新增 _sumDirSizeRecursive 带 phantom entry 过滤/路径去重/深度与文件数上限

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zheng020 2026-08-03 18:28:13 +08:00
parent a6859a67d3
commit bd3296421a
2 changed files with 270 additions and 26 deletions

View File

@ -150,11 +150,18 @@ export async function getCacheInfo(force = false) {
// 它们的子目录文件不在 currentSizeKB 也不在 sandboxBytes_statDir 不递归),
// 必须单独加,否则"已用空间"会少 1-几十 MB
// 注draft / progress / preload / guide / others 是 storage key已被 currentSizeKB 包含,不重复加
const SANDBOX_ONLY_CATEGORY_IDS = new Set(['sandbox-tmp', 'sandbox-residual'])
const sandboxOnlyBytes = categories
.filter((c) => SANDBOX_ONLY_CATEGORY_IDS.has(c.id) && c.sizeBytes > 0)
.reduce((sum, c) => sum + c.sizeBytes, 0)
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes + sandboxOnlyBytes
// ★ 2026-08-03 移除 sandboxOnlyBytes
// getSandboxTotalSize 现在走 native / 完整 data 目录扫描,已覆盖全部 doc 子目录
// (业务 tmp、share/avatar/canvas、databases、cache再按分类加一遍会双计
// (排查 345.8MB vs 系统 413MB 时发现)。分类 sizes 仍展示在列表,但不进 appUsedBytes。
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
// ★ 2026-08-03 诊断appUsedBytes 构成,定位"已用空间 X MB"从哪来
console.info(
`[cacheManager] appUsedBytes=${Math.round(appUsedBytes / 1048576)}MB = ` +
`storage(${Math.round((currentSizeKB * 1024) / 1048576)}MB) + ` +
`sandbox(${Math.round(sandboxBytes / 1048576)}MB); ` +
`categories=[${categories.map((c) => `${c.id}:${Math.round(c.sizeBytes / 1048576)}MB`).join(', ')}]`
)
const quotaTotalBytes = limitSizeKB * 1024
const raw = quotaTotalBytes - appUsedBytes
const quotaAvailableBytes = Math.max(0, raw)

View File

@ -532,10 +532,27 @@ async function queryAndroidAppBytes() {
reason: 'no_stats'
}
// getAppBytes() 是 long 返回proxy 不稳 → parseFloat 兜底
const bytes = parseFloat(plus.android.invoke(stats, 'getAppBytes'))
if (!Number.isFinite(bytes) || bytes < 0) {
console.warn('[ioPath] queryAndroidAppBytes: invalid bytes =', bytes)
// ★ 2026-08-03 修复getAppBytes() 在部分设备 proxy 只返回 base.apk 的 21MB系统"存储"里实际 413MB
// 改为分别读 getApkBytes / getDataBytes / getCacheBytes 再求和——三者是 getAppBytes 的组成部分,
// 单独读时 proxy 解析更可靠(避免 'getAppBytes' 被误解析到 'getApkBytes')。
// 任一为 NaN/负 → 丢弃该部分;三部分都无效 → 判失败,调用方走目录扫描降级。
const rawParts = [
parseFloat(plus.android.invoke(stats, 'getApkBytes')),
parseFloat(plus.android.invoke(stats, 'getDataBytes')),
parseFloat(plus.android.invoke(stats, 'getCacheBytes')),
]
const parts = rawParts.filter((v) => Number.isFinite(v) && v >= 0)
// 若三部分都有效且完全相同,很可能是 proxy 把三个方法都解析成了同一个(如都返回 base.apk→ 不可信
if (parts.length === 3 && parts[0] === parts[1] && parts[1] === parts[2]) {
console.warn('[ioPath] queryAndroidAppBytes: apk/data/cache identical, proxy unreliable =', parts)
return {
supported: false,
reason: 'invalid_bytes'
}
}
const bytes = parts.reduce((s, v) => s + v, 0)
if (parts.length === 0 || bytes <= 0) {
console.warn('[ioPath] queryAndroidAppBytes: invalid bytes =', parts)
return {
supported: false,
reason: 'invalid_bytes'
@ -565,10 +582,14 @@ async function queryAndroidAppBytes() {
/**
* 计算"app 已用空间"
*
* Android API 26优先走 StorageStatsManager.getAppBytes()系统记账O(1)
* 原生查询任一环节失败 降级到旧实现"APK + Σ4 沙盒根遍历"4s 单根兜底与降级前等价
* Android API < 26直接走旧实现queryStatsForPackage API 26 才有
* iOS旧实现 4 沙盒根遍历iOS .app bundle 沙盒外不可访问 sandbox 估算
* Android
* - 优先走 StorageStatsManager系统"存储"口径 = APK + data + cacheO(1)
* getAppBytes 改成 getApkBytes + getDataBytes + getCacheBytes 三部分求和
* 规避部分设备 proxy getAppBytes 误解析成只返回 base.apk21MB vs 系统 413MB bug
* - native 返回值 < MIN_NATIVE_BYTES30MB app 自带 160MB 静态资源不可能低于此 视为不可信
* 降级到"APK + Σ4 沙盒根遍历"PRIVATE_WWW 递归数全解压资源PRIVATE_DOC 顶层 + 分类补子目录
* Android API < 26直接走 4 根遍历queryStatsForPackage API 26 才有
* iOS4 沙盒根遍历iOS .app bundle 沙盒外不可访问 sandbox 估算
* H5 / 小程序return 0
*
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
@ -596,20 +617,124 @@ async function queryAndroidAppBytes() {
*
* 单个根失败/超时 0不影响其他根
*/
/**
* 获取 Android APK 安装包大小nativeO(1)
* Context.getApplicationInfo().getSourceDir() + java.io.File.length()
* Android / 任一环节失败 0
* @returns {Promise<number>}
*/
async function getAndroidApkSize() {
// #ifdef APP-PLUS
try {
if (typeof plus === 'undefined' || !plus.android) return 0
const main = plus.android.runtimeMainActivity()
if (!main) return 0
// 走 Context.getApplicationInfo()App.vue#setPermissions 验证过可工作)
// 不走 PackageManager.getApplicationInfo(pkg, 0)plus.android proxy 对 PackageManager
// 的 getApplicationInfo 重载方法暴露不稳定,会报 "pm.getApplicationInfo is not a function"。
const appInfo = main.getApplicationInfo()
if (!appInfo) return 0
const sourceDir = plus.android.invoke(appInfo, 'getSourceDir') || appInfo.plusGetAttribute?.('sourceDir')
if (!sourceDir) return 0
const File = plus.android.importClass('java.io.File')
const f = new File(sourceDir)
const size = f.length()
if (typeof size === 'number' && size > 0) return size
return 0
} catch (e) {
console.warn('[ioPath] getAndroidApkSize failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
// nativeStorageStatsManager返回值的可信下限。
// 本 app 自带 160MB 静态资源,任何合理的"已用空间"都远超 30MB
// 部分设备 getAppBytes proxy 只返回 base.apk 的 21MB系统"存储"实际 413MB
// 低于该阈值即视为不可信,降级到目录扫描。
const MIN_NATIVE_BYTES = 30 * 1024 * 1024
/**
* 2026-08-03 决定性降级完整扫描 app data 目录 + APK 大小对齐系统"存储"口径 413MB
*
* 背景StorageStatsManagergetAppBytes在部分设备 plus.android proxy 只返回 base.apk21MB
* getApkBytes/getDataBytes/getCacheBytes 三部分求和也不可靠仍降级纯目录扫描是唯一
* 确定性路径
*
* data 目录 = /data/user/0/<pkg> files _doc/_www/databasescachecode_cache
* shared_prefsno_backup 正是系统"存储" APK+data+cache data+cache 部分
* apkSize = APK 部分
*
* plus.io.resolveLocalFileSystemURL('file://' + 路径) 解析 _getRealFileSize 同机制
* 任一环节失败 返回 0由调用方再降级到 4 根扫描
*
* @returns {Promise<number>} 总字节失败返回 0
*/
async function _scanFullAppUsage() {
// #ifdef APP-PLUS
try {
const main = plus.android.runtimeMainActivity()
if (!main) return 0
let dataDirPath = null
try {
const dataDir = plus.android.invoke(main, 'getDataDir')
dataDirPath = plus.android.invoke(dataDir, 'getPath')
} catch (e) {
/* fallthrough 到包名拼接 */
}
if (!dataDirPath) dataDirPath = '/data/user/0/' + main.getPackageName()
const entry = await new Promise((resolve) => {
plus.io.resolveLocalFileSystemURL(
'file://' + dataDirPath,
(e) => resolve(e),
() => resolve(null)
)
})
if (!entry) {
console.warn('[ioPath] _scanFullAppUsage: resolveLocalFileSystemURL failed for', dataDirPath)
return 0
}
const sub = await _sumDirSizeRecursive(entry)
const apkSize = await getAndroidApkSize()
console.info(
`[ioPath] _scanFullAppUsage: apkSize=${Math.round(apkSize / 1048576)}MB, ` +
`dataDir=${Math.round(sub.bytes / 1048576)}MB, total=${Math.round((apkSize + sub.bytes) / 1048576)}MB`
)
return apkSize + sub.bytes
} catch (e) {
console.warn('[ioPath] _scanFullAppUsage failed:', e?.message)
return 0
}
// #endif
// #ifndef APP-PLUS
return 0
// #endif
}
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
const t0 = Date.now()
const tNative = {
start: Date.now()
}
try {
if (plus.os.name === 'Android') {
// native 快路径StorageStatsManager = 系统"存储"口径 APK+data+cacheO(1))。
// ★ 2026-08-03去掉 liberate 分流——getAppBytes 修成三部分求和后通用;
// 但仍可能被 proxy 返回假值21MB vs 系统 413MB用 MIN_NATIVE_BYTES 阈值拦截降级。
const native = await queryAndroidAppBytes()
if (native.supported && typeof native.bytes === 'number') {
if (native.supported && typeof native.bytes === 'number' && native.bytes >= MIN_NATIVE_BYTES) {
return native.bytes
}
console.warn(`[ioPath] native bytes implausible (${native.bytes})`)
// ★ 2026-08-03native 不可靠 → 完整 data 目录扫描(对齐系统"存储"413MB含 databases/cache
const full = await _scanFullAppUsage()
if (full > 0) return full
}
// iOS / 最后降级APK 大小 + Σ4 沙盒根
const apkSize = await getAndroidApkSize()
const rootTypes = [
plus.io.PRIVATE_DOC,
plus.io.PRIVATE_WWW,
@ -622,19 +747,27 @@ export async function getSandboxTotalSize() {
const result = await Promise.race([
_sumDirSizeByRootType(rootType),
new Promise((_, rej) =>
setTimeout(() => rej(new Error('rootType timeout')), 8000)
// ★ 2026-08-03 用户反馈超时,放宽 8s → 25snative 降级时 _www/业务目录树
// 在 Android 12 phantom entries 设备上递归扫描可能很慢
setTimeout(() => rej(new Error('rootType timeout')), 25000)
),
]).catch((e) => {
console.warn(
`[ioPath] _statDir failed for rootType=${rootType} (took ${Date.now() - tRoot}ms):`,
`[ioPath] root sum failed for rootType=${rootType} (took ${Date.now() - tRoot}ms):`,
e?.message)
return 0
})
return result
})
)
const total = sizes.reduce((sum, s) => sum + s, 0)
return total
const sandboxBytes = sizes.reduce((sum, s) => sum + s, 0)
// ★ 2026-08-03 诊断:输出 apkSize + 每根构成,便于定位"为什么是 X MB"
console.info(
`[ioPath] getSandboxTotalSize done (${Date.now() - t0}ms): apkSize=${Math.round(apkSize / 1048576)}MB, ` +
`roots=[${sizes.map((s) => Math.round(s / 1048576) + 'MB').join(', ')}], ` +
`total=${Math.round((apkSize + sandboxBytes) / 1048576)}MB`
)
return apkSize + sandboxBytes
} catch (e) {
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message, `(${Date.now() - t0}ms)`)
return 0
@ -698,6 +831,104 @@ async function _isPrivateWwwAvailable() {
// #endif
}
// ── 4 根扫描getSandboxTotalSize递归求和防护常量 ──
// _www / PRIVATE_DOC 是目录树,必须递归才能数全。
// _statDir 只扫顶层是给 sandbox-tmp / sandbox-residual 这种扁平结构用的,不能用于根扫描。
const MAX_RECURSIVE_DEPTH = 8
// 2026-08-03全 data 目录扫描_scanFullAppUsage要覆盖 www(~500) + doc + cache + databases
// 2000 会截断导致低估;放到 5000扫描慢设备仍有 20s 兜底)
const MAX_RECURSIVE_FILES = 5000
/**
* 判断 entry 是否为真实条目过滤 Android 12 readEntries 返回的 phantom entries
* 真实条目 fullPath 是带 '/' 的路径 '_doc/xxx''_www/static/xxx'或长 file:// URL
* phantom 条目 fullPath 常为 undefined / / 短垃圾串 file()/toURL() 可能返回假 size
* scanSandboxResidualAll / clearSandboxResidualGlobal 里的 isReal 判定同一思路
* 这里做根无关_www/_documents 等其它根也要用
*/
/**
* entry 的真实路径标识fullPath 优先退化用 toURL()
* 同一底层文件的 phantom 重复条目会返回相同 key用于按路径去重
*/
function _entryKey(entry) {
try {
return (entry && (entry.fullPath || (entry.toURL && entry.toURL()) || '')) || ''
} catch (e) {
return ''
}
}
// data 目录顶层子目录名_scanFullAppUsage resolve 到它们fullPath 可能是短名而非绝对路径)
const DATA_DIR_TOPS = new Set(['files', 'databases', 'cache', 'code_cache', 'shared_prefs', 'no_backup'])
function _isRealEntry(entry) {
const fp = _entryKey(entry)
if (typeof fp !== 'string' || fp.length === 0) return false
// 真实条目路径含 '/''_doc/a.png'、'/data/user/0/<pkg>/files'
// 个别 toURL() 变体是长 URL无 '/' 也按真实处理
if (fp.includes('/') || fp.length > 20) return true
// 全目录扫描顶层data 目录子目录可能只有短名('files'/'databases'/...),放行
return DATA_DIR_TOPS.has(fp)
}
/**
* 递归求和目录下所有文件真实字节getSandboxTotalSize 4 根扫描用
*
* _statDir只扫顶层的区别递归进子目录liberate _www APK 解压出的
* 目录树只扫顶层会把 static/uni_modules/ 等子目录整个漏掉值偏小
*
* 防护task #16/#22/#33/#35 踩坑后加的避免 Android 12 phantom entries 打爆
* - _listDir 自带 readEntries 上限/超时MAX_READ_ATTEMPTS=15单目录不会死循环
* - MAX_RECURSIVE_DEPTH递归深度上限_www/PRIVATE_DOC 正常深度 2-48 足够
* - MAX_RECURSIVE_FILES文件总数上限state 按引用共享超限截断防慢设备撞 8s 兜底
* - 同层同名 dedupAndroid 12 readEntries 会重复返回同名 phantom entries
*
* @param {DirectoryEntry} dirEntry
* @param {number} depth 递归深度内部用
* @param {{files:number, bytes:number}} state 全局累计文件数上限用内部用
* @returns {Promise<{bytes:number, count:number}>}
*/
async function _sumDirSizeRecursive(dirEntry, depth = 0, state) {
// #ifdef APP-PLUS
if (!state) state = { files: 0, bytes: 0 }
if (depth > MAX_RECURSIVE_DEPTH) return { bytes: 0, count: 0 }
const entries = await _listDir(dirEntry)
const seenDirs = new Set()
const seenFiles = new Set()
let bytes = 0
let count = 0
for (const e of entries) {
if (state.files >= MAX_RECURSIVE_FILES) break
// 过滤 phantom entriesfile()/toURL() 可能返回假 size会把字节数撑大
if (!_isRealEntry(e)) continue
// 按真实路径去重(而非 nameAndroid 12 phantom 可能"不同名同 path"或"同名不同 path"
// 只按 name 去重会漏掉同一底层文件的重复条目 → 字节虚高
const key = _entryKey(e)
if (e.isDirectory) {
if (seenDirs.has(key)) continue
seenDirs.add(key)
// eslint-disable-next-line no-await-in-loop
const sub = await _sumDirSizeRecursive(e, depth + 1, state)
bytes += sub.bytes
count += sub.count
} else if (e.isFile) {
if (seenFiles.has(key)) continue
seenFiles.add(key)
// eslint-disable-next-line no-await-in-loop
const sz = await _getRealFileSize(e)
bytes += sz
count++
state.files++
state.bytes += sz
}
}
return { bytes, count }
// #endif
// #ifndef APP-PLUS
return { bytes: 0, count: 0 }
// #endif
}
/**
* 取指定 sandbox 根类型对应的 DirectoryEntry递归求总字节
* memoize每次按需取避免 4 个根互相干扰只在 getSandboxTotalSize 单次调用
@ -714,10 +945,10 @@ async function _sumDirSizeByRootType(rootType) {
const safety = setTimeout(() => {
if (!settled) {
settled = true
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} safety timeout 6s`)
reject(new Error('safety timeout 6s'))
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} safety timeout 20s`)
reject(new Error('safety timeout 20s'))
}
}, 6000)
}, 20000)
plus.io.requestFileSystem(
rootType,
async (fs) => {
@ -725,10 +956,16 @@ async function _sumDirSizeByRootType(rootType) {
settled = true
clearTimeout(safety)
try {
const sub = await _statDir(fs.root)
// ★ 2026-08-03 双计修复345.8MB 虚高根因):
// PRIVATE_DOC 只扫顶层——其业务子目录由 sandbox-tmp / sandbox-residual 分类
// 在 appUsedBytes 里作为 sandboxOnlyBytes 单独计;这里再递归会与它们双计。
// PRIVATE_WWWliberate 解压资源)没有任何分类覆盖,必须递归数全子目录。
const sub = rootType === plus.io.PRIVATE_DOC
? await _statDir(fs.root)
: await _sumDirSizeRecursive(fs.root)
resolve(sub.bytes)
} catch (e) {
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} _statDir failed:`, e?.message)
console.warn(`[ioPath._sumDirSizeByRootType] rootType=${rootType} sum failed:`, e?.message)
reject(e)
}
},