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. **媒体缓存风险**
|
|||
|
|
- 删除文件时需要确保没有正在使用
|
|||
|
|
- 缓存元数据需要持久化存储
|
|||
|
|
- 清理操作应该在后台线程执行
|