18 KiB
18 KiB
步骤 04:连接与首次同步
实现完整的连接流程:Identity 管理、Connect、WaitConnected、首次同步。
一、目标
- Identity 生成与持久化
- ClientOption 组装
- 事件处理器注册
- Connect + WaitConnected 流程
- 首次同步(ListChannels + ListClients + ClientID)
- 连接状态与错误处理
二、任务清单
4.1 Identity 管理
目标:实现 TeamSpeak 加密身份的生成与持久化存储。
任务:
-
生成 Identity
// Go 侧通过 Bridge 暴露生成接口 // Kotlin 侧调用生成并获取 Identity 字符串 val identity = TSBridge.generateIdentity() -
持久化存储
- 使用
SharedPreferences或DataStore存储 Identity - Key 建议:
ts_identity - 首次启动时生成,后续启动时读取
- 使用
-
读取与恢复
fun loadOrCreateIdentity(context: Context): String { val prefs = context.getSharedPreferences("ts_config", Context.MODE_PRIVATE) return prefs.getString("ts_identity", null) ?: TSBridge.generateIdentity().also { prefs.edit().putString("ts_identity", it).apply() } }
注意事项:
- Identity 是客户端加密身份,必须持久化,否则每次连接会被服务器视为新用户
- 生成后不可更改,丢失需重新生成(会丢失服务器端的权限关联)
4.2 连接流程实现
目标:实现完整的连接流程,从配置组装到连接成功。
任务:
-
连接参数数据类
data class ConnectionConfig( val address: String, // 服务器地址(IP/域名/TSDNS) val nickname: String, // 显示昵称 val password: String? = null, // 服务器密码(可选) val defaultChannel: String? = null, // 默认频道(可选) val defaultChannelPassword: String? = null // 默认频道密码(可选) ) -
Bridge 层连接接口封装
// TSBridge.kt 中新增 fun connect(config: ConnectionConfig): Result<Unit> { return try { val identity = loadOrCreateIdentity(context) // 调用 Go 侧 NewClient + Connect tsClient.newClient(identity, config.address, config.nickname) if (config.password != null) { tsClient.setServerPassword(config.password) } if (config.defaultChannel != null) { tsClient.setDefaultChannel(config.defaultChannel) if (config.defaultChannelPassword != null) { tsClient.setDefaultChannelPassword(config.defaultChannelPassword) } } tsClient.connect() Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } -
WaitConnected 实现
suspend fun waitConnected(timeout: Duration = 30.seconds): Result<Unit> { return withContext(Dispatchers.IO) { try { // Go 侧阻塞等待,支持 context 取消 tsClient.waitConnected(timeout.inWholeMilliseconds) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } } -
完整连接流程
// ServerViewModel.kt suspend fun connect(config: ConnectionConfig) { _connectionState.value = ConnectionState.Connecting // 1. 注册事件处理器(Connect 前必须完成) registerEventHandlers() // 2. 发起连接 val connectResult = TSBridge.connect(config) if (connectResult.isFailure) { _connectionState.value = ConnectionState.Failed(connectResult.exceptionOrNull()!!) return } // 3. 等待连接就绪 val waitResult = TSBridge.waitConnected() if (waitResult.isFailure) { _connectionState.value = ConnectionState.Failed(waitResult.exceptionOrNull()!!) return } // 4. 连接成功,等待 OnConnected 事件触发首次同步 }
连接状态枚举:
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState() // WaitConnected 成功
object Syncing : ConnectionState() // 首次同步中
object Ready : ConnectionState() // 业务就绪
data class Failed(val error: Throwable) : ConnectionState()
}
时序约束:
Connect()成功只表示连接流程已启动- 发送业务命令前必须等待
WaitConnected()成功 - 事件处理器必须在
Connect()前注册,避免早期事件丢失
4.3 事件注册
目标:在 Connect 前注册所有必要的事件处理器,确保不遗漏早期服务端推送。
任务:
-
事件处理器注册(Go Bridge 侧)
// bridge.go 中暴露注册接口 func (b *Bridge) RegisterEventHandlers() { b.client.OnConnected(func() { b.notifyEvent("connected", nil) }) b.client.OnDisconnected(func(err error) { b.notifyEvent("disconnected", map[string]interface{}{ "error": err.Error(), }) }) b.client.OnClientEnter(func(info ClientInfo) { data, _ := json.Marshal(info) b.notifyEvent("client_enter", string(data)) }) b.client.OnClientLeave(func(event ClientLeftViewEvent) { data, _ := json.Marshal(event) b.notifyEvent("client_leave", string(data)) }) b.client.OnClientMoved(func(event ClientMovedEvent) { data, _ := json.Marshal(event) b.notifyEvent("client_moved", string(data)) }) b.client.OnTextMessage(func(msg TextMessage) { data, _ := json.Marshal(msg) b.notifyEvent("text_message", string(data)) }) b.client.OnPoked(func(event PokeEvent) { data, _ := json.Marshal(event) b.notifyEvent("poked", string(data)) }) b.client.OnKicked(func(reason string) { b.notifyEvent("kicked", reason) }) } -
Kotlin 侧事件监听
// TSBridge.kt fun setEventListener(listener: (String, String) -> Unit) { // 接收 Go 侧通过 JNI 回调的事件 eventCallback = listener } // ServerViewModel.kt fun registerEventHandlers() { TSBridge.setEventListener { event, data -> when (event) { "connected" -> handleConnected() "disconnected" -> handleDisconnected(data) "client_enter" -> handleClientEnter(data) "client_leave" -> handleClientLeave(data) "client_moved" -> handleClientMoved(data) "text_message" -> handleTextMessage(data) "poked" -> handlePoked(data) "kicked" -> handleKicked(data) } } } -
事件处理器职责
事件 处理器 职责 connectedhandleConnected()触发首次同步流程 disconnectedhandleDisconnected()清理会话状态,更新 UI client_enterhandleClientEnter()增量同步:添加成员到基线 client_leavehandleClientLeave()增量同步:从基线移除成员 client_movedhandleClientMoved()增量同步:更新成员频道位置 text_messagehandleTextMessage()消息归档:按 TargetMode 存储 pokedhandlePoked()显示 Poke 通知 kickedhandleKicked()处理踢出,清理状态
注意事项:
- 事件处理器必须在
Connect()之前注册 - 事件回调在 Go 的事件循环 goroutine 中串行执行,不要做耗时操作
- 需要通过事件队列串行化 JNI 回调,避免并发问题
4.4 首次同步逻辑
目标:连接成功后,建立完整的频道基线和成员基线。
触发时机:收到 OnConnected 事件后立即执行。
任务:
-
并行请求三个数据源
// ServerViewModel.kt private suspend fun performInitialSync() { _connectionState.value = ConnectionState.Syncing try { // 并行请求频道列表、成员列表、自身 ID val channelsDeferred = async { TSBridge.listChannels() } val clientsDeferred = async { TSBridge.listClients() } val selfIdDeferred = async { TSBridge.getClientId() } val channels = channelsDeferred.await() val clients = clientsDeferred.await() val selfId = selfIdDeferred.await() // 原子提交到状态仓库 repository.updateBaseline( channels = channels, clients = clients, selfClientId = selfId ) _connectionState.value = ConnectionState.Ready } catch (e: Exception) { _connectionState.value = ConnectionState.SyncFailed(e) // 允许重试,不进入业务就绪 } } -
数据模型定义
// data/Models.kt data class ChannelInfo( val id: Long, val parentId: Long, val name: String, val order: Long = 0, val isPassword: Boolean = false, val isPermanent: Boolean = false, val maxClients: Int = -1 ) data class ClientInfo( val id: Int, val nickname: String, val channelId: Long, val uid: String, val type: Int = 0, val serverGroups: List<String> = emptyList() ) -
状态仓库实现
// data/Repository.kt class ChannelRepository { private val _channels = MutableStateFlow<List<ChannelInfo>>(emptyList()) val channels: StateFlow<List<ChannelInfo>> = _channels private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList()) val clients: StateFlow<List<ClientInfo>> = _clients private val _selfClientId = MutableStateFlow<Int?>(null) val selfClientId: StateFlow<Int?> = _selfClientId // 按频道 ID 索引的成员列表 private val _channelClients = MutableStateFlow<Map<Long, List<ClientInfo>>>(emptyMap()) val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = _channelClients fun updateBaseline(channels: List<ChannelInfo>, clients: List<ClientInfo>, selfClientId: Int) { _channels.value = channels _clients.value = clients _selfClientId.value = selfClientId // 建立频道-成员索引 _channelClients.value = clients.groupBy { it.channelId } } } -
Bridge 层查询(通过 TsClient 封装)
// TSBridge.kt — 直接返回 Kotlin 友好类型,无需 JSON 解析 fun getChannelList(): List<TsChannel> = TsClient.getChannelList() fun getClientList(): List<TsClientInfo> = TsClient.getClientList() fun getClientId(): Long = TsClient.getClientId()
同步状态机:
sealed class SyncState {
object Unsynced : SyncState() // 已连接但尚无完整数据
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
object Synchronized : SyncState() // 列表基线可供 UI 使用
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
}
原子提交原则:
- 频道列表、成员列表、自身 ID 必须全部成功才能提交
- 任一失败则不进入业务就绪状态
- 允许重试,避免在不完整数据上执行业务操作
4.5 错误处理与状态反馈
目标:实现完整的错误处理机制,确保用户能获得清晰的状态反馈。
任务:
-
连接错误分类
sealed class ConnectionError : Exception() { object InvalidAddress : ConnectionError() // 地址解析失败 object AuthenticationFailed : ConnectionError() // 密码错误 object ServerFull : ConnectionError() // 服务器满员 object Banned : ConnectionError() // 被封禁 object NetworkError : ConnectionError() // 网络问题 object Timeout : ConnectionError() // 连接超时 data class Other(val message: String) : ConnectionError() } -
错误映射与处理
fun mapConnectionError(error: Exception): ConnectionError { val message = error.message?.lowercase() ?: "" return when { message.contains("resolve") || message.contains("address") -> ConnectionError.InvalidAddress message.contains("password") || message.contains("auth") -> ConnectionError.AuthenticationFailed message.contains("full") || message.contains("limit") -> ConnectionError.ServerFull message.contains("ban") -> ConnectionError.Banned message.contains("timeout") -> ConnectionError.Timeout message.contains("network") || message.contains("connection") -> ConnectionError.NetworkError else -> ConnectionError.Other(error.message ?: "Unknown error") } } -
状态反馈 UI
@Composable fun ConnectionStatusIndicator(state: ConnectionState) { when (state) { ConnectionState.Disconnected -> { Text("未连接", color = MaterialTheme.colorScheme.onSurfaceVariant) } ConnectionState.Connecting -> { CircularProgressIndicator(modifier = Modifier.size(24.dp)) Text("正在连接...") } ConnectionState.Connected -> { CircularProgressIndicator(modifier = Modifier.size(24.dp)) Text("已连接,正在同步...") } ConnectionState.Syncing -> { CircularProgressIndicator(modifier = Modifier.size(24.dp)) Text("正在同步数据...") } ConnectionState.Ready -> { Icon(Icons.Default.CheckCircle, tint = Color.Green) Text("就绪") } is ConnectionState.Failed -> { Icon(Icons.Default.Error, tint = Color.Red) Text("连接失败: ${state.error.getLocalizedMessage()}", color = MaterialTheme.colorScheme.error) Button(onClick = { /* 重试 */ }) { Text("重试") } } } } -
同步失败重试机制
// ServerViewModel.kt private suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) { var retryCount = 0 while (retryCount < maxRetries) { try { performInitialSync() return // 成功则退出 } catch (e: Exception) { retryCount++ if (retryCount >= maxRetries) { _connectionState.value = ConnectionState.SyncFailed(e) return } // 等待后重试 delay(1000L * retryCount) } } } -
断开连接清理
fun disconnect() { // 1. 停止语音(如有) voiceViewModel.stopVoice() // 2. 清理会话状态 repository.clearSession() // 3. 调用 SDK 断开 TSBridge.disconnect() // 4. 更新状态 _connectionState.value = ConnectionState.Disconnected }
错误日志记录:
private fun logConnectionError(error: ConnectionError) {
Log.e(TAG, "Connection error: ${error::class.simpleName}", error)
// 可选:上报到崩溃分析服务
}
三、验收标准
功能验收
-
Identity 持久化
- 首次启动自动生成 Identity 并存储
- 后续启动读取已有 Identity,不重复生成
- 清除应用数据后能重新生成
-
连接流程
- 输入有效地址、昵称后能成功连接服务器
- 输入错误密码时显示明确错误提示
- 连接超时时(30秒)显示超时错误
- 无网络时显示网络错误
-
首次同步
- 连接成功后自动执行首次同步
- 频道列表正确显示(包含所有频道)
- 成员列表正确显示(包含所有在线用户)
- 自己的客户端 ID 正确识别
-
状态反馈
- 连接过程中显示加载状态
- 同步过程中显示同步状态
- 就绪后显示就绪状态
- 错误时显示错误信息和重试按钮
-
断开连接
- 主动断开后状态正确重置
- 被动断开(网络中断)能检测并提示
- 被踢出时显示踢出原因
性能验收
- 首次同步在 3 秒内完成(标准服务器,< 100 频道,< 500 用户)
- 连接建立时间 < 5 秒(正常网络环境)
代码质量验收
- 所有网络操作在 IO 线程执行
- 事件回调通过事件队列串行化
- 无内存泄漏(正确取消协程)
- 错误处理覆盖所有已知异常场景
测试用例
| 场景 | 输入 | 预期结果 |
|---|---|---|
| 正常连接 | 有效地址、昵称、无密码 | 连接成功,频道列表显示 |
| 密码保护服务器 | 有效地址、昵称、正确密码 | 连接成功 |
| 错误密码 | 有效地址、昵称、错误密码 | 显示"密码错误"提示 |
| 无效地址 | 无效地址 | 显示"地址解析失败"提示 |
| 网络断开 | 断开网络后连接 | 显示"网络错误"提示 |
| 服务器满员 | 满员服务器 | 显示"服务器已满"提示 |
| 被封禁 | 被封禁的 UID | 显示"已被封禁"提示 |
| 连接超时 | 阻断 UDP 30 秒 | 显示"连接超时"提示 |
| 断开重连 | 断开后重新连接 | 状态正确重置,可重新连接 |
四、参考文档
docs/流程/01_连接服务器.md- 完整生命周期、时序图docs/流程/08_状态同步.md- ① 首次同步docs/sdk文档-go.md- 连接管理 API