303 lines
8.2 KiB
JavaScript
303 lines
8.2 KiB
JavaScript
// 头像文件缓存管理工具
|
||
//
|
||
// ★ 2026-07-31 补充 B:加 LRU 上限 50 个文件
|
||
// 原实现每看一次头像 → uni.saveFile → _doc/uniapp_save/ 累积, 无 TTL 无上限,
|
||
// 生产设备几个月后 uniapp_save/ 累积几百个文件, sandbox-residual 扫描单类就要 1-3s,
|
||
// 是「存储空间」页面超时的主因之一。
|
||
// 现在:每次写入前 evictOldestIfNeeded() 把 LRU 索引压回 ≤ 50 个,
|
||
// 老的 storage 记录 + savedFilePath 同步删除。
|
||
//
|
||
// LRU 索引用一个独立 storage key(__avatar_cache_lru_index__)维护有序数组:
|
||
// [{ hash: number, cachedAt: number }, ...] 按 cachedAt 升序
|
||
// 读路径 (getCachedAvatarPath) 不更新索引, 写路径 (downloadAndCacheAvatar) 才插入 + 驱逐。
|
||
// 读多写少场景(头像 UI)开销可忽略。
|
||
|
||
const AVATAR_CACHE_CAP = 50
|
||
const AVATAR_LRU_INDEX_KEY = '__avatar_cache_lru_index__'
|
||
|
||
/**
|
||
* 从 avatarUrl 生成 hash 值(与 getCacheKey 共用,避免改 storage key 格式)
|
||
*/
|
||
function getCacheHash(avatarUrl) {
|
||
let hash = 0;
|
||
for (let i = 0; i < avatarUrl.length; i++) {
|
||
hash = ((hash << 5) - hash) + avatarUrl.charCodeAt(i);
|
||
hash = hash & hash;
|
||
}
|
||
return Math.abs(hash);
|
||
}
|
||
|
||
/**
|
||
* 生成缓存key
|
||
* @param {string} avatarUrl - OSS头像路径
|
||
* @returns {string}
|
||
*/
|
||
function getCacheKey(avatarUrl) {
|
||
return `avatar_file_${getCacheHash(avatarUrl)}`;
|
||
}
|
||
|
||
/**
|
||
* 读 LRU 索引(容错:解析失败返回空数组)
|
||
*/
|
||
function getLruIndex() {
|
||
try {
|
||
const raw = uni.getStorageSync(AVATAR_LRU_INDEX_KEY);
|
||
if (raw) {
|
||
const arr = JSON.parse(raw);
|
||
if (Array.isArray(arr)) return arr;
|
||
}
|
||
} catch (e) { /* swallow */ }
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 写 LRU 索引(容错:写入失败不影响主流程)
|
||
*/
|
||
function setLruIndex(arr) {
|
||
try {
|
||
uni.setStorageSync(AVATAR_LRU_INDEX_KEY, JSON.stringify(arr));
|
||
} catch (e) { /* swallow */ }
|
||
}
|
||
|
||
/**
|
||
* 按 hash 删 LRU 索引项 + 删对应 storage key + savedFilePath
|
||
* @param {number} hash
|
||
*/
|
||
function evictByHash(hash) {
|
||
try {
|
||
const storageKey = `avatar_file_${hash}`;
|
||
const cached = uni.getStorageSync(storageKey);
|
||
if (cached) {
|
||
try {
|
||
const data = JSON.parse(cached);
|
||
if (data?.localPath) {
|
||
uni.removeSavedFile({ filePath: data.localPath, fail: () => {} });
|
||
}
|
||
} catch (e) { /* skip */ }
|
||
uni.removeStorageSync(storageKey);
|
||
}
|
||
} catch (e) { /* swallow */ }
|
||
}
|
||
|
||
/**
|
||
* ★ 2026-07-31 LRU 驱逐:每次写入前调, 保持索引 ≤ AVATAR_CACHE_CAP
|
||
* 超出部分按 cachedAt 升序逐个删(最老的先删)
|
||
* - 删 storage key (avatar_file_<hash>)
|
||
* - 删 savedFilePath 文件
|
||
* - 删 LRU 索引项
|
||
*/
|
||
function evictOldestIfNeeded() {
|
||
const index = getLruIndex();
|
||
while (index.length >= AVATAR_CACHE_CAP) {
|
||
const oldest = index.shift();
|
||
if (oldest?.hash != null) evictByHash(oldest.hash);
|
||
}
|
||
setLruIndex(index);
|
||
}
|
||
|
||
/**
|
||
* 从本地缓存获取头像文件路径
|
||
* @param {string} avatarUrl - OSS头像路径
|
||
* @returns {Promise<string|null>} 缓存的本地文件路径,如果没有则返回null
|
||
*/
|
||
export async function getCachedAvatarPath(avatarUrl) {
|
||
if (!avatarUrl) return null;
|
||
|
||
try {
|
||
const cacheKey = getCacheKey(avatarUrl);
|
||
const cached = uni.getStorageSync(cacheKey);
|
||
|
||
if (cached) {
|
||
const cacheData = JSON.parse(cached);
|
||
|
||
// 检查缓存的avatar_url是否匹配(防止hash碰撞)
|
||
if (cacheData.avatarUrl === avatarUrl && cacheData.localPath) {
|
||
// 验证文件是否还存在
|
||
return new Promise((resolve) => {
|
||
uni.getFileInfo({
|
||
filePath: cacheData.localPath,
|
||
success: () => {
|
||
console.log('使用缓存的头像文件:', cacheData.localPath);
|
||
resolve(cacheData.localPath);
|
||
},
|
||
fail: () => {
|
||
// 文件不存在,清除缓存记录
|
||
console.log('缓存文件不存在,清除记录');
|
||
uni.removeStorageSync(cacheKey);
|
||
resolve(null);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取缓存头像路径失败:', error);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 下载并缓存头像文件到本地
|
||
* @param {string} avatarUrl - OSS头像路径
|
||
* @param {string} realUrl - 真实的预签名URL
|
||
* @returns {Promise<string|null>} 本地文件路径,失败返回null
|
||
*/
|
||
export async function downloadAndCacheAvatar(avatarUrl, realUrl) {
|
||
if (!avatarUrl || !realUrl) return null;
|
||
|
||
try {
|
||
console.log('开始下载头像文件...');
|
||
|
||
// 1. 下载文件到临时目录
|
||
const downloadResult = await new Promise((resolve, reject) => {
|
||
uni.downloadFile({
|
||
url: realUrl,
|
||
success: (res) => {
|
||
if (res.statusCode === 200) {
|
||
resolve(res.tempFilePath);
|
||
} else {
|
||
reject(new Error(`下载失败,状态码: ${res.statusCode}`));
|
||
}
|
||
},
|
||
fail: (err) => {
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 2. 将临时文件保存为永久文件
|
||
const savedPath = await new Promise((resolve, reject) => {
|
||
uni.saveFile({
|
||
tempFilePath: downloadResult,
|
||
success: (res) => {
|
||
resolve(res.savedFilePath);
|
||
},
|
||
fail: (err) => {
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 3. 保存文件路径到缓存
|
||
const cacheKey = getCacheKey(avatarUrl);
|
||
const cacheData = {
|
||
avatarUrl: avatarUrl,
|
||
localPath: savedPath,
|
||
cachedAt: Date.now()
|
||
};
|
||
uni.setStorageSync(cacheKey, JSON.stringify(cacheData));
|
||
|
||
// ★ 2026-07-31 补充 B:LRU 索引 + 上限驱逐
|
||
// 1) 先把 LRU 索引压回 ≤ AVATAR_CACHE_CAP (50)
|
||
// 超出部分按 cachedAt 升序驱逐(删 storage key + savedFilePath + 索引项)
|
||
// 2) 再插入新条目到索引尾
|
||
// 容错:驱逐失败不阻塞主流程(最多导致 uniapp_save/ 仍超 50, 不会更糟)
|
||
try {
|
||
evictOldestIfNeeded()
|
||
const index = getLruIndex()
|
||
const hash = getCacheHash(avatarUrl)
|
||
// 同 hash 已存在则先移除(避免重复)
|
||
const filtered = index.filter((e) => e?.hash !== hash)
|
||
filtered.push({ hash, cachedAt: cacheData.cachedAt })
|
||
setLruIndex(filtered)
|
||
} catch (e) {
|
||
console.warn('[avatarCache] LRU index update failed:', e?.message)
|
||
}
|
||
|
||
console.log('头像文件已缓存到:', savedPath);
|
||
return savedPath;
|
||
|
||
} catch (error) {
|
||
console.error('下载并缓存头像失败:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除指定头像的缓存文件
|
||
* @param {string} avatarUrl - OSS头像路径
|
||
*/
|
||
export function clearAvatarCache(avatarUrl) {
|
||
if (!avatarUrl) return;
|
||
|
||
try {
|
||
const cacheKey = getCacheKey(avatarUrl);
|
||
const cached = uni.getStorageSync(cacheKey);
|
||
|
||
if (cached) {
|
||
const cacheData = JSON.parse(cached);
|
||
|
||
// 删除本地文件
|
||
if (cacheData.localPath) {
|
||
uni.removeSavedFile({
|
||
filePath: cacheData.localPath,
|
||
success: () => {
|
||
console.log('已删除缓存的头像文件:', cacheData.localPath);
|
||
},
|
||
fail: (err) => {
|
||
console.error('删除缓存文件失败:', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 删除缓存记录
|
||
uni.removeStorageSync(cacheKey);
|
||
// ★ 2026-07-31:同步清理 LRU 索引
|
||
try {
|
||
const hash = getCacheHash(avatarUrl)
|
||
const index = getLruIndex()
|
||
setLruIndex(index.filter((e) => e?.hash !== hash))
|
||
} catch (e) { /* swallow */ }
|
||
console.log('已清除头像缓存记录');
|
||
}
|
||
} catch (error) {
|
||
console.error('清除头像缓存失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除所有头像缓存文件
|
||
*/
|
||
export function clearAllAvatarCache() {
|
||
try {
|
||
const storageInfo = uni.getStorageInfoSync();
|
||
const keys = storageInfo.keys || [];
|
||
|
||
// 找到所有头像缓存的key并删除
|
||
keys.forEach(key => {
|
||
if (key.startsWith('avatar_file_')) {
|
||
try {
|
||
const cached = uni.getStorageSync(key);
|
||
if (cached) {
|
||
const cacheData = JSON.parse(cached);
|
||
|
||
// 删除本地文件
|
||
if (cacheData.localPath) {
|
||
uni.removeSavedFile({
|
||
filePath: cacheData.localPath,
|
||
success: () => {
|
||
console.log('已删除缓存文件:', cacheData.localPath);
|
||
},
|
||
fail: () => {
|
||
// 忽略错误,可能文件已经不存在
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 删除缓存记录
|
||
uni.removeStorageSync(key);
|
||
} catch (e) {
|
||
console.error('清除单个缓存失败:', e);
|
||
}
|
||
}
|
||
});
|
||
|
||
// ★ 2026-07-31:清空 LRU 索引
|
||
setLruIndex([])
|
||
|
||
console.log('已清除所有头像缓存');
|
||
} catch (error) {
|
||
console.error('清除所有头像缓存失败:', error);
|
||
}
|
||
} |