Files
ts-mobile-go/docs/implementation/09_断开连接.md
T
2026-07-20 19:01:03 +08:00

45 KiB
Raw Blame History

步骤 09:断开连接

实现断开连接流程:主动断开、被动断开、被踢处理、会话清理。 对应流程:docs/流程/07_断开连接.md 依赖步骤:08(语音通信)


一、目标

  • 主动断开流程(Disconnect)— 服务器详情卡中触发
  • 被动断开处理(OnDisconnected)— 网络异常或服务端断开
  • 被踢处理(OnKicked)— 自己被频道踢或服务器踢
  • 会话状态清理 — 停止语音、清除频道/成员/Pending
  • 断线重连机制 — 自动重连 + 手动重连 + 放弃
  • 返回主页逻辑 — 清理后导航回服务器配置页

二、任务清单

9.1 主动断开

目标:实现用户主动断开连接的完整流程,从 UI 触发到会话清理。

对应流程docs/流程/07_断开连接.md 时序图 — 用户主动断开分支

对应 UI 设计docs/UI架构设计.md 3.1 服务器详情卡 — 断开服务器按钮

对应 SDKdocs/sdk文档-go.mdDisconnect() error

断开时序

用户点击 "断开服务器"
  → 弹出确认对话框 "确定要断开连接吗?"
  → 确认
  → ServerViewModel.disconnect()
    → 状态改为 Disconnecting(阻止新命令/语音/移动)
    → TSBridge.disconnect()
      → Go SDK Disconnect()
        → 发送 shutdown reason 到服务器
        → 服务器关闭会话
      → Go SDK 触发 OnDisconnected(nil)
    → 清理会话状态
    → 导航回服务器配置页

任务

  1. 断开确认对话框

    在服务器详情卡中,点击"断开服务器"按钮时弹出确认对话框:

    // 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("取消")
                }
            }
        )
    }
    
  2. 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 变化后执行)
    }
    
  3. 服务器详情卡集成

    // 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 断线重连

对应 SDKdocs/sdk文档-go.mdOnDisconnected(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 次 停止 显示放弃提示

任务

  1. 连接状态密封类

    // 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()
    }
    
  2. 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
        )
    }
    
  3. 重连横幅 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 被踢处理

对应 SDKdocs/sdk文档-go.mdOnKicked(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 中识别出自己被踢,上层无需再次判断。

任务

  1. 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")
    }
    
  2. 被踢全屏提示 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 污染下一次连接
  • 清理顺序:先停止活跃操作 → 再清除数据

任务

  1. 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")
    }
    
  2. 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(重连后) 横幅消失 → 全量同步 → 恢复正常
重连失败 超过最大重试次数 显示放弃提示 → 返回主页

任务

  1. 主页面断开状态观察

    在主 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 }
                        }
                    }
                )
            }
        }
    }
    
  2. 频道列表页断开状态指示

    对应 docs/UI架构设计.md 2.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、onDisconnected
  • android/app/src/main/java/com/tsmobile/app/TSBridge.kt - Bridge 层实现