# 聊天系统性能优化设计方案 ## 文档信息 - **创建日期**: 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. **媒体缓存风险** - 删除文件时需要确保没有正在使用 - 缓存元数据需要持久化存储 - 清理操作应该在后台线程执行