26 KiB
26 KiB
步骤 06:频道切换
实现频道切换流程:频道点击处理、密码弹窗、ClientMove 命令发送、等待 OnClientMoved 服务端确认、自身频道状态更新。 对应流程:
docs/流程/03_切换频道.md依赖步骤:05(频道列表页)
一、目标
- 频道点击事件处理(区分有密码/无密码频道)
- 密码输入弹窗组件
- ChannelSwitchState 状态机(idle → requesting → waitingServerEvent → idle/failed)
- ClientMove 命令发送(通过 TSBridge.MoveToChannel)
- 等待 OnClientMoved 服务端事实确认
- 自身频道状态更新(④ 自身状态同步)
- 错误处理与用户反馈
二、任务清单
6.1 频道点击处理
目标:在频道列表页中处理频道点击事件,区分有密码和无密码频道。
前置条件:
- 步骤 05 的 ChannelListScreen 已实现
- ChannelRow 组件已支持点击事件
任务:
-
频道点击入口
// ChannelListScreen.kt - ChannelTreeContent 中的 onChannelClick 回调 @Composable fun ChannelTreeContent( channelViewModel: ChannelViewModel, onChannelClick: (ChannelInfo) -> Unit, onClientClick: (ClientInfo) -> Unit, onNavigateToChat: () -> Unit ) { // ... 已有实现 ... } -
频道点击逻辑(ChannelViewModel)
// ChannelViewModel.kt // 密码弹窗状态 private val _showPasswordDialog = MutableStateFlow(false) val showPasswordDialog: StateFlow<Boolean> = _showPasswordDialog // 待切换的目标频道 private val _pendingSwitchChannel = MutableStateFlow<ChannelInfo?>(null) val pendingSwitchChannel: StateFlow<ChannelInfo?> = _pendingSwitchChannel /** * 处理频道点击事件 * 对应 UI架构设计.md 频道树交互规则 */ fun onChannelClicked(channel: ChannelInfo) { // 如果是当前频道,忽略 if (channel.id == repository.selfChannelId.value) { Log.d(TAG, "Already in channel ${channel.id}, ignoring click") return } // 检查是否正在切换中 if (_switchState.value != ChannelSwitchState.Idle) { Log.w(TAG, "Channel switch already in progress, ignoring click") return } if (channel.isPassword) { // 有密码频道:弹出密码输入框 _pendingSwitchChannel.value = channel _showPasswordDialog.value = true } else { // 无密码频道:直接发起切换 viewModelScope.launch { performChannelSwitch(channel.id, "") } } }
6.2 密码弹窗组件
目标:实现频道密码输入弹窗,支持密码错误重试。
任务:
-
密码弹窗 Composable
// ui/components/ChannelPasswordDialog.kt @Composable fun ChannelPasswordDialog( channelName: String, onConfirm: (password: String) -> Unit, onDismiss: () -> Unit, isError: Boolean = false, errorMessage: String = "密码错误,请重试" ) { var password by remember { mutableStateOf("") } var showError by remember { mutableStateOf(isError) } // 当 isError 变化时更新本地状态 LaunchedEffect(isError) { showError = isError if (isError) { password = "" // 清空输入框 } } AlertDialog( onDismissRequest = onDismiss, title = { Text( text = "该频道需要密码", style = MaterialTheme.typography.titleMedium ) }, text = { Column { Text( text = "频道:$channelName", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = password, onValueChange = { password = it showError = false }, label = { Text("输入频道密码") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), isError = showError, supportingText = if (showError) { { Text(errorMessage, color = MaterialTheme.colorScheme.error) } } else null, modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { TextButton( onClick = { onConfirm(password) }, enabled = password.isNotEmpty() ) { Text("进入") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } ) } -
在 ChannelListScreen 中集成密码弹窗
// ChannelListScreen.kt @Composable fun ChannelListScreen( channelViewModel: ChannelViewModel, serverViewModel: ServerViewModel, voiceViewModel: VoiceViewModel, onNavigateToChat: () -> Unit, onNavigateToServerConfig: () -> Unit, onOpenServerDetail: () -> Unit, onOpenChannelDetail: (channelId: Long) -> Unit, onOpenVoiceCard: () -> Unit ) { val showPasswordDialog by channelViewModel.showPasswordDialog.collectAsState() val pendingChannel by channelViewModel.pendingSwitchChannel.collectAsState() val switchState by channelViewModel.switchState.collectAsState() Column(modifier = Modifier.fillMaxSize()) { // ... 已有布局 ... } // 密码弹窗 if (showPasswordDialog && pendingChannel != null) { ChannelPasswordDialog( channelName = pendingChannel!!.name, onConfirm = { password -> channelViewModel.confirmPasswordAndSwitch(password) }, onDismiss = { channelViewModel.dismissPasswordDialog() }, isError = switchState is ChannelSwitchState.Failed, errorMessage = (switchState as? ChannelSwitchState.Failed)?.error ?: "密码错误,请重试" ) } } -
密码确认与取消逻辑
// ChannelViewModel.kt /** * 用户确认密码,发起切换 */ fun confirmPasswordAndSwitch(password: String) { val channel = _pendingSwitchChannel.value ?: return _showPasswordDialog.value = false viewModelScope.launch { performChannelSwitch(channel.id, password) } } /** * 用户取消密码输入 */ fun dismissPasswordDialog() { _showPasswordDialog.value = false _pendingSwitchChannel.value = null _switchState.value = ChannelSwitchState.Idle }
6.3 ChannelSwitchState 状态机
目标:实现频道切换的状态管理,确保命令响应与事件事实分离。
状态定义:
// data/Models.kt 或 ChannelViewModel.kt
/**
* 频道切换状态机
* 对应 docs/流程/03_切换频道.md 中的状态转换
*
* 状态转换:
* Idle → Requesting:用户发起切换请求
* Requesting → WaitingServerEvent:ClientMove 命令成功
* Requesting → Failed:ClientMove 命令失败
* WaitingServerEvent → Idle:收到自己的 OnClientMoved 事件
* Failed → Idle:用户重试或取消
*/
sealed class ChannelSwitchState {
/** 空闲状态,可以发起新的切换 */
object Idle : ChannelSwitchState()
/** 正在发送 ClientMove 命令 */
object Requesting : ChannelSwitchState()
/** ClientMove 命令成功,等待服务端 OnClientMoved 事件确认 */
data class WaitingServerEvent(val targetChannelId: Long) : ChannelSwitchState()
/** 切换失败(命令被拒绝或超时) */
data class Failed(val error: String) : ChannelSwitchState()
}
状态机实现:
// ChannelViewModel.kt
// 切换状态
private val _switchState = MutableStateFlow<ChannelSwitchState>(ChannelSwitchState.Idle)
val switchState: StateFlow<ChannelSwitchState> = _switchState
// 等待服务端确认的超时 Job
private var switchTimeoutJob: Job? = null
/**
* 执行频道切换
* 对应 docs/流程/03_切换频道.md 时序图
*/
private suspend fun performChannelSwitch(targetChannelId: Long, password: String) {
Log.d(TAG, "Requesting channel switch to $targetChannelId")
// 状态改为 requesting
_switchState.value = ChannelSwitchState.Requesting
try {
// 发送 ClientMove 命令
// TSBridge.MoveToChannel 返回空串表示成功,否则返回错误信息
val error = TSBridge.moveSelfToChannel(targetChannelId.toString(), password)
if (error.isEmpty()) {
// 命令成功,等待服务端事件确认
_switchState.value = ChannelSwitchState.WaitingServerEvent(targetChannelId)
// 启动超时检测(10秒)
switchTimeoutJob?.cancel()
switchTimeoutJob = viewModelScope.launch {
delay(10_000)
// 超时:如果还在等待状态,视为失败
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
Log.w(TAG, "Channel switch timeout waiting for server event")
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
}
}
Log.d(TAG, "ClientMove command accepted, waiting for server event")
} else {
// 命令被拒绝
Log.w(TAG, "ClientMove command rejected: $error")
_switchState.value = ChannelSwitchState.Failed(mapMoveError(error))
}
} catch (e: Exception) {
Log.e(TAG, "ClientMove command failed", e)
_switchState.value = ChannelSwitchState.Failed("切换失败:${e.message}")
}
}
/**
* 映射 MoveToChannel 错误信息为用户友好的提示
*/
private fun mapMoveError(error: String): String {
return when {
error.contains("password", ignoreCase = true) -> "密码错误"
error.contains("permission", ignoreCase = true) -> "权限不足"
error.contains("full", ignoreCase = true) -> "频道已满"
error.contains("banned", ignoreCase = true) -> "你已被该频道封禁"
else -> "切换失败:$error"
}
}
6.4 自身状态同步(④)
目标:处理自己的 OnClientMoved 事件,确认频道切换完成。
关键原则:
- 命令响应(ClientMove nil)不等于状态已提交
- 必须等待服务端推送的 OnClientMoved 事件才能更新本地频道事实
- 通过比对 ClientID == selfID 识别自己的移动事件
任务:
-
处理 OnClientMoved 事件(区分自己和他人)
// ChannelViewModel.kt /** * 处理客户端移动事件 * 对应 docs/流程/08_状态同步.md ② 增量同步 + ④ 自身状态同步 * * @param clientId 移动的客户端 ID * @param targetChannelId 目标频道 ID(字符串形式) */ fun handleClientMoved(clientId: Int, targetChannelId: Long) { Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId") val selfId = repository.selfClientId.value if (clientId == selfId) { // ④ 自身状态同步:这是自己的移动事件 handleSelfMoved(targetChannelId) } else { // ② 增量同步:这是其他用户的移动事件 handleOtherClientMoved(clientId, targetChannelId) } } /** * 处理自己的移动事件 * 对应 docs/流程/03_切换频道.md 中等待 OnClientMoved 确认的分支 */ private fun handleSelfMoved(targetChannelId: Long) { val currentState = _switchState.value // 更新自身频道事实 repository.updateSelfChannel(targetChannelId) // 清除目标频道的未读标记 clearUnread(targetChannelId) when (currentState) { is ChannelSwitchState.WaitingServerEvent -> { // 正常流程:确认切换完成 Log.d(TAG, "Channel switch confirmed by server: target=$targetChannelId") switchTimeoutJob?.cancel() _switchState.value = ChannelSwitchState.Idle _pendingSwitchChannel.value = null } is ChannelSwitchState.Requesting -> { // 罕见情况:事件先于命令响应到达 Log.d(TAG, "Server event arrived before command response") switchTimeoutJob?.cancel() _switchState.value = ChannelSwitchState.Idle _pendingSwitchChannel.value = null } else -> { // 非切换流程中的移动(例如被管理员移动) Log.d(TAG, "Self moved by external action to channel $targetChannelId") _switchState.value = ChannelSwitchState.Idle } } } /** * 处理其他用户的移动事件(增量同步) */ private fun handleOtherClientMoved(clientId: Int, targetChannelId: Long) { val existingClient = repository.getClientById(clientId) if (existingClient != null) { // 成员存在:更新频道位置 repository.updateClientChannel(clientId, targetChannelId) } else { // 成员不存在:触发补偿同步 Log.w(TAG, "Unknown client $clientId, triggering compensation sync") viewModelScope.launch { compensateClientList() } } } -
切换超时处理
// ChannelViewModel.kt /** * 重试频道切换(失败后) */ fun retryChannelSwitch() { val channel = _pendingSwitchChannel.value ?: return _switchState.value = ChannelSwitchState.Idle viewModelScope.launch { performChannelSwitch(channel.id, "") } } /** * 取消频道切换 */ fun cancelChannelSwitch() { switchTimeoutJob?.cancel() _switchState.value = ChannelSwitchState.Idle _pendingSwitchChannel.value = null _showPasswordDialog.value = false }
6.5 切换状态 UI 反馈
目标:在频道列表页显示切换状态,提供用户反馈。
任务:
-
切换中指示器
// ui/components/SwitchingIndicator.kt @Composable fun ChannelSwitchingIndicator( state: ChannelSwitchState, onRetry: () -> Unit, onCancel: () -> Unit ) { when (state) { is ChannelSwitchState.Requesting -> { LinearProgressIndicator( modifier = Modifier.fillMaxWidth() ) } is ChannelSwitchState.WaitingServerEvent -> { LinearProgressIndicator( modifier = Modifier.fillMaxWidth() ) } is ChannelSwitchState.Failed -> { Snackbar( action = { TextButton(onClick = onRetry) { Text("重试") } TextButton(onClick = onCancel) { Text("取消") } } ) { Text(state.error) } } else -> { /* Idle: 不显示任何指示 */ } } } -
在 ChannelListScreen 中集成
// ChannelListScreen.kt @Composable fun ChannelListScreen( // ... 参数 ... ) { val switchState by channelViewModel.switchState.collectAsState() Column(modifier = Modifier.fillMaxSize()) { // 头部 ChannelListHeader(/* ... */) // 切换状态指示器 if (switchState != ChannelSwitchState.Idle) { ChannelSwitchingIndicator( state = switchState, onRetry = { channelViewModel.retryChannelSwitch() }, onCancel = { channelViewModel.cancelChannelSwitch() } ) } // 中部:频道树 Box(modifier = Modifier.weight(1f)) { // ... 已有实现 ... } // ... 其余布局 ... } }
6.6 事件处理器注册
目标:确保 ChannelViewModel 的事件处理方法被 ServerViewModel 正确调用。
任务:
// ServerViewModel.kt - 在 registerEventHandlers 中添加
fun registerEventHandlers() {
TSBridge.setCallbacks(object : TSBridge.Callbacks {
// ... 已有回调 ...
override fun onClientMoved(id: Int, targetChannelID: String) {
val targetId = targetChannelID.toLongOrNull() ?: return
channelViewModel.handleClientMoved(id, targetId)
}
// ... 其他回调 ...
})
}
三、状态与数据流
3.1 频道切换状态机
┌──────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄───────────────────────────────┤
└────┬────┘ │
│ 用户点击频道 │
▼ │
┌─────────────┐ │
│ Requesting │ │
└──────┬──────┘ │
│ │
┌───────────┴───────────┐ │
│ │ │
▼ ▼ │
┌───────────┐ ┌──────────┐ │
│ Failed │ │ Waiting │ │
│ │ │ Server │ │
└─────┬─────┘ │ Event │ │
│ └────┬─────┘ │
│ │ │
│ ┌─────────────────┤ │
│ │ │ │
│ ▼ ▼ │
│ 超时 OnClientMoved │
│ │ (selfID match) │
│ │ │ │
└───┴─────────────────┴─────────────────────────┘
3.2 数据流向
用户操作 ChannelViewModel TSBridge/Go 服务端
│ │ │ │
│ 点击频道 │ │ │
├───────────────────→│ │ │
│ │ │ │
│ │ 有密码? │ │
│ ├─→ 显示密码弹窗 │ │
│ 输入密码 │ │ │
├───────────────────→│ │ │
│ │ │ │
│ │ MoveToChannel(id, pwd) │ │
│ ├──────────────────────────→│ clientmove │
│ │ ├─────────────────→│
│ │ │ │
│ │ │ 命令响应 │
│ │ │←─────────────────┤
│ │ error == "" ? │ │
│ │←──────────────────────────┤ │
│ │ │ │
│ │ 状态 → WaitingServerEvent │ │
│ │ │ │
│ │ │ notifyclientmoved│
│ │ │←─────────────────┤
│ │ OnClientMoved(selfID) │ │
│ │←──────────────────────────┤ │
│ │ │ │
│ │ 更新自身频道 │ │
│ │ 状态 → Idle │ │
│ │ │ │
3.3 命令响应与事件事实的区分
关键原则(对应 docs/流程/03_切换频道.md):
| 概念 | 含义 | 处理方式 |
|---|---|---|
| ClientMove 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed |
| ClientMove 返回 nil | 命令被服务器接受 | 状态 → WaitingServerEvent,继续等待 |
| OnClientMoved(selfID) | 服务器确认移动完成 | 更新本地频道事实,状态 → Idle |
为什么不能用命令响应直接更新频道?
- 命令响应只表示服务器接受了请求
- 实际移动可能因权限、密码、容量等原因被延迟拒绝
- 只有服务端推送的
notifyclientmoved事件才是最终事实
四、验收标准
功能验收
-
无密码频道切换
- 点击无密码频道 → 直接发送 ClientMove
- 显示切换中进度指示
- 收到 OnClientMoved 后切换完成
- 当前频道栏更新为目标频道
-
有密码频道切换
- 点击有密码频道 → 弹出密码输入框
- 输入密码后发送 ClientMove
- 密码错误 → 显示错误提示,清空输入框,允许重试
- 点击取消 → 关闭弹窗,不发送命令
-
切换状态管理
- 切换中禁止发起新的切换
- 切换超时(10秒)显示失败提示
- 失败后可重试或取消
- 被管理员移动时正确更新状态
-
自身状态同步
- 只有匹配 selfID 的 OnClientMoved 才更新自身频道
- 命令响应不直接提交频道事实
- 切换完成后清除目标频道的未读标记
错误处理验收
| 错误场景 | 预期行为 |
|---|---|
| 密码错误 | 弹窗显示错误,清空输入框 |
| 频道已满 | Snackbar 提示"频道已满" |
| 权限不足 | Snackbar 提示"权限不足" |
| 网络超时 | 10秒后显示超时提示,可重试 |
| 被管理员移动 | 静默更新当前频道 |
性能验收
- 切换响应时间 < 100ms(UI 反馈)
- 服务端确认时间 < 3s(正常网络)
- 密码弹窗弹出/关闭动画流畅
测试用例
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 无密码切换 | 点击无密码频道 | 进度条 → 切换完成 → 当前频道更新 |
| 有密码切换 | 点击有密码频道 → 输入密码 → 点击进入 | 密码弹窗 → 进度条 → 切换完成 |
| 密码错误 | 输入错误密码 | 弹窗显示错误,清空输入框 |
| 取消密码 | 点击取消 | 弹窗关闭,无网络请求 |
| 切换超时 | 断网后切换 | 10秒后显示超时提示 |
| 重试切换 | 失败后点击重试 | 重新发送 ClientMove |
| 被管理员移动 | 管理员移动你到其他频道 | 当前频道静默更新 |
| 重复点击 | 快速点击多个频道 | 只处理第一次点击 |
| 切换中点击 | 切换进行中点击其他频道 | 忽略点击 |
五、参考文档
docs/流程/03_切换频道.md- 时序图、状态机、事件依赖docs/流程/08_状态同步.md- ④ 自身状态同步docs/UI架构设计.md- 2.2 频道列表页交互、4.2 密码弹窗docs/sdk文档-go.md- ClientMove API、OnClientMoved 事件docs/implementation/02_Bridge层实现.md- MoveToChannel、onClientMoved 回调docs/implementation/05_频道列表页.md- ChannelViewModel、ChannelListScreen