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

1218 lines
45 KiB
Markdown
Raw 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.
# 步骤 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)
→ 清理会话状态
→ 导航回服务器配置页
```
**任务**
1. **断开确认对话框**
在服务器详情卡中,点击"断开服务器"按钮时弹出确认对话框:
```kotlin
// 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 主动断开方法**
```kotlin
// 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. **服务器详情卡集成**
```kotlin
// 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 次 | 停止 | 显示放弃提示 |
**任务**
1. **连接状态密封类**
```kotlin
// 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 被动断开处理**
```kotlin
// 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**
```kotlin
// 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` 中识别出自己被踢,上层无需再次判断。
**任务**
1. **ServerViewModel 被踢处理**
```kotlin
// 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**
```kotlin
// 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 会话清理**
```kotlin
// 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 清理入口**
```kotlin
// 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 中观察连接状态,处理导航:
```kotlin
// 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 头部右侧连接状态指示:
```kotlin
// 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 接口
**已有回调定义**
```kotlin
// 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)
}
```
**任务**
```kotlin
// 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 层实现