33 KiB
33 KiB
步骤 12:主题与收尾
实现暗色主题、边缘情况处理、稳定性优化。 对应设计:
docs/UI架构设计.md- 4.4 主题切换 依赖步骤:11(卡片与全局交互)
一、目标
- 暗色/亮色主题切换 — 全局 Material 3 动态主题
- 主题持久化 — DataStore 保存用户选择,启动时自动应用
- 边缘情况处理 — 空状态、异常输入、极端场景覆盖
- 内存泄漏检查 — ViewModel / 协程 / 回调 / 音频资源释放
- 性能优化 — 列表滚动、重组范围、图片/动画优化
- 最终集成验证 — 全链路冒烟测试
二、任务清单
12.1 主题系统
目标:实现 Material 3 暗色/亮色主题切换,全局生效并持久化用户选择。
对应设计:docs/UI架构设计.md 4.4 主题切换:
- 入口位置:服务器配置页右上角 🌙 图标
- 切换方式:点击在亮色/暗色主题间切换
- 持久化:选择保存到本地配置,下次启动自动应用
- 影响范围:全局所有页面和卡片
任务:
-
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 } } } -
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) } } } -
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 ) } -
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 ) } } } } } -
服务器配置页主题切换按钮
对应
docs/UI架构设计.md2.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 边缘情况
目标:覆盖各种边缘场景,确保应用在异常输入、极端数据、特殊字符等情况下不崩溃。
任务:
-
空状态处理
为所有列表和数据展示区域提供空状态 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 暂无发言 — -
输入验证加固
// 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 = "" ) -
频道名/成员名特殊字符处理
// 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 ) } -
极端数据场景处理
场景 处理方式 频道数 > 100 使用 LazyColumn 虚拟化,避免一次性渲染 成员数 > 500 LazyColumn 虚拟化 + 分页加载 消息数 > 1000 限制内存中保留最近 200 条,其余从归档加载 频道名为空 显示 "(未命名频道)" 成员昵称为空 显示 "未知用户" 消息内容为空 不显示该消息,记录日志 服务器返回异常 JSON try-catch + 默认值,不崩溃 SDK 方法调用超时 设置 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 稳定性优化
目标:确保资源正确释放、协程不泄漏、音频设备正确管理,提升应用稳定性。
任务:
-
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 } } -
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() } } -
协程作用域安全
// 所有 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 { ... } -
音频设备切换与焦点管理
// 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) } -
音频录制/播放设备异常处理
// 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 } } -
列表性能优化
// 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- 测试说明