45 KiB
步骤 09:断开连接
实现断开连接流程:主动断开、被动断开、被踢处理、会话清理。 对应流程:
docs/流程/07_断开连接.md依赖步骤:08(语音通信)
一、目标
- 主动断开流程(Disconnect)— 服务器详情卡中触发
- 被动断开处理(OnDisconnected)— 网络异常或服务端断开
- 被踢处理(OnKicked)— 自己被频道踢或服务器踢
- 会话状态清理 — 停止语音、清除频道/成员/Pending
- 断线重连机制 — 自动重连 + 手动重连 + 放弃
- 返回主页逻辑 — 清理后导航回服务器配置页
二、任务清单
9.1 主动断开
目标:实现用户主动断开连接的完整流程,从 UI 触发到会话清理。
对应流程:docs/流程/07_断开连接.md 时序图 — 用户主动断开分支
对应 UI 设计:docs/UI架构设计.md 3.1 服务器详情卡 — 断开服务器按钮
对应 SDK:docs/sdk文档-go.md — Disconnect() error
断开时序:
用户点击 "断开服务器"
→ 弹出确认对话框 "确定要断开连接吗?"
→ 确认
→ ServerViewModel.disconnect()
→ 状态改为 Disconnecting(阻止新命令/语音/移动)
→ TSBridge.disconnect()
→ Go SDK Disconnect()
→ 发送 shutdown reason 到服务器
→ 服务器关闭会话
→ Go SDK 触发 OnDisconnected(nil)
→ 清理会话状态
→ 导航回服务器配置页
任务:
-
断开确认对话框
在服务器详情卡中,点击"断开服务器"按钮时弹出确认对话框:
// ui/components/DisconnectConfirmDialog.kt @Composable fun DisconnectConfirmDialog( onConfirm: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("断开连接") }, text = { Text("确定要断开与服务器的连接吗?") }, confirmButton = { TextButton(onClick = onConfirm) { Text("断开", color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } ) } -
ServerViewModel 主动断开方法
// viewmodel/ServerViewModel.kt /** * 主动断开连接(用户触发) * * 对应 docs/流程/07_断开连接.md: * "用户明确要求离开服务器" * "立即阻止新聊天、移动和语音请求" */ fun disconnect() { Log.i(TAG, "User requested disconnect") // 1. 设置断开中状态,阻止新操作 _connectionState.value = ConnectionState.Disconnecting // 2. 停止语音活动 voiceViewModel.stopVoice() // 3. 调用 SDK 断开 try { TSBridge.disconnect() } catch (e: Exception) { Log.e(TAG, "Disconnect error", e) } // 4. 清理会话状态 clearSession() // 5. 更新连接状态 _connectionState.value = ConnectionState.Disconnected // 6. 导航回主页(由 UI 层观察 connectionState 变化后执行) } -
服务器详情卡集成
// ui/components/ServerDetailCard.kt @Composable fun ServerDetailCard( serverViewModel: ServerViewModel, onDismiss: () -> Unit ) { val serverInfo by serverViewModel.serverInfo.collectAsState() var showDisconnectDialog by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { // 标题栏 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text("服务器详情", style = MaterialTheme.typography.titleMedium) IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "关闭") } } Spacer(Modifier.height(16.dp)) // 服务器信息 InfoRow("服务器名", serverInfo.name) InfoRow("服务器地址", serverInfo.address) InfoRow("在线人数", "${serverInfo.clientsOnline} / ${serverInfo.maxClients}") Spacer(Modifier.height(24.dp)) // 断开按钮 Button( onClick = { showDisconnectDialog = true }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ), modifier = Modifier.fillMaxWidth() ) { Text("断开服务器") } } // 确认对话框 if (showDisconnectDialog) { DisconnectConfirmDialog( onConfirm = { showDisconnectDialog = false serverViewModel.disconnect() onDismiss() // 关闭卡片 }, onDismiss = { showDisconnectDialog = false } ) } }
9.2 被动断开处理
目标:处理网络异常或服务端主动断开的情况,显示重连 UI 并支持自动重连。
对应流程:docs/流程/07_断开连接.md 时序图 — 网络或服务器异常分支
对应 UI 设计:docs/UI架构设计.md 5.4 断线重连
对应 SDK:docs/sdk文档-go.md — OnDisconnected(fn func(error))
TSBridge 回调:TSBridge.Callbacks.onDisconnected(message: String)
被动断开时序:
网络异常 / 服务端关闭
→ Go SDK 触发 OnDisconnected(error)
→ TSBridge.onDisconnected(message)
→ ServerViewModel.handleDisconnected(message)
→ 停止语音活动
→ 设置连接状态为 DisconnectedWithError
→ 显示重连横幅
→ 启动自动重连计时器
重连策略:
| 尝试次数 | 间隔 | 说明 |
|---|---|---|
| 第 1 次 | 2 秒 | 快速重试 |
| 第 2 次 | 4 秒 | 递增 |
| 第 3 次 | 8 秒 | 递增 |
| 第 4 次 | 16 秒 | 递增 |
| 第 5 次 | 30 秒 | 最后一次 |
| 超过 5 次 | 停止 | 显示放弃提示 |
任务:
-
连接状态密封类
// data/Models.kt /** * 连接状态机 * * 对应 docs/流程/07_断开连接.md 状态树: * Disconnecting — 正在主动或被动结束会话 * Terminated — 当前会话资源已清理 * * 状态转换: * Idle → Connecting:用户点击连接 * Connecting → Connected:握手成功 * Connected → Disconnecting:主动断开 / 网络异常 / 被踢 * Connected → Reconnecting:网络异常(自动重连) * Disconnecting → Disconnected:断开完成 * Reconnecting → Connected:重连成功 * Reconnecting → Disconnected:重连失败 / 用户放弃 * Disconnected → Idle:返回主页 */ sealed class ConnectionState { /** 空闲 — 未连接 */ object Idle : ConnectionState() /** 连接中 — 正在握手 */ object Connecting : ConnectionState() /** 已连接 — 正常会话中 */ object Connected : ConnectionState() /** 断开中 — 正在主动断开 */ object Disconnecting : ConnectionState() /** 重连中 — 网络异常后自动重连 */ data class Reconnecting( val attempt: Int, val maxAttempts: Int = 5, val reason: String = "" ) : ConnectionState() /** 已断开 — 会话结束 */ data class Disconnected( val reason: String = "", val wasKicked: Boolean = false ) : ConnectionState() } -
ServerViewModel 被动断开处理
// viewmodel/ServerViewModel.kt companion object { private const val TAG = "ServerViewModel" private const val MAX_RECONNECT_ATTEMPTS = 5 private val RECONNECT_DELAYS = longArrayOf(2000, 4000, 8000, 16000, 30000) } // ── 重连状态 ── private var reconnectAttempt = 0 private var reconnectJob: Job? = null private var lastConnectParams: ConnectParams? = null /** * 处理被动断开(由 TSBridge 回调触发) * * 对应 docs/流程/07_断开连接.md: * "OnDisconnected(error) — 携带异常断开原因" * "停止全部依赖连接的操作" */ fun handleDisconnected(message: String) { Log.w(TAG, "handleDisconnected: $message") // 如果是主动断开,不触发重连 if (_connectionState.value is ConnectionState.Disconnecting) { return } // 1. 停止语音 voiceViewModel.onDisconnected() // 2. 保存断开原因 _lastDisconnectReason.value = message // 3. 启动自动重连 if (lastConnectParams != null) { startReconnect(message) } else { // 无连接参数,直接标记断开 _connectionState.value = ConnectionState.Disconnected( reason = message, wasKicked = false ) } } /** * 启动自动重连 * * 对应 docs/UI架构设计.md 5.4: * "自动重连:最多尝试 5 次,间隔递增(2s → 4s → 8s → 16s → 30s)" */ private fun startReconnect(reason: String) { reconnectAttempt = 0 _connectionState.value = ConnectionState.Reconnecting( attempt = 0, reason = reason ) reconnectJob = viewModelScope.launch { while (reconnectAttempt < MAX_RECONNECT_ATTEMPTS && _connectionState.value is ConnectionState.Reconnecting ) { val delayMs = RECONNECT_DELAYS[reconnectAttempt] Log.i(TAG, "Reconnect attempt ${reconnectAttempt + 1}/$MAX_RECONNECT_ATTEMPTS in ${delayMs}ms") delay(delayMs) reconnectAttempt++ _connectionState.value = ConnectionState.Reconnecting( attempt = reconnectAttempt, reason = reason ) val params = lastConnectParams ?: break val error = TSBridge.connect( host = params.host, nickname = params.nickname, password = params.password, defaultChannel = params.defaultChannel, defaultChannelPassword = params.defaultChannelPassword, callbacks = createCallbacks() ) if (error.isEmpty()) { Log.i(TAG, "Reconnect succeeded on attempt $reconnectAttempt") // 重连成功由 onConnected 回调处理 return@launch } Log.w(TAG, "Reconnect attempt $reconnectAttempt failed: $error") } // 重连失败 Log.w(TAG, "All reconnect attempts failed") _connectionState.value = ConnectionState.Disconnected( reason = reason, wasKicked = false ) } } /** * 手动重连(用户点击"手动重连"按钮) * * 对应 docs/UI架构设计.md 5.4: * "手动重连:用户主动触发立即重连" */ fun manualReconnect() { reconnectJob?.cancel() reconnectAttempt = 0 val params = lastConnectParams ?: return _connectionState.value = ConnectionState.Connecting viewModelScope.launch { val error = TSBridge.connect( host = params.host, nickname = params.nickname, password = params.password, defaultChannel = params.defaultChannel, defaultChannelPassword = params.defaultChannelPassword, callbacks = createCallbacks() ) if (error.isNotEmpty()) { _connectionState.value = ConnectionState.Disconnected( reason = error, wasKicked = false ) } // 成功由 onConnected 回调处理 } } /** * 放弃重连(用户点击"放弃"按钮) * * 对应 docs/UI架构设计.md 5.4: * "放弃:停止重连 → 显示完整断线提示 → 返回主页" */ fun abandonReconnect() { reconnectJob?.cancel() reconnectJob = null reconnectAttempt = 0 clearSession() _connectionState.value = ConnectionState.Disconnected( reason = _lastDisconnectReason.value, wasKicked = false ) } -
重连横幅 UI
// ui/components/ReconnectBanner.kt @Composable fun ReconnectBanner( reconnectState: ConnectionState.Reconnecting, onManualReconnect: () -> Unit, onAbandon: () -> Unit ) { Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 4.dp ) { Column( modifier = Modifier.padding(12.dp) ) { Row( verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp) ) Spacer(Modifier.width(8.dp)) Text( text = "连接已断开", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onErrorContainer ) } Spacer(Modifier.height(4.dp)) Text( text = "正在尝试重连... (${reconnectState.attempt}/${reconnectState.maxAttempts})", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer ) if (reconnectState.reason.isNotEmpty()) { Text( text = reconnectState.reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) ) } Spacer(Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { TextButton(onClick = onManualReconnect) { Text("手动重连") } TextButton(onClick = onAbandon) { Text("放弃", color = MaterialTheme.colorScheme.error) } } } } }
9.3 被踢处理
目标:处理自己被踢出频道或服务器的情况,显示全屏提示并清理会话。
对应流程:docs/流程/07_断开连接.md 时序图 — 自己被踢分支
对应 UI 设计:docs/UI架构设计.md 5.3 被踢处理
对应 SDK:docs/sdk文档-go.md — OnKicked(fn func(string)),ClientLeftViewEvent.ReasonID
TSBridge 回调:TSBridge.Callbacks.onKicked(reason: String)
被踢时序:
服务器发送 notifyclientleftview (reasonid=4 或 5)
→ Go SDK 识别为自己被踢
→ Go SDK 触发 OnKicked(reason)
→ TSBridge.onKicked(reason)
→ ServerViewModel.handleKicked(reason)
→ 立即停止语音
→ 清理会话状态
→ 设置连接状态为 Disconnected(wasKicked=true)
→ 显示被踢全屏提示
ReasonID 说明:
| ReasonID | 含义 | 说明 |
|---|---|---|
| 4 | 频道踢出 | 被踢出当前频道,但仍在服务器上 |
| 5 | 服务器踢出 | 被踢出整个服务器 |
注意:Go SDK 的
OnKicked已从notifyclientleftview中识别出自己被踢,上层无需再次判断。
任务:
-
ServerViewModel 被踢处理
// viewmodel/ServerViewModel.kt /** * 处理被踢事件(由 TSBridge 回调触发) * * 对应 docs/流程/07_断开连接.md: * "OnKicked(reason) — SDK 将自己被踢转换为独立事件" * "必须与普通成员离开区分" * * 对应 docs/UI架构设计.md 5.3: * "全屏覆盖提示,不可通过点击外部关闭" */ fun handleKicked(reason: String) { Log.w(TAG, "handleKicked: $reason") // 1. 立即停止语音(断开或被踢必须立即停止) voiceViewModel.stopVoice() // 2. 停止重连(如果正在进行) reconnectJob?.cancel() reconnectJob = null // 3. 清理会话状态 clearSession() // 4. 设置被踢状态 _connectionState.value = ConnectionState.Disconnected( reason = reason, wasKicked = true ) // 5. 记录被踢原因供 UI 显示 _kickReason.value = reason Log.i(TAG, "Kicked from server, session cleared") } -
被踢全屏提示 UI
// ui/screens/KickedScreen.kt @Composable fun KickedScreen( reason: String, onReconnect: () -> Unit, onBackToHome: () -> Unit ) { // 全屏覆盖,不可通过点击外部关闭 Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .systemBarsPadding(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // 图标 Icon( imageVector = Icons.Default.PersonOff, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error ) Spacer(Modifier.height(24.dp)) // 标题 Text( text = "你已被踢出", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface ) Spacer(Modifier.height(12.dp)) // 原因 if (reason.isNotEmpty()) { Text( text = "原因:$reason", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Text( text = "你已被服务器管理员踢出", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Spacer(Modifier.height(32.dp)) // 重新连接按钮 Button( onClick = onReconnect, modifier = Modifier.fillMaxWidth() ) { Text("重新连接") } Spacer(Modifier.height(12.dp)) // 返回主页按钮 OutlinedButton( onClick = onBackToHome, modifier = Modifier.fillMaxWidth() ) { Text("返回主页") } } } }
9.4 会话清理逻辑
目标:统一清理当前会话的所有临时状态,防止旧数据污染下次连接。
对应流程:docs/流程/07_断开连接.md 状态树 — Terminated 分支:
- 清理命令请求(取消未完成的业务操作)
- 清理语音与传输(停止语音和文件连接)
- 清理会话状态(移除频道成员等临时事实)
关键原则:
- 断开时统一清理当前会话资源
- 防止旧成员、频道和 Pending 污染下一次连接
- 清理顺序:先停止活跃操作 → 再清除数据
任务:
-
Repository 会话清理
// data/Repository.kt /** * 清理当前会话的所有临时状态 * * 对应 docs/流程/07_断开连接.md: * "清理频道、成员和 Pending — 防止旧会话事实污染下一次连接" * * 清理顺序: * 1. 清除频道列表和频道树 * 2. 清除客户端列表 * 3. 清除当前频道/客户端状态 * 4. 清除消息历史 * 5. 清除待处理操作 */ fun clearSession() { Log.i(TAG, "clearSession: clearing all session data") // 频道数据 _channels.value = emptyList() _channelTree.value = emptyList() _currentChannelId.value = 0L _currentChannel.value = null // 客户端数据 _clients.value = emptyList() _currentClientId.value = 0 // 消息数据 _messages.value = emptyMap() _unreadCounts.value = emptyMap() // 服务器信息 _serverInfo.value = ServerInfo() // 连接状态 _isConnected.value = false Log.d(TAG, "clearSession: all session data cleared") } -
ServerViewModel 清理入口
// viewmodel/ServerViewModel.kt /** * 清理会话(统一入口) * * 对应 docs/流程/07_断开连接.md 统一实现原则: * "断开时统一清理当前会话资源" */ private fun clearSession() { // 1. 停止语音 voiceViewModel.stopVoice() // 2. 清除 Repository 数据 repository.clearSession() // 3. 清除聊天 ViewModel 状态 chatViewModel.clearMessages() // 4. 清除频道 ViewModel 状态 channelViewModel.clearChannels() // 5. 清除连接参数(可选:保留用于重连) // lastConnectParams 保留,供重连使用 Log.i(TAG, "Session cleared") }
9.5 断开 UI 反馈
目标:整合所有断开场景的 UI 反馈,确保用户在每种情况下都有清晰的视觉提示。
对应 UI 设计:docs/UI架构设计.md 5.1 连接状态、5.3 被踢处理、5.4 断线重连
场景汇总:
| 场景 | 触发 | UI 行为 |
|---|---|---|
| 主动断开 | 用户点击"断开服务器" | 确认对话框 → 断开 → 返回主页 |
| 网络异常 | OnDisconnected(error) | 顶部重连横幅 → 自动重连 |
| 被踢出 | OnKicked(reason) | 全屏被踢提示 → 重新连接/返回主页 |
| 重连成功 | onConnected(重连后) | 横幅消失 → 全量同步 → 恢复正常 |
| 重连失败 | 超过最大重试次数 | 显示放弃提示 → 返回主页 |
任务:
-
主页面断开状态观察
在主 Activity 或 NavGraph 中观察连接状态,处理导航:
// MainActivity.kt 或 NavGraph.kt @Composable fun AppNavigation(serverViewModel: ServerViewModel) { val connectionState by serverViewModel.connectionState.collectAsState() val navController = rememberNavController() // 观察连接状态变化,处理导航 LaunchedEffect(connectionState) { when (connectionState) { is ConnectionState.Disconnected -> { val state = connectionState as ConnectionState.Disconnected if (state.wasKicked) { // 被踢 → 导航到被踢页面 navController.navigate("kicked") { popUpTo("channelList") { inclusive = true } } } else { // 普通断开 → 返回主页 navController.navigate("serverConfig") { popUpTo("channelList") { inclusive = true } } } } is ConnectionState.Connected -> { // 连接/重连成功 → 导航到频道列表 navController.navigate("channelList") { popUpTo("serverConfig") { inclusive = true } } } else -> { /* 其他状态不处理导航 */ } } } NavHost(navController, startDestination = "serverConfig") { composable("serverConfig") { ServerConfigScreen(serverViewModel) } composable("channelList") { // 重连横幅 val state = connectionState if (state is ConnectionState.Reconnecting) { ReconnectBanner( reconnectState = state, onManualReconnect = { serverViewModel.manualReconnect() }, onAbandon = { serverViewModel.abandonReconnect() } ) } ChannelListScreen(...) } composable("kicked") { val state = connectionState as? ConnectionState.Disconnected KickedScreen( reason = serverViewModel.kickReason.collectAsState().value, onReconnect = { serverViewModel.manualReconnect() }, onBackToHome = { serverViewModel.abandonReconnect() navController.navigate("serverConfig") { popUpTo("kicked") { inclusive = true } } } ) } } } -
频道列表页断开状态指示
对应
docs/UI架构设计.md2.2 头部右侧连接状态指示:// ui/components/ConnectionStatusIndicator.kt @Composable fun ConnectionStatusIndicator( connectionState: ConnectionState, modifier: Modifier = Modifier ) { val (color, text) = when (connectionState) { is ConnectionState.Connected -> Color(0xFF4CAF50) to "已连接" is ConnectionState.Connecting -> Color(0xFFFF9800) to "连接中" is ConnectionState.Disconnecting -> Color(0xFFFF9800) to "断开中" is ConnectionState.Reconnecting -> Color(0xFFFF9800) to "重连中" is ConnectionState.Disconnected -> Color(0xFFF44336) to "已断开" is ConnectionState.Idle -> Color.Gray to "未连接" } Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(8.dp) .background(color, CircleShape) ) Spacer(Modifier.width(4.dp)) Text( text = text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }
三、状态与数据流
3.1 连接状态机
┌──────────────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄───────────────────────────────────────────┤
└────┬────┘ │
│ 点击连接 │
▼ │
┌─────────────┐ │
│ Connecting │ │
└──┬────────┬─┘ │
│ │ │
成功 │ │ 失败 │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ Connected │ │ Disconnected│ │
└──────┬───────┘ └──────────────┘ │
│ │
│ 主动断开 / 网络异常 / 被踢 │
▼ │
┌──────────────────────────────────┐ │
│ Disconnecting │ │
│ (主动断开时直接进入此状态) │ │
└──────┬───────────────────────────┘ │
│ │
│ 网络异常且有重连参数 │
▼ │
┌──────────────┐ │
│ Reconnecting │──── 重连成功 ───→ Connected │
│ (attempt/N) │ │
└──────┬───────┘ │
│ │
│ 超过最大次数 / 用户放弃 │
▼ │
┌──────────────┐ │
│ Disconnected │──── 返回主页 ───→ Idle │
│ (wasKicked?) │ │
└──────────────┘ │
│ │
│ 被踢 → 用户点击重新连接 │
└──────────── Connecting ─────────────────────────────────┘
3.2 主动断开数据流
UI (服务器详情卡) ServerViewModel TSBridge Go SDK 服务器
│ │ │ │ │
│ 点击"断开服务器" │ │ │ │
├───────────────────→│ │ │ │
│ │ 确认对话框 │ │ │
│◄───────────────────┤ │ │ │
│ 确认 │ │ │ │
├───────────────────→│ │ │ │
│ │ disconnect() │ │ │
│ ├───────────────────→│ │ │
│ │ │ disconnect() │ │
│ │ ├───────────────→│ │
│ │ │ │ shutdown │
│ │ │ ├─────────────→│
│ │ │ │ │
│ │ │ │ 连接关闭 │
│ │ │ │◄─────────────┤
│ │ │ onDisconnected │ │
│ │ │◄───────────────┤ │
│ │ clearSession() │ │ │
│ ├───────────────────→│ (Repository) │ │
│ │ 状态 → Disconnected│ │ │
│ 导航回主页 │ │ │ │
│◄───────────────────┤ │ │ │
3.3 被动断开与重连数据流
网络/服务器 Go SDK TSBridge ServerViewModel UI
│ │ │ │ │
│ 网络中断 │ │ │ │
│ ──────── X ──────│ │ │ │
│ │ onDisconnected │ │ │
│ ├───────────────→│ │ │
│ │ │ onDisconnected │ │
│ │ ├───────────────→│ │
│ │ │ │ stopVoice() │
│ │ │ │ startReconnect() │
│ │ │ │ 状态→Reconnecting│
│ │ │ ├─────────────────→│
│ │ │ │ │ 显示横幅
│ │ │ │ │
│ │ │ │ delay(2s) │
│ │ │ │ connect() │
│ │ ├────────────────┤ │
│ │◄───────────────┤ │ │
│ │ Connect │ │ │
│◄─────────────────┤ │ │ │
│ │ │ │ │
│ 连接成功 │ │ │ │
│─────────────────→│ │ │ │
│ │ onConnected │ │ │
│ ├───────────────→│ │ │
│ │ │ onConnected │ │
│ │ ├───────────────→│ │
│ │ │ │ 全量同步 │
│ │ │ │ 状态→Connected │
│ │ │ ├─────────────────→│
│ │ │ │ │ 横幅消失
3.4 被踢数据流
服务器 Go SDK TSBridge ServerViewModel UI
│ │ │ │ │
│ notifyclient │ │ │ │
│ leftview │ │ │ │
│ (reasonid=5) │ │ │ │
├─────────────────→│ │ │ │
│ │ 识别为自己被踢 │ │ │
│ │ onKicked │ │ │
│ ├───────────────→│ │ │
│ │ │ onKicked │ │
│ │ ├───────────────→│ │
│ │ │ │ stopVoice() │
│ │ │ │ clearSession() │
│ │ │ │ 状态→Disconnected│
│ │ │ │ (wasKicked=true) │
│ │ │ ├─────────────────→│
│ │ │ │ │ 全屏提示
│ │ │ │ │
│ │ │ │ 用户选择: │
│ │ │ │ 重新连接/返回主页 │
3.5 断开前置依赖矩阵
对应 docs/流程/07_断开连接.md 事件依赖:
| 操作或事件 | 必须依赖 | 建议依赖 | 依赖失败时的处理 |
|---|---|---|---|
Disconnect(主动) |
已连接 | 无 | 未连接时忽略 |
OnDisconnected(被动) |
已注册处理器 | 保存断开原因 | 停止全部依赖连接的操作 |
OnKicked(被踢) |
已注册处理器 | 区分 reasonid 4 和 5 | 清理会话且不伪装为普通成员离开 |
| 会话清理 | 断开事件触发 | 按顺序清理 | 部分清理失败不影响其他清理 |
四、与其他步骤的集成
4.1 与语音通信集成(步骤 08)
- 主动断开时调用
voiceViewModel.stopVoice() - 被动断开时调用
voiceViewModel.onDisconnected() - 被踢时调用
voiceViewModel.stopVoice() - 重连成功后调用
voiceViewModel.onReconnected()
对应 docs/流程/05_语音通信.md:
- "断开或被踢必须立即停止"
- "OnDisconnected(error) → StopCapture → 状态改为 blocked"
- "重连并同步前禁止恢复发送" → 重连成功后恢复 Idle
4.2 与频道列表页集成(步骤 05)
- 断开后频道树清空
- 断开后当前频道栏隐藏
- 重连成功后重新加载频道树
4.3 与聊天页集成(步骤 07)
- 断开后消息列表清空
- 断开后消息输入框禁用
- 重连成功后重新加载消息历史
4.4 与状态同步集成(步骤 10)
- 重连成功后执行全量同步(
docs/流程/08_状态同步.md⑥) - 全量同步完成后恢复正常使用
4.5 与服务器配置页集成(步骤 03)
- 断开后导航回服务器配置页
- 保留上次连接参数(地址、昵称、密码)用于重连
- 被踢后"重新连接"使用相同参数
五、TSBridge 回调注册
目标:在 ServerViewModel 中注册 OnDisconnected 和 OnKicked 回调。
对应实现:android/app/src/main/java/com/tsmobile/app/TSBridge.kt — Callbacks 接口
已有回调定义:
// TSBridge.kt 中已有:
interface Callbacks {
fun onConnected()
fun onDisconnected(message: String) // ← 被动断开
fun onTextMessage(msg: TextMsg)
fun onClientEnter(client: Client)
fun onClientLeave(id: Int, reasonMsg: String)
fun onClientMoved(id: Int, targetChannelID: String)
fun onKicked(reason: String) // ← 被踢
fun onVoiceData(clientID: Int, data: ByteArray, codec: Int)
}
任务:
// viewmodel/ServerViewModel.kt
/**
* 创建回调对象(供 TSBridge.connect 和重连使用)
*/
private fun createCallbacks(): TSBridge.Callbacks {
return object : TSBridge.Callbacks {
override fun onConnected() {
Log.i(TAG, "onConnected")
handleConnected()
}
override fun onDisconnected(message: String) {
Log.w(TAG, "onDisconnected: $message")
handleDisconnected(message)
}
override fun onTextMessage(msg: TextMsg) {
chatViewModel.handleTextMessage(msg)
}
override fun onClientEnter(client: Client) {
channelViewModel.handleClientEnter(client)
}
override fun onClientLeave(id: Int, reasonMsg: String) {
channelViewModel.handleClientLeave(id, reasonMsg)
}
override fun onClientMoved(id: Int, targetChannelID: String) {
channelViewModel.handleClientMoved(id, targetChannelID)
}
override fun onKicked(reason: String) {
Log.w(TAG, "onKicked: $reason")
handleKicked(reason)
}
override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) {
voiceViewModel.handleVoiceData(clientID, data, codec)
}
}
}
六、验收标准
功能验收
-
主动断开
- 服务器详情卡中有"断开服务器"按钮
- 点击后弹出确认对话框
- 确认后执行断开,返回服务器配置页
- 断开过程中语音停止、会话清理
-
被动断开
- 网络异常时自动检测断开
- 顶部显示重连横幅(含断开原因)
- 自动重连最多 5 次,间隔递增
- 重连成功后横幅消失,恢复正常
-
被踢处理
- 被踢后显示全屏提示(含踢出原因)
- 提供"重新连接"和"返回主页"两个选项
- 被踢后语音立即停止、会话清理
-
重连机制
- 自动重连:2s → 4s → 8s → 16s → 30s
- 手动重连:立即尝试连接
- 放弃重连:停止重连,返回主页
- 重连成功后执行全量同步
-
会话清理
- 断开后频道列表清空
- 断开后客户端列表清空
- 断开后消息历史清空
- 断开后语音停止
- 无旧数据污染新连接
-
状态指示
- 头部连接状态指示正确显示
- 未连接时显示灰色"未连接"
- 连接中显示橙色"连接中"
- 已连接显示绿色"已连接"
- 重连中显示橙色"重连中"
错误处理验收
| 错误场景 | 预期行为 |
|---|---|
| 主动断开时 SDK 报错 | 忽略错误,强制清理会话 |
| 重连时密码错误 | 停止重连,显示错误,返回主页 |
| 重连时服务器满 | 继续重试直到最大次数 |
| 重连时网络仍不可用 | 继续重试直到最大次数 |
| 被踢后重连再次被踢 | 显示被踢提示,不自动重连 |
| 清理会话时部分失败 | 继续清理其他部分,记录日志 |
测试用例
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 主动断开 | 服务器详情卡 → 断开服务器 → 确认 | 断开,返回主页 |
| 主动断开取消 | 服务器详情卡 → 断开服务器 → 取消 | 不断开,卡片关闭 |
| 网络异常 | 断开网络 | 显示重连横幅,自动重连 |
| 重连成功 | 网络恢复 | 重连成功,横幅消失,全量同步 |
| 重连失败 | 持续断网 5 次 | 停止重连,显示放弃提示 |
| 手动重连 | 点击"手动重连" | 立即尝试连接 |
| 放弃重连 | 点击"放弃" | 停止重连,返回主页 |
| 被频道踢 | 被管理员踢出频道 | 全屏提示,可重连或返回 |
| 被服务器踢 | 被管理员踢出服务器 | 全屏提示,可重连或返回 |
| 被踢重连 | 被踢后点击"重新连接" | 使用相同参数重新连接 |
| 被踢返回 | 被踢后点击"返回主页" | 返回服务器配置页 |
| 语音停止 | 发言中断开连接 | 语音立即停止 |
| 数据清理 | 断开后重新连接 | 新连接无旧数据 |
| 状态指示 | 观察头部状态变化 | 颜色和文字正确 |
七、参考文档
docs/流程/07_断开连接.md- 时序图、状态树、事件依赖docs/UI架构设计.md- 3.1 服务器详情卡、5.3 被踢处理、5.4 断线重连docs/sdk文档-go.md- 1. 连接管理(Disconnect)、2. 事件注册(OnDisconnected、OnKicked)docs/implementation/02_Bridge层实现.md- TSBridge.disconnect、Callbacks 接口docs/implementation/08_语音通信.md- VoiceViewModel.stopVoice、onDisconnectedandroid/app/src/main/java/com/tsmobile/app/TSBridge.kt- Bridge 层实现