首次推送

This commit is contained in:
sansen
2026-07-20 19:01:03 +08:00
parent ea01b9cf99
commit ef1bf61f9f
4484 changed files with 937163 additions and 1 deletions
+998
View File
@@ -0,0 +1,998 @@
# 步骤 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` - 测试说明