38 KiB
38 KiB
步骤 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 按住发言 │ │ ⬆ │ │
│ │静音 │ │ │ │语音 │ │ ← 展开语音卡
│ └──────┘ └────────────────┘ └─────┘ │
└──────────────────────────────────────┘
任务:
-
创建 ChannelListScreen.kt
// 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 ) } } -
头部组件
@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) ) } -
同步加载态组件
@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 频道树组件
目标:实现可展开/折叠的频道树,支持层级缩进和状态图标。
任务:
-
频道树数据模型
// 频道树节点,包含子频道和成员 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 ) -
频道树构建逻辑
// 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) } } -
频道树 Composable
@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 ) } } } -
频道行组件
@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 ) } } } -
成员行组件
@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 成员显示
目标:按频道分组显示在线成员,支持增量更新。
任务:
-
成员分组索引
// ChannelViewModel.kt // 从 Repository 获取按频道分组的成员列表 val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients -
成员操作菜单
@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 未读指示逻辑
目标:实现频道未读消息和 @提及的指示。
任务:
-
未读状态数据模型
data class UnreadState( val channelId: Long, val unreadCount: Int = 0, val hasMention: Boolean = false ) -
未读状态管理
// 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) } } -
整合到频道树
// 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 当前频道栏
目标:显示当前所在频道信息,点击跳转聊天页。
任务:
@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 和展开入口。
任务:
@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 实现
目标:实现频道列表页的核心状态管理,包含增量同步和补偿同步。
任务:
-
ChannelViewModel 定义
// 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 } -
首次同步方法
// 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() } } -
增量同步 — OnClientEnter
// ChannelViewModel.kt /** * 处理客户端进入事件(增量同步) * 对应 08 状态同步 ② */ fun handleClientEnter(clientInfo: ClientInfo) { Log.d(TAG, "Client enter: ${clientInfo.id} (${clientInfo.nickname}) -> channel ${clientInfo.channelId}") // 按 ID 覆盖,幂等操作 repository.addOrUpdateClient(clientInfo) } -
增量同步 — OnClientMoved
// 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() } } } -
增量同步 — OnClientLeave
// ChannelViewModel.kt /** * 处理客户端离开事件(增量同步) * 对应 08 状态同步 ② */ fun handleClientLeave(clientId: Int, reasonMsg: String) { Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg") // 幂等删除,重复删除安全 repository.removeClient(clientId) } -
补偿同步
// 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) } } -
频道展开/折叠
// 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 } -
事件处理器注册(供 ServerViewModel 调用)
// 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 同步状态机
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、事件处理器