- DBManager DB创建管理器 - FormatTimeTool - ImageViewer工具 - InputArear工具 - OverlayPage工具 - 下拉刷新容器工具 - 视频查看器工具
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();
|