Files

1218 lines
45 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 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 层实现