Compare commits

...

4 Commits

Author SHA1 Message Date
407ca10dce feat:添加内存缓存数据 2026-07-30 18:36:28 +08:00
853b9f30d8 fix:正确访问已用内存大小bug 2026-07-30 16:39:13 +08:00
7667aadf3a fix:总内存显示bug修复 2026-07-30 15:44:17 +08:00
2f672920d9 feat:增加实际使用量 2026-07-30 15:34:50 +08:00
5 changed files with 453 additions and 97 deletions

View File

@ -161,6 +161,11 @@ const info = await cacheManager.getCacheInfo()
// quotaTotalBytes, // 配额总量 = uni.getStorageInfoSync().limitSize*1024
// quotaAvailableBytes, // 配额可用 = quotaTotalBytes - appUsedBytes
// usagePercent, // 百分比0-1001 位小数)= appUsedBytes / quotaTotalBytes * 100
// deviceTotalBytes, // 设备总存储字节Android 走 StatFs(Environment.getDataDirectory())iOS 走 NSFileManager.attributesOfFileSystem
// deviceFreeBytes, // 设备可用字节,同上
// deviceUsagePercent, // 设备占比0-1002 位小数)= appUsedBytes / deviceTotalBytes * 100
// // 注deviceTotalBytes/Free 通过 Native.js 在 ioPath.getDeviceStorageInfo() 取得
// // 与 getAndroidApkSize / useShare.js 同模式;不算 native plugin
// othersBytes, // "其他" section 大小 = 所有黑名单 keys + 不可清理文件大小
// categories: [{id, label, description, sizeBytes, keyCount, warning}, ...]
// }
@ -265,13 +270,18 @@ await invalidateAll() // 清空 memoryMap + inFlightMap
│ │
│ 49.7 MB │ ← 大字号主指标(主色)
│ │
│ 占据配额 16% 存储空间 │ ← 百分比副文字(次要色)
│ │
│ ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░ 16% │ ← 进度条(当前用量占配额比例)
│ │
│ ┌────────────┬─────────────┬────────────┐ │
│ │ Topfans 已用空间 │ 内存缓存空间 │ 总内存空间 │ │ ← 一行 3 个 chip 标签(无数值)
│ └────────────┴─────────────┴────────────┘ │ 数值已分别显示在:大字 / 缓存分类 sum / 总配额
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 设备总空间 │ 设备可用 │ │ ← 新增HTML5+ 设备级信息行
│ │ 128.0 GB │ 45.2 GB │ │ plus.io.getStorageInfo非 APP-PLUS 显示 "—"
│ └──────────────────────────────────────┘ │
│ │
│ 占设备 0.04% 存储空间 │ ← 百分比副文字(分母=设备总存储2 位小数)
├─────────────────────────────────────────────┤
│ 缓存分类 │ ← section 标题(次要色)
├─────────────────────────────────────────────┤
@ -314,8 +324,13 @@ await invalidateAll() // 清空 memoryMap + inFlightMap
- 该行**不可点击进入详情**(没有可清理内容)
**关于"磁盘" vs "配额"**
- MVP 用 `uni.getStorageInfoSync().limitSize`app 自己的 SQLite 配额),文案写"配额"而非"磁盘",不误导用户
- 设备级"磁盘总空间/可用空间"需要 native pluginiOS `NSFileManager` / Android `StatFs`**MVP 不引入**列为后续优化§2
- **进度条**仍以 `uni.getStorageInfoSync().limitSize`app 自己的 SQLite 配额)为 100%,表达"app 缓存压力"(用户的清理决策依据)
- **百分比分母** = 设备总存储(`appUsedBytes / deviceTotalBytes * 100`),即"占设备 X% 存储空间"
- **设备信息行**(设备总空间 / 设备可用)通过 **Native.js**`utils/ioPath.js#getDeviceStorageInfo()` 取得:
- Android`plus.android.importClass('android.os.StatFs')` + `Environment.getDataDirectory()`,无需权限
- iOS`plus.ios.invoke('NSFileManager', 'attributesOfFileSystemForPath:error:', '/')` 读 `NSFileSystemSize` / `NSFileSystemFreeSize`public API
- 非 APP-PLUS 平台或失败时返回 `{ totalBytes: 0, freeBytes: 0 }`,前端 `formatSize(0)` 显示 `—`
- Native.js **不是** native plugin不需要写 Java/ObjC、不需要 `nativeplugins/` 目录、不需要 `manifest.json` 改动),与项目已有 `getAndroidApkSize` / `useShare.js` 同模式
**草稿/引导的二级提示**:行右侧副文字"按账号分组清理",警示用户详情页会有分组。
@ -416,15 +431,19 @@ cacheManager.getCacheInfo()
│ └─ others.computeSize → 上述未匹配的 key 汇总
├─ [并行 2] 读取存储配额
│ ├─ uni.getStorageInfoSync() → { currentSize (KB), limitSize (KB) }
│ └─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/
│ ├─ ioPath.getSandboxTotalSize() → 沙盒 doc 目录下所有文件总字节数(新增 ioPath 只读 API与 scanSandboxTmpFiles 不同,前者含白名单 preload/share/image 所有文件,后者仅统计 tmp/
│ └─ ioPath.getDeviceStorageInfo() → 设备级 { totalBytes, freeBytes }Android 走 StatFsiOS 走 NSFileManagerNative.js非 native plugin非 APP-PLUS 返回 {0,0}
聚合:
totalBytes = sum(categories.sizeBytes) // 可清理总量(=内存缓存空间 chip
appUsedBytes = currentSize * 1024 + sandboxBytes // 软件占用(大字 + Topfans已用空间 chip
// 注currentSize 是 uni storage 全量(含黑名单),这是 WeChat "已用空间" 口径
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip
quotaTotalBytes = limitSize * 1024 // 配额总量(=总内存空间 chip,进度条 100% 基准
quotaAvailableBytes = quotaTotalBytes - appUsedBytes // 配额可用
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 百分比
usagePercent = (appUsedBytes / quotaTotalBytes) * 100 // 配额百分比(进度条用)
deviceTotalBytes = deviceTotalKB * 1024 // 设备总存储(设备信息行"设备总空间"
deviceFreeBytes = deviceFreeKB * 1024 // 设备可用(设备信息行"设备可用"
deviceUsagePercent = (appUsedBytes / deviceTotalBytes) * 100 // 设备百分比(百分比副文字"占设备 X%"用)
othersBytes = sum(黑名单 keys size) + 不可清理文件大小 // "其他" section
渲染页面(大字 + 百分比 + 进度条 + 3 chip 标签 + 6 缓存分类行 + 1 其他 section

View File

@ -414,7 +414,10 @@
"path": "pages/profile/cache-cleanup",
"style": {
"navigationBarTitleText": "存储空间",
"enablePullDownRefresh": true
"enablePullDownRefresh": true,
"app-plus": {
"bounce": "none"
}
}
},
{

View File

@ -10,62 +10,67 @@
<!-- #endif -->
<text class="hero-label">Topfans 已用空间</text>
<text class="hero-value">{{ formatSize(info.appUsedBytes) }}</text>
<!-- hero-value 焦点位loading 时显示 spinner加载完显示真实数字 -->
<view v-if="loading" class="value-spinner spinner-hero"></view>
<text v-else class="hero-value">{{ formatSize(info.appUsedBytes) }}</text>
<!-- 嵌套进度条Topfans 已用空间 内存缓存空间叠在蓝内 总内存空间剩余绿 -->
<!-- 嵌套进度条Topfans 已用空间 其他 app 使用空间 剩余可用空间绿 -->
<view class="progress-bar">
<!-- 绿段剩余配额贴在右侧 -->
<!-- 绿段剩余可用空间贴在右侧 -->
<view
class="seg-available"
:style="{ width: availablePct + '%' }"
></view>
<!-- 蓝段完整 Topfans 已用从左 -->
<!-- 蓝段Topfans 已用空间从左 -->
<view
class="seg-app"
:style="{ width: appPct + '%' }"
class="seg-topfans"
:style="{ width: topfansPct + '%' }"
></view>
<!-- 紫段缓存从左叠在蓝段内不超过蓝段右边界 -->
<!-- 橙段其他 app 使用空间从蓝段右边界开始紧接蓝段 -->
<view
class="seg-cache"
:style="{ width: cachePct + '%' }"
class="seg-other"
:style="{ left: topfansPct + '%', width: otherPct + '%' }"
></view>
</view>
<view class="chips">
<view class="chip">
<view class="chip-dot dot-app"></view>
<view class="chip-dot dot-topfans"></view>
<text>Topfans 已用空间</text>
</view>
<view class="chip">
<view class="chip-dot dot-cache"></view>
<text>内存缓存空间</text>
<view class="chip-dot dot-other"></view>
<text>其他 app 使用空间</text>
</view>
<view class="chip">
<view class="chip-dot dot-available"></view>
<text>总内存空间</text>
<text>剩余可用空间</text>
</view>
</view>
<!-- 设备级信息总空间 / 可用空间来自 plus.io.getStorageInfo -->
<view class="device-info">
<view class="device-info-item">
<text class="device-info-label">设备总空间</text>
<view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceTotalBytes) }}</text>
</view>
<view class="device-info-divider"></view>
<view class="device-info-item">
<text class="device-info-label">设备可用</text>
<view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text>
</view>
</view>
<text class="hero-percent"
>占手机 {{ info.usagePercent.toFixed(1) }}% 存储空间</text
>设备 {{ info.deviceUsagePercent.toFixed(2) }}% 存储空间</text
>
</view>
<!-- 缓存分类卡片 -->
<view class="section-card">
<text class="section-title">缓存分类</text>
<!-- 全局空状态失败时不显示避免冲突显示 -->
<view
v-if="
!loadFailed &&
info.totalBytes === 0 &&
info.categories.every((c) => c.sizeBytes <= 0)
"
class="empty"
>
<view class="empty-icon"></view>
<text class="empty-text">当前无缓存可清理</text>
</view>
<view
v-for="(cat, index) in info.categories"
:key="cat.id"
@ -79,6 +84,7 @@
</view>
<view class="row-sub">
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text>
<view v-else-if="loading" class="value-spinner spinner-sm"></view>
<text v-else class="size"
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} </text
>
@ -97,7 +103,8 @@
>包含运行 Topfans 的必要数据账号会话其他账号的数据等</text
>
</view>
<text class="size">{{ formatSize(info.othersBytes) }}</text>
<view v-if="loading" class="value-spinner spinner-sm"></view>
<text v-else class="size">{{ formatSize(info.othersBytes) }}</text>
</view>
</view>
@ -115,38 +122,49 @@
<script setup>
import { ref, computed } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
import { getCacheInfo, formatSize } from "@/utils/cacheManager";
import { getCacheInfo, formatSize, peekCache } from "@/utils/cacheManager";
const info = ref({
// info load()
const INITIAL_INFO = Object.freeze({
appUsedBytes: 0,
quotaTotalBytes: 0,
quotaAvailableBytes: 0,
usagePercent: 0,
deviceTotalBytes: 0,
deviceFreeBytes: 0,
deviceUsagePercent: 0,
othersBytes: 0,
totalBytes: 0,
categories: [],
});
const info = ref({ ...INITIAL_INFO });
const loadFailed = ref(false);
// hero-value loading getCacheInfo 4 + Native.js ~2.1s
const loading = ref(false);
// quotaTotalBytes = 100%
// deviceTotalBytes = 100%
// 100% 0
// = Topfans = 绿 =
const appPct = computed(() => {
const total = info.value.quotaTotalBytes;
// = Topfans = + app+绿 =
const topfansPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.min(100, Math.max(0, (info.value.appUsedBytes / total) * 100));
});
const cachePct = computed(() => {
const total = info.value.quotaTotalBytes;
const otherPct = computed(() => {
const total = info.value.deviceTotalBytes;
if (!total) return 0;
// Topfans cache used
const cache = Math.min(info.value.totalBytes, info.value.appUsedBytes);
return Math.min(appPct.value, Math.max(0, (cache / total) * 100));
// app + = deviceTotal - Topfans -
// appUsedBytes + deviceFreeBytes deviceTotalBytes
const otherBytes = Math.max(
0,
info.value.deviceTotalBytes - info.value.appUsedBytes - info.value.deviceFreeBytes
);
return Math.min(100 - topfansPct.value, Math.max(0, (otherBytes / total) * 100));
});
const availablePct = computed(() => {
const total = info.value.quotaTotalBytes;
const total = info.value.deviceTotalBytes;
if (!total) return 0;
return Math.max(0, 100 - appPct.value);
return Math.max(0, 100 - topfansPct.value - otherPct.value);
});
// APP-PLUS
@ -160,12 +178,29 @@ const isIos = (() => {
})();
// #endif
async function load() {
async function load(force = false) {
loadFailed.value = false;
// spinner await
// loading 便 500ms spinner
if (!force) {
const cached = peekCache();
if (cached) {
info.value = cached;
return;
}
}
// spinner
info.value = { ...INITIAL_INFO };
loading.value = true;
const t0 = Date.now();
try {
const result = await Promise.race([
getCacheInfo(),
getCacheInfo(force),
new Promise((_, rej) =>
// getCacheInfo < 3s4 + Native.js + handlers
// 2s ~2.1s5s 2x
setTimeout(() => rej(new Error("timeout")), 5000),
),
]);
@ -173,6 +208,16 @@ async function load() {
} catch (e) {
console.warn("[cache-cleanup] load failed:", e.message);
loadFailed.value = true;
} finally {
// / loading loadFailed=true
// spinner 500ms
const elapsed = Date.now() - t0;
const remaining = 500 - elapsed;
if (remaining > 0) {
setTimeout(() => { loading.value = false }, remaining);
} else {
loading.value = false;
}
}
}
@ -189,7 +234,8 @@ function goDetail(id) {
// 使 onMounted
onShow(load);
onPullDownRefresh(async () => {
await load();
// 5min
await load(true);
uni.stopPullDownRefresh();
});
</script>
@ -201,6 +247,8 @@ onPullDownRefresh(async () => {
background: #f5f7fa;
min-height: 100vh;
box-sizing: border-box;
/* 关闭 WebView 层回弹iOS 顶部/底部橡皮筋 + Android 边缘光晕) */
overscroll-behavior: none;
}
/* ── Hero 卡片 ── */
@ -231,6 +279,41 @@ onPullDownRefresh(async () => {
display: block;
margin-top: 24rpx;
}
/* ── 设备级信息行(总空间 / 可用空间)── */
.device-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding: 16rpx 20rpx;
background: #fafbfc;
border-radius: 12rpx;
box-sizing: border-box;
}
.device-info-item {
display: flex;
flex-direction: column;
gap: 4rpx;
flex: 1;
min-width: 0;
}
.device-info-divider {
width: 1rpx;
height: 32rpx;
background: #e8e8e8;
margin: 0 16rpx;
flex-shrink: 0;
}
.device-info-label {
font-size: 22rpx;
color: #8c8c8c;
}
.device-info-value {
font-size: 28rpx;
font-weight: 500;
color: #262626;
}
.hero-tip {
display: flex;
align-items: center;
@ -252,10 +335,9 @@ onPullDownRefresh(async () => {
border-radius: 8rpx;
overflow: hidden;
}
/* absolute 绿
紫叠在蓝之上 视觉上"缓存占用 Topfans 已用的一部分" */
.seg-app,
.seg-cache,
/* 三段都用 absolute 定位(蓝、橙从左贴齐→依次拼接;绿从右贴齐) */
.seg-topfans,
.seg-other,
.seg-available {
position: absolute;
top: 0;
@ -263,13 +345,16 @@ onPullDownRefresh(async () => {
min-width: 0;
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.2);
}
.seg-app {
.seg-topfans {
left: 0;
background: #1890ff;
/* Topfans 通常 < 0.1% 设备总空间,强制最小可见宽度 2%(保持视觉存在感) */
min-width: 2%;
}
.seg-cache {
.seg-other {
/* 防御left 默认 0inline style 会用 topfansPct% 覆盖 */
left: 0;
background: #722ed1;
background: #fa8c16;
}
.seg-available {
right: 0;
@ -298,16 +383,20 @@ onPullDownRefresh(async () => {
flex-shrink: 0;
}
.chip-dot {
width: 8rpx;
height: 8rpx;
/* 12rpx 8rpx
display: inline-block 防御 uniapp <view> 不渲染的极端 case */
display: inline-block;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
vertical-align: middle;
}
.dot-app {
.dot-topfans {
background: #1890ff;
}
.dot-cache {
background: #722ed1;
.dot-other {
background: #fa8c16;
}
.dot-available {
background: #52c41a;
@ -397,29 +486,41 @@ onPullDownRefresh(async () => {
font-size: 26rpx;
}
/* ── 空状态 ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
gap: 16rpx;
}
.empty-icon {
width: 96rpx;
height: 96rpx;
/* ── 加载 spinner5 处数字位置共用loading 时转圈loaded 后切真实数字) ── */
.value-spinner {
display: inline-block;
border-style: solid;
border-color: #e6e6e6;
border-top-color: #1890ff;
border-radius: 50%;
background: #f6ffed;
color: #52c41a;
font-size: 48rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
vertical-align: middle;
animation: value-spin 0.8s linear infinite;
-webkit-animation: value-spin 0.8s linear infinite;
}
.empty-text {
color: #8c8c8c;
font-size: 28rpx;
.spinner-hero {
/* hero-value占位高度 ≈ 88rpx 字号56 + margin 8+24避免布局抖动 */
width: 56rpx;
height: 56rpx;
border-width: 6rpx;
margin: 8rpx 0 24rpx;
}
.spinner-md {
/* device-info-value内联在 28rpx 文字旁 */
width: 24rpx;
height: 24rpx;
border-width: 3rpx;
}
.spinner-sm {
/* row .size / others内联在 26rpx 文字旁 */
width: 20rpx;
height: 20rpx;
border-width: 2rpx;
}
@keyframes value-spin {
to { transform: rotate(360deg); }
}
@-webkit-keyframes value-spin {
to { -webkit-transform: rotate(360deg); }
}
/* ── 加载失败按钮 ── */
@ -435,3 +536,16 @@ onPullDownRefresh(async () => {
cursor: pointer;
}
</style>
<style>
/* scopedscoped [data-v-xxx]
::-webkit-scrollbar 是伪元素没有 DOM 属性scoped 匹配不上
这里隐藏整页滚动条不影响下拉刷新 */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
appearance: none;
}
</style>

View File

@ -9,7 +9,7 @@ import othersHandler from './handlers/othersHandler'
import preloadHandler from './handlers/preloadHandler'
import guideHandler from './handlers/guideHandler'
import draftHandler from './handlers/draftHandler'
import { getSandboxTotalSize } from './ioPath'
import { getSandboxTotalSize, getDeviceStorageInfo } from './ioPath'
// ── 黑名单§3.4.1)──
// 全等匹配
@ -50,6 +50,72 @@ export function formatSize(bytes) {
const handlers = new Map() // handlerId → handler
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise
// ── 内存缓存:避免每次进 cache-cleanup 页都走 4 沙盒根遍历(最坏 ~2s ──
// 5min TTL
// - 默认命中返回缓存(瞬时显示)
// - 清理动作完成后调 deductFromCache() 局部扣减(不清空),下次返回列表仍命中
// - invalidateCache() 仅用于特殊场景(如调试 / 用户主动重置)
const CACHE_TTL_MS = 5 * 60 * 1000
let _cachedInfo = null
let _cachedAt = 0
/** 清理缓存:清理动作完成后必须调用,下次 getCacheInfo 必重算 */
export function invalidateCache() {
_cachedInfo = null
_cachedAt = 0
}
/**
* 清理后局部扣减缓存不重算
* - 对应 category sizeBytes / keyCount 扣减
* - totalBytes / appUsedBytes 同步扣减
* - usagePercent / deviceUsagePercent 重新计算
* 不动 othersBytes黑名单 + sandboxBytes 不直接减少5min 内下次进页面会被 force=下拉刷新校正
*
* 关键必须创建新对象赋给 _cachedInfo而不是 in-place 修改属性
* 否则 cache-cleanup.vue `info.value = cached` 检测到引用未变Object.is 相等
* 不会触发 Vue 响应式更新 对应 category sizeBytes 显示仍是旧值
*/
function deductFromCache(id, freedBytes, freedKeyCount) {
if (!_cachedInfo || !Array.isArray(_cachedInfo.categories)) return
const newCategories = _cachedInfo.categories.map((c) =>
c.id === id
? {
...c,
sizeBytes: Math.max(0, (c.sizeBytes || 0) - freedBytes),
keyCount: Math.max(0, (c.keyCount || 0) - (freedKeyCount || 0)),
}
: c
)
const newAppUsedBytes = Math.max(0, (_cachedInfo.appUsedBytes || 0) - freedBytes)
_cachedInfo = {
..._cachedInfo,
categories: newCategories,
totalBytes: Math.max(0, (_cachedInfo.totalBytes || 0) - freedBytes),
appUsedBytes: newAppUsedBytes,
usagePercent: _cachedInfo.quotaTotalBytes > 0
? (newAppUsedBytes / _cachedInfo.quotaTotalBytes) * 100
: 0,
deviceUsagePercent: _cachedInfo.deviceTotalBytes > 0
? (newAppUsedBytes / _cachedInfo.deviceTotalBytes) * 100
: 0,
}
_cachedAt = Date.now()
}
/**
* 同步检查缓存命中且未过期 直接返回缓存对象不走 spinner
* 否则返回 null需要走 getCacheInfo 异步计算
*/
export function peekCache() {
if (_cachedInfo && Date.now() - _cachedAt < CACHE_TTL_MS) {
return _cachedInfo
}
return null
}
// ── 注册 API ──
export function registerCategory(handler) {
if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
@ -66,8 +132,15 @@ function getHandler(id) {
/**
* 列表页读取汇总 + 存储配额 + 其他 section 数据
* @param {boolean} force 强制重算跳过 30s 缓存下拉刷新/清理后用
*/
export async function getCacheInfo() {
export async function getCacheInfo(force = false) {
// 缓存命中30s 内且不强制刷新 → 同步返回,避免重复走沙盒遍历
const now = Date.now()
if (!force && _cachedInfo && now - _cachedAt < CACHE_TTL_MS) {
return _cachedInfo
}
// 缓存未命中或过期或强制刷新 → 重新计算
// [并行 1] 所有 handler 的 computeSize
const categories = await Promise.all(
Array.from(handlers.values()).map(async (h) => {
@ -105,6 +178,15 @@ export async function getCacheInfo() {
} catch (e) {
console.warn('[cacheManager] getSandboxTotalSize failed:', e.message)
}
let deviceTotalBytes = 0, deviceFreeBytes = 0
try {
const dev = await getDeviceStorageInfo()
// ioPath.getDeviceStorageInfo 返回的 totalBytes/freeBytes 已是字节Android: blockSize*blocks, iOS: NSFileSystemSize不要再 * 1024
deviceTotalBytes = dev.totalBytes || 0
deviceFreeBytes = dev.freeBytes || 0
} catch (e) {
console.warn('[cacheManager] getDeviceStorageInfo failed:', e.message)
}
const totalBytes = categories.reduce((sum, c) => sum + (c.sizeBytes > 0 ? c.sizeBytes : 0), 0)
const appUsedBytes = currentSizeKB * 1024 + sandboxBytes
@ -113,12 +195,21 @@ export async function getCacheInfo() {
const quotaAvailableBytes = Math.max(0, raw)
const quotaExceeded = raw < 0
const usagePercent = quotaTotalBytes > 0 ? (appUsedBytes / quotaTotalBytes) * 100 : 0
// 设备级数据已在 try 块里取好ioPath 返回的就是字节,不再 * 1024
const deviceUsagePercent = deviceTotalBytes > 0
? (appUsedBytes / deviceTotalBytes) * 100
: 0
return {
const result = {
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
deviceTotalBytes, deviceFreeBytes, deviceUsagePercent,
othersBytes: blacklistBytes + sandboxBytes,
categories,
}
// 写入缓存(即便部分字段失败/降级也缓存,避免反复重算;清理动作会主动 invalidate
_cachedInfo = result
_cachedAt = Date.now()
return result
}
/**
@ -150,6 +241,9 @@ export function cleanCategory(id) {
if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
}
// 局部扣减缓存(不清空):返回列表页时 cache-cleanup 还能命中缓存,瞬时显示新数据
const { freedBytes = 0, keyCount = 0 } = result || {}
deductFromCache(id, freedBytes, keyCount)
return result
} catch (e) {
console.warn(`[cacheManager] clean failed: ${id}`, e.message)
@ -176,6 +270,9 @@ export function cleanCategoryGroup(id, opts = {}) {
if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
}
// 局部扣减缓存(不清空)
const { freedBytes = 0, keyCount = 0 } = result || {}
deductFromCache(id, freedBytes, keyCount)
return result
} catch (e) {
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)

View File

@ -368,22 +368,50 @@ async function getAndroidApkSize() {
}
/**
* 计算"app 已用空间" = Android APK 安装包大小 + sandbox 运行时数据大小
* iOS / H5 上只算 sandboxiOS bundle 不易从 js 取大小 sandbox 估算
* 计算"app 已用空间" = Android APK 安装包大小 + 4 sandbox 根目录下所有文件总字节
* iOS / H5 上只算 sandboxiOS .app bundle 沙盒外不可访问 sandbox 估算
* 用于 cacheManager.getCacheInfo() 聚合 appUsedBytes
*
* 4 sandbox
* PRIVATE_DOC (2) _doc 私有文档用户数据/缓存 主用绝大多数占用在此
* PRIVATE_WWW (1) _www 私有资源 manifest.json runmode=liberate 时才有内容
* PUBLIC_DOCUMENTS (3) _documents 公共文档 5+ App 共享
* PUBLIC_DOWNLOADS (4) _downloads 公共下载 5+ App 共享
*
* PRIVATE_WWW 行为说明
* - liberate 模式沙盒根不存在 requestFileSystem 两个回调都不触发 2s 超时降级为 0
* - liberate 模式首次启动资源从 APK 解压到 _www 可正常遍历求和
* liberate 模式下 _www 内容是 APK 资源的解压副本理论上是双倍计数但用户明确要求保留 4 个根遍历
*
* 单个根失败/超时 0不影响其他根
*/
export async function getSandboxTotalSize() {
// #ifdef APP-PLUS
try {
// Android: APK 大小 + sandbox doc 目录运行时数据
// Android: APK 大小 + 4 个沙盒根目录运行时数据
const apkSize = await getAndroidApkSize()
let sandboxBytes = 0
try {
const root = await getSandboxRootDir()
sandboxBytes = await _sumDirSize(root)
} catch (e) {
console.warn('[ioPath] _sumDirSize failed:', e?.message)
}
const rootTypes = [
plus.io.PRIVATE_DOC,
plus.io.PRIVATE_WWW,
plus.io.PUBLIC_DOCUMENTS,
plus.io.PUBLIC_DOWNLOADS,
]
// 并行遍历 4 个沙盒根(互不依赖),单根失败/超时 → 0
// 2s 超时防御性PRIVATE_WWW 在非 liberate 模式下挂死靠它兜底
const sizes = await Promise.all(
rootTypes.map(async (rootType) =>
Promise.race([
_sumDirSizeByRootType(rootType),
new Promise((_, rej) =>
setTimeout(() => rej(new Error('rootType timeout')), 2000)
),
]).catch((e) => {
console.warn(`[ioPath] _sumDirSize failed for rootType=${rootType}:`, e?.message)
return 0
})
)
)
const sandboxBytes = sizes.reduce((sum, s) => sum + s, 0)
return apkSize + sandboxBytes
} catch (e) {
console.warn('[ioPath] getSandboxTotalSize failed:', e?.message)
@ -395,6 +423,31 @@ export async function getSandboxTotalSize() {
// #endif
}
/**
* 取指定 sandbox 根类型对应的 DirectoryEntry递归求总字节
* memoize每次按需取避免 4 个根互相干扰只在 getSandboxTotalSize 单次调用
*/
function _sumDirSizeByRootType(rootType) {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
rootType,
async (fs) => {
try {
resolve(await _sumDirSize(fs.root))
} catch (e) {
reject(e)
}
},
(e) => reject(e)
)
})
// #endif
// #ifndef APP-PLUS
return Promise.resolve(0)
// #endif
}
/**
* 扫描 sandbox doc "所有业务子目录的 tmp/ 文件总字节数"不包含白名单 preload/share/image
* 用于 sandboxTmpHandler.computeSize
@ -522,3 +575,73 @@ async function _listDir(dirEntry) {
return []
// #endif
}
/**
* 获取设备级存储信息Native.jsAndroid StatFsiOS NSFileManager
* 不需要任何 native plugin APP-PLUS 平台或失败时返回安全零值
* 与项目已有 getAndroidApkSize / useShare.js Native.js 模式 1:1 对齐
* @returns {Promise<{totalBytes:number, freeBytes:number}>}
*/
export function getDeviceStorageInfo() {
return new Promise((resolve) => {
// #ifdef APP-PLUS
try {
if (typeof plus === 'undefined') {
return resolve({ totalBytes: 0, freeBytes: 0 })
}
if (plus.os.name === 'Android') {
// Android用 StatFs 查 /data 分区(即系统"内部存储"
// 不需要任何权限Android 10+ 分区存储不影响 StatFsStatFs 查的是卷级元数据)
const Environment = plus.android.importClass('android.os.Environment')
const StatFs = plus.android.importClass('android.os.StatFs')
const dataDir = Environment.getDataDirectory()
const stat = new StatFs(dataDir.getPath())
// getBlockSizeLong 是 API 18+project minSdkVersion=21 保证可用
// plus.android 对 *Long 变体代理有时不稳定 → parseFloat 兜底
const blockSize = parseFloat(plus.android.invoke(stat, 'getBlockSizeLong'))
const totalBlocks = parseFloat(plus.android.invoke(stat, 'getBlockCountLong'))
const freeBlocks = parseFloat(plus.android.invoke(stat, 'getAvailableBlocksLong'))
if (blockSize > 0 && totalBlocks > 0) {
resolve({
totalBytes: blockSize * totalBlocks,
freeBytes: blockSize * freeBlocks,
})
} else {
console.warn('[ioPath] getDeviceStorageInfo (Android) got non-positive values')
resolve({ totalBytes: 0, freeBytes: 0 })
}
} else if (plus.os.name === 'iOS') {
// iOS用 NSFileManager.attributesOfFileSystem 查系统卷
// NSFileSystemSize / NSFileSystemFreeSize 是 public API参照 useShare.js 调用风格)
const fm = plus.ios.invoke('NSFileManager', 'defaultManager')
if (!fm) {
console.warn('[ioPath] getDeviceStorageInfo (iOS) fm is null')
return resolve({ totalBytes: 0, freeBytes: 0 })
}
const attrs = plus.ios.invoke(fm, 'attributesOfFileSystemForPath:error:', '/')
if (attrs) {
const total = parseFloat(attrs.plusGetAttribute('NSFileSystemSize')) || 0
const free = parseFloat(attrs.plusGetAttribute('NSFileSystemFreeSize')) || 0
// 必须 deleteObject否则 NSObject proxy 会泄露(参照 useShare.js 清理模式)
plus.ios.deleteObject(attrs)
plus.ios.deleteObject(fm)
resolve({ totalBytes: total, freeBytes: free })
} else {
plus.ios.deleteObject(fm)
console.warn('[ioPath] getDeviceStorageInfo (iOS) attrs is null')
resolve({ totalBytes: 0, freeBytes: 0 })
}
} else {
// Harmony / 其它平台:暂不支持,返回 0
resolve({ totalBytes: 0, freeBytes: 0 })
}
} catch (e) {
console.warn('[ioPath] getDeviceStorageInfo failed:', e?.message)
resolve({ totalBytes: 0, freeBytes: 0 })
}
// #endif
// #ifndef APP-PLUS
resolve({ totalBytes: 0, freeBytes: 0 })
// #endif
})
}