feat:添加内存缓存数据

This commit is contained in:
zheng020 2026-07-30 18:36:28 +08:00
parent 853b9f30d8
commit 407ca10dce
2 changed files with 163 additions and 44 deletions

View File

@ -10,7 +10,9 @@
<!-- #endif --> <!-- #endif -->
<text class="hero-label">Topfans 已用空间</text> <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 已用空间 其他 app 使用空间 剩余可用空间绿 --> <!-- 嵌套进度条Topfans 已用空间 其他 app 使用空间 剩余可用空间绿 -->
<view class="progress-bar"> <view class="progress-bar">
@ -50,12 +52,14 @@
<view class="device-info"> <view class="device-info">
<view class="device-info-item"> <view class="device-info-item">
<text class="device-info-label">设备总空间</text> <text class="device-info-label">设备总空间</text>
<text class="device-info-value">{{ formatSize(info.deviceTotalBytes) }}</text> <view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceTotalBytes) }}</text>
</view> </view>
<view class="device-info-divider"></view> <view class="device-info-divider"></view>
<view class="device-info-item"> <view class="device-info-item">
<text class="device-info-label">设备可用</text> <text class="device-info-label">设备可用</text>
<text class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text> <view v-if="loading" class="value-spinner spinner-md"></view>
<text v-else class="device-info-value">{{ formatSize(info.deviceFreeBytes) }}</text>
</view> </view>
</view> </view>
@ -67,18 +71,6 @@
<!-- 缓存分类卡片 --> <!-- 缓存分类卡片 -->
<view class="section-card"> <view class="section-card">
<text class="section-title">缓存分类</text> <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 <view
v-for="(cat, index) in info.categories" v-for="(cat, index) in info.categories"
:key="cat.id" :key="cat.id"
@ -92,6 +84,7 @@
</view> </view>
<view class="row-sub"> <view class="row-sub">
<text v-if="cat.sizeBytes === -1" class="error">? · 加载失败</text> <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" <text v-else class="size"
>{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} </text >{{ formatSize(cat.sizeBytes) }} · {{ cat.keyCount }} </text
> >
@ -110,7 +103,8 @@
>包含运行 Topfans 的必要数据账号会话其他账号的数据等</text >包含运行 Topfans 的必要数据账号会话其他账号的数据等</text
> >
</view> </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>
</view> </view>
@ -128,9 +122,10 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; 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, appUsedBytes: 0,
quotaTotalBytes: 0, quotaTotalBytes: 0,
quotaAvailableBytes: 0, quotaAvailableBytes: 0,
@ -142,7 +137,10 @@ const info = ref({
totalBytes: 0, totalBytes: 0,
categories: [], categories: [],
}); });
const info = ref({ ...INITIAL_INFO });
const loadFailed = ref(false); const loadFailed = ref(false);
// hero-value loading getCacheInfo 4 + Native.js ~2.1s
const loading = ref(false);
// deviceTotalBytes = 100% // deviceTotalBytes = 100%
// 100% 0 // 100% 0
@ -180,11 +178,26 @@ const isIos = (() => {
})(); })();
// #endif // #endif
async function load() { async function load(force = false) {
loadFailed.value = 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 { try {
const result = await Promise.race([ const result = await Promise.race([
getCacheInfo(), getCacheInfo(force),
new Promise((_, rej) => new Promise((_, rej) =>
// getCacheInfo < 3s4 + Native.js + handlers // getCacheInfo < 3s4 + Native.js + handlers
// 2s ~2.1s5s 2x // 2s ~2.1s5s 2x
@ -195,6 +208,16 @@ async function load() {
} catch (e) { } catch (e) {
console.warn("[cache-cleanup] load failed:", e.message); console.warn("[cache-cleanup] load failed:", e.message);
loadFailed.value = true; 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;
}
} }
} }
@ -211,7 +234,8 @@ function goDetail(id) {
// 使 onMounted // 使 onMounted
onShow(load); onShow(load);
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await load(); // 5min
await load(true);
uni.stopPullDownRefresh(); uni.stopPullDownRefresh();
}); });
</script> </script>
@ -462,29 +486,41 @@ onPullDownRefresh(async () => {
font-size: 26rpx; font-size: 26rpx;
} }
/* ── 空状态 ── */ /* ── 加载 spinner5 处数字位置共用loading 时转圈loaded 后切真实数字) ── */
.empty { .value-spinner {
display: flex; display: inline-block;
flex-direction: column; border-style: solid;
align-items: center; border-color: #e6e6e6;
padding: 80rpx 0; border-top-color: #1890ff;
gap: 16rpx;
}
.empty-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%; border-radius: 50%;
background: #f6ffed; vertical-align: middle;
color: #52c41a; animation: value-spin 0.8s linear infinite;
font-size: 48rpx; -webkit-animation: value-spin 0.8s linear infinite;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
} }
.empty-text { .spinner-hero {
color: #8c8c8c; /* hero-value占位高度 ≈ 88rpx 字号56 + margin 8+24避免布局抖动 */
font-size: 28rpx; 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); }
} }
/* ── 加载失败按钮 ── */ /* ── 加载失败按钮 ── */

View File

@ -50,6 +50,72 @@ export function formatSize(bytes) {
const handlers = new Map() // handlerId → handler const handlers = new Map() // handlerId → handler
const cleanInFlight = new Map() // `${id}` 或 `${id}#${uid}` → Promise 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 ── // ── 注册 API ──
export function registerCategory(handler) { export function registerCategory(handler) {
if (!handler?.id) throw new Error('[cacheManager] handler.id is required') if (!handler?.id) throw new Error('[cacheManager] handler.id is required')
@ -66,8 +132,15 @@ function getHandler(id) {
/** /**
* 列表页读取汇总 + 存储配额 + 其他 section 数据 * 列表页读取汇总 + 存储配额 + 其他 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 // [并行 1] 所有 handler 的 computeSize
const categories = await Promise.all( const categories = await Promise.all(
Array.from(handlers.values()).map(async (h) => { Array.from(handlers.values()).map(async (h) => {
@ -127,12 +200,16 @@ export async function getCacheInfo() {
? (appUsedBytes / deviceTotalBytes) * 100 ? (appUsedBytes / deviceTotalBytes) * 100
: 0 : 0
return { const result = {
totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent, totalBytes, appUsedBytes, quotaTotalBytes, quotaAvailableBytes, quotaExceeded, usagePercent,
deviceTotalBytes, deviceFreeBytes, deviceUsagePercent, deviceTotalBytes, deviceFreeBytes, deviceUsagePercent,
othersBytes: blacklistBytes + sandboxBytes, othersBytes: blacklistBytes + sandboxBytes,
categories, categories,
} }
// 写入缓存(即便部分字段失败/降级也缓存,避免反复重算;清理动作会主动 invalidate
_cachedInfo = result
_cachedAt = Date.now()
return result
} }
/** /**
@ -164,6 +241,9 @@ export function cleanCategory(id) {
if (id === 'preload') { if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } 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 return result
} catch (e) { } catch (e) {
console.warn(`[cacheManager] clean failed: ${id}`, e.message) console.warn(`[cacheManager] clean failed: ${id}`, e.message)
@ -190,6 +270,9 @@ export function cleanCategoryGroup(id, opts = {}) {
if (id === 'preload') { if (id === 'preload') {
try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) } try { invalidateAll() } catch (e) { console.warn('[cacheManager] invalidateAll failed:', e.message) }
} }
// 局部扣减缓存(不清空)
const { freedBytes = 0, keyCount = 0 } = result || {}
deductFromCache(id, freedBytes, keyCount)
return result return result
} catch (e) { } catch (e) {
console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message) console.warn(`[cacheManager] cleanGroup failed: ${id}`, e.message)