Files
ts-mobile-go/docs/implementation/04_连接与首次同步.md
2026-07-20 19:01:03 +08:00

18 KiB
Raw Permalink Blame History

步骤 04:连接与首次同步

实现完整的连接流程:Identity 管理、Connect、WaitConnected、首次同步。


一、目标

  • Identity 生成与持久化
  • ClientOption 组装
  • 事件处理器注册
  • Connect + WaitConnected 流程
  • 首次同步(ListChannels + ListClients + ClientID
  • 连接状态与错误处理

二、任务清单

4.1 Identity 管理

目标:实现 TeamSpeak 加密身份的生成与持久化存储。

任务

  1. 生成 Identity

    // Go 侧通过 Bridge 暴露生成接口
    // Kotlin 侧调用生成并获取 Identity 字符串
    val identity = TSBridge.generateIdentity()
    
  2. 持久化存储

    • 使用 SharedPreferencesDataStore 存储 Identity
    • Key 建议:ts_identity
    • 首次启动时生成,后续启动时读取
  3. 读取与恢复

    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 连接流程实现

目标:实现完整的连接流程,从配置组装到连接成功。

任务

  1. 连接参数数据类

    data class ConnectionConfig(
        val address: String,        // 服务器地址(IP/域名/TSDNS
        val nickname: String,       // 显示昵称
        val password: String? = null,       // 服务器密码(可选)
        val defaultChannel: String? = null, // 默认频道(可选)
        val defaultChannelPassword: String? = null // 默认频道密码(可选)
    )
    
  2. 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)
        }
    }
    
  3. 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)
            }
        }
    }
    
  4. 完整连接流程

    // 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 前注册所有必要的事件处理器,确保不遗漏早期服务端推送。

任务

  1. 事件处理器注册(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)
        })
    }
    
  2. 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)
            }
        }
    }
    
  3. 事件处理器职责

    事件 处理器 职责
    connected handleConnected() 触发首次同步流程
    disconnected handleDisconnected() 清理会话状态,更新 UI
    client_enter handleClientEnter() 增量同步:添加成员到基线
    client_leave handleClientLeave() 增量同步:从基线移除成员
    client_moved handleClientMoved() 增量同步:更新成员频道位置
    text_message handleTextMessage() 消息归档:按 TargetMode 存储
    poked handlePoked() 显示 Poke 通知
    kicked handleKicked() 处理踢出,清理状态

注意事项

  • 事件处理器必须在 Connect() 之前注册
  • 事件回调在 Go 的事件循环 goroutine 中串行执行,不要做耗时操作
  • 需要通过事件队列串行化 JNI 回调,避免并发问题

4.4 首次同步逻辑

目标:连接成功后,建立完整的频道基线和成员基线。

触发时机:收到 OnConnected 事件后立即执行。

任务

  1. 并行请求三个数据源

    // 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)
            // 允许重试,不进入业务就绪
        }
    }
    
  2. 数据模型定义

    // 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()
    )
    
  3. 状态仓库实现

    // 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 }
        }
    }
    
  4. 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 错误处理与状态反馈

目标:实现完整的错误处理机制,确保用户能获得清晰的状态反馈。

任务

  1. 连接错误分类

    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()
    }
    
  2. 错误映射与处理

    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")
        }
    }
    
  3. 状态反馈 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("重试")
                }
            }
        }
    }
    
  4. 同步失败重试机制

    // 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)
            }
        }
    }
    
  5. 断开连接清理

    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