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:
parent
a6859a67d3
commit
bd3296421a
@ -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)
|
||||
|
||||
@ -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 + cache),O(1)。
|
||||
* getAppBytes 改成 getApkBytes + getDataBytes + getCacheBytes 三部分求和,
|
||||
* 规避部分设备 proxy 把 getAppBytes 误解析成只返回 base.apk(21MB vs 系统 413MB)的 bug。
|
||||
* - native 返回值 < MIN_NATIVE_BYTES(30MB,本 app 自带 160MB 静态资源不可能低于此)→ 视为不可信,
|
||||
* 降级到"APK + Σ4 沙盒根遍历"(PRIVATE_WWW 递归数全解压资源、PRIVATE_DOC 顶层 + 分类补子目录)。
|
||||
* Android API < 26:直接走 4 根遍历(queryStatsForPackage 自 API 26 才有)。
|
||||
* iOS:4 沙盒根遍历(iOS .app bundle 沙盒外不可访问,按 sandbox 估算)。
|
||||
* H5 / 小程序:return 0。
|
||||
*
|
||||
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes。
|
||||
@ -596,20 +617,124 @@ async function queryAndroidAppBytes() {
|
||||
*
|
||||
* 单个根失败/超时 → 0(不影响其他根)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取 Android APK 安装包大小(native,O(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
|
||||
}
|
||||
|
||||
// native(StorageStatsManager)返回值的可信下限。
|
||||
// 本 app 自带 160MB 静态资源,任何合理的"已用空间"都远超 30MB;
|
||||
// 部分设备 getAppBytes proxy 只返回 base.apk 的 21MB(系统"存储"实际 413MB),
|
||||
// 低于该阈值即视为不可信,降级到目录扫描。
|
||||
const MIN_NATIVE_BYTES = 30 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* ★ 2026-08-03 决定性降级:完整扫描 app data 目录 + APK 大小(对齐系统"存储"口径 413MB)。
|
||||
*
|
||||
* 背景:StorageStatsManager(getAppBytes)在部分设备 plus.android proxy 只返回 base.apk(21MB),
|
||||
* 且 getApkBytes/getDataBytes/getCacheBytes 三部分求和也不可靠(仍降级)。纯目录扫描是唯一
|
||||
* 确定性路径。
|
||||
*
|
||||
* data 目录 = /data/user/0/<pkg>,覆盖 files(含 _doc/_www/databases)、cache、code_cache、
|
||||
* shared_prefs、no_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+cache,O(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-03:native 不可靠 → 完整 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 → 25s:native 降级时 _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-4,8 足够)
|
||||
* - MAX_RECURSIVE_FILES:文件总数上限(state 按引用共享),超限截断防慢设备撞 8s 兜底
|
||||
* - 同层同名 dedup:Android 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 entries(file()/toURL() 可能返回假 size,会把字节数撑大)
|
||||
if (!_isRealEntry(e)) continue
|
||||
// 按真实路径去重(而非 name):Android 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_WWW(liberate 解压资源)没有任何分类覆盖,必须递归数全子目录。
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user