diff --git a/docs/OPTIMIZATION_DESIGN.md b/docs/OPTIMIZATION_DESIGN.md new file mode 100644 index 0000000..5b1a7e8 --- /dev/null +++ b/docs/OPTIMIZATION_DESIGN.md @@ -0,0 +1,1725 @@ +# 聊天系统性能优化设计方案 + +## 文档信息 + +- **创建日期**: 2026-03-18 +- **项目**: 胡萝卜 BBS (Carrot BBS) 前端 +- **范围**: 消息系统性能优化 + +--- + +## 目录 + +1. [架构概述](#架构概述) +2. [P0 分页状态管理](#p0-分页状态管理) +3. [P0 同步状态机](#p0-同步状态机) +4. [P1 差异更新](#p1-差异更新) +5. [P1 媒体缓存清理](#p1-媒体缓存清理) +6. [实现优先级与依赖关系](#实现优先级与依赖关系) + +--- + +## 架构概述 + +### 当前消息系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UI Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ChatScreen │ │ MessageList │ │ +│ │ useChatScreen │ │ Screen │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +└───────────┼─────────────────────┼───────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Store Layer (Zustand) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ messageManager (MessageManager) ││ +│ │ - messages Map ││ +│ │ - conversations: Conversation[] ││ +│ │ - activeConversation: string | null ││ +│ │ - isConnected: boolean ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ messageManagerHooks (React Hooks) ││ +│ │ - useChat(conversationId) ││ +│ │ - useMessages(conversationId) ││ +│ │ - useConversations() ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ UseCase Layer │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ProcessMessageUseCase ││ +│ │ - WebSocket 事件监听与处理 ││ +│ │ - 消息去重 (processedMessageIds) ││ +│ │ - 已读状态管理 (pendingReadMap) ││ +│ │ - 消息持久化到 Repository ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DataSource Layer │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐│ +│ │WebSocketClient│ │CacheDataSource│ │LocalDataSource (SQLite)││ +│ │ │ │ (Memory+Async) │ │ ││ +│ │ - 事件订阅 │ │ - get/set │ │ - messages 表 ││ +│ │ - emit │ │ - delete │ │ - conversations 表 ││ +│ │ - 连接状态 │ │ - clear │ │ - users 表 ││ +│ └───────────────┘ └───────────────┘ └───────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ sseService (SSE Client) ││ +│ │ - EventSource 连接管理 ││ +│ │ - 重连逻辑 (maxReconnectAttempts=20) ││ +│ │ - AppState 监听 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 问题分析 + +基于代码分析,当前系统存在以下问题: + +| 问题 | 影响 | 优先级 | +|------|------|--------| +| 分页状态管理不完善 | 用户滚动加载时可能重复请求同一页数据 | P0 | +| 连接状态反馈不清晰 | 用户不知道连接是否正常 | P0 | +| 全量数据更新 | 大房间每次更新都刷新整个消息列表 | P1 | +| 媒体缓存无清理机制 | 长期使用后存储空间爆炸 | P1 | + +--- + +## P0 分页状态管理 + +### 现有代码分析 + +#### 问题点 + +1. **`useChatScreen.ts` (第388-408行)** + ```typescript + const loadMoreHistory = useCallback(async () => { + if (!conversationId || !hasMoreHistory || loadingMore) { + return; + } + // 缺少分页游标管理,可能导致重复加载 + setLoadingMore(true); + try { + await loadMoreMessages(); + // 没有追踪当前加载到了哪一页 + } finally { + setLoadingMore(false); + } + }, [...]); + ``` + +2. **`messageManagerHooks.ts` (第156-158行)** + ```typescript + setIsLoading(true); + const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); + // 直接使用 minSeq,但没有缓存 minSeq 的状态 + ``` + +3. **`MessageRepository.ts` (第125-137行)** + ```typescript + async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) { + // 依赖 beforeSeq 参数,但没有分页偏移量管理 + } + ``` + +#### 架构缺陷 + +- 没有独立的分页状态存储 +- `hasMoreMessages` 状态与实际数据可能不同步 +- 并发加载时缺少互斥机制 +- 没有加载中的分页信息缓存 + +### 实现方案 + +#### 1. 新增分页状态管理器 + +```typescript +// src/stores/pagination/PaginationStateManager.ts + +interface PaginationState { + conversationId: string; + currentMinSeq: number; // 当前已加载的最小seq + currentMaxSeq: number; // 当前已加载的最大seq + pages: Map; // pageNumber -> messageIds + loadingPageNumbers: Set; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + totalLoaded: number; +} + +class PaginationStateManager { + private states: Map = new Map(); + + // 获取或创建分页状态 + getOrCreate(conversationId: string): PaginationState { ... } + + // 标记页面加载中 + markPageLoading(conversationId: string, pageNumber: number): void { ... } + + // 标记页面加载完成 + markPageLoaded(conversationId: string, pageNumber: number, messageIds: string[], hasMore: boolean): void { ... } + + // 检查页面是否已加载 + isPageLoaded(conversationId: string, pageNumber: number): boolean { ... } + + // 检查页面是否正在加载 + isPageLoading(conversationId: string, pageNumber: number): boolean { ... } + + // 重置分页状态 + reset(conversationId: string): void { ... } +} +``` + +#### 2. 修改 useMessages Hook + +```typescript +// src/stores/messageManagerHooks.ts + +interface UseMessagesOptions { + pageSize?: number; + prefetchThreshold?: number; // 预加载阈值 +} + +function useMessages( + conversationId: string | null, + options: UseMessagesOptions = {} +) { + const { + pageSize = 20, + prefetchThreshold = 5 + } = options; + + // 分页状态 + const [paginationState, setPaginationState] = useState(null); + + // 计算当前页码 + const currentPage = useMemo(() => { + if (!paginationState) return 1; + return Math.ceil( + (paginationState.totalLoaded - messages.length) / pageSize + ) + 1; + }, [paginationState, messages.length]); + + // 加载更多(带分页保护) + const loadMoreMessages = useCallback(async () => { + if (!conversationId || !paginationState) return; + + // 计算下一页 + const nextPage = Math.floor(paginationState.totalLoaded / pageSize) + 1; + + // 检查是否正在加载 + if (paginationState.loadingPageNumbers.has(nextPage)) { + return; + } + + // 检查是否已加载 + if (paginationState.pages.has(nextPage)) { + return; + } + + await messageManager.loadMoreMessages( + conversationId, + paginationState.currentMinSeq, + pageSize + ); + }, [conversationId, paginationState, pageSize]); + + // 预加载检测 + useEffect(() => { + if (!paginationState || !hasMore) return; + + const currentPageLoaded = messages.length; + const threshold = pageSize - prefetchThreshold; + + if (currentPageLoaded <= threshold && !paginationState.loadingPageNumbers.has(1)) { + loadMoreMessages(); + } + }, [messages.length, hasMore, loadMoreMessages]); +} +``` + +#### 3. 修改 MessageManager + +```typescript +// src/stores/message/MessageManager.ts + +class MessageManager { + private paginationManager = new PaginationStateManager(); + + async loadMoreMessages( + conversationId: string, + beforeSeq: number, + limit: number + ): Promise { + // 检查分页状态 + const pageNumber = this.calculatePageNumber(conversationId, beforeSeq); + + if (this.paginationManager.isPageLoading(conversationId, pageNumber)) { + return []; // 避免重复加载 + } + + if (this.paginationManager.isPageLoaded(conversationId, pageNumber)) { + return []; // 已加载,直接返回 + } + + this.paginationManager.markPageLoading(conversationId, pageNumber); + + try { + // 调用 API + const response = await messageService.getMessages(conversationId, { + before_seq: beforeSeq, + limit + }); + + const messageIds = response.messages.map(m => m.id); + + this.paginationManager.markPageLoaded( + conversationId, + pageNumber, + messageIds, + response.has_more + ); + + // 更新消息存储 + this.appendMessages(conversationId, response.messages); + + return response.messages; + } catch (error) { + // 加载失败,清除分页状态 + this.paginationManager.markPageFailed(conversationId, pageNumber); + throw error; + } + } +} +``` + +#### 4. 修改数据库查询 + +```typescript +// src/services/database.ts (或 MessageRepository) + +export async function getMessagesBeforeSeq( + conversationId: string, + beforeSeq: number, + limit: number +): Promise { + // 确保使用索引优化查询 + const result = await db.getAllAsync(` + SELECT * FROM messages + WHERE conversationId = ? AND seq < ? + ORDER BY seq DESC + LIMIT ? + `, [conversationId, beforeSeq, limit]); + + return result; +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/stores/pagination/PaginationStateManager.ts` | 新增 | 分页状态管理器 | +| `src/stores/pagination/index.ts` | 新增 | 导出文件 | +| `src/stores/messageManagerHooks.ts` | 修改 | 集成分页状态管理 | +| `src/stores/message/MessageManager.ts` | 修改 | 添加分页保护逻辑 | +| `src/data/repositories/MessageRepository.ts` | 修改 | 优化分页查询 | + +### 关键设计决策 + +1. **分页粒度**: 以 `beforeSeq` 为游标,而非 offset,避免消息新增导致的分页漂移 +2. **页面缓存**: 使用 `Map` 缓存已加载页面,避免重复请求 +3. **加载锁**: 使用 `Set` 追踪正在加载的页面,防止并发重复加载 +4. **预加载**: 当滚动到距离底部 `prefetchThreshold` 条消息时,提前加载下一页 + +--- + +## P0 同步状态机 + +### 现有代码分析 + +#### 问题点 + +1. **`sseService.ts` (第406-408行)** + ```typescript + isConnected(): boolean { + return this.source != null; + } + // 状态判断过于简单,source != null 不代表真正连接成功 + ``` + +2. **`WebSocketClient.ts` (第23-35行)** + ```typescript + export interface WebSocketEvents { + 'connected': void; + 'disconnected': void; + 'error': Error; + } + // 只有 connected/disconnected 两种状态 + ``` + +3. **`sseService.ts` (第190-228行)** + ```typescript + async connect(): Promise { + // isConnecting 状态没有暴露给外部 + // 外部无法区分"正在连接"和"连接失败" + } + ``` + +4. **`messageManagerHooks.ts` (第390-405行)** + ```typescript + const [isConnected, setIsConnected] = useState(() => messageManager.isConnected()); + // 连接状态变化没有详细的状态分类 + ``` + +#### 状态定义缺失 + +当前没有区分以下状态: +- **Connecting**: 正在建立连接 +- **Connected**: 连接已建立 +- **Reconnecting**: 连接断开,正在重连 +- **Disconnected**: 连接已断开 +- **Error**: 连接错误 + +### 实现方案 + +#### 1. 定义连接状态机 + +```typescript +// src/services/connection/ConnectionState.ts + +export enum ConnectionStateType { + IDLE = 'idle', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + DISCONNECTED = 'disconnected', + ERROR = 'error', +} + +export interface ConnectionState { + type: ConnectionStateType; + timestamp: number; + error?: Error; + retryCount?: number; + lastConnectedAt?: number; +} + +export interface ConnectionStateChangeEvent { + previousState: ConnectionState; + currentState: ConnectionState; + reason?: string; +} +``` + +#### 2. 创建连接状态管理器 + +```typescript +// src/services/connection/ConnectionStateManager.ts + +type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void; + +class ConnectionStateManager { + private state: ConnectionState = { + type: ConnectionStateType.IDLE, + timestamp: Date.now(), + }; + + private listeners: Set = new Set(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 20; + + // 获取当前状态 + getState(): ConnectionState { ... } + + // 状态转换 + transition(newType: ConnectionStateType, reason?: string, error?: Error): void { + const previousState = this.state; + this.state = { + type: newType, + timestamp: Date.now(), + error, + retryCount: newType === ConnectionStateType.RECONNECTING + ? this.reconnectAttempts + : undefined, + lastConnectedAt: newType === ConnectionStateType.CONNECTED + ? Date.now() + : this.state.lastConnectedAt, + }; + + this.notifyListeners({ previousState, currentState: this.state, reason }); + } + + // 增加重试计数 + incrementRetry(): number { + this.reconnectAttempts++; + return this.reconnectAttempts; + } + + // 重置重试计数 + resetRetry(): void { + this.reconnectAttempts = 0; + } + + // 订阅状态变化 + subscribe(listener: ConnectionStateListener): () => void { ... } + + // 获取可读的状态描述 + getStatusDescription(): string { + switch (this.state.type) { + case ConnectionStateType.IDLE: + return '未连接'; + case ConnectionStateType.CONNECTING: + return '正在连接...'; + case ConnectionStateType.CONNECTED: + return '已连接'; + case ConnectionStateType.RECONNECTING: + return `正在重连 (${this.state.retryCount}/${this.maxReconnectAttempts})`; + case ConnectionStateType.DISCONNECTED: + return '已断开'; + case ConnectionStateType.ERROR: + return `连接错误: ${this.state.error?.message || '未知错误'}`; + } + } +} + +export const connectionStateManager = new ConnectionStateManager(); +``` + +#### 3. 修改 SSEService + +```typescript +// src/services/sseService.ts + +class SSEService { + private connectionStateManager = connectionStateManager; + + async connect(): Promise { + if (this.isConnecting || this.isConnected()) { + return true; + } + + this.connectionStateManager.transition( + ConnectionStateType.CONNECTING, + 'initiated' + ); + + try { + const token = await api.getToken(); + if (!token) { + throw new Error('No auth token'); + } + + // ... 建立连接 ... + + this.source.addEventListener('open', () => { + this.connectionStateManager.resetRetry(); + this.connectionStateManager.transition( + ConnectionStateType.CONNECTED, + 'connection_opened' + ); + }); + + this.source.addEventListener('error', (error) => { + this.connectionStateManager.transition( + ConnectionStateType.ERROR, + 'connection_error', + error + ); + this.scheduleReconnect(); + }); + + return true; + } catch (error) { + this.connectionStateManager.transition( + ConnectionStateType.ERROR, + 'connection_failed', + error + ); + this.scheduleReconnect(); + return false; + } + } + + private scheduleReconnect(): void { + const retryCount = this.connectionStateManager.incrementRetry(); + + if (retryCount >= this.maxReconnectAttempts) { + this.connectionStateManager.transition( + ConnectionStateType.DISCONNECTED, + 'max_reconnect_exceeded' + ); + return; + } + + this.connectionStateManager.transition( + ConnectionStateType.RECONNECTING, + 'scheduling_reconnect' + ); + + this.reconnectTimer = setTimeout(() => { + this.connect(); + }, this.reconnectDelay); + } +} +``` + +#### 4. 创建连接状态 Hook + +```typescript +// src/hooks/useConnectionState.ts + +export function useConnectionState() { + const [state, setState] = useState( + () => connectionStateManager.getState() + ); + + useEffect(() => { + const unsubscribe = connectionStateManager.subscribe((event) => { + setState(event.currentState); + }); + + return unsubscribe; + }, []); + + return { + state, + status: connectionStateManager.getStatusDescription(), + isConnected: state.type === ConnectionStateType.CONNECTED, + isConnecting: state.type === ConnectionStateType.CONNECTING, + isReconnecting: state.type === ConnectionStateType.RECONNECTING, + isDisconnected: state.type === ConnectionStateType.DISCONNECTED, + isError: state.type === ConnectionStateType.ERROR, + retryCount: state.retryCount, + error: state.error, + reconnect: () => sseService.connect(), + disconnect: () => sseService.disconnect(), + }; +} +``` + +#### 5. UI 集成示例 + +```typescript +// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx + +export function ConnectionStatusBadge() { + const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState(); + + if (isConnected) { + return null; // 已连接不显示 + } + + return ( + + {isReconnecting && ( + + )} + + {isReconnecting + ? `连接中断,正在重连 (${retryCount}/20)` + : status} + + + ); +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/services/connection/ConnectionState.ts` | 新增 | 连接状态定义 | +| `src/services/connection/ConnectionStateManager.ts` | 新增 | 连接状态管理器 | +| `src/services/connection/index.ts` | 新增 | 导出文件 | +| `src/services/sseService.ts` | 修改 | 集成状态管理器 | +| `src/hooks/useConnectionState.ts` | 新增 | 连接状态 Hook | +| `src/hooks/index.ts` | 修改 | 导出新 Hook | + +### Mermaid 状态图 + +```mermaid +stateDiagram-v2 + [*] --> IDLE + IDLE --> CONNECTING: 调用 connect() + CONNECTING --> CONNECTED: 连接成功 + CONNECTING --> ERROR: 连接失败 + CONNECTED --> RECONNECTING: 连接断开 + RECONNECTING --> CONNECTED: 重连成功 + RECONNECTING --> DISCONNECTED: 超过最大重试次数 + RECONNECTING --> ERROR: 重连失败 + ERROR --> RECONNECTING: 自动重连 + ERROR --> DISCONNECTED: 停止重连 + DISCONNECTED --> CONNECTING: 手动重连 + IDLE --> CONNECTING: 手动重连 + CONNECTED --> DISCONNECTED: 调用 disconnect() +``` + +--- + +## P1 差异更新 + +### 现有代码分析 + +#### 问题点 + +1. **`MessageManager` 消息更新机制** + - 每当有新消息时,通过 `setMessages` 更新整个消息数组 + - 大房间场景下,频繁的全量更新会导致 UI 卡顿 + - 没有区分不同类型的消息更新(新增、删除、修改) + +2. **`messageManagerHooks.ts` (第33-46行)** + ```typescript + const unsubscribe = messageManager.subscribe((event: MessageEvent) => { + switch (event.type) { + case 'conversations_updated': + setConversations(messageManager.getConversations()); + break; + // ... 其他都是全量更新 + } + }); + ``` + +3. **`useChatScreen.ts` (第148-162行)** + ```typescript + const messages = useMemo(() => { + return messageManagerMessages.map(m => ({ + // 全量映射 + })); + }, [messageManagerMessages]); + ``` + +4. **`ProcessMessageUseCase.ts` (第159-199行)** + ```typescript + private async handleNewMessage(message: any): Promise { + // 每条消息都触发完整流程 + this.notifySubscribers({ + type: 'message_received', + payload: { message, ... } + }); + } + ``` + +#### 性能瓶颈 + +- 1000人群聊,每秒10条消息 = 每秒10000次 UI 更新操作 +- 全量 `setMessages` 触发 FlatList 完整重渲染 +- 没有虚拟列表优化 + +### 实现方案 + +#### 1. 定义消息更新类型 + +```typescript +// src/stores/message/MessageUpdateTypes.ts + +export enum MessageUpdateType { + APPEND = 'append', // 追加新消息到末尾 + PREPEND = 'prepend', // 预置历史消息到开头 + UPDATE = 'update', // 更新单条消息 + DELETE = 'delete', // 删除单条消息 + BATCH_APPEND = 'batch_append', // 批量追加 + BATCH_PREPEND = 'batch_prepend', // 批量预置 + RECALL = 'recall', // 撤回消息 + CLEAR = 'clear', // 清空会话 +} + +export interface MessageUpdate { + type: MessageUpdateType; + conversationId: string; + payload: T; + timestamp: number; +} + +export interface AppendPayload { + messages: Message[]; +} + +export interface PrependPayload { + messages: Message[]; + hasMoreBefore: boolean; +} + +export interface UpdatePayload { + messageId: string; + updates: Partial; +} + +export interface DeletePayload { + messageId: string; +} + +export interface BatchAppendPayload { + messages: Message[]; +} + +export interface RecallPayload { + messageId: string; +} +``` + +#### 2. 创建差异更新 Hook + +```typescript +// src/hooks/useDifferentialMessages.ts + +export function useDifferentialMessages(conversationId: string | null) { + const [messages, setMessages] = useState([]); + const pendingUpdatesRef = useRef([]); + const flushScheduledRef = useRef(false); + + // 批量处理更新 + const flushUpdates = useCallback(() => { + if (pendingUpdatesRef.current.length === 0) return; + + const updates = pendingUpdatesRef.current; + pendingUpdatesRef.current = []; + flushScheduledRef.current = false; + + setMessages(currentMessages => { + const newMessages = [...currentMessages]; + + for (const update of updates) { + switch (update.type) { + case MessageUpdateType.APPEND: + for (const msg of update.payload.messages) { + if (!newMessages.find(m => m.id === msg.id)) { + newMessages.push(msg); + } + } + break; + + case MessageUpdateType.PREPEND: + const prependMessages = update.payload.messages + .filter(m => !newMessages.find(n => n.id === m.id)); + newMessages.unshift(...prependMessages.reverse()); + break; + + case MessageUpdateType.UPDATE: + const updateIdx = newMessages.findIndex(m => m.id === update.payload.messageId); + if (updateIdx !== -1) { + newMessages[updateIdx] = { + ...newMessages[updateIdx], + ...update.payload.updates + }; + } + break; + + case MessageUpdateType.DELETE: + newMessages = newMessages.filter(m => m.id !== update.payload.messageId); + break; + + case MessageUpdateType.RECALL: + const recallIdx = newMessages.findIndex(m => m.id === update.payload.messageId); + if (recallIdx !== -1) { + newMessages[recallIdx] = { + ...newMessages[recallIdx], + status: 'recalled', + segments: [], + }; + } + break; + + case MessageUpdateType.BATCH_APPEND: + for (const msg of update.payload.messages) { + if (!newMessages.find(m => m.id === msg.id)) { + newMessages.push(msg); + } + } + break; + } + } + + return newMessages; + }); + }, []); + + // 调度批量更新(16ms 内合并) + const scheduleFlush = useCallback(() => { + if (flushScheduledRef.current) return; + flushScheduledRef.current = true; + requestAnimationFrame(flushUpdates); + }, [flushUpdates]); + + // 处理消息更新 + const handleMessageUpdate = useCallback((update: MessageUpdate) => { + pendingUpdatesRef.current.push(update); + scheduleFlush(); + }, [scheduleFlush]); + + // 订阅 MessageManager + useEffect(() => { + if (!conversationId) return; + + const unsubscribe = messageManager.subscribe((event) => { + if (event.type === 'message_received') { + const { message, isCurrentUser } = event.payload; + handleMessageUpdate({ + type: isCurrentUser + ? MessageUpdateType.APPEND + : MessageUpdateType.PREPEND, + conversationId, + payload: { messages: [message] }, + timestamp: Date.now(), + }); + } + + if (event.type === 'messages_loaded') { + const { messages, direction } = event.payload; + handleMessageUpdate({ + type: direction === 'before' + ? MessageUpdateType.PREPEND + : MessageUpdateType.BATCH_APPEND, + conversationId, + payload: { messages }, + timestamp: Date.now(), + }); + } + + if (event.type === 'message_recalled') { + handleMessageUpdate({ + type: MessageUpdateType.RECALL, + conversationId, + payload: { messageId: event.payload.messageId }, + timestamp: Date.now(), + }); + } + }); + + return unsubscribe; + }, [conversationId, handleMessageUpdate]); + + return { messages }; +} +``` + +#### 3. 优化 FlatList 渲染 + +```typescript +// src/screens/message/components/ChatScreen/OptimizedMessageList.tsx + +export function OptimizedMessageList({ + messages, + onLoadMore, + hasMore, +}: { + messages: Message[]; + onLoadMore: () => void; + hasMore: boolean; +}) { + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50, + minimumViewTime: 100, + }); + + // 关键消息路径优化 + const keyExtractor = useCallback((item: Message) => item.id, []); + + const getItemLayout = useCallback((data: Message[] | null, index: number) => { + // 估算每条消息高度(可以根据类型调整) + const BASE_HEIGHT = 60; + const IMAGE_EXTRA = 200; + const EXTRA_PER_SEGMENT = 30; + + let length = BASE_HEIGHT; + if (data) { + const message = data[index]; + if (message.segments) { + length += message.segments.length * EXTRA_PER_SEGMENT; + } + if (message.segments?.some(s => s.type === 'image')) { + length += IMAGE_EXTRA; + } + } + + return { + length, + offset: data.slice(0, index).reduce((sum, m) => { + // 计算累计高度 + return sum + BASE_HEIGHT + + (m.segments?.length || 0) * EXTRA_PER_SEGMENT + + (m.segments?.some(s => s.type === 'image') ? IMAGE_EXTRA : 0); + }, 0), + index, + }; + }, []); + + // 消息类型判断 + const isSameDay = useCallback((prev: Message, next: Message) => { + const prevDate = new Date(prev.created_at).toDateString(); + const nextDate = new Date(next.created_at).toDateString(); + return prevDate === nextDate; + }, []); + + const renderItem = useCallback(({ item, index }: { item: Message; index: number }) => { + const prevItem = messages[index - 1]; + + return ( + 50} + /> + ); + }, [isSameDay]); + + return ( + + ); +} +``` + +#### 4. 修改 MessageManager 支持差异事件 + +```typescript +// src/stores/message/MessageManager.ts + +class MessageManager { + // 发送差异更新事件 + private emitMessageUpdate(update: MessageUpdate): void { + this.subscribers.forEach(subscriber => { + if (subscriber.types.includes(update.type) || subscriber.types.includes('*')) { + subscriber.callback(update); + } + }); + } + + async handleIncomingMessage(message: WSChatMessage): Promise { + // 检查消息是否已存在 + const existingMessages = this.messages.get(message.conversation_id) || []; + if (existingMessages.find(m => m.id === message.id)) { + return; // 避免重复 + } + + const newMessage = this.createMessageObject(message); + + // 直接发送差异更新,而不是全量更新 + this.emitMessageUpdate({ + type: MessageUpdateType.APPEND, + conversationId: message.conversation_id, + payload: { messages: [newMessage] }, + timestamp: Date.now(), + }); + + // 异步保存到数据库 + this.persistMessage(newMessage); + } +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/stores/message/MessageUpdateTypes.ts` | 新增 | 消息更新类型定义 | +| `src/hooks/useDifferentialMessages.ts` | 新增 | 差异更新 Hook | +| `src/stores/messageManagerHooks.ts` | 修改 | 集成差异更新 | +| `src/stores/message/MessageManager.ts` | 修改 | 添加差异事件支持 | +| `src/screens/message/components/ChatScreen/OptimizedMessageList.tsx` | 新增 | 优化的消息列表组件 | + +### 性能收益 + +| 场景 | 优化前 | 优化后 | +|------|--------|--------| +| 1000人群,每秒10条消息 | 10000次/秒 UI 更新 | 60次/秒 UI 更新 | +| 滚动加载100条历史消息 | 全量重渲染100条 | 仅渲染可见区域~20条 | +| 消息撤回 | 全量更新 + UI 重建 | 单条更新 | + +--- + +## P1 媒体缓存清理 + +### 现有代码分析 + +#### 问题点 + +1. **`CacheDataSource.ts` (第14-28行)** + ```typescript + export class CacheDataSource implements ICacheDataSource { + private memoryCache: Map> = new Map(); + private maxSize: number; // 默认 100 条 + private defaultTtl: number; // 默认 5 分钟 + + // 没有媒体文件专用缓存管理 + // 没有磁盘空间监控 + } + ``` + +2. **缺少媒体缓存机制** + - 图片、视频、音频没有独立的缓存策略 + - 没有按会话清理过期媒体的功能 + - 没有用户手动清理入口 + +3. **`LocalDataSource.ts` (第73-86行)** + ```typescript + // 消息表结构 + `CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + conversationId TEXT NOT NULL, + segments TEXT + // 没有存储媒体文件路径 + )` + ``` + - 消息表没有记录媒体缓存路径 + - 无法追踪哪些媒体文件属于哪个会话 + +#### 存储风险 + +- 图片缓存:无限制增长 +- 视频缓存:无限制增长 +- 音频缓存:无限制增长 +- 总计:可能导致存储空间耗尽 + +### 实现方案 + +#### 1. 定义媒体缓存配置 + +```typescript +// src/services/media/MediaCacheConfig.ts + +export interface MediaCacheConfig { + // 图片配置 + image: { + maxMemoryCacheSize: number; // 内存缓存数量,默认 50 + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 500 + maxAge: number; // 缓存有效期(小时),默认 168 (7天) + maxAgeForConversation: number; // 会话内媒体保留时间(天),默认 30 + }; + + // 视频配置 + video: { + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 1000 + maxAge: number; // 缓存有效期(小时),默认 72 (3天) + autoPreload: boolean; // 是否自动预加载,默认 false + }; + + // 音频配置 + audio: { + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 200 + maxAge: number; // 缓存有效期(天),默认 7 + }; + + // 全局配置 + global: { + checkOnStartup: boolean; // 启动时检查清理,默认 true + checkInterval: number; // 定期检查间隔(小时),默认 24 + lowStorageThreshold: number; // 低存储空间阈值(MB),默认 500 + }; +} + +export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { + image: { + maxMemoryCacheSize: 50, + maxDiskCacheSize: 500, + maxAge: 168, // 7 days + maxAgeForConversation: 30, + }, + video: { + maxDiskCacheSize: 1000, + maxAge: 72, // 3 days + autoPreload: false, + }, + audio: { + maxDiskCacheSize: 200, + maxAge: 168, // 7 days + }, + global: { + checkOnStartup: true, + checkInterval: 24, + lowStorageThreshold: 500, + }, +}; +``` + +#### 2. 创建媒体缓存管理器 + +```typescript +// src/services/media/MediaCacheManager.ts + +import * as FileSystem from 'expo-file-system'; +import { cacheDataSource } from '../data/datasources/CacheDataSource'; + +export enum MediaType { + IMAGE = 'image', + VIDEO = 'video', + AUDIO = 'audio', +} + +export interface MediaCacheInfo { + type: MediaType; + uri: string; + localPath: string; + conversationId?: string; + messageId?: string; + size: number; + createdAt: number; + lastAccessedAt: number; +} + +class MediaCacheManager { + private config: MediaCacheConfig; + private cacheDirectory: string; + private mediaRecords: Map = new Map(); + + // 缓存目录 + private readonly CACHE_DIR = `${FileSystem.documentDirectory}media_cache/`; + private readonly IMAGE_DIR = `${this.CACHE_DIR}images/`; + private readonly VIDEO_DIR = `${this.CACHE_DIR}videos/`; + private readonly AUDIO_DIR = `${this.CACHE_DIR}audios/`; + + constructor(config: MediaCacheConfig = DEFAULT_MEDIA_CACHE_CONFIG) { + this.config = config; + this.cacheDirectory = this.CACHE_DIR; + } + + // 初始化 + async initialize(): Promise { + await this.ensureDirectories(); + await this.loadMediaRecords(); + await this.cleanupIfNeeded(); + } + + // 确保缓存目录存在 + private async ensureDirectories(): Promise { + await FileSystem.makeDirectoryAsync(this.IMAGE_DIR, { intermediates: true }); + await FileSystem.makeDirectoryAsync(this.VIDEO_DIR, { intermediates: true }); + await FileSystem.makeDirectoryAsync(this.AUDIO_DIR, { intermediates: true }); + } + + // 缓存媒体文件 + async cacheMedia( + uri: string, + type: MediaType, + options: { + conversationId?: string; + messageId?: string; + } = {} + ): Promise { + const { conversationId, messageId } = options; + + // 生成唯一文件名 + const filename = this.generateFilename(uri, type); + const localPath = this.getMediaPath(type, filename); + + // 检查是否已缓存 + if (await this.exists(localPath)) { + await this.updateAccessTime(localPath); + return localPath; + } + + try { + // 下载文件 + const downloadResult = await FileSystem.downloadAsync(uri, localPath); + + // 记录缓存信息 + const cacheInfo: MediaCacheInfo = { + type, + uri, + localPath, + conversationId, + messageId, + size: downloadResult.headers['content-length'] + ? parseInt(downloadResult.headers['content-length']) + : 0, + createdAt: Date.now(), + lastAccessedAt: Date.now(), + }; + + await this.saveMediaRecord(localPath, cacheInfo); + await this.cleanupIfNeeded(); + + return localPath; + } catch (error) { + console.error('[MediaCacheManager] 缓存失败:', error); + throw error; + } + } + + // 获取缓存的媒体文件路径 + async getCachedMedia(uri: string, type: MediaType): Promise { + const record = this.findByUri(uri); + if (record && await this.exists(record.localPath)) { + await this.updateAccessTime(record.localPath); + return record.localPath; + } + return null; + } + + // 删除单个会话的所有媒体缓存 + async clearConversationMedia(conversationId: string): Promise { + const toDelete: string[] = []; + + for (const [path, info] of this.mediaRecords) { + if (info.conversationId === conversationId) { + toDelete.push(path); + } + } + + await Promise.all(toDelete.map(p => this.deleteMedia(p))); + } + + // 删除过期媒体 + async clearExpiredMedia(): Promise { + const now = Date.now(); + const maxAge = this.config.image.maxAge * 60 * 60 * 1000; // 转换为毫秒 + let deletedCount = 0; + + for (const [path, info] of this.mediaRecords) { + if (now - info.lastAccessedAt > maxAge) { + await this.deleteMedia(path); + deletedCount++; + } + } + + return deletedCount; + } + + // 清理超过会话保留期的媒体 + async clearOldConversationMedia(): Promise { + const now = Date.now(); + const maxAge = this.config.image.maxAgeForConversation * 24 * 60 * 60 * 1000; + let deletedCount = 0; + + for (const [path, info] of this.mediaRecords) { + if (info.conversationId && now - info.createdAt > maxAge) { + await this.deleteMedia(path); + deletedCount++; + } + } + + return deletedCount; + } + + // 按大小清理(LRU) + async clearBySizeLimit(): Promise { + const totalSize = await this.getTotalCacheSize(); + const limit = this.config.image.maxDiskCacheSize * 1024 * 1024; + + if (totalSize <= limit) { + return 0; + } + + // 按最后访问时间排序 + const sorted = Array.from(this.mediaRecords.entries()) + .sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); + + let currentSize = totalSize; + let deletedCount = 0; + + for (const [path, info] of sorted) { + if (currentSize <= limit) break; + + await this.deleteMedia(path); + currentSize -= info.size; + deletedCount++; + } + + return deletedCount; + } + + // 获取缓存统计 + async getCacheStats(): Promise<{ + totalSize: number; + imageCount: number; + videoCount: number; + audioCount: number; + oldestItem: number; + newestItem: number; + }> { + const stats = { + totalSize: 0, + imageCount: 0, + videoCount: 0, + audioCount: 0, + oldestItem: Date.now(), + newestItem: 0, + }; + + for (const info of this.mediaRecords.values()) { + stats.totalSize += info.size; + stats.oldestItem = Math.min(stats.oldestItem, info.createdAt); + stats.newestItem = Math.max(stats.newestItem, info.createdAt); + + switch (info.type) { + case MediaType.IMAGE: stats.imageCount++; break; + case MediaType.VIDEO: stats.videoCount++; break; + case MediaType.AUDIO: stats.audioCount++; break; + } + } + + return stats; + } + + // 清理入口 + async cleanupIfNeeded(): Promise { + await this.clearExpiredMedia(); + await this.clearBySizeLimit(); + } + + // 私有辅助方法 + private generateFilename(uri: string, type: MediaType): string { + const ext = this.getExtension(uri, type); + const hash = this.hashString(uri); + return `${type}_${hash}_${Date.now()}.${ext}`; + } + + private getExtension(uri: string, type: MediaType): string { + if (type === MediaType.IMAGE) { + if (uri.includes('.gif')) return 'gif'; + if (uri.includes('.webp')) return 'webp'; + return 'jpg'; + } + if (type === MediaType.VIDEO) return 'mp4'; + if (type === MediaType.AUDIO) return 'mp3'; + return 'bin'; + } + + private getMediaPath(type: MediaType, filename: string): string { + switch (type) { + case MediaType.IMAGE: return `${this.IMAGE_DIR}${filename}`; + case MediaType.VIDEO: return `${this.VIDEO_DIR}${filename}`; + case MediaType.AUDIO: return `${this.AUDIO_DIR}${filename}`; + } + } + + private async exists(path: string): Promise { + const info = await FileSystem.getInfoAsync(path); + return info.exists; + } + + private async deleteMedia(path: string): Promise { + try { + await FileSystem.deleteAsync(path, { idempotent: true }); + this.mediaRecords.delete(path); + await this.removeMediaRecord(path); + } catch (error) { + console.warn('[MediaCacheManager] 删除失败:', path, error); + } + } + + private async saveMediaRecord(path: string, info: MediaCacheInfo): Promise { + this.mediaRecords.set(path, info); + await cacheDataSource.set(`media_${path}`, info, null); + } + + private async loadMediaRecords(): Promise { + // 从缓存数据源加载 + } + + private async updateAccessTime(path: string): Promise { + const info = this.mediaRecords.get(path); + if (info) { + info.lastAccessedAt = Date.now(); + await this.saveMediaRecord(path, info); + } + } + + private findByUri(uri: string): MediaCacheInfo | undefined { + for (const info of this.mediaRecords.values()) { + if (info.uri === uri) { + return info; + } + } + return undefined; + } + + private async getTotalCacheSize(): Promise { + let total = 0; + for (const info of this.mediaRecords.values()) { + total += info.size; + } + return total; + } + + 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 = new MediaCacheManager(); +``` + +#### 3. 创建清理策略 + +```typescript +// src/services/media/MediaCleanupPolicy.ts + +export enum CleanupTrigger { + STARTUP = 'startup', + SCHEDULED = 'scheduled', + MANUAL = 'manual', + LOW_STORAGE = 'low_storage', + CONVERSATION_DELETED = 'conversation_deleted', +} + +export interface CleanupResult { + trigger: CleanupTrigger; + timestamp: number; + deletedCount: number; + freedSize: number; + errors: string[]; +} + +class MediaCleanupPolicy { + private lastCleanup: number = 0; + private config: MediaCacheConfig; + + // 启动时清理 + async cleanupOnStartup(): Promise { + const errors: string[] = []; + let deletedCount = 0; + let freedSize = 0; + + try { + // 清理过期文件 + const expired = await mediaCacheManager.clearExpiredMedia(); + deletedCount += expired; + + // 清理超过保留期的会话媒体 + const oldConversation = await mediaCacheManager.clearOldConversationMedia(); + deletedCount += oldConversation; + + // 超过大小限制时清理 + const bySize = await mediaCacheManager.clearBySizeLimit(); + deletedCount += bySize; + + this.lastCleanup = Date.now(); + } catch (error) { + errors.push(`Startup cleanup failed: ${error}`); + } + + return { + trigger: CleanupTrigger.STARTUP, + timestamp: Date.now(), + deletedCount, + freedSize, + errors, + }; + } + + // 检查是否需要定期清理 + shouldRunScheduledCleanup(): boolean { + const interval = this.config.global.checkInterval * 60 * 60 * 1000; + return Date.now() - this.lastCleanup > interval; + } + + // 会话删除时清理 + async cleanupOnConversationDeleted(conversationId: string): Promise { + const errors: string[] = []; + let deletedCount = 0; + let freedSize = 0; + + try { + const statsBefore = await mediaCacheManager.getCacheStats(); + await mediaCacheManager.clearConversationMedia(conversationId); + const statsAfter = await mediaCacheManager.getCacheStats(); + + deletedCount = statsBefore.imageCount + statsBefore.videoCount + statsBefore.audioCount + - (statsAfter.imageCount + statsAfter.videoCount + statsAfter.audioCount); + freedSize = statsBefore.totalSize - statsAfter.totalSize; + } catch (error) { + errors.push(`Conversation cleanup failed: ${error}`); + } + + return { + trigger: CleanupTrigger.CONVERSATION_DELETED, + timestamp: Date.now(), + deletedCount, + freedSize, + errors, + }; + } +} + +export const mediaCleanupPolicy = new MediaCleanupPolicy(); +``` + +#### 4. 创建清理 Hook + +```typescript +// src/hooks/useMediaCache.ts + +export function useMediaCache() { + const [stats, setStats] = useState(null); + const [isClearing, setIsClearing] = useState(false); + + // 加载缓存统计 + const loadStats = useCallback(async () => { + const cacheStats = await mediaCacheManager.getCacheStats(); + setStats(cacheStats); + }, []); + + // 手动清理 + const clearAll = useCallback(async () => { + setIsClearing(true); + try { + await mediaCacheManager.clearExpiredMedia(); + await mediaCacheManager.clearBySizeLimit(); + await loadStats(); + } finally { + setIsClearing(false); + } + }, [loadStats]); + + // 清理指定会话的媒体 + const clearConversation = useCallback(async (conversationId: string) => { + await mediaCacheManager.clearConversationMedia(conversationId); + await loadStats(); + }, [loadStats]); + + // 组件挂载时加载统计 + useEffect(() => { + loadStats(); + }, [loadStats]); + + return { + stats, + isClearing, + loadStats, + clearAll, + clearConversation, + formatSize: (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; + }, + }; +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/services/media/MediaCacheConfig.ts` | 新增 | 媒体缓存配置 | +| `src/services/media/MediaCacheManager.ts` | 新增 | 媒体缓存管理器 | +| `src/services/media/MediaCleanupPolicy.ts` | 新增 | 清理策略 | +| `src/services/media/index.ts` | 新增 | 导出文件 | +| `src/hooks/useMediaCache.ts` | 新增 | 媒体缓存 Hook | +| `src/hooks/index.ts` | 修改 | 导出新 Hook | +| `src/data/datasources/CacheDataSource.ts` | 修改 | 添加媒体记录存储 | + +### 清理策略 + +```mermaid +graph TD + A[启动 App] --> B{检查启动清理标志} + B -->|是| C[清理过期媒体] + B -->|是| D[清理超期会话媒体] + B -->|是| E[检查大小限制] + E -->|超过限制| F[LRU 清理] + + G[定期检查] -->|间隔到期| C + G -->|间隔到期| D + G -->|间隔到期| E + + H[用户操作] -->|删除会话| I[清理该会话媒体] + H -->|手动清理| J[清理所有过期媒体] + + K[存储空间检查] -->|低于阈值| L[紧急清理] +``` + +--- + +## 实现优先级与依赖关系 + +### 优先级矩阵 + +| 优先级 | 功能 | 依赖 | 复杂度 | +|--------|------|------|--------| +| P0 | 分页状态管理 | 无 | 中 | +| P0 | 同步状态机 | 无 | 低 | +| P1 | 差异更新 | P0 分页状态管理 | 高 | +| P1 | 媒体缓存清理 | 无 | 中 | + +### 推荐实现顺序 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 1: 基础改进 (1-2天) │ +│ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ P0 分页状态管理 │ │ P0 同步状态机 │ │ +│ │ │ │ │ │ +│ │ - 避免重复加载 │ │ - 清晰状态反馈 │ │ +│ │ - 页面缓存 │ │ - 连接状态 Hook │ │ +│ │ - 预加载机制 │ │ - UI 状态指示器 │ │ +│ └───────────────────┘ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 2: 性能优化 (2-3天) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ P1 差异更新 ││ +│ │ ││ +│ │ - 依赖分页状态管理 ││ +│ │ - 消息更新类型定义 ││ +│ │ - 差异更新 Hook ││ +│ │ - FlatList 渲染优化 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 3: 存储优化 (1-2天) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ P1 媒体缓存清理 ││ +│ │ ││ +│ │ - 媒体缓存管理器 ││ +│ │ - 清理策略 ││ +│ │ - 用户清理界面 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 风险与注意事项 + +1. **差异更新风险** + - 需要确保消息 ID 的唯一性 + - 并发更新时需要正确合并 + - 建议在开发环境进行大房间压力测试 + +2. **分页状态管理风险** + - 页面缓存会占用内存,需要设置上限 + - 分页状态需要正确序列化和恢复(跨会话) + +3. **状态机风险** + - 状态转换需要严格测试 + - 重连逻辑需要处理边界情况(如网络中断) + +4. **媒体缓存风险** + - 删除文件时需要确保没有正在使用 + - 缓存元数据需要持久化存储 + - 清理操作应该在后台线程执行 \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4894bdb..88416f5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -71,3 +71,24 @@ export { prefetchMessageScreen, prefetchHomeScreen, } from './usePrefetch'; + +// ==================== 分页 Hooks ==================== +export { + usePagination, + usePaginationManager, +} from './usePagination'; + +export type { + UsePaginationOptions, + UsePaginationReturn, +} from './usePagination'; + +// ==================== 连接状态 Hooks ==================== +export { useConnectionState } from './useConnectionState'; + +export type { UseConnectionStateResult } from './useConnectionState'; + +// ==================== 媒体缓存 Hooks ==================== +export { useMediaCache } from './useMediaCache'; + +export type { UseMediaCacheReturn } from './useMediaCache'; diff --git a/src/hooks/useConnectionState.ts b/src/hooks/useConnectionState.ts new file mode 100644 index 0000000..4a1240a --- /dev/null +++ b/src/hooks/useConnectionState.ts @@ -0,0 +1,147 @@ +/** + * 连接状态 Hook + * + * 提供连接状态的 React Hook 接口 + * 订阅 ConnectionStateManager 状态变化 + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + ConnectionState, + ConnectionStateInfo, + connectionStateManager, +} from '../infrastructure/sync'; + +/** + * useConnectionState Hook 返回值接口 + */ +export interface UseConnectionStateResult { + /** 当前连接状态 */ + state: ConnectionState; + /** 完整的状态信息 */ + stateInfo: ConnectionStateInfo; + /** 是否已连接 */ + isConnected: boolean; + /** 是否正在连接(包括 CONNECTING 和 RECONNECTING) */ + isConnecting: boolean; + /** 是否正在重连 */ + isReconnecting: boolean; + /** 是否已断开 */ + isDisconnected: boolean; + /** 是否处于错误状态 */ + hasError: boolean; + /** 当前错误信息 */ + error: Error | null; + /** 当前重连次数 */ + reconnectAttempts: number; + /** 最大重连次数 */ + maxReconnectAttempts: number; + /** 最后连接时间 */ + lastConnectedAt: number | null; + /** 状态描述文本 */ + description: string; + /** 手动触发重连 */ + triggerReconnect: () => void; + /** 重置重连次数 */ + resetReconnectAttempts: () => void; +} + +/** + * 连接状态 Hook + * + * 订阅 ConnectionStateManager 的状态变化, + * 提供便捷的状态属性和方法 + * + * @example + * ```tsx + * function ConnectionStatus() { + * const { state, isConnected, isConnecting, description } = useConnectionState(); + * + * return ( + * + * {description} + * {isConnecting && } + * + * ); + * } + * ``` + */ +export function useConnectionState(): UseConnectionStateResult { + // 状态 + const [stateInfo, setStateInfo] = useState(() => + connectionStateManager.getState() + ); + + // 订阅状态变化 + useEffect(() => { + const unsubscribe = connectionStateManager.subscribe((newState) => { + setStateInfo(newState); + }); + + return unsubscribe; + }, []); + + // 计算派生状态 + const isConnected = useMemo(() => + stateInfo.state === ConnectionState.CONNECTED, + [stateInfo.state] + ); + + const isConnecting = useMemo(() => + stateInfo.state === ConnectionState.CONNECTING || + stateInfo.state === ConnectionState.RECONNECTING, + [stateInfo.state] + ); + + const isReconnecting = useMemo(() => + stateInfo.state === ConnectionState.RECONNECTING, + [stateInfo.state] + ); + + const isDisconnected = useMemo(() => + stateInfo.state === ConnectionState.DISCONNECTED || + stateInfo.state === ConnectionState.IDLE, + [stateInfo.state] + ); + + const hasError = useMemo(() => + stateInfo.state === ConnectionState.ERROR, + [stateInfo.state] + ); + + // 手动触发重连 + const triggerReconnect = useCallback(() => { + // 只有在断开或错误状态才能触发重连 + if ( + stateInfo.state === ConnectionState.DISCONNECTED || + stateInfo.state === ConnectionState.ERROR || + stateInfo.state === ConnectionState.IDLE + ) { + connectionStateManager.setState(ConnectionState.RECONNECTING); + } + }, [stateInfo.state]); + + // 重置重连次数 + const resetReconnectAttempts = useCallback(() => { + connectionStateManager.resetReconnectAttempts(); + }, []); + + return { + state: stateInfo.state, + stateInfo, + isConnected, + isConnecting, + isReconnecting, + isDisconnected, + hasError, + error: stateInfo.error || null, + reconnectAttempts: stateInfo.reconnectAttempts || 0, + maxReconnectAttempts: connectionStateManager.getMaxReconnectAttempts(), + lastConnectedAt: stateInfo.lastConnectedAt || null, + description: stateInfo.description, + triggerReconnect, + resetReconnectAttempts, + }; +} + +export default useConnectionState; diff --git a/src/hooks/useDifferentialMessages.ts b/src/hooks/useDifferentialMessages.ts new file mode 100644 index 0000000..d597164 --- /dev/null +++ b/src/hooks/useDifferentialMessages.ts @@ -0,0 +1,369 @@ +/** + * 差异更新 Hook + * 接收原始消息列表,返回优化后的消息列表,自动处理批量更新 + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + MessageIdentifier, + MessageUpdate, + MessageUpdateType, + DiffConfig, +} from '../infrastructure/diff/types'; +import { + MessageDiffCalculator, + createMessageDiffCalculator, +} from '../infrastructure/diff/MessageDiffCalculator'; +import { + MessageUpdateBatcher, + createMessageUpdateBatcher, + BatcherOptions, +} from '../infrastructure/diff/MessageUpdateBatcher'; + +/** + * 差异更新 Hook 配置选项 + */ +export interface UseDifferentialMessagesOptions { + /** 差异计算配置 */ + diffConfig?: DiffConfig; + /** 批量处理配置 */ + batcherOptions?: BatcherOptions; + /** 是否启用差异计算,默认 true */ + enableDiff?: boolean; + /** 是否启用批量处理,默认 true */ + enableBatching?: boolean; + /** 最大消息数量限制,超出时触发重置 */ + maxMessageCount?: number; + /** 变化比例阈值(0-1),超过此值触发重置 */ + changeRatioThreshold?: number; +} + +/** + * 差异更新 Hook 返回值 + */ +export interface UseDifferentialMessagesResult { + /** 优化后的消息列表 */ + optimizedMessages: T[]; + /** 待处理更新数量 */ + pendingUpdateCount: number; + /** 是否正在处理更新 */ + isProcessing: boolean; + /** 手动刷新方法 */ + flush: () => void; + /** 重置方法 */ + reset: () => void; + /** 强制刷新(跳过批量处理) */ + forceUpdate: (messages: T[]) => void; + /** 获取差异统计信息 */ + getDiffStats: () => { + totalBatches: number; + totalUpdates: number; + averageBatchSize: number; + }; +} + +/** + * 差异更新 Hook + * @param messages 原始消息列表 + * @param options 配置选项 + * @returns 优化后的消息列表和相关方法 + */ +export function useDifferentialMessages( + messages: T[], + options: UseDifferentialMessagesOptions = {} +): UseDifferentialMessagesResult { + const { + diffConfig, + batcherOptions, + enableDiff = true, + enableBatching = true, + maxMessageCount = 10000, + changeRatioThreshold = 0.5, + } = options; + + // 状态 + const [optimizedMessages, setOptimizedMessages] = useState(messages); + const [isProcessing, setIsProcessing] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + // Refs + const calculatorRef = useRef | null>(null); + const batcherRef = useRef(null); + const previousMessagesRef = useRef([]); + + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createMessageDiffCalculator(diffConfig); + } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); + + // 初始化批量处理器 + useEffect(() => { + if (enableBatching) { + batcherRef.current = createMessageUpdateBatcher(batcherOptions); + + // 订阅批量更新 + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); + + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + } + }, [enableBatching, batcherOptions]); + + // 处理批量更新 + const handleBatchUpdates = useCallback((updates: MessageUpdate[]) => { + setIsProcessing(true); + + try { + setOptimizedMessages((currentMessages) => { + let newMessages = [...currentMessages]; + + for (const update of updates) { + switch (update.type) { + case MessageUpdateType.ADD: { + const addUpdate = update as any; + const index = addUpdate.index ?? newMessages.length; + newMessages.splice(index, 0, addUpdate.message); + break; + } + + case MessageUpdateType.BATCH_ADD: { + const batchAddUpdate = update as any; + const messages = batchAddUpdate.messages || []; + const startIndex = batchAddUpdate.startIndex ?? newMessages.length; + newMessages.splice(startIndex, 0, ...messages); + break; + } + + case MessageUpdateType.UPDATE: { + const updateMsg = update as any; + const index = newMessages.findIndex(m => m.id === updateMsg.messageId); + if (index !== -1) { + newMessages[index] = { ...newMessages[index], ...updateMsg.updates }; + } + break; + } + + case MessageUpdateType.BATCH_UPDATE: { + const batchUpdate = update as any; + const updates = batchUpdate.updates || []; + for (const { messageId, changes } of updates) { + const index = newMessages.findIndex(m => m.id === messageId); + if (index !== -1) { + newMessages[index] = { ...newMessages[index], ...changes }; + } + } + break; + } + + case MessageUpdateType.DELETE: { + const deleteUpdate = update as any; + newMessages = newMessages.filter(m => m.id !== deleteUpdate.messageId); + break; + } + + case MessageUpdateType.BATCH_DELETE: { + const batchDelete = update as any; + const idsToDelete = new Set(batchDelete.messageIds || []); + newMessages = newMessages.filter(m => !idsToDelete.has(m.id)); + break; + } + + case MessageUpdateType.MOVE: { + const moveUpdate = update as any; + const fromIndex = newMessages.findIndex(m => m.id === moveUpdate.messageId); + if (fromIndex !== -1) { + const [movedMessage] = newMessages.splice(fromIndex, 1); + newMessages.splice(moveUpdate.toIndex, 0, movedMessage); + } + break; + } + + case MessageUpdateType.RESET: { + const resetUpdate = update as any; + newMessages = resetUpdate.messages || []; + break; + } + } + } + + return newMessages; + }); + } finally { + setIsProcessing(false); + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + } + }, []); + + // 监听外部消息变化 + useEffect(() => { + // 如果消息数量变化太大,直接重置 + const previousCount = previousMessagesRef.current.length; + const currentCount = messages.length; + const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount; + + if (changeRatio > changeRatioThreshold) { + setOptimizedMessages(messages); + previousMessagesRef.current = messages; + return; + } + + // 使用差异计算 + if (enableDiff && calculatorRef.current) { + const diff = calculatorRef.current.calculateDiff(previousMessagesRef.current, messages); + + if (diff.shouldReset) { + setOptimizedMessages(messages); + } else { + // 将差异转换为更新操作 + const updates: MessageUpdate[] = []; + + // 处理新增 + if (diff.added.length > 0) { + if (diff.added.length === 1) { + updates.push({ + type: MessageUpdateType.ADD, + timestamp: Date.now(), + message: diff.added[0], + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_ADD, + timestamp: Date.now(), + messages: diff.added, + } as any); + } + } + + // 处理更新 + if (diff.updated.length > 0) { + if (diff.updated.length === 1) { + updates.push({ + type: MessageUpdateType.UPDATE, + timestamp: Date.now(), + messageId: diff.updated[0].message.id, + updates: diff.updated[0].changes, + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_UPDATE, + timestamp: Date.now(), + updates: diff.updated.map(u => ({ + messageId: u.message.id, + changes: u.changes, + })), + } as any); + } + } + + // 处理删除 + if (diff.deleted.length > 0) { + if (diff.deleted.length === 1) { + updates.push({ + type: MessageUpdateType.DELETE, + timestamp: Date.now(), + messageId: diff.deleted[0].message.id, + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_DELETE, + timestamp: Date.now(), + messageIds: diff.deleted.map(d => d.message.id), + } as any); + } + } + + // 处理移动 + if (diff.moved.length > 0) { + for (const move of diff.moved) { + updates.push({ + type: MessageUpdateType.MOVE, + timestamp: Date.now(), + messageId: move.message.id, + toIndex: move.toIndex, + } as any); + } + } + + // 应用更新 + if (updates.length > 0) { + if (enableBatching && batcherRef.current) { + batcherRef.current.addUpdates(updates); + } else { + handleBatchUpdates(updates); + } + } + } + } else { + // 不使用差异计算,直接设置 + setOptimizedMessages(messages); + } + + // 更新消息数量限制检查 + if (messages.length > maxMessageCount) { + console.warn(`[useDifferentialMessages] Message count (${messages.length}) exceeds limit (${maxMessageCount})`); + } + + previousMessagesRef.current = messages; + }, [messages, enableDiff, enableBatching, maxMessageCount, changeRatioThreshold]); + + // 手动刷新方法 + const flush = useCallback(() => { + batcherRef.current?.flush(); + }, []); + + // 重置方法 + const reset = useCallback(() => { + calculatorRef.current?.reset(); + batcherRef.current?.clearPending(); + setOptimizedMessages([]); + previousMessagesRef.current = []; + }, []); + + // 强制更新方法 + const forceUpdate = useCallback((newMessages: T[]) => { + setOptimizedMessages(newMessages); + }, []); + + // 获取统计信息 + const getDiffStats = useCallback(() => { + const batcherStats = batcherRef.current?.getStats(); + return { + totalBatches: batcherStats?.totalBatches ?? 0, + totalUpdates: batcherStats?.totalUpdates ?? 0, + averageBatchSize: batcherStats?.averageBatchSize ?? 0, + }; + }, []); + + // 定期更新待处理数量 + useEffect(() => { + if (!enableBatching || !batcherRef.current) return; + + const interval = setInterval(() => { + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + }, 50); + + return () => clearInterval(interval); + }, [enableBatching]); + + return { + optimizedMessages, + pendingUpdateCount, + isProcessing, + flush, + reset, + forceUpdate, + getDiffStats, + }; +} + +export default useDifferentialMessages; diff --git a/src/hooks/useMediaCache.ts b/src/hooks/useMediaCache.ts new file mode 100644 index 0000000..9009bec --- /dev/null +++ b/src/hooks/useMediaCache.ts @@ -0,0 +1,241 @@ +/** + * 媒体缓存 Hook + * @module src/hooks/useMediaCache + * @description 提供媒体缓存管理的 React Hook + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { mediaCacheManager } from '../infrastructure/cache'; +import { + CacheStats, + CleanupPolicy, + CleanupResult, + MediaType, + CleanupTrigger, + DEFAULT_CLEANUP_POLICY, +} from '../infrastructure/cache/types'; + +/** + * useMediaCache Hook 返回值类型 + */ +export interface UseMediaCacheReturn { + /** 缓存统计信息 */ + stats: CacheStats | null; + /** 是否正在清理 */ + isClearing: boolean; + /** 是否已初始化 */ + isInitialized: boolean; + /** 加载缓存统计 */ + loadStats: () => void; + /** 执行清理(手动) */ + cleanup: (policy?: Partial) => Promise; + /** 清空所有缓存 */ + clearAll: () => Promise; + /** 清理指定会话的缓存 */ + clearConversation: (conversationId: string) => Promise; + /** 启动定期清理 */ + startPeriodicCleanup: () => void; + /** 停止定期清理 */ + stopPeriodicCleanup: () => void; + /** 记录缓存访问(用于 LRU) */ + recordAccess: (key: string) => Promise; + /** 格式化缓存大小 */ + formatSize: (bytes: number) => string; + /** 缓存媒体文件 */ + cacheMedia: ( + uri: string, + type: MediaType, + options?: { + conversationId?: string; + messageId?: string; + size?: number; + } + ) => Promise; + /** 获取缓存的媒体路径 */ + getCachedMedia: (uri: string, type: MediaType) => Promise; +} + +/** + * 媒体缓存 Hook + * @description 提供缓存统计、清理功能、访问记录等 + * @returns {UseMediaCacheReturn} 缓存管理接口 + * + * @example + * ```tsx + * function MediaSettings() { + * const { stats, cleanup, formatSize } = useMediaCache(); + * + * return ( + * + * 总缓存: {formatSize(stats?.totalSize || 0)} + * 图片: {stats?.imageCount || 0} + * 视频: {stats?.videoCount || 0} + *