Files
ts-mobile-go/docs/状态控制设计.md
T

432 lines
18 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 状态控制设计
## 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()` 本身是幂等操作,无副作用 |