1218 lines
45 KiB
Markdown
1218 lines
45 KiB
Markdown
# 步骤 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 层实现
|