Files
ts-mobile-go/docs/状态控制设计.md
2026-07-20 19:01:03 +08:00

432 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 状态控制设计
## 1. 整体架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Go Bridge (TSBridge) │
│ onConnected / onDisconnected / onTextMessage / onClientEnter │
│ onClientLeave / onClientMoved / onKicked / onVoiceData / onPoked│
└──────────────────────────────┬──────────────────────────────────┘
│ JNI 回调
┌──────────────────────────────────────────────────────────────────┐
│ ServerViewModel (协调者) │
│ connectionState / serverInfo / kickReason / pokeNotification │
│ 拥有其他 ViewModel 引用,分发事件 │
└──────┬────────────┬────────────┬────────────┬────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ ChannelVM │ │ ChatVM │ │ VoiceVM │ │ Repository│
│ 频道列表 │ │ 文字聊天 │ │ 语音通信 │ │ (单例) │
│ 频道切换 │ │ 消息归档 │ │ PTT控制 │ │ 数据仓库 │
│ 未读状态 │ │ 送达确认 │ │ 说话检测 │ │ │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Compose UI (StateFlow collectAsState) │
│ NavGraph → ChannelListScreen / ChatScreen / KickedScreen │
└──────────────────────────────────────────────────────────────────┘
```
## 2. Repository(单例数据仓库)
所有 ViewModel 共享的底层数据源。ViewModel 读取 Repository 的 StateFlow,事件处理通过 Repository 方法更新。
```
Repository (object)
├── channels: StateFlow<List<ChannelInfo>> // 频道列表(低频,按需请求)
├── clients: StateFlow<List<ClientInfo>> // 客户端列表(高频,人员变动全量刷新)
├── channelClients: StateFlow<Map<String, List<ClientInfo>>> // 按频道索引(由 clients 派生)
├── selfClientId: StateFlow<Int> // 自身客户端 ID
├── currentChannelId: StateFlow<String> // 当前频道 ID
├── serverInfo: StateFlow<ServerInfo?> // 服务器信息
├── unreadCounts: StateFlow<Map<String, Int>> // 未读消息计数
├── activeChatKey: String? // 当前查看的会话
└── messageArchives: ConcurrentHashMap // 消息归档
```
**数据更新频率设计原则**
| 数据 | 变动频率 | SDK 事件支持 | 更新策略 |
|------|---------|-------------|---------|
| 频道列表 | 极低频 | 无事件 | 连接时全量请求一次,之后按需刷新(超5分钟过期检测) |
| 客户端列表 | 高频 | Enter/Leave/Move | 每次事件全量刷新 `refreshClientList()` |
| 频道树 | — | — | 不维护中间状态,UI 层直接从 channels + clients 实时计算 |
**关键方法**
- `performInitialSync()` — 首次同步(串行请求频道列表、客户端列表、自身 ID)
- `refreshClientList()` — 全量刷新客户端列表(人员变动时调用,高频)
- `updateBaseline()` — 原子提交基线数据
- `clearSession()` — 清理会话(断开连接时调用)
- `archiveMessage()` — 归档消息
- `confirmMessageDelivery()` — 匹配回显确认送达
## 3. ViewModel 状态明细
### 3.1 ServerViewModel — 连接与事件协调
**职责**:管理连接生命周期、接收 Go Bridge 回调并分发给其他 ViewModel。
```
ServerViewModel
├── state: StateFlow<ServerScreenState>
│ ├── address / nickname / password // 输入字段
│ ├── connectState: ConnectState // 连接按钮状态
│ │ ├── IDLE 空闲,等待用户输入
│ │ ├── CONNECTING 连接中
│ │ ├── SUCCESS 连接成功(触发导航)
│ │ ├── FAILED 连接失败
│ │ └── TIMEOUT 连接超时
│ └── errorMessage / validationErrors
├── connectionState: StateFlow<ConnectionState?>
│ ├── null 未进入频道列表页
│ ├── Connected 已连接
│ ├── Disconnecting 断开中
│ ├── Reconnecting 重连中(attempt, reason
│ └── Disconnected 已断开(reason, wasKicked
├── serverInfo: StateFlow<ServerInfo?>
├── kickReason: StateFlow<String>
├── pokeNotification / showPokeNotification
├── themeMode: StateFlow<ThemeMode>
└── recents: StateFlow<List<RecentConnection>>
```
**Bridge 回调分发**
| 回调 | 分发目标 |
|------|---------|
| `onConnected` | `channelViewModel.performInitialSync()` → 导航到频道列表 |
| `onDisconnected` | `Repository.clearSession()` + `channelViewModel.clearChannels()` + `chatViewModel.clearMessages()` |
| `onTextMessage` | `chatViewModel.handleTextMessage()` + `channelViewModel.onTextMessage()` |
| `onClientEnter` | `channelViewModel.handleClientEnter()` |
| `onClientLeave` | `channelViewModel.handleClientLeave()` |
| `onClientMoved` | `channelViewModel.handleClientMoved()` |
| `onKicked` | `Repository.clearSession()` + 所有 ViewModel 清理 → 导航到 KickedScreen |
| `onVoiceData` | `voiceViewModel.handleVoiceData()` |
| `onPoked` | `serverViewModel.handlePoked()` |
**ViewModel 引用**(由 NavGraph LaunchedEffect 设置):
```
serverViewModel.channelViewModel = channelViewModel
serverViewModel.chatViewModel = chatViewModel
serverViewModel.voiceViewModel = voiceViewModel
```
### 3.2 ChannelViewModel — 频道列表与切换
**职责**:管理同步状态、频道切换状态机、展开状态、未读指示。频道树不在 ViewModel 中维护,由 UI 层实时计算。
```
ChannelViewModel
├── syncState: StateFlow<SyncState>
│ ├── Unsynced 已连接但尚无完整数据(初始状态)
│ ├── Syncing 正在调用 ListChannels / ListClients
│ ├── Synchronized 列表基线可供 UI 使用
│ └── SyncFailed 同步失败
├── channels: StateFlow<List<ChannelInfo>> // Repository 引用(低频)
├── clients: StateFlow<List<ClientInfo>> // Repository 引用(高频)
├── channelClients: StateFlow<Map<String, List<ClientInfo>>> // Repository 引用
├── selfClientId: StateFlow<Int> // Repository 引用
├── currentChannelId: StateFlow<String> // Repository 引用
├── expandedChannelIds: StateFlow<Set<String>> // 本地状态(初始同步时自动展开有成员的频道)
├── hasUnreadMessage: StateFlow<Boolean> // 本地状态(当前频道外有新消息)
├── switchState: StateFlow<ChannelSwitchState>
│ ├── Idle 空闲
│ ├── Requesting 正在发送 ClientMove
│ ├── WaitingServerEvent 等待 OnClientMoved 确认
│ └── Failed 切换失败
├── showPasswordDialog / pendingSwitchChannel
└── channelDetailInfo / showChannelDetailCard
```
**频道树构建(UI 层)**
UI 直接从源 StateFlow 实时计算,不维护中间状态:
```kotlin
// ChannelListScreen
val tree = remember(channels, clients, expandedChannelIds) {
channelViewModel.buildChannelTree(channels, clients, expandedChannelIds, unreadStates)
}
```
**频道数据更新策略**
- 频道列表是静态数据,SDK 不提供频道增删改事件
- 连接时全量请求一次(`performInitialSync`),之后按需刷新(超5分钟过期检测)
- 人员变动事件(Enter/Leave/Move)只刷新客户端列表,不碰频道列表
- 如果管理员创建/删除了频道,用户下次切换频道时自动检测过期并刷新
**频道切换状态机**
```
用户点击频道
Idle ──→ Requesting ──→ WaitingServerEvent ──→ Idle
│ │ │
│ ▼ ▼
│ Failed 超时 → Failed
│ │
└───────────┘
用户重试/取消
```
**事件处理**
| 事件 | 处理 |
|------|------|
| `handleClientEnter()` | `Repository.refreshClientList()` 全量刷新(高频) |
| `handleClientLeave()` | `Repository.refreshClientList()` 全量刷新(高频) |
| `handleClientMoved()` | 区分自己/他人,全量刷新 + 更新 currentChannelId(高频) |
| `onTextMessage()` | 设置 hasUnreadMessage 标记(仅当 targetId != currentChannelId |
### 3.3 ChatViewModel — 文字聊天
**职责**:管理消息列表、发送状态、送达确认。
```
ChatViewModel
├── messages: StateFlow<List<ChatMessage>> // 当前会话消息
├── sendState: StateFlow<MessageSendState>
│ ├── Idle 空闲
│ ├── Sending 发送中
│ └── Failed 发送失败
├── currentTargetMode: Int // 1=私聊, 2=频道, 3=服务器
├── currentTargetId: Long // 目标 ID
└── deliveryTimeoutJob // 送达确认超时
```
**消息生命周期**
```
用户输入
sendMessage()
├── 1. 创建 PENDING 消息 → Repository.archiveMessage()
├── 2. TSBridge.sendTextMessage()
│ ├── 成功 → 等待回显(10秒超时)
│ └── 失败 → 标记 FAILED
收到 OnTextMessage 回显
├── Repository.confirmMessageDelivery() 匹配 PENDING
│ ├── 匹配 → 标记 SENT(送达确认)
│ └── 不匹配 → 正常归档(他人消息)
超时未确认 → 标记 FAILED
```
**会话管理**
- `enterChat()` — 设置 activeChatKey,加载消息,清除未读
- `leaveChat()` — 清除 activeChatKey,重置发送状态
- `clearMessages()` — 清空消息列表(断开连接时)
### 3.4 VoiceViewModel — 语音通信
**职责**:管理 PTT、静音、语音状态、说话检测。
```
VoiceViewModel
├── voiceState: StateFlow<VoiceState>
│ ├── Idle 空闲(可 PTT)
│ ├── Transmitting 发送中(PTT 按下)
│ └── Blocked 受阻(原因:未连接/未入频道/权限/断开)
├── isMuted: StateFlow<Boolean> // 默认 true
├── isTransmitting: StateFlow<Boolean>
├── speakerEnabled: StateFlow<Boolean>
├── denoiseEnabled: StateFlow<Boolean>
├── showVoiceCard: StateFlow<Boolean>
├── outputDevice: StateFlow<VoiceOutputDevice>
├── inputVolume / outputVolume: StateFlow<Float>
├── isSelfSpeaking: StateFlow<Boolean>
└── speakingClients: StateFlow<Map<Int, Long>>
```
**PTT 状态机**
```
Idle ──→ startTransmit() ──→ Transmitting ──→ stopTransmit() ──→ Idle
│ │
│ 前置检查失败 │ 异常
▼ ▼
Blocked ←──────────────────────────┘
└── clearBlocked() ──→ Idle
```
## 4. 状态生命周期
### 4.1 连接建立
```
用户点击连接
ServerViewModel.connect()
│ connectState = CONNECTING
TSBridge.connect() (IO 线程)
onConnected 回调
├── Repository 记录连接
├── ChannelVM.performInitialSync()
│ ├── syncState = Syncing
│ ├── Repository.performInitialSync() (串行请求)
│ ├── syncState = Synchronized
│ └── autoExpandChannelsWithClients()
├── ConnectionService.start() (前台服务保活)
├── connectState = SUCCESS
└── connectionState = Connected
NavGraph 导航到 CHANNEL_LIST
```
### 4.2 正常断开
```
用户点击断开
ServerViewModel.disconnect()
├── ConnectionService.stop()
├── reconnectJob?.cancel()
├── connectionState = Disconnecting
├── voiceViewModel.stopVoice()
├── TSBridge.disconnect()
├── Repository.clearSession()
│ ├── channels/clients = empty
│ ├── selfClientId = 0
│ ├── currentChannelId = "0"
│ ├── channelClients = empty
│ └── messageArchives.clear()
├── channelViewModel.clearChannels()
│ ├── syncState = Unsynced
│ ├── expandedChannelIds = empty
│ ├── unreadStates = empty
│ └── switchState = Idle
├── chatViewModel.clearMessages()
│ ├── messages = empty
│ └── sendState = Idle
├── connectionState = null
└── connectState = IDLE
NavGraph 导航到 SERVER_CONFIG
```
### 4.3 被踢出
```
onKicked 回调
├── ConnectionService.stop()
├── reconnectJob?.cancel()
├── voiceViewModel.stopVoice()
├── Repository.clearSession()
├── channelViewModel.clearChannels()
├── kickReason = reason
└── connectionState = Disconnected(wasKicked=true)
NavGraph 导航到 KICKED
```
### 4.4 网络断开(自动重连已禁用)
```
onDisconnected 回调
├── 检查 connectionState
│ ├── Disconnecting → 忽略(主动断开)
│ └── 其他 → 继续
├── voiceViewModel.onDisconnected()
│ └── voiceState = Blocked("连接已断开")
├── ConnectionService.stop()
├── Repository.clearSession()
├── channelViewModel.clearChannels()
└── connectionState = Disconnected(wasKicked=false)
NavGraph 导航到 SERVER_CONFIG
```
## 5. 状态流向图
```
┌──────────────────────────────┐
│ Repository │
│ channels (低频) / clients (高频) │
└──────┬───────────────────────┘
│ StateFlow(只读引用)
┌────────────┼────────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────┐
│ ChannelVM │ │ ChatVM │ │ VoiceVM │
│ 同步状态 │ │ 消息管理 │ │ 语音控制 │
│ 切换状态机 │ │ 送达确认 │ │ PTT │
│ 展开/未读 │ │ │ │ │
└─────┬──────┘ └────┬─────┘ └────┬─────┘
│ StateFlow │ StateFlow │ StateFlow
▼ ▼ ▼
┌─────────────────────────────────────────┐
│ Compose UI Layer │
│ collectAsState() → remember 派生计算 │
└─────────────────────────────────────────┘
```
**数据流规则**
1. **Repository** 持有底层数据。`channels` 低频更新(连接时请求),`clients` 高频更新(每次人员变动全量刷新)
2. **ViewModel** 读取 Repository 的 StateFlow 引用,仅管理本地 UI 状态(expandedChannelIds、hasUnreadMessage、switchState 等)
3. **UI** 通过 `collectAsState()` 订阅 StateFlow,使用 `remember` 实时派生计算(频道树)。不维护中间 combine 状态
4. **事件** 从 ServerViewModelBridge 回调)→ ViewModel 方法 → Repository 更新 → StateFlow 自动通知 UI
## 6. 导航状态控制
NavGraph 监听 `connectionState` 变化,自动处理导航:
```
connectionState 变化
├── Disconnected(wasKicked=true) → navigate(KICKED)
├── Disconnected(wasKicked=false) → navigate(SERVER_CONFIG)
├── Connected (当前在 KICKED) → navigate(CHANNEL_LIST)
└── 其他 → 不处理
```
**页面与 ViewModel 生命周期**
- ViewModel 作用域绑定到 NavBackStackEntry
- 从 CHANNEL_LIST 导航到 CHAT 时,ChannelVM 不销毁(在返回栈中)
- 从 CHAT 返回 CHANNEL_LIST 时,ChannelVM 状态保持
- 如果系统回收 ViewModel(内存不足),`LaunchedEffect` 检测 `syncState != Synchronized` 并重新触发同步
## 7. 已知状态问题与防护
| 问题 | 防护措施 |
|------|---------|
| ViewModel 被系统回收后 syncState 回到 Unsynced | ChannelListScreen 的 `LaunchedEffect` 自动重新同步 |
| 详情按钮快速连点 | ViewModel 层 500ms 防抖 |
| `leaveChat()` 被调用多次(Compose 重组) | `leaveChat()` 本身是幂等操作,无副作用 |