Files
2026-07-20 19:01:03 +08:00

38 KiB
Raw Permalink Blame History

步骤 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

    // 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. 头部组件

    @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. 同步加载态组件

    @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. 频道树数据模型

    // 频道树节点,包含子频道和成员
    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. 频道树构建逻辑

    // 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

    @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. 频道行组件

    @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. 成员行组件

    @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. 成员分组索引

    // ChannelViewModel.kt
    // 从 Repository 获取按频道分组的成员列表
    val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
    
  2. 成员操作菜单

    @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. 未读状态数据模型

    data class UnreadState(
        val channelId: Long,
        val unreadCount: Int = 0,
        val hasMention: Boolean = false
    )
    
  2. 未读状态管理

    // 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. 整合到频道树

    // 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 实现

目标:实现频道列表页的核心状态管理,包含增量同步和补偿同步。

任务

  1. 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
    }
    
  2. 首次同步方法

    // 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

    // 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

    // 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

    // ChannelViewModel.kt
    
    /**
     * 处理客户端离开事件(增量同步)
     * 对应 08 状态同步 ②
     */
    fun handleClientLeave(clientId: Int, reasonMsg: String) {
        Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
    
        // 幂等删除,重复删除安全
        repository.removeClient(clientId)
    }
    
  6. 补偿同步

    // 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. 频道展开/折叠

    // 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 调用)

    // 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、事件处理器