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

999 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 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 {
/**
* 服务器地址验证
* 支持:域名、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. **频道名/成员名特殊字符处理**
```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` - 测试说明