Files
frontend/docs/OPTIMIZATION_DESIGN.md
lafay 62b55aec31
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
feat(infrastructure): add pagination, cache, and sync infrastructure
- 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
2026-03-18 23:05:28 +08:00

54 KiB
Raw Blame History

聊天系统性能优化设计方案

文档信息

  • 创建日期: 2026-03-18
  • 项目: 胡萝卜 BBS (Carrot BBS) 前端
  • 范围: 消息系统性能优化

目录

  1. 架构概述
  2. P0 分页状态管理
  3. P0 同步状态机
  4. P1 差异更新
  5. P1 媒体缓存清理
  6. 实现优先级与依赖关系

架构概述

当前消息系统架构

┌─────────────────────────────────────────────────────────────────┐
│                        UI Layer                                  │
│  ┌─────────────────┐  ┌─────────────────┐                      │
│  │  ChatScreen     │  │ MessageList     │                      │
│  │  useChatScreen  │  │ Screen          │                      │
│  └────────┬────────┘  └────────┬────────┘                    │
└───────────┼─────────────────────┼───────────────────────────────┘
            │                     │
            ▼                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Store Layer (Zustand)                         │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │ messageManager (MessageManager)                              ││
│  │  - messages Map<conversationId, Message[]>                 ││
│  │  - 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行)

    const loadMoreHistory = useCallback(async () => {
      if (!conversationId || !hasMoreHistory || loadingMore) {
        return;
      }
      // 缺少分页游标管理,可能导致重复加载
      setLoadingMore(true);
      try {
        await loadMoreMessages();
        // 没有追踪当前加载到了哪一页
      } finally {
        setLoadingMore(false);
      }
    }, [...]);
    
  2. messageManagerHooks.ts (第156-158行)

    setIsLoading(true);
    const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20);
    // 直接使用 minSeq但没有缓存 minSeq 的状态
    
  3. MessageRepository.ts (第125-137行)

    async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) {
      // 依赖 beforeSeq 参数,但没有分页偏移量管理
    }
    

架构缺陷

  • 没有独立的分页状态存储
  • hasMoreMessages 状态与实际数据可能不同步
  • 并发加载时缺少互斥机制
  • 没有加载中的分页信息缓存

实现方案

1. 新增分页状态管理器

// src/stores/pagination/PaginationStateManager.ts

interface PaginationState {
  conversationId: string;
  currentMinSeq: number;        // 当前已加载的最小seq
  currentMaxSeq: number;        // 当前已加载的最大seq
  pages: Map<number, string[]>;  // pageNumber -> messageIds
  loadingPageNumbers: Set<number>;
  hasMoreBefore: boolean;
  hasMoreAfter: boolean;
  totalLoaded: number;
}

class PaginationStateManager {
  private states: Map<string, PaginationState> = 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

// 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<PaginationState | null>(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

// src/stores/message/MessageManager.ts

class MessageManager {
  private paginationManager = new PaginationStateManager();

  async loadMoreMessages(
    conversationId: string,
    beforeSeq: number,
    limit: number
  ): Promise<Message[]> {
    // 检查分页状态
    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. 修改数据库查询

// src/services/database.ts (或 MessageRepository)

export async function getMessagesBeforeSeq(
  conversationId: string,
  beforeSeq: number,
  limit: number
): Promise<CachedMessage[]> {
  // 确保使用索引优化查询
  const result = await db.getAllAsync<CachedMessage>(`
    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<pageNumber, messageIds[]> 缓存已加载页面,避免重复请求
  3. 加载锁: 使用 Set<pageNumber> 追踪正在加载的页面,防止并发重复加载
  4. 预加载: 当滚动到距离底部 prefetchThreshold 条消息时,提前加载下一页

P0 同步状态机

现有代码分析

问题点

  1. sseService.ts (第406-408行)

    isConnected(): boolean {
      return this.source != null;
    }
    // 状态判断过于简单source != null 不代表真正连接成功
    
  2. WebSocketClient.ts (第23-35行)

    export interface WebSocketEvents {
      'connected': void;
      'disconnected': void;
      'error': Error;
    }
    // 只有 connected/disconnected 两种状态
    
  3. sseService.ts (第190-228行)

    async connect(): Promise<boolean> {
      // isConnecting 状态没有暴露给外部
      // 外部无法区分"正在连接"和"连接失败"
    }
    
  4. messageManagerHooks.ts (第390-405行)

    const [isConnected, setIsConnected] = useState(() => messageManager.isConnected());
    // 连接状态变化没有详细的状态分类
    

状态定义缺失

当前没有区分以下状态:

  • Connecting: 正在建立连接
  • Connected: 连接已建立
  • Reconnecting: 连接断开,正在重连
  • Disconnected: 连接已断开
  • Error: 连接错误

实现方案

1. 定义连接状态机

// 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. 创建连接状态管理器

// src/services/connection/ConnectionStateManager.ts

type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void;

class ConnectionStateManager {
  private state: ConnectionState = {
    type: ConnectionStateType.IDLE,
    timestamp: Date.now(),
  };
  
  private listeners: Set<ConnectionStateListener> = 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

// src/services/sseService.ts

class SSEService {
  private connectionStateManager = connectionStateManager;

  async connect(): Promise<boolean> {
    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

// src/hooks/useConnectionState.ts

export function useConnectionState() {
  const [state, setState] = useState<ConnectionState>(
    () => 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 集成示例

// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx

export function ConnectionStatusBadge() {
  const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState();

  if (isConnected) {
    return null; // 已连接不显示
  }

  return (
    <View style={styles.badge}>
      {isReconnecting && (
        <ActivityIndicator size="small" color="#666" />
      )}
      <Text style={styles.text}>
        {isReconnecting 
          ? `连接中断,正在重连 (${retryCount}/20)` 
          : status}
      </Text>
    </View>
  );
}

修改/新增文件列表

文件 操作 说明
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 状态图

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行)

    const unsubscribe = messageManager.subscribe((event: MessageEvent) => {
      switch (event.type) {
        case 'conversations_updated':
          setConversations(messageManager.getConversations());
          break;
        // ... 其他都是全量更新
      }
    });
    
  3. useChatScreen.ts (第148-162行)

    const messages = useMemo<GroupMessage[]>(() => {
      return messageManagerMessages.map(m => ({
        // 全量映射
      }));
    }, [messageManagerMessages]);
    
  4. ProcessMessageUseCase.ts (第159-199行)

    private async handleNewMessage(message: any): Promise<void> {
      // 每条消息都触发完整流程
      this.notifySubscribers({
        type: 'message_received',
        payload: { message, ... }
      });
    }
    

性能瓶颈

  • 1000人群聊每秒10条消息 = 每秒10000次 UI 更新操作
  • 全量 setMessages 触发 FlatList 完整重渲染
  • 没有虚拟列表优化

实现方案

1. 定义消息更新类型

// 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<T = any> {
  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<Message>;
}

export interface DeletePayload {
  messageId: string;
}

export interface BatchAppendPayload {
  messages: Message[];
}

export interface RecallPayload {
  messageId: string;
}

2. 创建差异更新 Hook

// src/hooks/useDifferentialMessages.ts

export function useDifferentialMessages(conversationId: string | null) {
  const [messages, setMessages] = useState<Message[]>([]);
  const pendingUpdatesRef = useRef<MessageUpdate[]>([]);
  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 渲染

// 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 (
      <MessageBubble
        message={item}
        showDateSeparator={!prevItem || !isSameDay(prevItem, item)}
        showTimeSeparator={prevItem && item.seq - prevItem.seq > 50}
      />
    );
  }, [isSameDay]);

  return (
    <FlatList
      data={messages}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      onEndReached={hasMore ? onLoadMore : null}
      onEndReachedThreshold={5}
      maxToRenderPerBatch={10}
      windowSize={21}
      removeClippedSubviews={true}
      initialNumToRender={20}
    />
  );
}

4. 修改 MessageManager 支持差异事件

// 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<void> {
    // 检查消息是否已存在
    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行)

    export class CacheDataSource implements ICacheDataSource {
      private memoryCache: Map<string, CacheEntry<any>> = new Map();
      private maxSize: number;      // 默认 100 条
      private defaultTtl: number;   // 默认 5 分钟
    
      // 没有媒体文件专用缓存管理
      // 没有磁盘空间监控
    }
    
  2. 缺少媒体缓存机制

    • 图片、视频、音频没有独立的缓存策略
    • 没有按会话清理过期媒体的功能
    • 没有用户手动清理入口
  3. LocalDataSource.ts (第73-86行)

    // 消息表结构
    `CREATE TABLE IF NOT EXISTS messages (
      id TEXT PRIMARY KEY NOT NULL,
      conversationId TEXT NOT NULL,
      segments TEXT
      // 没有存储媒体文件路径
    )`
    
    • 消息表没有记录媒体缓存路径
    • 无法追踪哪些媒体文件属于哪个会话

存储风险

  • 图片缓存:无限制增长
  • 视频缓存:无限制增长
  • 音频缓存:无限制增长
  • 总计:可能导致存储空间耗尽

实现方案

1. 定义媒体缓存配置

// 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. 创建媒体缓存管理器

// 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<string, MediaCacheInfo> = 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<void> {
    await this.ensureDirectories();
    await this.loadMediaRecords();
    await this.cleanupIfNeeded();
  }

  // 确保缓存目录存在
  private async ensureDirectories(): Promise<void> {
    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<string> {
    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<string | null> {
    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<void> {
    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<number> {
    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<number> {
    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<number> {
    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<void> {
    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<boolean> {
    const info = await FileSystem.getInfoAsync(path);
    return info.exists;
  }

  private async deleteMedia(path: string): Promise<void> {
    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<void> {
    this.mediaRecords.set(path, info);
    await cacheDataSource.set(`media_${path}`, info, null);
  }

  private async loadMediaRecords(): Promise<void> {
    // 从缓存数据源加载
  }

  private async updateAccessTime(path: string): Promise<void> {
    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<number> {
    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. 创建清理策略

// 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<CleanupResult> {
    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<CleanupResult> {
    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

// src/hooks/useMediaCache.ts

export function useMediaCache() {
  const [stats, setStats] = useState<CacheStats | null>(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 修改 添加媒体记录存储

清理策略

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. 媒体缓存风险

    • 删除文件时需要确保没有正在使用
    • 缓存元数据需要持久化存储
    • 清理操作应该在后台线程执行