# 步骤 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<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 — 主题状态管理**

   ```kotlin
   // 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 主题配置**

   ```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<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` - 测试说明
