- 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
54 KiB
54 KiB
聊天系统性能优化设计方案
文档信息
- 创建日期: 2026-03-18
- 项目: 胡萝卜 BBS (Carrot BBS) 前端
- 范围: 消息系统性能优化
目录
架构概述
当前消息系统架构
┌─────────────────────────────────────────────────────────────────┐
│ 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 分页状态管理
现有代码分析
问题点
-
useChatScreen.ts(第388-408行)const loadMoreHistory = useCallback(async () => { if (!conversationId || !hasMoreHistory || loadingMore) { return; } // 缺少分页游标管理,可能导致重复加载 setLoadingMore(true); try { await loadMoreMessages(); // 没有追踪当前加载到了哪一页 } finally { setLoadingMore(false); } }, [...]); -
messageManagerHooks.ts(第156-158行)setIsLoading(true); const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); // 直接使用 minSeq,但没有缓存 minSeq 的状态 -
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 |
修改 | 优化分页查询 |
关键设计决策
- 分页粒度: 以
beforeSeq为游标,而非 offset,避免消息新增导致的分页漂移 - 页面缓存: 使用
Map<pageNumber, messageIds[]>缓存已加载页面,避免重复请求 - 加载锁: 使用
Set<pageNumber>追踪正在加载的页面,防止并发重复加载 - 预加载: 当滚动到距离底部
prefetchThreshold条消息时,提前加载下一页
P0 同步状态机
现有代码分析
问题点
-
sseService.ts(第406-408行)isConnected(): boolean { return this.source != null; } // 状态判断过于简单,source != null 不代表真正连接成功 -
WebSocketClient.ts(第23-35行)export interface WebSocketEvents { 'connected': void; 'disconnected': void; 'error': Error; } // 只有 connected/disconnected 两种状态 -
sseService.ts(第190-228行)async connect(): Promise<boolean> { // isConnecting 状态没有暴露给外部 // 外部无法区分"正在连接"和"连接失败" } -
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 差异更新
现有代码分析
问题点
-
MessageManager消息更新机制- 每当有新消息时,通过
setMessages更新整个消息数组 - 大房间场景下,频繁的全量更新会导致 UI 卡顿
- 没有区分不同类型的消息更新(新增、删除、修改)
- 每当有新消息时,通过
-
messageManagerHooks.ts(第33-46行)const unsubscribe = messageManager.subscribe((event: MessageEvent) => { switch (event.type) { case 'conversations_updated': setConversations(messageManager.getConversations()); break; // ... 其他都是全量更新 } }); -
useChatScreen.ts(第148-162行)const messages = useMemo<GroupMessage[]>(() => { return messageManagerMessages.map(m => ({ // 全量映射 })); }, [messageManagerMessages]); -
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 媒体缓存清理
现有代码分析
问题点
-
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 分钟 // 没有媒体文件专用缓存管理 // 没有磁盘空间监控 } -
缺少媒体缓存机制
- 图片、视频、音频没有独立的缓存策略
- 没有按会话清理过期媒体的功能
- 没有用户手动清理入口
-
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 媒体缓存清理 ││
│ │ ││
│ │ - 媒体缓存管理器 ││
│ │ - 清理策略 ││
│ │ - 用户清理界面 ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
风险与注意事项
-
差异更新风险
- 需要确保消息 ID 的唯一性
- 并发更新时需要正确合并
- 建议在开发环境进行大房间压力测试
-
分页状态管理风险
- 页面缓存会占用内存,需要设置上限
- 分页状态需要正确序列化和恢复(跨会话)
-
状态机风险
- 状态转换需要严格测试
- 重连逻辑需要处理边界情况(如网络中断)
-
媒体缓存风险
- 删除文件时需要确保没有正在使用
- 缓存元数据需要持久化存储
- 清理操作应该在后台线程执行