FrontTools/CacheManager.ts
Rain 3ab1f2db24 [入库]初次入库各种工具
- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
2026-04-02 16:09:00 +08:00

488 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DBManager, DBSchema } from '@/src/service/DBManager/DBManager';
/* ======================= 类型定义 ======================= */
// 策略:下载后 tempFilePath 立即可用saveFile 延迟批量执行,实现无感持久化
// TODO: 增加删除功能 实现文件删除
interface CacheIndexRow {
key: string;
url: string;
local_path: string;
size: number;
create_time: number;
last_access: number;
}
interface PendingSaveItem {
key: string;
url: string;
tempFilePath: string;
createTime: number;
}
/* ======================= 任务队列 ======================= */
class TaskQueue {
private running = 0;
private queue: Array<() => Promise<void>> = [];
constructor(
private concurrency: number,
private spaceDelay: number = 0
) {}
add(task: () => Promise<void>) {
this.queue.push(task);
this.run();
}
private async run() {
if (this.running >= this.concurrency) return;
const task = this.queue.shift();
if (!task) return;
this.running++;
setTimeout(() => {
task()
.catch(console.error)
.finally(() => {
this.running--;
this.run();
});
}, this.spaceDelay);
}
}
/* ======================= CacheManager ======================= */
class CacheManager {
/* -------- 基础配置 -------- */
private readonly rootPath = '_doc/cache.db';
private readonly PERSIST_DELAY = 3000;
private readonly PERSIST_BATCH_SIZE = 5;
private readonly PERSIST_INTERVAL = 200;
/* -------- 内存结构 -------- */
private memoryIndex = new Map<string, string>();
private downloading = new Set<string>();
private checkingDB = new Set<string>();
private dirtyKeys = new Set<string>();
/* -------- 延迟持久化队列 -------- */
private pendingSave: PendingSaveItem[] = [];
private persistTimer: any = null;
private flushTimer: any = null;
/* -------- IO 队列 -------- */
private downloadQueue = new TaskQueue(2);
private checkQueue = new TaskQueue(2, 10);
/* -------- 异步等待 -------- */
private waitingCallbacks = new Map<string, Array<(path: string) => void>>();
private preloadReady = false; // 标记 preloadIndex 是否完成
/* -------- 数据库 -------- */
private db: DBManager;
/* ======================= 初始化 ======================= */
constructor() {
const schema: DBSchema = {
dbName: 'cache.db',
dbPath: this.rootPath,
tableName: 'cache_index',
columns: `
key TEXT PRIMARY KEY,
url TEXT,
local_path TEXT,
size INTEGER,
create_time INTEGER,
last_access INTEGER
`,
indexes: ['last_access'],
primaryKey: 'key'
};
this.db = new DBManager(schema);
this.preloadIndex();
this.listenAppLifecycle();
}
public async preloadIndex() {
try {
await this.db.open();
const rows = await this.db.find<CacheIndexRow>({
columns: ['key', 'local_path'],
orderBy: 'last_access DESC',
limit: 2000,
});
rows.forEach(r => {
if (r.local_path) {
this.memoryIndex.set(r.key, r.local_path);
}
});
console.log(`[CacheManager] 索引预热完成,共 ${rows.length}`);
this.preloadReady = true; // 标记预热完成
} catch (e) {
console.error(e);
this.preloadReady = true; // 即使失败也要标记,避免一直等待
}
}
private listenAppLifecycle() {
// @ts-ignore
if (typeof uni !== 'undefined' && uni.onAppHide) {
// @ts-ignore
uni.onAppHide(() => {
this.flushPendingSave();
this.flushAccessTimes();
});
}
}
/* ======================= 对外接口 ======================= */
/**
* 同步缓存入口(显代存)
* 立即返回,若无缓存则返回原 URL后台自动下载
*/
public GetCache(url: string): string {
if (!url) return '';
// 非 HTTP/HTTPS 链接直接返回原值,不尝试下载
if (!this.isHttpUrl(url)) return url;
const key = this.hash(url);
// 内存命中直接返回
if (this.memoryIndex.has(key)) {
this.touch(key);
return this.memoryIndex.get(key)!;
}
// 如果 preload 还没完成,等待完成后再检查
if (!this.preloadReady) {
// 同步等待 preload 完成(最多等待 100ms
const startTime = Date.now();
while (!this.preloadReady && Date.now() - startTime < 100) {
// 空循环等待
}
// 等待完成后再次检查内存
if (this.memoryIndex.has(key)) {
this.touch(key);
return this.memoryIndex.get(key)!;
}
}
// 延迟到下一 tick 执行,避免 UI 卡顿
setTimeout(() => {this.checkQueue.add(() => this.checkDBOrDownload(url, key));}, 0);
// 立即返回原 URL
return url;
}
/**
* 异步缓存入口
* 等待缓存就绪后返回本地路径,全程 await
*/
public async GetCacheAsync(url: string): Promise<string> {
if (!url) return '';
// 非 HTTP/HTTPS 链接直接返回原值,不尝试下载
if (!this.isHttpUrl(url)) return url;
const key = this.hash(url);
// 如果 preload 还没完成,先等待完成
if (!this.preloadReady) {
await this.waitForPreload();
}
// 内存命中直接返回
if (this.memoryIndex.has(key)) {
this.touch(key);
return this.memoryIndex.get(key)!;
}
// 检查 DB
const dbPath = await this.checkDB(key);
if (dbPath) {
this.memoryIndex.set(key, dbPath);
this.touch(key);
return dbPath;
}
// 需要下载,等待下载完成
return this.downloadAndWait(url, key);
}
/**
* 等待 preloadIndex 完成
*/
private async waitForPreload(): Promise<void> {
if (this.preloadReady) return;
return new Promise(resolve => {
const check = () => {
if (this.preloadReady) {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
});
}
/* ======================= 异步下载等待 ======================= */
private async checkDB(key: string): Promise<string | null> {
try {
const rows = await this.db.find<CacheIndexRow>({
where: { key },
columns: ['local_path'],
limit: 1
});
if (rows.length > 0 && rows[0].local_path) {
return rows[0].local_path;
}
} catch (e) {
console.error(e);
}
return null;
}
private downloadAndWait(url: string, key: string): Promise<string> {
return new Promise((resolve) => {
// 如果已经在下载,加入等待队列
if (this.downloading.has(key)) {
this.addWaitingCallback(key, resolve);
return;
}
// 如果已有缓存路径,直接返回
if (this.memoryIndex.has(key)) {
resolve(this.memoryIndex.get(key)!);
return;
}
// 加入等待队列并开始下载
this.addWaitingCallback(key, resolve);
this.enqueueDownload(url, key);
});
}
private addWaitingCallback(key: string, callback: (path: string) => void) {
if (!this.waitingCallbacks.has(key)) {
this.waitingCallbacks.set(key, []);
}
this.waitingCallbacks.get(key)!.push(callback);
}
private resolveWaitingCallbacks(key: string, path: string) {
const callbacks = this.waitingCallbacks.get(key);
if (callbacks) {
callbacks.forEach(cb => cb(path));
this.waitingCallbacks.delete(key);
}
}
/* ======================= 下载与写盘 ======================= */
private async checkDBOrDownload(url: string, key: string) {
// 防重:正在查 / 正在下 / 已在内存 → 跳过
if (this.checkingDB.has(key) || this.downloading.has(key) || this.memoryIndex.has(key)) {
return;
}
this.checkingDB.add(key);
try {
const rows = await this.db.find<CacheIndexRow>({
where: { key },
columns: ['local_path'],
limit: 1
});
if (rows.length > 0 && rows[0].local_path) {
this.memoryIndex.set(key, rows[0].local_path);
this.touch(key);
} else {
this.enqueueDownload(url, key);
}
} catch (e) {
console.error(e);
} finally {
this.checkingDB.delete(key);
}
}
private enqueueDownload(url: string, key: string) {
if (this.downloading.has(key)) return;
this.downloading.add(key);
this.downloadQueue.add(async () => {
try {
const res = await uni.downloadFile({ url });
if (res.statusCode !== 200) {
this.resolveWaitingCallbacks(key, url); // 下载失败返回原 URL
return;
}
// 下载完立即用 tempFilePathUI 无感
const tempUrl = plus.io.convertLocalFileSystemURL(res.tempFilePath);
this.memoryIndex.set(key, tempUrl);
// 通知等待中的异步调用
this.resolveWaitingCallbacks(key, tempUrl);
// 丢进待持久化队列
this.pendingSave.push({
key,
url,
tempFilePath: res.tempFilePath,
createTime: Date.now()
});
this.schedulePersist();
} catch (e) {
console.warn('[CacheManager] 下载失败:', url, e);
this.resolveWaitingCallbacks(key, url); // 下载失败返回原 URL
} finally {
this.downloading.delete(key);
}
});
}
/* ======================= 延迟批量持久化 ======================= */
private schedulePersist() {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
this.runPersistBatch();
}, this.PERSIST_DELAY);
}
private async runPersistBatch() {
if (this.pendingSave.length === 0) return;
const batch = this.pendingSave.splice(0, this.PERSIST_BATCH_SIZE);
for (let i = 0; i < batch.length; i++) {
const item = batch[i];
try {
const saveRes = await uni.saveFile({ tempFilePath: item.tempFilePath });
const finalUrl = plus.io.convertLocalFileSystemURL(saveRes.savedFilePath);
this.memoryIndex.set(item.key, finalUrl);
// 写 DB不阻塞
this.db.Upsert({
key: item.key,
url: item.url,
local_path: finalUrl,
size: 0,
create_time: item.createTime,
last_access: Date.now()
}).catch(e => console.error('[CacheManager] DB写入失败:', e));
} catch (e) {
console.warn('[CacheManager] 持久化失败:', item.url, e);
}
// 每个文件之间间隔,避免 IO 堆积
if (i < batch.length - 1) {
await this.sleep(this.PERSIST_INTERVAL);
}
}
// 如果还有剩余,继续调度
if (this.pendingSave.length > 0) {
this.schedulePersist();
}
}
private async flushPendingSave() {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
const all = this.pendingSave.splice(0);
for (const item of all) {
try {
const saveRes = await uni.saveFile({ tempFilePath: item.tempFilePath });
const finalUrl = plus.io.convertLocalFileSystemURL(saveRes.savedFilePath);
this.memoryIndex.set(item.key, finalUrl);
await this.db.Upsert({
key: item.key,
url: item.url,
local_path: finalUrl,
size: 0,
create_time: item.createTime,
last_access: Date.now()
});
} catch (e) {
console.warn('[CacheManager] flushPendingSave 失败:', item.url, e);
}
}
}
/* ======================= 工具方法 ======================= */
/**
* 判断是否为 HTTP/HTTPS 链接
*/
private isHttpUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://');
}
private hash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return (hash >>> 0).toString(36);
}
private touch(key: string) {
this.dirtyKeys.add(key);
this.scheduleFlush();
}
private scheduleFlush() {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushAccessTimes();
}, 5000);
}
private async flushAccessTimes() {
if (this.dirtyKeys.size === 0) return;
const keys = Array.from(this.dirtyKeys);
this.dirtyKeys.clear();
this.flushTimer = null;
const now = Date.now();
const keyString = keys.map(k => `'${k}'`).join(',');
try {
await this.db.executeSql(
`UPDATE cache_index SET last_access=${now} WHERE key IN (${keyString})`
);
} catch (e) {
console.error('Flush error', e);
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
/* ======================= 导出单例 ======================= */
export default new CacheManager();