488 lines
13 KiB
TypeScript
488 lines
13 KiB
TypeScript
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 下载完立即用 tempFilePath,UI 无感
|
|||
|
|
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();
|