FrontTools/CacheManager.ts

488 lines
13 KiB
TypeScript
Raw Normal View History

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();