# 步骤 12:主题与收尾 > 实现暗色主题、边缘情况处理、稳定性优化。 > 对应设计:`docs/UI架构设计.md` - 4.4 主题切换 > 依赖步骤:11(卡片与全局交互) --- ## 一、目标 - [ ] 暗色/亮色主题切换 — 全局 Material 3 动态主题 - [ ] 主题持久化 — DataStore 保存用户选择,启动时自动应用 - [ ] 边缘情况处理 — 空状态、异常输入、极端场景覆盖 - [ ] 内存泄漏检查 — ViewModel / 协程 / 回调 / 音频资源释放 - [ ] 性能优化 — 列表滚动、重组范围、图片/动画优化 - [ ] 最终集成验证 — 全链路冒烟测试 --- ## 二、任务清单 ### 12.1 主题系统 **目标**:实现 Material 3 暗色/亮色主题切换,全局生效并持久化用户选择。 **对应设计**:`docs/UI架构设计.md` 4.4 主题切换: - 入口位置:服务器配置页右上角 🌙 图标 - 切换方式:点击在亮色/暗色主题间切换 - 持久化:选择保存到本地配置,下次启动自动应用 - 影响范围:全局所有页面和卡片 **任务**: 1. **ThemeMode 枚举与 DataStore 持久化** ```kotlin // ui/theme/ThemeMode.kt enum class ThemeMode { LIGHT, // 亮色 DARK, // 暗色 SYSTEM // 跟随系统(默认) } ``` ```kotlin // 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 = 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 — 主题状态管理** ```kotlin // viewmodel/ThemeViewModel.kt class ThemeViewModel(application: Application) : AndroidViewModel(application) { private val themePreferences = ThemePreferences(application) val themeMode: StateFlow = 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 主题配置** ```kotlin // 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 集成** ```kotlin // 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 布局 — 右上角主题图标: ```kotlin // 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: ```kotlin // 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. **输入验证加固** ```kotlin // data/InputValidator.kt object InputValidator { /** * 服务器地址验证 * 支持:域名、IP(v4/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. **频道名/成员名特殊字符处理** ```kotlin // 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. **全局异常捕获** ```kotlin // 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 生命周期管理** ```kotlin // 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 回调生命周期管理** ```kotlin // 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. **协程作用域安全** ```kotlin // 所有 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. **音频设备切换与焦点管理** ```kotlin // 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. **音频录制/播放设备异常处理** ```kotlin // 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. **列表性能优化** ```kotlin // ui/screens/ChannelListScreen.kt @Composable fun ChannelTreeList( channelTree: List, 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, 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` - 测试说明