Files

1081 lines
38 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 05:频道列表页
> 实现频道列表页,包括频道树渲染、成员显示、未读指示、当前频道栏和 ChannelViewModel。
---
## 一、目标
- [ ] ChannelListScreen 页面布局(头部 + 频道树 + 当前频道栏 + 底部语音控制)
- [ ] 频道树组件(展开/折叠、层级缩进、状态图标)
- [ ] 成员列表显示(按频道分组、在线状态)
- [ ] 当前频道栏(显示当前频道名、人数、成员预览)
- [ ] 底部语音控制区(基础:静音按钮 + PTT + 展开入口)
- [ ] 未读消息指示(红点、@提及 badge
- [ ] ChannelViewModel 实现(状态管理、增量同步、补偿同步)
---
## 二、任务清单
### 5.1 页面布局
**目标**:实现频道列表页的整体三段式布局。
**布局结构**
```
┌──────────────────────────────────────┐
│ 头部(三段) │
│ ┌────┐ ┌──────────────────┐ ┌─────┐ │
│ │ ☰ │ │ MyServer │ │ 🟢 │ │
│ │服务│ │ 192.168.1.1:9987 │ │连接 │ │ ← 连接状态指示
│ │器卡│ │ 42人在线 │ │状态 │ │
│ └────┘ └──────────────────┘ └─────┘ │
├──────────────────────────────────────┤
│ 中部:频道树 + 成员 │
│ │
│ ▼ 📁 默认频道 │ ← 展开/折叠
│ ▼ 📁 大厅 │
│ 👤 Alice │ ← 点击弹出成员操作菜单
│ 👤 Bob 🔇 │
│ 👤 Charlie 🎤 │
│ ▶ 📁 游戏区 🔒 │ ← 🔒 = 有密码
│ (3人) │ ← 折叠时显示人数
│ ▶ 📁 音乐区 │
│ (1人) │
│ ▼ 📁 VIP 频道 🔒 │
│ 👤 Admin │
│ │
│ (频道列表支持滚动) │
├──────────────────────────────────────┤
│ 中部-底部:当前频道栏 │
│ ┌──────────────────────────────────┐ │
│ │ 💬 大厅 (5人) Alice🎤 Bob │ │ ← 点击跳转到聊天页
│ └──────────────────────────────────┘ │
├──────────────────────────────────────┤
│ 底部:语音控制 │
│ ┌──────┐ ┌────────────────┐ ┌─────┐ │
│ │ 🎤 │ │ PTT 按住发言 │ │ ⬆ │ │
│ │静音 │ │ │ │语音 │ │ ← 展开语音卡
│ └──────┘ └────────────────┘ └─────┘ │
└──────────────────────────────────────┘
```
**任务**
1. **创建 ChannelListScreen.kt**
```kotlin
// ui/screens/ChannelListScreen.kt
@Composable
fun ChannelListScreen(
channelViewModel: ChannelViewModel,
serverViewModel: ServerViewModel,
voiceViewModel: VoiceViewModel,
onNavigateToChat: () -> Unit,
onNavigateToServerConfig: () -> Unit,
onOpenServerDetail: () -> Unit,
onOpenChannelDetail: (channelId: Long) -> Unit,
onOpenVoiceCard: () -> Unit
) {
val connectionState by serverViewModel.connectionState.collectAsState()
val syncState by channelViewModel.syncState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 头部
ChannelListHeader(
serverName = serverViewModel.serverName,
serverAddress = serverViewModel.serverAddress,
onlineCount = serverViewModel.onlineCount,
connectionState = connectionState,
onOpenServerDetail = onOpenServerDetail,
onOpenChannelDetail = { onOpenChannelDetail(channelViewModel.currentChannelId) }
)
// 中部:频道树或同步加载态
Box(modifier = Modifier.weight(1f)) {
when (syncState) {
SyncState.Syncing -> SyncLoadingIndicator()
SyncState.SyncFailed -> SyncFailedView(onRetry = { channelViewModel.retrySync() })
else -> ChannelTreeContent(
channelViewModel = channelViewModel,
onChannelClick = { /* 切换频道逻辑 */ },
onClientClick = { /* 成员操作菜单 */ },
onNavigateToChat = onNavigateToChat
)
}
}
// 中部-底部:当前频道栏
CurrentChannelBar(
channelViewModel = channelViewModel,
onClick = onNavigateToChat
)
// 底部:语音控制
VoiceControlBar(
voiceViewModel = voiceViewModel,
onExpand = onOpenVoiceCard
)
}
}
```
2. **头部组件**
```kotlin
@Composable
fun ChannelListHeader(
serverName: String,
serverAddress: String,
onlineCount: Int,
connectionState: ConnectionState,
onOpenServerDetail: () -> Unit,
onOpenChannelDetail: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:服务器卡按钮
IconButton(onClick = onOpenServerDetail) {
Icon(Icons.Default.Menu, contentDescription = "服务器详情")
}
// 中部:服务器信息
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
Text(serverName, style = MaterialTheme.typography.titleMedium)
Text(serverAddress, style = MaterialTheme.typography.bodySmall)
Text("${onlineCount}人在线", style = MaterialTheme.typography.bodySmall)
}
// 右侧:连接状态指示
ConnectionStatusDot(connectionState)
}
}
@Composable
fun ConnectionStatusDot(state: ConnectionState) {
val color = when (state) {
ConnectionState.Ready -> Color.Green
ConnectionState.Connecting, ConnectionState.Connected, ConnectionState.Syncing -> Color.Yellow
is ConnectionState.Failed -> Color.Red
else -> Color.Gray
}
Box(
modifier = Modifier
.size(12.dp)
.background(color, CircleShape)
)
}
```
3. **同步加载态组件**
```kotlin
@Composable
fun SyncLoadingIndicator() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("正在同步服务器数据...")
}
}
}
@Composable
fun SyncFailedView(onRetry: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("同步失败", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRetry) {
Text("重试")
}
}
}
}
```
### 5.2 频道树组件
**目标**:实现可展开/折叠的频道树,支持层级缩进和状态图标。
**任务**
1. **频道树数据模型**
```kotlin
// 频道树节点,包含子频道和成员
data class ChannelTreeNode(
val channel: ChannelInfo,
val children: List<ChannelTreeNode>,
val clients: List<ClientInfo>,
val isExpanded: Boolean = false,
val unreadCount: Int = 0,
val hasMention: Boolean = false
)
```
2. **频道树构建逻辑**
```kotlin
// ChannelViewModel.kt
fun buildChannelTree(
channels: List<ChannelInfo>,
clients: List<ClientInfo>,
expandedIds: Set<Long>
): List<ChannelTreeNode> {
// 按 ParentID 分组
val childrenMap = channels.groupBy { it.parentId }
// 递归构建树
fun buildNode(channel: ChannelInfo): ChannelTreeNode {
val children = (childrenMap[channel.id] ?: emptyList())
.sortedBy { it.order }
.map { buildNode(it) }
val channelClients = clients.filter { it.channelId == channel.id }
return ChannelTreeNode(
channel = channel,
children = children,
clients = channelClients,
isExpanded = expandedIds.contains(channel.id)
)
}
// 顶级频道(parentId == 0
return (childrenMap[0L] ?: emptyList())
.sortedBy { it.order }
.map { buildNode(it) }
}
```
3. **频道树 Composable**
```kotlin
@Composable
fun ChannelTreeContent(
channelViewModel: ChannelViewModel,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onNavigateToChat: () -> Unit
) {
val tree by channelViewModel.channelTree.collectAsState()
val selfClientId by channelViewModel.selfClientId.collectAsState()
LazyColumn(modifier = Modifier.fillMaxSize()) {
tree.forEach { node ->
channelTreeNodeItems(
node = node,
depth = 0,
selfClientId = selfClientId,
onChannelClick = onChannelClick,
onClientClick = onClientClick,
onToggleExpand = { channelViewModel.toggleExpand(it) }
)
}
}
}
// 递归添加 LazyColumn 项
fun LazyListScope.channelTreeNodeItems(
node: ChannelTreeNode,
depth: Int,
selfClientId: Int?,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onToggleExpand: (Long) -> Unit
) {
item(key = "channel_${node.channel.id}") {
ChannelRow(
node = node,
depth = depth,
onClick = {
if (node.children.isNotEmpty()) {
onToggleExpand(node.channel.id)
} else {
onChannelClick(node.channel)
}
},
onLongClick = { /* 弹出频道操作菜单 */ }
)
}
if (node.isExpanded) {
// 显示成员
node.clients.forEach { client ->
item(key = "client_${client.id}") {
ClientRow(
client = client,
depth = depth + 1,
isSelf = client.id == selfClientId,
onClick = { onClientClick(client) }
)
}
}
// 递归显示子频道
node.children.forEach { child ->
channelTreeNodeItems(
node = child,
depth = depth + 1,
selfClientId = selfClientId,
onChannelClick = onChannelClick,
onClientClick = onClientClick,
onToggleExpand = onToggleExpand
)
}
}
}
```
4. **频道行组件**
```kotlin
@Composable
fun ChannelRow(
node: ChannelTreeNode,
depth: Int,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 24).dp)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 展开/折叠图标
if (node.children.isNotEmpty()) {
Icon(
imageVector = if (node.isExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
} else {
Spacer(modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(8.dp))
// 频道图标
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
// 频道名
Text(
text = node.channel.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
// 状态图标
if (node.channel.isPassword) {
Icon(Icons.Default.Lock, contentDescription = "有密码", modifier = Modifier.size(16.dp))
}
// 未读指示
if (node.unreadCount > 0) {
Badge { Text("${node.unreadCount}") }
} else if (node.hasMention) {
Box(
modifier = Modifier
.size(8.dp)
.background(Color.Red, CircleShape)
)
}
// 折叠时显示人数
if (!node.isExpanded && node.clients.isNotEmpty()) {
Text(
text = "(${node.clients.size}人)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
```
5. **成员行组件**
```kotlin
@Composable
fun ClientRow(
client: ClientInfo,
depth: Int,
isSelf: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 24).dp)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(28.dp)) // 对齐频道行的文字
// 用户图标
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = if (isSelf) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
// 昵称
Text(
text = client.nickname,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
// 服务器组图标(可选)
if (client.serverGroups.isNotEmpty()) {
Icon(
imageVector = Icons.Default.Shield,
contentDescription = "管理员",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
}
}
```
### 5.3 成员显示
**目标**:按频道分组显示在线成员,支持增量更新。
**任务**
1. **成员分组索引**
```kotlin
// ChannelViewModel.kt
// 从 Repository 获取按频道分组的成员列表
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
```
2. **成员操作菜单**
```kotlin
@Composable
fun ClientActionMenu(
client: ClientInfo,
isSelf: Boolean,
onPoke: () -> Unit,
onCopyNickname: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text(client.nickname, style = MaterialTheme.typography.titleMedium)
Text("频道: ${getChannelName(client.channelId)}",
style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {},
dismissButton = {},
text = {
Column {
if (!isSelf) {
ListItem(
headlineContent = { Text("Poke") },
leadingContent = { Icon(Icons.Default.TouchApp, null) },
modifier = Modifier.clickable { onPoke(); onDismiss() }
)
}
ListItem(
headlineContent = { Text("复制昵称") },
leadingContent = { Icon(Icons.Default.ContentCopy, null) },
modifier = Modifier.clickable { onCopyNickname(); onDismiss() }
)
}
}
)
}
```
### 5.4 未读指示逻辑
**目标**:实现频道未读消息和 @提及的指示。
**任务**
1. **未读状态数据模型**
```kotlin
data class UnreadState(
val channelId: Long,
val unreadCount: Int = 0,
val hasMention: Boolean = false
)
```
2. **未读状态管理**
```kotlin
// ChannelViewModel.kt
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
fun onTextMessage(msg: TextMessage) {
// 只处理频道消息(TargetMode == 2
if (msg.TargetMode != 2) return
val channelId = msg.Target.toLong()
// 如果不在该频道,更新未读状态
if (channelId != repository.selfChannelId.value) {
val current = _unreadStates.value[channelId] ?: UnreadState(channelId)
val hasMention = msg.Message.contains("@${repository.selfNickname.value}")
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
put(channelId, current.copy(
unreadCount = current.unreadCount + 1,
hasMention = current.hasMention || hasMention
))
}
}
}
fun clearUnread(channelId: Long) {
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
remove(channelId)
}
}
```
3. **整合到频道树**
```kotlin
// buildChannelTree 时整合未读状态
fun buildChannelTree(
channels: List<ChannelInfo>,
clients: List<ClientInfo>,
expandedIds: Set<Long>,
unreadStates: Map<Long, UnreadState>
): List<ChannelTreeNode> {
// ... 构建树逻辑 ...
return ChannelTreeNode(
channel = channel,
children = children,
clients = channelClients,
isExpanded = expandedIds.contains(channel.id),
unreadCount = unreadStates[channel.id]?.unreadCount ?: 0,
hasMention = unreadStates[channel.id]?.hasMention ?: false
)
}
```
### 5.5 当前频道栏
**目标**:显示当前所在频道信息,点击跳转聊天页。
**任务**
```kotlin
@Composable
fun CurrentChannelBar(
channelViewModel: ChannelViewModel,
onClick: () -> Unit
) {
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
val channels by channelViewModel.channels.collectAsState()
val channelClients by channelViewModel.channelClients.collectAsState()
val currentChannel = channels.find { it.id == currentChannelId }
val clients = channelClients[currentChannelId] ?: emptyList()
if (currentChannel != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 频道图标和名称
Icon(Icons.Default.Chat, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${currentChannel.name} (${clients.size}人)",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.weight(1f))
// 成员预览(最多显示 3 个名字)
val previewNames = clients.take(3).joinToString(" ") { it.nickname }
Text(
text = previewNames,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
```
### 5.6 底部语音控制区(基础)
**目标**:实现基础语音控制栏,包含静音按钮、PTT 和展开入口。
**任务**
```kotlin
@Composable
fun VoiceControlBar(
voiceViewModel: VoiceViewModel,
onExpand: () -> Unit
) {
val isMuted by voiceViewModel.isMuted.collectAsState()
val isTransmitting by voiceViewModel.isTransmitting.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// 静音按钮
IconButton(onClick = { voiceViewModel.toggleMute() }) {
Icon(
imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = if (isMuted) "取消静音" else "静音",
tint = if (isMuted) Color.Red else MaterialTheme.colorScheme.onSurface
)
}
// PTT 按钮
Button(
onClick = {},
modifier = Modifier
.weight(1f)
.height(48.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { voiceViewModel.startTransmit() },
onDragEnd = { voiceViewModel.stopTransmit() },
onDragCancel = { voiceViewModel.stopTransmit() },
onDrag = { _, _ -> }
)
},
colors = ButtonDefaults.buttonColors(
containerColor = if (isTransmitting) Color.Green
else MaterialTheme.colorScheme.primaryContainer
)
) {
Text(if (isTransmitting) "正在发言..." else "PTT 按住发言")
}
// 展开语音卡按钮
IconButton(onClick = onExpand) {
Icon(Icons.Default.ExpandLess, contentDescription = "展开语音卡")
}
}
}
```
### 5.7 ChannelViewModel 实现
**目标**:实现频道列表页的核心状态管理,包含增量同步和补偿同步。
**任务**
1. **ChannelViewModel 定义**
```kotlin
// viewmodel/ChannelViewModel.kt
class ChannelViewModel(
private val repository: ChannelRepository,
private val application: Application
) : ViewModel() {
companion object {
private const val TAG = "ChannelViewModel"
}
// ─── 同步状态 ─────────────────────────────────────────
private val _syncState = MutableStateFlow<SyncState>(SyncState.Unsynced)
val syncState: StateFlow<SyncState> = _syncState
// ─── 频道数据 ─────────────────────────────────────────
val channels: StateFlow<List<ChannelInfo>> = repository.channels
val clients: StateFlow<List<ClientInfo>> = repository.clients
val selfClientId: StateFlow<Int?> = repository.selfClientId
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
// ─── UI 状态 ─────────────────────────────────────────
private val _expandedChannelIds = MutableStateFlow<Set<Long>>(emptySet())
val expandedChannelIds: StateFlow<Set<Long>> = _expandedChannelIds
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
// ─── 频道树 ─────────────────────────────────────────
val channelTree: StateFlow<List<ChannelTreeNode>> = combine(
channels, clients, expandedChannelIds, unreadStates
) { chs, cls, expanded, unread ->
buildChannelTree(chs, cls, expanded, unread)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// ─── 当前频道 ────────────────────────────────────────
val currentChannelId: StateFlow<Long> = repository.selfChannelId
}
```
2. **首次同步方法**
```kotlin
// ChannelViewModel.kt
/**
* 执行首次同步,由 ServerViewModel 在 onConnected 后调用
*/
suspend fun performInitialSync() {
_syncState.value = SyncState.Syncing
try {
// 并行请求三个数据源(TsClient 直接返回 Kotlin 类型)
val channels = TSBridge.getChannelList()
val clients = TSBridge.getClientList()
val selfId = TSBridge.getClientId()
// 原子提交到仓库
repository.updateBaseline(
channels = channels,
clients = clients,
selfClientId = selfId.toInt()
)
// 自动展开有成员的频道
autoExpandChannelsWithClients()
_syncState.value = SyncState.Synchronized
Log.d(TAG, "Initial sync completed: ${channels.size} channels, ${clients.size} clients")
} catch (e: Exception) {
Log.e(TAG, "Initial sync failed", e)
_syncState.value = SyncState.SyncFailed(e)
}
}
/**
* 带重试的首次同步
*/
suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) {
var retryCount = 0
while (retryCount < maxRetries) {
try {
performInitialSync()
if (_syncState.value == SyncState.Synchronized) return
} catch (e: Exception) {
Log.w(TAG, "Sync attempt ${retryCount + 1} failed", e)
}
retryCount++
if (retryCount < maxRetries) {
delay(1000L * retryCount) // 递增延迟
}
}
// 所有重试失败
if (_syncState.value != SyncState.Synchronized) {
_syncState.value = SyncState.SyncFailed(Exception("同步失败,已重试 $maxRetries 次"))
}
}
fun retrySync() {
viewModelScope.launch {
performInitialSyncWithRetry()
}
}
```
3. **增量同步 — OnClientEnter**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端进入事件(增量同步)
* 对应 08 状态同步 ②
*/
fun handleClientEnter(clientInfo: ClientInfo) {
Log.d(TAG, "Client enter: ${clientInfo.id} (${clientInfo.nickname}) -> channel ${clientInfo.channelId}")
// 按 ID 覆盖,幂等操作
repository.addOrUpdateClient(clientInfo)
}
```
4. **增量同步 — OnClientMoved**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端移动事件(增量同步 + 补偿同步)
* 对应 08 状态同步 ② + ③
*/
fun handleClientMoved(clientId: Int, targetChannelId: Long) {
Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
val existingClient = repository.getClientById(clientId)
if (existingClient != null) {
// 成员存在:更新频道位置
repository.updateClientChannel(clientId, targetChannelId)
// 如果是自己,更新当前频道
if (clientId == repository.selfClientId.value) {
repository.updateSelfChannel(targetChannelId)
clearUnread(targetChannelId)
}
} else {
// 成员不存在:触发补偿同步(08 状态同步 ③)
Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
viewModelScope.launch { compensateClientList() }
}
}
```
5. **增量同步 — OnClientLeave**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端离开事件(增量同步)
* 对应 08 状态同步 ②
*/
fun handleClientLeave(clientId: Int, reasonMsg: String) {
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
// 幂等删除,重复删除安全
repository.removeClient(clientId)
}
```
6. **补偿同步**
```kotlin
// ChannelViewModel.kt
/**
* 补偿同步:重新获取完整成员列表
* 对应 08 状态同步 ③
*/
private suspend fun compensateClientList() {
try {
val clients = TSBridge.getClientList()
repository.replaceAllClients(clients)
Log.d(TAG, "Compensation sync completed: ${clients.size} clients")
} catch (e: Exception) {
Log.e(TAG, "Compensation sync failed", e)
}
}
/**
* 补偿同步:重新获取完整频道列表
* 当事件引用未知频道时触发
*/
private suspend fun compensateChannelList() {
try {
val channels = TSBridge.getChannelList()
repository.replaceAllChannels(channels)
Log.d(TAG, "Channel compensation sync completed: ${channels.size} channels")
} catch (e: Exception) {
Log.e(TAG, "Channel compensation sync failed", e)
}
}
```
7. **频道展开/折叠**
```kotlin
// ChannelViewModel.kt
fun toggleExpand(channelId: Long) {
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
if (contains(channelId)) remove(channelId) else add(channelId)
}
}
private fun autoExpandChannelsWithClients() {
val clientsByChannel = repository.channelClients.value
val toExpand = clientsByChannel.filter { it.value.isNotEmpty() }.keys
_expandedChannelIds.value = toExpand
}
```
8. **事件处理器注册(供 ServerViewModel 调用)**
```kotlin
// ChannelViewModel.kt
/**
* 注册事件处理器,由 ServerViewModel 在 Connect 前调用
* 事件处理器在 Connect 前必须完成注册,避免早期事件丢失
*/
fun registerEventHandlers(callbacks: TSBridge.Callbacks) {
// 通过 ServerViewModel 的事件回调链式处理
// 具体实现在 ServerViewModel.registerEventHandlers() 中
// ChannelViewModel 的方法被 ServerViewModel 调用
}
```
---
## 三、状态与数据流
### 3.1 数据流向
```
ServerViewModel ChannelViewModel UI
│ │ │
│ OnConnected │ │
├─────────────────────────────→│ performInitialSync() │
│ │ │
│ OnClientEnter(info) │ │
├─────────────────────────────→│ handleClientEnter() │
│ │ │
│ OnClientMoved(id, channelId) │ │
├─────────────────────────────→│ handleClientMoved() │
│ │ │
│ OnClientLeave(id, reason) │ │
├─────────────────────────────→│ handleClientLeave() │
│ │ │
│ OnTextMessage(msg) │ │
├─────────────────────────────→│ onTextMessage() │
│ │ │
│ │ channelTree (StateFlow) │
│ ├─────────────────────────→│
│ │ │
│ │ syncState (StateFlow) │
│ ├─────────────────────────→│
```
### 3.2 同步状态机
```kotlin
sealed class SyncState {
object Unsynced : SyncState() // 已连接但尚无完整数据
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
object Synchronized : SyncState() // 列表基线可供 UI 使用
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
}
```
状态转换:
- `Unsynced → Syncing`:收到 OnConnected 后开始首次同步
- `Syncing → Synchronized`:三个数据源全部成功
- `Syncing → SyncFailed`:任一数据源失败
- `SyncFailed → Syncing`:用户点击重试
### 3.3 成员实体状态树
对应 `docs/流程/02_浏览频道.md` 成员实体状态树:
```
成员实体状态树
├── 基线分支(首次同步)
│ ├── ListClients 获取完整成员列表
│ ├── 按 ClientInfo.ID 建表去重
│ └── 按 ChannelID 建索引
├── 增量分支(事件驱动)
│ ├── OnClientEnter → set(ID, info)
│ ├── OnClientMoved → 覆盖 ChannelID
│ └── OnClientLeave → delete(ID)
├── 当前用户分支
│ ├── ClientID 本地调用
│ ├── 事件 ClientID 比对
│ └── 更新自身频道事实
└── 修复分支(补偿同步)
├── 检测未知 ClientID → ListClients
└── 检测未知 ChannelID → ListChannels
```
---
## 四、验收标准
### 功能验收
- [ ] **频道树渲染**
- 频道按层级正确缩进显示
- 展开/折叠交互正常
- 折叠时显示频道内人数
- 有密码频道显示 🔒 图标
- [ ] **成员显示**
- 成员按所属频道正确分组
- 自己的昵称高亮显示
- 点击成员弹出操作菜单
- [ ] **未读指示**
- 非当前频道收到消息时显示红点
- 消息包含 @昵称 时显示数字 badge
- 进入频道后清除未读标记
- [ ] **当前频道栏**
- 正确显示当前频道名和人数
- 显示成员预览(最多 3 个名字)
- 点击跳转到聊天页
- [ ] **同步功能**
- 首次同步完成后频道树正确显示
- 同步中显示加载指示器
- 同步失败显示重试按钮
- 新成员进入时增量更新
- 成员离开时增量移除
- 成员移动时增量更新位置
- 未知成员触发补偿同步
### 性能验收
- [ ] 频道树渲染流畅(100 频道、500 用户无卡顿)
- [ ] 增量更新无闪烁(DiffUtil 或 Compose recompose 优化)
- [ ] 首次同步在 3 秒内完成
### 代码质量验收
- [ ] 状态管理清晰,单向数据流
- [ ] 事件处理幂等(重复事件安全处理)
- [ ] 补偿同步有日志记录
- [ ] 无内存泄漏(viewModelScope 正确使用)
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 首次加载 | 连接成功后进入频道列表 | 显示加载态 → 频道树正确显示 |
| 展开频道 | 点击折叠的频道 | 显示子频道和成员 |
| 折叠频道 | 点击展开的频道 | 隐藏子频道和成员,显示人数 |
| 新成员进入 | 其他用户加入服务器 | 频道树增量更新,无需刷新 |
| 成员离开 | 其他用户退出 | 频道树增量更新,人数减少 |
| 成员移动 | 其他用户切换频道 | 两个频道的人数同步更新 |
| 未读消息 | 其他频道收到消息 | 频道名旁显示红点 |
| @提及 | 收到包含 @昵称 的消息 | 显示数字 badge |
| 进入频道 | 点击有未读的频道 | 未读标记清除 |
| 网络断开 | 断网后 | 显示断开状态,数据冻结 |
| 补偿同步 | 收到未知成员的移动事件 | 自动重新获取完整列表 |
---
## 五、参考文档
- `docs/UI架构设计.md` - 2.2 频道列表页
- `docs/流程/02_浏览频道.md` - 状态树、时序图
- `docs/流程/08_状态同步.md` - ② 增量同步、③ 补偿同步
- `docs/sdk文档-go.md` - ListChannels、ListClients、事件处理器