feat(infrastructure): add pagination, cache, and sync infrastructure
All checks were successful
Frontend CI / ota-android (push) Successful in 14m38s
Frontend CI / build-android-apk (push) Successful in 1h0m2s
Frontend CI / build-and-push-web (push) Successful in 10m29s

- Add pagination infrastructure with usePagination and usePaginationManager hooks
- Add connection state management hook for network status tracking
- Add media caching infrastructure with useMediaCache hook
- Add differential message synchronization infrastructure
- Export new hooks from central index file
- Remove backup file MainNavigator.tsx.bak
This commit is contained in:
lafay
2026-03-18 23:05:28 +08:00
parent a9c514f664
commit 62b55aec31
20 changed files with 5893 additions and 1126 deletions

View File

@@ -0,0 +1,726 @@
/**
* 媒体缓存管理器
* @module src/infrastructure/cache/MediaCacheManager
* @description 管理图片、视频、音频缓存,支持多种清理策略
*/
import { File, Paths } from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
MediaType,
CleanupTrigger,
CacheEntry,
CacheStats,
CleanupPolicy,
CleanupResult,
MediaCacheConfig,
DEFAULT_MEDIA_CACHE_CONFIG,
DEFAULT_CLEANUP_POLICY,
} from './types';
/**
* 缓存记录存储键前缀
*/
const CACHE_RECORD_PREFIX = 'media_cache_record_';
/**
* 获取缓存目录路径
*/
function getCacheDir(): string {
const cachePath = Paths.cache;
return `${cachePath}media_cache/`;
}
/**
* 获取图片缓存目录
*/
function getImageDir(): string {
return `${getCacheDir()}images/`;
}
/**
* 获取视频缓存目录
*/
function getVideoDir(): string {
return `${getCacheDir()}videos/`;
}
/**
* 获取音频缓存目录
*/
function getAudioDir(): string {
return `${getCacheDir()}audios/`;
}
/**
* 媒体缓存管理器类
* @class MediaCacheManager
* @description 单例模式,管理所有媒体文件的缓存
*/
export class MediaCacheManager {
private static instance: MediaCacheManager;
private cache: Map<string, CacheEntry> = new Map();
private config: MediaCacheConfig;
private policy: CleanupPolicy;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
private lastCleanupTime: number = 0;
private isInitialized: boolean = false;
/**
* 获取单例实例
*/
public static getInstance(): MediaCacheManager {
if (!MediaCacheManager.instance) {
MediaCacheManager.instance = new MediaCacheManager();
}
return MediaCacheManager.instance;
}
/**
* 私有构造函数
*/
private constructor(
config?: Partial<MediaCacheConfig>,
policy?: Partial<CleanupPolicy>
) {
this.config = { ...DEFAULT_MEDIA_CACHE_CONFIG, ...config };
this.policy = { ...DEFAULT_CLEANUP_POLICY, ...policy };
}
/**
* 初始化缓存管理器
* 确保缓存目录存在并加载缓存记录
*/
public async initialize(): Promise<void> {
if (this.isInitialized) return;
try {
// 确保缓存目录存在
await this.ensureDirectories();
// 加载缓存记录
await this.loadCacheRecords();
// 启动时清理
if (this.policy.cleanupOnStart) {
await this.cleanup(CleanupTrigger.STARTUP);
}
// 启动定期清理
this.startPeriodicCleanup();
this.isInitialized = true;
} catch (error) {
console.error('[MediaCacheManager] 初始化失败:', error);
throw error;
}
}
/**
* 确保缓存目录存在
*/
private async ensureDirectories(): Promise<void> {
try {
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
for (const dir of dirs) {
const dirFile = new File(dir);
if (!await dirFile.exists) {
await dirFile.create();
}
}
} catch (error) {
console.error('[MediaCacheManager] 创建缓存目录失败:', error);
}
}
/**
* 从持久化存储加载缓存记录
*/
private async loadCacheRecords(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length === 0) return;
const records = await AsyncStorage.multiGet(recordKeys);
for (const [key, value] of records) {
if (value) {
const entry: CacheEntry = JSON.parse(value);
// 验证文件是否存在
const exists = await this.checkFileExists(entry.localPath);
if (exists) {
this.cache.set(entry.key, entry);
} else {
// 文件不存在,删除记录
await AsyncStorage.removeItem(key);
}
}
}
} catch (error) {
console.error('[MediaCacheManager] 加载缓存记录失败:', error);
}
}
/**
* 检查文件是否存在
*/
private async checkFileExists(path: string): Promise<boolean> {
try {
const file = new File(path);
return await file.exists;
} catch {
return false;
}
}
/**
* 记录缓存访问用于LRU
*/
public async recordAccess(key: string): Promise<void> {
const entry = this.cache.get(key);
if (entry) {
entry.lastAccessedAt = Date.now();
await this.saveCacheRecord(entry);
}
}
/**
* 缓存媒体文件
*/
public async cacheMedia(
uri: string,
type: MediaType,
options: {
conversationId?: string;
messageId?: string;
size?: number;
} = {}
): Promise<string> {
const { conversationId, messageId, size = 0 } = options;
// 生成唯一键
const key = this.generateCacheKey(uri, type);
const localPath = this.getMediaPath(type, key);
// 检查是否已缓存
const existingEntry = this.cache.get(key);
if (existingEntry) {
await this.recordAccess(key);
return existingEntry.localPath;
}
try {
// 下载文件
const destination = new File(localPath);
const downloaded = await File.downloadFileAsync(uri, destination);
// 获取实际文件大小
let fileSize = size;
// fileSize 为 0 时使用默认大小,实际应用中可以通过其他方式获取
// 创建缓存条目
const entry: CacheEntry = {
key,
type,
uri,
localPath: downloaded.uri,
size: fileSize,
conversationId,
messageId,
createdAt: Date.now(),
lastAccessedAt: Date.now(),
};
// 保存记录
this.cache.set(key, entry);
await this.saveCacheRecord(entry);
// 检查是否需要清理
await this.checkAndCleanup();
return downloaded.uri;
} catch (error) {
console.error('[MediaCacheManager] 缓存媒体失败:', error);
throw error;
}
}
/**
* 获取缓存的媒体路径
*/
public async getCachedMedia(uri: string, type: MediaType): Promise<string | null> {
const key = this.generateCacheKey(uri, type);
const entry = this.cache.get(key);
if (entry) {
const exists = await this.checkFileExists(entry.localPath);
if (exists) {
await this.recordAccess(key);
return entry.localPath;
} else {
// 文件不存在,删除记录
this.cache.delete(key);
await this.removeCacheRecord(key);
}
}
return null;
}
/**
* 获取缓存统计信息
*/
public getCacheStats(): CacheStats {
const stats: CacheStats = {
totalSize: 0,
imageCount: 0,
videoCount: 0,
audioCount: 0,
totalEntries: this.cache.size,
oldestItem: Date.now(),
newestItem: 0,
};
for (const entry of this.cache.values()) {
stats.totalSize += entry.size;
stats.oldestItem = Math.min(stats.oldestItem, entry.createdAt);
stats.newestItem = Math.max(stats.newestItem, entry.createdAt);
switch (entry.type) {
case MediaType.IMAGE:
stats.imageCount++;
break;
case MediaType.VIDEO:
stats.videoCount++;
break;
case MediaType.AUDIO:
stats.audioCount++;
break;
}
}
return stats;
}
/**
* 清理操作入口
*/
public async cleanup(trigger: CleanupTrigger): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
try {
// 1. 清理过期缓存
const expiredResult = await this.cleanupExpired();
deletedCount += expiredResult.deletedCount;
freedSize += expiredResult.freedSize;
deletedItems.push(...expiredResult.deletedItems || []);
errors.push(...expiredResult.errors);
// 2. LRU清理如果启用
if (this.policy.enableLRU) {
const lruResult = await this.cleanupLRU();
deletedCount += lruResult.deletedCount;
freedSize += lruResult.freedSize;
deletedItems.push(...lruResult.deletedItems || []);
errors.push(...lruResult.errors);
}
// 3. 阈值清理
const thresholdResult = await this.cleanupByThreshold();
deletedCount += thresholdResult.deletedCount;
freedSize += thresholdResult.freedSize;
deletedItems.push(...thresholdResult.deletedItems || []);
errors.push(...thresholdResult.errors);
this.lastCleanupTime = Date.now();
} catch (error) {
errors.push(`清理失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
return {
trigger,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 清理过期缓存
*/
public async cleanupExpired(): Promise<CleanupResult> {
const now = Date.now();
const maxAge = this.policy.maxAge;
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
const entriesToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (now - entry.lastAccessedAt > maxAge) {
entriesToDelete.push(key);
}
}
for (const key of entriesToDelete) {
try {
const entry = this.cache.get(key);
if (entry) {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
}
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* LRU清理 - 删除最近最少使用的缓存
*/
public async cleanupLRU(targetSize?: number): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
// 如果没有指定目标大小,使用最大缓存大小的一半
const target = targetSize || Math.floor(this.policy.maxSize / 2);
const currentStats = this.getCacheStats();
if (currentStats.totalSize <= target) {
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount: 0,
freedSize: 0,
errors: [],
deletedItems: [],
};
}
// 按最后访问时间排序
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
let currentSize = currentStats.totalSize;
for (const [key, entry] of sortedEntries) {
if (currentSize <= target) break;
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
currentSize -= entry.size;
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 按阈值清理 - 超过最大大小时清理
*/
private async cleanupByThreshold(): Promise<CleanupResult> {
const currentStats = this.getCacheStats();
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
// 检查总大小是否超过限制
if (currentStats.totalSize <= this.policy.maxSize) {
// 检查条目数是否超过限制
if (currentStats.totalEntries <= this.policy.maxEntries) {
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount: 0,
freedSize: 0,
errors: [],
deletedItems: [],
};
}
}
// 需要清理
const targetSize = Math.floor(this.policy.maxSize * 0.8); // 清理到80%
const targetEntries = Math.floor(this.policy.maxEntries * 0.8);
// 按LRU排序
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
let currentSize = currentStats.totalSize;
let currentEntries = currentStats.totalEntries;
for (const [key, entry] of sortedEntries) {
// 两个条件:大小或条目数
if (currentSize <= targetSize && currentEntries <= targetEntries) break;
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
currentSize -= entry.size;
currentEntries--;
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.MANUAL,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 删除指定会话的所有媒体缓存
*/
public async clearConversationMedia(conversationId: string): Promise<CleanupResult> {
const errors: string[] = [];
let deletedCount = 0;
let freedSize = 0;
const deletedItems: string[] = [];
const entriesToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (entry.conversationId === conversationId) {
entriesToDelete.push(key);
}
}
for (const key of entriesToDelete) {
try {
const entry = this.cache.get(key);
if (entry) {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
this.cache.delete(key);
await this.removeCacheRecord(key);
deletedCount++;
freedSize += entry.size;
deletedItems.push(key);
}
} catch (error) {
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
return {
trigger: CleanupTrigger.CONVERSATION_DELETED,
timestamp: Date.now(),
deletedCount,
freedSize,
errors,
deletedItems,
};
}
/**
* 清空所有缓存
*/
public async clearAll(): Promise<void> {
for (const entry of this.cache.values()) {
try {
const file = new File(entry.localPath);
if (await file.exists) {
await file.delete();
}
} catch (error) {
console.warn(`[MediaCacheManager] 删除文件失败: ${entry.localPath}`, error);
}
}
this.cache.clear();
// 清除所有记录
const keys = await AsyncStorage.getAllKeys();
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
if (recordKeys.length > 0) {
await AsyncStorage.multiRemove(recordKeys);
}
}
/**
* 启动定期清理
*/
public startPeriodicCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
this.cleanupTimer = setInterval(async () => {
// 检查是否需要清理
if (Date.now() - this.lastCleanupTime >= this.policy.cleanupInterval) {
await this.cleanup(CleanupTrigger.SCHEDULED);
}
}, this.policy.cleanupInterval);
}
/**
* 停止定期清理
*/
public stopPeriodicCleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
/**
* 检查是否需要清理
*/
private async checkAndCleanup(): Promise<void> {
const stats = this.getCacheStats();
// 检查大小
if (stats.totalSize > this.policy.maxSize) {
await this.cleanupLRU();
}
// 检查条目数
if (stats.totalEntries > this.policy.maxEntries) {
await this.cleanupLRU();
}
}
/**
* 保存缓存记录到持久化存储
*/
private async saveCacheRecord(entry: CacheEntry): Promise<void> {
try {
const key = `${CACHE_RECORD_PREFIX}${entry.key}`;
await AsyncStorage.setItem(key, JSON.stringify(entry));
} catch (error) {
console.error('[MediaCacheManager] 保存缓存记录失败:', error);
}
}
/**
* 删除缓存记录
*/
private async removeCacheRecord(key: string): Promise<void> {
try {
const storageKey = `${CACHE_RECORD_PREFIX}${key}`;
await AsyncStorage.removeItem(storageKey);
} catch (error) {
console.error('[MediaCacheManager] 删除缓存记录失败:', error);
}
}
/**
* 生成缓存键
*/
private generateCacheKey(uri: string, type: MediaType): string {
const hash = this.hashString(uri);
return `${type.toLowerCase()}_${hash}_${Date.now()}`;
}
/**
* 获取媒体文件存储路径
*/
private getMediaPath(type: MediaType, key: string): string {
const ext = this.getExtension(key, type);
switch (type) {
case MediaType.IMAGE:
return `${getImageDir()}${key}.${ext}`;
case MediaType.VIDEO:
return `${getVideoDir()}${key}.${ext}`;
case MediaType.AUDIO:
return `${getAudioDir()}${key}.${ext}`;
}
}
/**
* 根据类型获取文件扩展名
*/
private getExtension(key: string, type: MediaType): string {
if (type === MediaType.IMAGE) {
if (key.includes('gif')) return 'gif';
if (key.includes('webp')) return 'webp';
return 'jpg';
}
if (type === MediaType.VIDEO) return 'mp4';
if (type === MediaType.AUDIO) return 'mp3';
return 'bin';
}
/**
* 字符串哈希函数
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
}
/**
* 导出单例实例
*/
export const mediaCacheManager = MediaCacheManager.getInstance();