Files
ts-mobile-go/docs/implementation/06_频道切换.md
T
2026-07-20 19:01:03 +08:00

712 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 06:频道切换
> 实现频道切换流程:频道点击处理、密码弹窗、ClientMove 命令发送、等待 OnClientMoved 服务端确认、自身频道状态更新。
> 对应流程:`docs/流程/03_切换频道.md`
> 依赖步骤:05(频道列表页)
---
## 一、目标
- [ ] 频道点击事件处理(区分有密码/无密码频道)
- [ ] 密码输入弹窗组件
- [ ] ChannelSwitchState 状态机(idle → requesting → waitingServerEvent → idle/failed
- [ ] ClientMove 命令发送(通过 TSBridge.MoveToChannel
- [ ] 等待 OnClientMoved 服务端事实确认
- [ ] 自身频道状态更新(④ 自身状态同步)
- [ ] 错误处理与用户反馈
---
## 二、任务清单
### 6.1 频道点击处理
**目标**:在频道列表页中处理频道点击事件,区分有密码和无密码频道。
**前置条件**
- 步骤 05 的 ChannelListScreen 已实现
- ChannelRow 组件已支持点击事件
**任务**
1. **频道点击入口**
```kotlin
// ChannelListScreen.kt - ChannelTreeContent 中的 onChannelClick 回调
@Composable
fun ChannelTreeContent(
channelViewModel: ChannelViewModel,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onNavigateToChat: () -> Unit
) {
// ... 已有实现 ...
}
```
2. **频道点击逻辑(ChannelViewModel**
```kotlin
// 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 密码弹窗组件
**目标**:实现频道密码输入弹窗,支持密码错误重试。
**任务**
1. **密码弹窗 Composable**
```kotlin
// 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("取消")
}
}
)
}
```
2. **在 ChannelListScreen 中集成密码弹窗**
```kotlin
// 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 ?: "密码错误,请重试"
)
}
}
```
3. **密码确认与取消逻辑**
```kotlin
// 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 状态机
**目标**:实现频道切换的状态管理,确保命令响应与事件事实分离。
**状态定义**
```kotlin
// data/Models.kt 或 ChannelViewModel.kt
/**
* 频道切换状态机
* 对应 docs/流程/03_切换频道.md 中的状态转换
*
* 状态转换:
* Idle → Requesting:用户发起切换请求
* Requesting → WaitingServerEventClientMove 命令成功
* Requesting → FailedClientMove 命令失败
* 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()
}
```
**状态机实现**
```kotlin
// 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 识别自己的移动事件
**任务**
1. **处理 OnClientMoved 事件(区分自己和他人)**
```kotlin
// 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() }
}
}
```
2. **切换超时处理**
```kotlin
// 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 反馈
**目标**:在频道列表页显示切换状态,提供用户反馈。
**任务**
1. **切换中指示器**
```kotlin
// 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: 不显示任何指示 */ }
}
}
```
2. **在 ChannelListScreen 中集成**
```kotlin
// 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 正确调用。
**任务**
```kotlin
// 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秒后显示超时提示,可重试 |
| 被管理员移动 | 静默更新当前频道 |
### 性能验收
- [ ] 切换响应时间 < 100msUI 反馈)
- [ ] 服务端确认时间 < 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