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

18 KiB
Raw Blame History

状态控制设计

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 实时计算,不维护中间状态:

// 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() 本身是幂等操作,无副作用