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

33 KiB
Raw Permalink Blame History

步骤 12:主题与收尾

实现暗色主题、边缘情况处理、稳定性优化。 对应设计:docs/UI架构设计.md - 4.4 主题切换 依赖步骤:11(卡片与全局交互)


一、目标

  • 暗色/亮色主题切换 — 全局 Material 3 动态主题
  • 主题持久化 — DataStore 保存用户选择,启动时自动应用
  • 边缘情况处理 — 空状态、异常输入、极端场景覆盖
  • 内存泄漏检查 — ViewModel / 协程 / 回调 / 音频资源释放
  • 性能优化 — 列表滚动、重组范围、图片/动画优化
  • 最终集成验证 — 全链路冒烟测试

二、任务清单

12.1 主题系统

目标:实现 Material 3 暗色/亮色主题切换,全局生效并持久化用户选择。

对应设计docs/UI架构设计.md 4.4 主题切换:

  • 入口位置:服务器配置页右上角 🌙 图标
  • 切换方式:点击在亮色/暗色主题间切换
  • 持久化:选择保存到本地配置,下次启动自动应用
  • 影响范围:全局所有页面和卡片

任务

  1. ThemeMode 枚举与 DataStore 持久化

    // ui/theme/ThemeMode.kt
    
    enum class ThemeMode {
        LIGHT,    // 亮色
        DARK,     // 暗色
        SYSTEM    // 跟随系统(默认)
    }
    
    // data/ThemePreferences.kt
    
    import android.content.Context
    import androidx.datastore.preferences.core.edit
    import androidx.datastore.preferences.core.stringPreferencesKey
    import androidx.datastore.preferences.preferencesDataStore
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.map
    
    private val Context.dataStore by preferencesDataStore(name = "settings")
    
    class ThemePreferences(private val context: Context) {
    
        companion object {
            private val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
        }
    
        /**
         * 读取主题模式(默认跟随系统)
         */
        val themeMode: Flow<ThemeMode> = context.dataStore.data.map { prefs ->
            when (prefs[THEME_MODE_KEY]) {
                ThemeMode.LIGHT.name -> ThemeMode.LIGHT
                ThemeMode.DARK.name -> ThemeMode.DARK
                else -> ThemeMode.SYSTEM
            }
        }
    
        /**
         * 保存主题模式
         */
        suspend fun setThemeMode(mode: ThemeMode) {
            context.dataStore.edit { prefs ->
                prefs[THEME_MODE_KEY] = mode.name
            }
        }
    }
    
  2. ThemeViewModel — 主题状态管理

    // viewmodel/ThemeViewModel.kt
    
    class ThemeViewModel(application: Application) : AndroidViewModel(application) {
    
        private val themePreferences = ThemePreferences(application)
    
        val themeMode: StateFlow<ThemeMode> = themePreferences.themeMode
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = ThemeMode.SYSTEM
            )
    
        /**
         * 切换主题模式
         *
         * 对应 docs/UI架构设计.md 4.4
         * "点击在亮色/暗色主题间切换"
         * "选择保存到本地配置"
         */
        fun toggleTheme() {
            viewModelScope.launch {
                val next = when (themeMode.value) {
                    ThemeMode.SYSTEM -> ThemeMode.LIGHT
                    ThemeMode.LIGHT -> ThemeMode.DARK
                    ThemeMode.DARK -> ThemeMode.SYSTEM
                }
                themePreferences.setThemeMode(next)
            }
        }
    }
    
  3. Material 3 主题配置

    // ui/theme/Theme.kt
    
    @Composable
    fun TSMobileTheme(
        themeMode: ThemeMode = ThemeMode.SYSTEM,
        content: @Composable () -> Unit
    ) {
        val darkTheme = when (themeMode) {
            ThemeMode.LIGHT -> false
            ThemeMode.DARK -> true
            ThemeMode.SYSTEM -> isSystemInDarkTheme()
        }
    
        val colorScheme = if (darkTheme) {
            darkColorScheme(
                primary = Color(0xFF90CAF9),
                onPrimary = Color(0xFF003258),
                primaryContainer = Color(0xFF00497D),
                onPrimaryContainer = Color(0xFFD1E4FF),
                secondary = Color(0xFFBBC7DB),
                onSecondary = Color(0xFF263141),
                surface = Color(0xFF1A1C1E),
                onSurface = Color(0xFFE3E2E6),
                surfaceVariant = Color(0xFF43474E),
                onSurfaceVariant = Color(0xFFC3C6CF),
                error = Color(0xFFFFB4AB),
                onError = Color(0xFF690005)
            )
        } else {
            lightColorScheme(
                primary = Color(0xFF1565C0),
                onPrimary = Color(0xFFFFFFFF),
                primaryContainer = Color(0xFFD1E4FF),
                onPrimaryContainer = Color(0xFF001D36),
                secondary = Color(0xFF535F70),
                onSecondary = Color(0xFFFFFFFF),
                surface = Color(0xFFFDFBFF),
                onSurface = Color(0xFF1A1C1E),
                surfaceVariant = Color(0xFFE0E3EC),
                onSurfaceVariant = Color(0xFF43474E),
                error = Color(0xFFBA1A1A),
                onError = Color(0xFFFFFFFF)
            )
        }
    
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            content = content
        )
    }
    
  4. MainActivity 集成

    // MainActivity.kt
    
    class MainActivity : ComponentActivity() {
    
        private val themeViewModel: ThemeViewModel by viewModels()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            setContent {
                val themeMode by themeViewModel.themeMode.collectAsState()
    
                TSMobileTheme(themeMode = themeMode) {
                    Surface(
                        modifier = Modifier.fillMaxSize(),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        MainApp(
                            themeViewModel = themeViewModel
                        )
                    }
                }
            }
        }
    }
    
  5. 服务器配置页主题切换按钮

    对应 docs/UI架构设计.md 2.1 布局 — 右上角主题图标:

    // ui/screens/ServerConfigScreen.kt
    
    @Composable
    fun ServerConfigScreen(
        serverViewModel: ServerViewModel,
        themeViewModel: ThemeViewModel
    ) {
        val themeMode by themeViewModel.themeMode.collectAsState()
    
        Column(modifier = Modifier.fillMaxSize()) {
            // 品牌区
            Box(modifier = Modifier.fillMaxWidth()) {
                // Logo + 描述
                Column(
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(top = 48.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    // Logo
                    Icon(
                        imageVector = Icons.Default.Headset,
                        contentDescription = null,
                        modifier = Modifier.size(64.dp),
                        tint = MaterialTheme.colorScheme.primary
                    )
                    Spacer(Modifier.height(12.dp))
                    Text(
                        text = "TeamSpeak Mobile",
                        style = MaterialTheme.typography.headlineMedium
                    )
                    Text(
                        text = "连接到你的 TeamSpeak 服务器",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
    
                // 主题切换按钮(右上角)
                IconButton(
                    onClick = { themeViewModel.toggleTheme() },
                    modifier = Modifier
                        .align(Alignment.TopEnd)
                        .padding(16.dp)
                ) {
                    Icon(
                        imageVector = when (themeMode) {
                            ThemeMode.LIGHT -> Icons.Default.LightMode
                            ThemeMode.DARK -> Icons.Default.DarkMode
                            ThemeMode.SYSTEM -> Icons.Default.SettingsBrightness
                        },
                        contentDescription = "切换主题",
                        tint = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
    
            // ... 输入区 + 最近连接 ...
        }
    }
    

12.2 边缘情况

目标:覆盖各种边缘场景,确保应用在异常输入、极端数据、特殊字符等情况下不崩溃。

任务

  1. 空状态处理

    为所有列表和数据展示区域提供空状态 UI:

    // ui/components/EmptyStateView.kt
    
    @Composable
    fun EmptyStateView(
        icon: ImageVector,
        title: String,
        subtitle: String = "",
        actionText: String? = null,
        onAction: (() -> Unit)? = null
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(32.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Icon(
                imageVector = icon,
                contentDescription = null,
                modifier = Modifier.size(64.dp),
                tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
            )
            Spacer(Modifier.height(16.dp))
            Text(
                text = title,
                style = MaterialTheme.typography.titleMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            if (subtitle.isNotEmpty()) {
                Spacer(Modifier.height(8.dp))
                Text(
                    text = subtitle,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
                )
            }
            if (actionText != null && onAction != null) {
                Spacer(Modifier.height(16.dp))
                TextButton(onClick = onAction) {
                    Text(actionText)
                }
            }
        }
    }
    

    各场景空状态:

    场景 图标 标题 副标题
    频道列表为空 FolderOpen 暂无频道 服务器没有任何频道
    当前频道无成员 PersonOutline 频道内无人 你是第一个进入的
    消息列表为空 ChatBubbleOutline 暂无消息 发送第一条消息吧
    最近连接为空 History 暂无记录 连接服务器后会在这里显示
    语音卡无人发言 VolumeOff 暂无发言
  2. 输入验证加固

    // data/InputValidator.kt
    
    object InputValidator {
    
        /**
         * 服务器地址验证
         * 支持:域名、IPv4/v6)、TSDNS、带端口
         */
        fun validateServerAddress(input: String): ValidationResult {
            val trimmed = input.trim()
            if (trimmed.isEmpty()) {
                return ValidationResult(false, "请输入服务器地址")
            }
            // 去除协议前缀
            val addr = trimmed
                .removePrefix("ts3server://")
                .removePrefix("ts3://")
                .trimEnd('/')
    
            // 基本格式检查:不能包含空格、必须有合法字符
            if (addr.contains(' ') || addr.length > 256) {
                return ValidationResult(false, "地址格式不正确")
            }
    
            // 端口检查(如果有)
            val parts = addr.split(":")
            if (parts.size == 2) {
                val port = parts[1].toIntOrNull()
                if (port == null || port !in 1..65535) {
                    return ValidationResult(false, "端口范围 1-65535")
                }
            } else if (parts.size > 2) {
                // IPv6 地址 — 必须包含在 [] 中
                if (!addr.startsWith("[")) {
                    return ValidationResult(false, "IPv6 地址需要用 [] 包裹")
                }
            }
    
            return ValidationResult(true)
        }
    
        /**
         * 昵称验证
         */
        fun validateNickname(input: String): ValidationResult {
            val trimmed = input.trim()
            if (trimmed.isEmpty()) {
                return ValidationResult(false, "请输入昵称")
            }
            if (trimmed.length > 30) {
                return ValidationResult(false, "昵称最长 30 个字符")
            }
            // 检查非法字符(TeamSpeak 限制)
            val illegalChars = listOf("\\", "/", "|", "\n", "\r", "\t")
            for (ch in illegalChars) {
                if (trimmed.contains(ch)) {
                    return ValidationResult(false, "昵称包含非法字符: '$ch'")
                }
            }
            return ValidationResult(true)
        }
    
        /**
         * 频道密码验证
         */
        fun validateChannelPassword(input: String): ValidationResult {
            if (input.isEmpty()) {
                return ValidationResult(false, "请输入频道密码")
            }
            if (input.length > 100) {
                return ValidationResult(false, "密码过长")
            }
            return ValidationResult(true)
        }
    
        /**
         * 聊天消息验证
         */
        fun validateMessage(input: String): ValidationResult {
            val trimmed = input.trim()
            if (trimmed.isEmpty()) {
                return ValidationResult(false, "消息不能为空")
            }
            if (trimmed.length > 1024) {
                return ValidationResult(false, "消息最长 1024 个字符")
            }
            return ValidationResult(true)
        }
    }
    
    data class ValidationResult(
        val isValid: Boolean,
        val errorMessage: String = ""
    )
    
  3. 频道名/成员名特殊字符处理

    // ui/components/TextExtensions.kt
    
    /**
     * 安全显示频道名/成员名
     * 处理:空名称、超长名称、特殊字符
     */
    @Composable
    fun SafeDisplayName(
        name: String,
        fallback: String = "未知",
        maxLength: Int = 50,
        style: TextStyle = MaterialTheme.typography.bodyMedium,
        maxLines: Int = 1
    ) {
        val displayName = when {
            name.isBlank() -> fallback
            name.length > maxLength -> name.take(maxLength) + "…"
            else -> name
        }
        Text(
            text = displayName,
            style = style,
            maxLines = maxLines,
            overflow = TextOverflow.Ellipsis
        )
    }
    
  4. 极端数据场景处理

    场景 处理方式
    频道数 > 100 使用 LazyColumn 虚拟化,避免一次性渲染
    成员数 > 500 LazyColumn 虚拟化 + 分页加载
    消息数 > 1000 限制内存中保留最近 200 条,其余从归档加载
    频道名为空 显示 "(未命名频道)"
    成员昵称为空 显示 "未知用户"
    消息内容为空 不显示该消息,记录日志
    服务器返回异常 JSON try-catch + 默认值,不崩溃
    SDK 方法调用超时 设置 5 秒超时,超时后显示错误提示
    服务器满/密码错/被封禁 显示对应错误信息,不自动重连
  5. 全局异常捕获

    // App.kt
    
    class App : Application() {
    
        override fun onCreate() {
            super.onCreate()
    
            // 全局未捕获异常处理
            val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
            Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
                Log.e("App", "Uncaught exception in ${thread.name}", throwable)
    
                // 写入崩溃日志文件(可用于后续分析)
                writeCrashLog(throwable)
    
                // 交给默认处理器(系统弹窗)
                defaultHandler?.uncaughtException(thread, throwable)
            }
        }
    
        private fun writeCrashLog(throwable: Throwable) {
            try {
                val file = File(getExternalFilesDir(null), "crash.log")
                file.appendText(
                    buildString {
                        appendLine("=== ${java.util.Date()} ===")
                        appendLine(throwable.stackTraceToString())
                        appendLine()
                    }
                )
            } catch (e: Exception) {
                Log.e("App", "Failed to write crash log", e)
            }
        }
    }
    

12.3 稳定性优化

目标:确保资源正确释放、协程不泄漏、音频设备正确管理,提升应用稳定性。

任务

  1. ViewModel 生命周期管理

    // viewmodel/VoiceViewModel.kt — 资源释放示例
    
    class VoiceViewModel : ViewModel() {
    
        private var audioTrack: AudioTrack? = null
        private var audioRecord: AudioRecord? = null
        private var voiceJob: Job? = null
    
        /**
         * ViewModel 销毁时释放所有资源
         */
        override fun onCleared() {
            super.onCleared()
            Log.d(TAG, "onCleared: releasing voice resources")
    
            // 停止语音
            stopVoice()
    
            // 释放音频资源
            audioTrack?.release()
            audioTrack = null
    
            audioRecord?.release()
            audioRecord = null
    
            // 取消协程
            voiceJob?.cancel()
            voiceJob = null
        }
    }
    
  2. TSBridge 回调生命周期管理

    // viewmodel/ServerViewModel.kt
    
    class ServerViewModel : ViewModel() {
    
        /**
         * 注册回调(连接时调用)
         */
        fun registerCallbacks() {
            TSBridge.setCallbacks(createCallbacks())
        }
    
        /**
         * 注销回调(断开时调用)
         *
         * 防止断开后仍然收到回调导致状态混乱
         */
        fun unregisterCallbacks() {
            TSBridge.setCallbacks(null)
        }
    
        override fun onCleared() {
            super.onCleared()
            unregisterCallbacks()
            reconnectJob?.cancel()
        }
    }
    
  3. 协程作用域安全

    // 所有 ViewModel 中的协程调用
    
    // ✅ 正确:使用 viewModelScope,自动在 ViewModel 销毁时取消
    fun fetchServerInfo() {
        viewModelScope.launch {
            try {
                val json = TSBridge.getServerInfoJSON()
                _serverInfo.value = Json.decodeFromString(json)
            } catch (e: CancellationException) {
                throw e  // 不要吞掉 CancellationException
            } catch (e: Exception) {
                Log.e(TAG, "fetchServerInfo failed", e)
            }
        }
    }
    
    // ❌ 错误:使用 GlobalScope,不会随 ViewModel 销毁取消
    // GlobalScope.launch { ... }
    
  4. 音频设备切换与焦点管理

    // viewmodel/VoiceViewModel.kt
    
    /**
     * 请求音频焦点
     * 进入语音频道时调用
     */
    private fun requestAudioFocus() {
        val audioManager = application.getSystemService(AudioManager::class.java)
        val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
            .setAudioAttributes(
                AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
                    .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                    .build()
            )
            .setOnAudioFocusChangeListener { focusChange ->
                when (focusChange) {
                    AudioManager.AUDIOFOCUS_LOSS -> {
                        // 永久丢失焦点 → 停止语音
                        stopVoice()
                    }
                    AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
                        // 暂时丢失 → 暂停发送
                        pauseTransmit()
                    }
                    AudioManager.AUDIOFOCUS_GAIN -> {
                        // 重新获得焦点 → 恢复
                        resumeTransmit()
                    }
                }
            }
            .build()
    
        audioManager.requestAudioFocus(focusRequest)
    }
    
    /**
     * 释放音频焦点
     * 离开语音频道时调用
     */
    private fun abandonAudioFocus() {
        val audioManager = application.getSystemService(AudioManager::class.java)
        audioManager.abandonAudioFocusRequest(focusRequest)
    }
    
  5. 音频录制/播放设备异常处理

    // viewmodel/VoiceViewModel.kt
    
    /**
     * 安全初始化音频录制
     */
    private fun initAudioRecord(): Boolean {
        return try {
            val bufferSize = AudioRecord.getMinBufferSize(
                SAMPLE_RATE,
                AudioFormat.CHANNEL_IN_MONO,
                AudioFormat.ENCODING_PCM_16BIT
            )
    
            audioRecord = AudioRecord(
                MediaRecorder.AudioSource.MIC,
                SAMPLE_RATE,
                AudioFormat.CHANNEL_IN_MONO,
                AudioFormat.ENCODING_PCM_16BIT,
                bufferSize * 2
            )
    
            if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
                Log.e(TAG, "AudioRecord failed to initialize")
                audioRecord?.release()
                audioRecord = null
                false
            } else {
                true
            }
        } catch (e: SecurityException) {
            Log.e(TAG, "Microphone permission denied", e)
            false
        } catch (e: Exception) {
            Log.e(TAG, "AudioRecord init failed", e)
            false
        }
    }
    
    /**
     * 安全初始化音频播放
     */
    private fun initAudioTrack(): Boolean {
        return try {
            val bufferSize = AudioTrack.getMinBufferSize(
                SAMPLE_RATE,
                AudioFormat.CHANNEL_OUT_MONO,
                AudioFormat.ENCODING_PCM_16BIT
            )
    
            audioTrack = AudioTrack.Builder()
                .setAudioAttributes(
                    AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
                        .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                        .build()
                )
                .setAudioFormat(
                    AudioFormat.Builder()
                        .setSampleRate(SAMPLE_RATE)
                        .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
                        .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                        .build()
                )
                .setBufferSizeInBytes(bufferSize * 2)
                .setTransferMode(AudioTrack.MODE_STREAM)
                .build()
    
            true
        } catch (e: Exception) {
            Log.e(TAG, "AudioTrack init failed", e)
            false
        }
    }
    
  6. 列表性能优化

    // ui/screens/ChannelListScreen.kt
    
    @Composable
    fun ChannelTreeList(
        channelTree: List<ChannelNode>,
        currentChannelId: Long,
        onChannelClick: (ChannelInfo) -> Unit,
        onChannelLongClick: (ChannelInfo) -> Unit
    ) {
        // 使用 key 优化重组
        LazyColumn {
            items(
                items = channelTree,
                key = { node -> "channel_${node.channel.id}_${node.depth}" }
            ) { node ->
                ChannelTreeItem(
                    node = node,
                    isCurrentChannel = node.channel.id == currentChannelId,
                    onClick = { onChannelClick(node.channel) },
                    onLongClick = { onChannelLongClick(node.channel) }
                )
            }
        }
    }
    
    @Composable
    fun MessageList(
        messages: List<TextMsg>,
        selfClientId: Int
    ) {
        LazyColumn(
            state = rememberLazyListState(),
            reverseLayout = true  // 新消息在底部
        ) {
            items(
                items = messages,
                key = { msg -> "${msg.senderID}_${msg.timestamp}" }
            ) { msg ->
                MessageItem(
                    message = msg,
                    isSelf = msg.senderID == selfClientId
                )
            }
        }
    }
    

12.4 测试与验证

目标:对全部功能进行端到端冒烟测试,确保各流程正常工作。

冒烟测试清单

编号 测试场景 操作步骤 预期结果
T01 首次连接 输入地址/昵称 → 点击连接 连接成功,频道列表显示
T02 快速连接 点击最近连接记录 自动填充并连接
T03 频道树浏览 展开/折叠子频道 频道树正确展开/折叠
T04 频道切换 点击无密码频道 切换成功,当前频道栏更新
T05 密码频道 点击有密码频道 → 输入密码 密码正确则进入,错误则提示
T06 发送消息 输入消息 → 点击发送 消息显示在列表中
T07 接收消息 其他成员发送消息 消息实时显示,未读指示更新
T08 PTT 发言 按住 PTT 按钮 → 松开 发言指示出现/消失
T09 静音切换 点击静音按钮 图标切换,语音停止/恢复
T10 服务器卡 点击头部左侧按钮 卡片弹出,信息正确
T11 断开连接 服务器卡 → 断开 → 确认 断开成功,返回配置页
T12 被踢处理 被管理员踢出 全屏提示,可重连/返回
T13 网络断开 断开网络 重连横幅,自动重连
T14 主题切换 点击右上角主题图标 主题切换,重启后保持
T15 Poke 长按成员 → Poke → 发送 对方收到通知
T16 语音卡 点击展开按钮 卡片显示,控制正常
T17 长时间运行 连接后静置 30 分钟 无崩溃、无内存持续增长

性能指标

指标 目标 测量方法
首次启动到可交互 < 2 秒 手动计时
连接建立 < 5 秒 Logcat 时间戳
首次同步完成 < 3 秒 Logcat 时间戳
频道列表滚动 FPS ≥ 55 FPS GPU 过度绘制 / Profiler
消息列表滚动 FPS ≥ 55 FPS GPU 过度绘制 / Profiler
内存占用(空闲) < 80 MB Android Profiler
内存占用(语音中) < 120 MB Android Profiler
APK 大小 < 30 MB 构建产物大小
ANR 发生率 0 Monkey 测试 / 手动测试

三、状态与数据流

3.1 主题状态流

用户点击主题按钮     ThemeViewModel       ThemePreferences (DataStore)    UI
  │                    │                        │                        │
  │ toggleTheme()      │                        │                        │
  ├───────────────────→│                        │                        │
  │                    │ setThemeMode(next)     │                        │
  │                    ├───────────────────────→│                        │
  │                    │                        │ 持久化到磁盘            │
  │                    │                        │                        │
  │                    │ themeMode Flow 发出新值 │                        │
  │                    │←───────────────────────┤                        │
  │                    │                        │                        │
  │                    │                        │   TSMobileTheme 重组    │
  │                    │                        │   全局颜色方案切换      │
  │                    │                        │                        │
  │   界面切换主题     │                        │                        │
  │←───────────────────────────────────────────────────────────────────→│

3.2 主题模式循环

  ┌─────────┐    点击     ┌─────────┐    点击     ┌─────────┐
  │ SYSTEM  │ ──────────→ │  LIGHT  │ ──────────→ │  DARK   │
  │ 跟随系统 │            │  亮色    │            │  暗色    │
  └─────────┘            └─────────┘            └─────────┘
       ↑                                              │
       │                    点击                       │
       └──────────────────────────────────────────────┘

四、与其他步骤的集成

4.1 与服务器配置页集成(步骤 03)

  • 主题切换按钮在品牌区右上角
  • 主题模式变更实时反映在输入框、按钮、最近连接列表样式上

4.2 与频道列表页集成(步骤 05

  • 频道树的图标、文字、背景跟随主题色
  • 未读指示的红点/数字 badge 在暗色主题下可见

4.3 与聊天页集成(步骤 07

  • 消息气泡颜色区分:自己 vs 他人,亮/暗色方案不同
  • 时间戳、发送者名称的颜色适配

4.4 与卡片集成(步骤 11

  • 所有 BottomSheet 卡片的背景、文字、按钮跟随主题
  • Poke 通知的容器颜色适配

4.5 与断开连接集成(步骤 09

  • 重连横幅的颜色使用 errorContainer / onErrorContainer
  • 被踢全屏提示的颜色适配

五、验收标准

功能验收

  • 主题切换

    • 服务器配置页右上角图标可切换主题
    • 切换后全局所有页面/卡片立即生效
    • 切换模式循环:跟随系统 → 亮色 → 暗色 → 跟随系统
    • 图标随模式变化(LightMode / DarkMode / SettingsBrightness
  • 主题持久化

    • 选择的主题模式保存到 DataStore
    • 关闭应用后重新启动,主题模式保持
    • 首次安装默认跟随系统
  • 边缘情况 — 空状态

    • 频道列表为空时显示空状态提示
    • 消息列表为空时显示空状态提示
    • 最近连接为空时显示空状态提示
  • 边缘情况 — 输入验证

    • 服务器地址为空 → 提示 "请输入服务器地址"
    • 服务器地址格式错误 → 提示 "地址格式不正确"
    • 昵称为空 → 提示 "请输入昵称"
    • 昵称包含非法字符 → 提示包含非法字符
    • 消息为空 → 发送按钮置灰
    • 消息超长 → 提示 "消息最长 1024 个字符"
  • 边缘情况 — 极端数据

    • 100+ 频道时列表滚动流畅
    • 500+ 成员时列表滚动流畅
    • 频道名/成员名为空时显示兜底文本
    • 服务器返回异常 JSON 时不崩溃
  • 稳定性 — 资源释放

    • 断开连接后音频资源释放
    • ViewModel 销毁后协程取消
    • 断开后回调注销,不收到旧事件
    • 音频焦点正确请求/释放
  • 稳定性 — 异常处理

    • 全局未捕获异常写入日志
    • SDK 方法调用超时不导致 ANR
    • 权限拒绝(麦克风)不崩溃,显示提示

性能验收

  • 首次启动到可交互 < 2 秒
  • 连接建立 < 5 秒
  • 频道列表滚动 FPS ≥ 55
  • 消息列表滚动 FPS ≥ 55
  • 内存占用(空闲) < 80 MB
  • 内存占用(语音中) < 120 MB
  • 无 ANR 发生
  • 无内存泄漏(LeakCanary 或 Profiler 检测)

代码质量验收

  • 所有 ViewModel 在 onCleared 中释放资源
  • 所有协程使用 viewModelScope
  • 所有 SDK 调用有 try-catch 保护
  • 所有 JSON 解析有异常处理和默认值
  • 无硬编码的字符串资源(使用 strings.xml
  • 无硬编码的颜色值(使用主题色)

测试用例

场景 操作 预期结果
亮色主题 切换到亮色 全局亮色,图标为太阳
暗色主题 切换到暗色 全局暗色,图标为月亮
跟随系统 切换到跟随系统 跟随系统设置,图标为亮度自动
主题持久化 切换主题 → 杀掉应用 → 重启 主题保持上次选择
空频道 连接无频道服务器 显示空状态提示
长消息 输入 1000 字符发送 发送成功,正常显示
特殊字符名 昵称含 emoji/特殊符号 正常显示,不崩溃
快速切换频道 连续快速点击频道 无崩溃,最终停留在正确频道
快速发送消息 连续快速点击发送 消息按序发送,无丢失
语音中切换主题 发言中切换暗色/亮色 主题切换,语音不中断
内存检查 连接 → 断开 → 重复 10 次 内存无持续增长
崩溃日志 触发未捕获异常 crash.log 文件生成

六、参考文档

  • docs/UI架构设计.md - 2.1 服务器配置页(主题切换按钮)、4.4 主题切换
  • docs/implementation/03_服务器配置页.md - ServerConfigScreen 集成点
  • docs/implementation/05_频道列表页.md - ChannelTreeList 性能优化
  • docs/implementation/07_聊天页.md - MessageList 性能优化
  • docs/implementation/09_断开连接.md - 资源释放、回调注销
  • docs/implementation/11_卡片与全局交互.md - 卡片主题适配
  • CLAUDE.md - 测试说明