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