首次推送
This commit is contained in:
@@ -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 {
|
||||
|
||||
/**
|
||||
* 服务器地址验证
|
||||
* 支持:域名、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` - 测试说明
|
||||
Reference in New Issue
Block a user