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

1725 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 聊天系统性能优化设计方案
## 文档信息
- **创建日期**: 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<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行)**
```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<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
```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<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
```typescript
// 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. 修改数据库查询
```typescript
// 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行)**
```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<boolean> {
// 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<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
```typescript
// 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
```typescript
// 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 集成示例
```typescript
// 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 状态图
```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<GroupMessage[]>(() => {
return messageManagerMessages.map(m => ({
// 全量映射
}));
}, [messageManagerMessages]);
```
4. **`ProcessMessageUseCase.ts` (第159-199行)**
```typescript
private async handleNewMessage(message: any): Promise<void> {
// 每条消息都触发完整流程
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<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
```typescript
// 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 渲染
```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 (
<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 支持差异事件
```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<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行)**
```typescript
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行)**
```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<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. 创建清理策略
```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<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
```typescript
// 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` | 修改 | 添加媒体记录存储 |
### 清理策略
```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. **媒体缓存风险**
- 删除文件时需要确保没有正在使用
- 缓存元数据需要持久化存储
- 清理操作应该在后台线程执行