Files
ts-mobile-go/docs/implementation/06_频道切换.md
T

712 lines
26 KiB
Markdown
Raw Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 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