首次推送

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
+111
View File
@@ -0,0 +1,111 @@
# 实施总览
> 本文档是 TeamSpeak Android 客户端实施计划的主索引,将设计文档拆分为可执行的实施步骤。
> 依据:`docs/sdk-bridge-api.md`、`docs/UI架构设计.md`、`docs/流程/`
---
## 〇、已完成工作
| 阶段 | 状态 | 说明 |
| --- | --- | --- |
| Go 层能力封装 | ✅ 完成 | `go/teamspeak/bridge.go` 已封装全部 SDK 能力,gomobile 编译为 AAR |
| Bridge 层实现 | ✅ 完成 | `TSBridge.kt` 单例直接包装 gomobile 导出的 `TSClient`,提供 Kotlin 友好 API |
**当前架构**
```
Go SDK (teamspeak-go) → gomobile → AAR → TSBridge.kt (应用层桥接,单例)
```
**事件流**Go goroutine → JNI callbackGo goroutine 线程)→ TSBridge → ViewModel → StateFlow → UI
详见 [sdk-bridge-api.md](../sdk-bridge-api.md)。
---
## 一、实施步骤索引
| 步骤 | 文档 | 主要内容 | 对应流程 | 依赖步骤 | 状态 |
| --- | --- | --- | --- | --- | --- |
| 01 | [项目基础设施](01_项目基础设施.md) | 项目结构、构建系统、依赖配置 | — | — | ✅ |
| 02 | [Bridge 层实现](02_Bridge层实现.md) | TSBridge → TsClient 桥接、事件监听 | — | 01 | ✅ |
| 03 | [服务器配置页](03_服务器配置页.md) | 连接 UI、输入验证、最近连接 | 01 连接服务器 | 02 | ⬚ |
| 04 | [连接与首次同步](04_连接与首次同步.md) | 连接流程、Identity、首次同步 | 01 + 08① | 03 | ⬚ |
| 05 | [频道列表页](05_频道列表页.md) | 频道树渲染、成员列表、未读指示 | 02 浏览频道 | 04 | ⬚ |
| 06 | [频道切换](06_频道切换.md) | 频道切换流程、密码弹窗、ClientMove | 03 切换频道 | 05 | ⬚ |
| 07 | [聊天页](07_聊天页.md) | 消息列表、发送消息、消息归档 | 04 文本消息 | 06 | ⬚ |
| 08 | [语音通信](08_语音通信.md) | PTT 按钮、Opus 编码、语音发送/接收 | 05 语音通信 | 06 | ⬚ |
| 09 | [断开连接](09_断开连接.md) | 主动断开、被动断开、被踢处理 | 07 断开连接 | 08 | ⬚ |
| 10 | [状态同步进阶](10_状态同步进阶.md) | 增量同步、补偿同步、重连全量同步 | 08 状态同步 ②③⑥ | 09 | ⬚ |
| 11 | [卡片与全局交互](11_卡片与全局交互.md) | 服务器详情卡、频道详情卡、语音卡、Poke | UI架构 三、四 | 10 | ⬚ |
| 12 | [主题与收尾](12_主题与收尾.md) | 暗色主题、边缘情况、稳定性 | — | 11 | ⬚ |
| 13 | [EventBus 架构](../流程/09_EventBus架构.md) | TS 事件与渲染线程分离、事件合并/节流 | — | 02 | ⬚ |
---
## 二、实施原则
1. **先跑通最小闭环**:连接 → 同步 → 显示频道 → 切换频道 → 发消息 → 断开
2. **每步可验证**:每个步骤完成后应能在真机或模拟器上运行并验证核心功能
3. **Bridge 层已完成**Go ↔ Kotlin 通信已通过 `TsClient` 封装,后续步骤直接调用
4. **状态管理清晰**:严格遵循流程文档中的状态树和事件依赖
5. **UI 后于逻辑**:先确保数据流正确,再打磨 UI 细节
---
## 三、技术栈确认
| 层级 | 技术 | 说明 |
| --- | --- | --- |
| 协议层 | Go + teamspeak-go | 编译为 AAR,通过 gomobile 绑定 |
| Kotlin 封装层 | TSBridge (单例) | 直接包装 gomobile TSClientJSON 传递复杂数据 |
| 桥接层 | TSBridge (单例) | 直接包装 gomobile TSClientJNI 回调转 EventBus 事件 |
| 事件总线 | EventBus (单例) | 事件收集、合并、节流,TS 线程与渲染线程分离 |
| UI 层 | Kotlin + Jetpack Compose | Material Design 3 主题 |
| 状态管理 | ViewModel + StateFlow | 单向数据流,通过 EventBus 接收 TS 事件 |
| 音频 | Opus 编解码 | Android MediaCodec 或第三方库 |
| 网络 | UDP (SDK) + TCP (文件传输) | SDK 内部处理 |
---
## 四、文件结构预期
```
android/app/src/main/java/com/tsmobile/app/
├── MainActivity.kt # 入口
├── TSBridge.kt # 应用层桥接(直接包装 gomobile TSClient
├── EventBus.kt # 事件总线(TS 事件收集、合并、分发)
├── data/ # 数据模型
│ ├── Models.kt # 频道、成员、消息等数据类
│ └── Repository.kt # 状态仓库
├── voice/ # 语音服务
│ ├── VoiceService.kt # 音频管线(采集、编码、解码、播放)
│ ├── OpusEncoder.kt # Opus 编码器
│ └── OpusDecoder.kt # Opus 解码器
├── ui/
│ ├── theme/ # Material 3 主题
│ ├── components/ # 可复用组件
│ └── screens/
│ ├── ServerConfigScreen.kt
│ ├── ChannelListScreen.kt
│ └── ChatScreen.kt
└── viewmodel/
├── ServerViewModel.kt # 连接生命周期(监听 Connected/Disconnected/Kicked
├── ChannelViewModel.kt # 频道列表(监听 ClientEnter/Leave/Moveddebounce 刷新)
├── ChatViewModel.kt # 消息归档(监听 TextMessage
└── VoiceViewModel.kt # 语音控制(VoiceService 直接处理,不经 EventBus
```
---
## 五、风险与注意事项
1. **gomobile 限制已解决**`TSBridge.kt` 直接包装 gomobile 导出的 `TSClient`,通过 JSON 字符串传递复杂数据
2. **线程安全**Go JNI 回调在 Go goroutine 线程上执行(非 Android 主线程),通过 `EventBus.emit()` 统一投递,ViewModel 在 `Dispatchers.Main` 上消费事件
3. **事件合并**:高频成员变化事件(ClientEnter/Leave/Moved)通过 debounce 合并,避免事件风暴导致频繁 refreshClientList
4. **Opus 编解码**SDK 不内置,需应用层集成(`voice/OpusEncoder.kt``voice/OpusDecoder.kt`
5. **语音延迟敏感**VoiceData 不经过 EventBus,由 VoiceService 在 Dispatchers.IO 上直接处理
6. **文件传输**:本文档范围暂不实现(见流程 06 说明)
7. **Identity 管理**:首次生成后需持久化存储
8. **TSBridge 是全局单例**:同一时间只能有一个活跃连接
@@ -0,0 +1,248 @@
# 步骤 01:项目基础设施
> 搭建项目骨架、构建系统、依赖配置,确保能编译运行空白应用。
---
## 一、目标
- [x] 建立 Android 项目基本结构
- [x] 配置 Go + gomobile 构建流程
- [x] 集成 teamspeak-go SDK
- [x] 确保能编译生成空白 APK
---
## 二、任务清单
### 2.1 Android 项目结构
**根目录**`android/`(在 IDE 中打开此目录,非仓库根目录)
```
android/
├── build.gradle.kts # 根构建脚本(插件声明)
├── settings.gradle.kts # 项目设置(仓库、模块)
├── gradle.properties # Gradle 属性
├── gradlew / gradlew.bat # Gradle Wrapper
└── app/
├── build.gradle.kts # 应用构建脚本(依赖、SDK 版本)
├── libs/ # gomobile AAR 产物存放处
│ └── teamspeak.aar # Go 编译产物(git ignore
└── src/main/
├── AndroidManifest.xml # 清单文件
└── java/com/tsmobile/app/
├── MainActivity.kt # 入口 Activity
├── TSBridge.kt # Go 桥接封装
└── voice/ # 语音模块(后续步骤扩展)
├── OpusEncoder.kt
├── OpusDecoder.kt
└── VoiceService.kt
```
**关键配置项**
| 配置 | 值 | 说明 |
| --- | --- | --- |
| `namespace` | `com.tsmobile.app` | 包名 |
| `compileSdk` | 35 | Android 15 |
| `minSdk` | 26 | Android 8.0gomobile 要求最低 API 26 |
| `targetSdk` | 35 | 目标 Android 15 |
| `jvmTarget` | 17 | Java 17 |
| `compose` | true | 启用 Jetpack Compose |
**AndroidManifest.xml 权限声明**
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```
- `INTERNET` / `ACCESS_NETWORK_STATE`:连接 TeamSpeak 服务器
- `RECORD_AUDIO`:语音功能(运行时动态申请)
---
### 2.2 Go 模块配置
**目录**`go/`
```
go/
├── go.mod # Go 模块定义
├── go.sum # 依赖校验
├── teamspeak/ # gomobile 导出包(bridge.go 所在)
└── _patches/ # 上游补丁(不可删除)
└── github.com/honeybbq/teamspeak-go/
```
**go.mod 关键内容**
```go
module tsmobile
go 1.26.0
require github.com/honeybbq/teamspeak-go v0.2.0
// 本地补丁替换(必须保留)
replace github.com/honeybbq/teamspeak-go => ./_patches/github.com/honeybbq/teamspeak-go
```
**gomobile 工具声明**go.mod 中):
```go
tool golang.org/x/mobile/cmd/gobind
```
**关键依赖**
| 依赖 | 用途 |
| --- | --- |
| `github.com/honeybbq/teamspeak-go` | TeamSpeak 协议实现 |
| `golang.org/x/mobile` | gomobile 工具链 |
| `golang.org/x/crypto` | 加密支持 |
**本地补丁说明**
`go/_patches/github.com/honeybbq/teamspeak-go/` 包含修改后的上游代码:
- 修复 32 位整数溢出问题(`math.MaxUint32` → 平台相关限制)
- 通过 `go.mod``replace` 指令应用
> ⚠️ **不可删除**此目录或移除 replace 指令,否则编译或运行时会出错。
---
### 2.3 构建脚本
项目提供两个构建脚本,位于仓库根目录:
| 脚本 | 平台 | 说明 |
| --- | --- | --- |
| `build.bat` | Windows | 批处理脚本 |
| `build.sh` | Linux/macOS | Shell 脚本 |
**构建流程分 4 步**
```
[1/4] 检查依赖 → [2/4] 下载 Go 依赖 → [3/4] Go → AAR → [4/4] Android → APK
```
**Step 1:检查依赖**
- 检查 `go` 命令是否可用
- 检查 `gomobile` 是否安装(不存在则自动安装并 init)
- 检查 `ANDROID_HOME` 环境变量
**Step 2:下载 Go 依赖**
```bash
cd go && go mod tidy
```
**Step 3Go → AAR**(核心步骤)
```bash
# Windows 需先设置编码
set JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
gomobile bind \
-target=android \
-androidapi=26 \
-ldflags="-linkmode=external -extldflags=-Wl,--hash-style=both" \
-o android/app/libs/teamspeak.aar \
./teamspeak
```
**必须的 ldflags**
- `-linkmode=external`:使用 NDK 外部链接器,防止 Go 运行时与 Android 信号处理冲突导致 SIGSEGV
- `-extldflags=-Wl,--hash-style=both`:生成兼容的 ELF 哈希表,防止 `dlopen failed: empty/missing DT_HASH`
**Step 4Android → APK**
```bash
cd android && ./gradlew assembleDebug
```
**输出**`android/app/build/outputs/apk/debug/app-debug.apk`
---
### 2.4 依赖管理
#### Kotlin/Android 依赖(app/build.gradle.kts
**Compose 相关**
| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| `compose-bom` | 2024.12.01 | Compose 版本目录 |
| `material3` | BOM 管理 | Material Design 3 |
| `material-icons-extended` | BOM 管理 | 扩展图标库 |
| `ui-tooling` | BOM 管理 | 调试工具 |
**架构组件**
| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| `activity-compose` | 1.9.3 | Compose Activity 集成 |
| `navigation-compose` | 2.8.5 | 导航框架 |
| `lifecycle-runtime-compose` | 2.8.7 | 生命周期感知 |
| `lifecycle-viewmodel-compose` | 2.8.7 | ViewModel 集成 |
**工具库**
| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| `datastore-preferences` | 1.1.1 | 持久化键值存储(替代 SharedPreferences |
| `kotlinx-coroutines-android` | 1.9.0 | 协程支持 |
| `kotlinx-serialization-json` | 1.7.3 | JSON 序列化 |
**gomobile AAR 引入方式**
```kotlin
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
```
`teamspeak.aar` 放入 `app/libs/` 目录即可自动引入。
#### Gradle 插件(根 build.gradle.kts
| 插件 | 版本 | 用途 |
| --- | --- | --- |
| `com.android.application` | 8.7.3 | Android 构建 |
| `org.jetbrains.kotlin.android` | 2.1.0 | Kotlin Android 支持 |
| `org.jetbrains.kotlin.plugin.compose` | 2.1.0 | Compose 编译器插件 |
| `org.jetbrains.kotlin.plugin.serialization` | 2.1.0 | 序列化插件 |
---
## 三、验收标准
| # | 验证项 | 验证方法 |
| --- | --- | --- |
| 1 | Go 模块可正常编译 | `cd go && go build ./teamspeak` 无报错 |
| 2 | gomobile 生成 AAR | 运行 `build.bat` / `build.sh` 第 3 步,`app/libs/teamspeak.aar` 存在且大小 > 0 |
| 3 | Android 项目可编译 | `cd android && gradlew.bat assembleDebug` 成功 |
| 4 | 空白 APK 可安装 | 安装 `app-debug.apk` 到设备/模拟器,启动无崩溃 |
| 5 | TSBridge 可调用 | 在 MainActivity 中添加 `TSBridge.isConnected()` 调用,编译通过 |
---
## 四、已完成清单
| 项目 | 状态 | 文件 |
| --- | --- | --- |
| Android 项目结构 | ✅ | `android/` 目录 |
| Gradle 构建配置 | ✅ | `android/build.gradle.kts`, `android/app/build.gradle.kts` |
| AndroidManifest | ✅ | `android/app/src/main/AndroidManifest.xml` |
| Go 模块配置 | ✅ | `go/go.mod` |
| 本地补丁 | ✅ | `go/_patches/` |
| 构建脚本 | ✅ | `build.bat`, `build.sh` |
| TSBridge 封装 | ✅ | `android/app/src/main/java/com/tsmobile/app/TSBridge.kt` |
| VoiceService 骨架 | ✅ | `android/app/src/main/java/com/tsmobile/app/voice/` |
---
## 五、参考文档
- `CLAUDE.md` — 构建命令、关键 flags 说明
- `docs/sdk文档-go.md` — SDK 依赖与 API
- `docs/UI架构设计.md` — 整体架构设计
+424
View File
@@ -0,0 +1,424 @@
# 步骤 02:Bridge 层实现(✅ 已完成)
> 实现 Go ↔ Kotlin 桥接层,包括 Go 侧 TSClient 导出、Kotlin 友好封装(TsClient.kt + TsModels.kt)、应用层桥接(TSBridge.kt)。
---
## 一、目标
- [x] Go 侧 TSClient 结构体及方法导出(`go/teamspeak/bridge.go`
- [x] gomobile 编译为 AAR`android/app/libs/teamspeak.aar`
- [x] Kotlin 友好封装层(`TsClient.kt` + `TsModels.kt`
- [x] 应用层桥接(`TSBridge.kt`
---
## 二、架构总览
```
┌─────────────────────────────────────────────────────────────┐
│ Kotlin 应用层 │
│ │
│ ViewModel ──→ TSBridge (object) ──→ TsClient (object) │
│ │ │ │
│ │ TsListener 回调 │ Kotlin 友好 API │
│ ↑ │ │
│ └── TsListener ─────────┘ │
│ │
├─────────────────────────────────────────────────────────────┤
│ gomobile 自动生成层 │
│ │
│ teamspeak.TSClient (Java) ← Go TSClient struct │
│ teamspeak.EventCallback (Java) ← Go EventCallback interface│
│ teamspeak.* 数据类 (Java) ← Go 导出结构体 │
│ │
├─────────────────────────────────────────────────────────────┤
│ Go 层 │
│ │
│ bridge.go (TSClient + EventCallback) ──→ teamspeak-go SDK │
│ │ │ │
│ │ 事件队列 + 单消费者 goroutine │ On* 回调 │
│ └──→ JNI 回调 ──→ EventCallback │ │
│ │
└─────────────────────────────────────────────────────────────┘
```
**数据流**
| 方向 | 路径 | 说明 |
| --- | --- | --- |
| Kotlin → Go | `TSBridge``TsClient` → gomobile `TSClient` → Go `Client` | 用户操作 |
| Go → Kotlin | Go `On*` → 事件队列 → JNI → `EventCallback``TsClient` 转换 → `TsListener``TSBridge` | 事件推送 |
---
## 三、三层架构详解
### 3.1 Go 层(bridge.go
**文件**`go/teamspeak/bridge.go`
Go 侧 Bridge 是 gomobile 导出的核心,将 teamspeak-go SDK 封装为可供 Kotlin 调用的 `TSClient` 类。
#### 导出的接口与结构
```go
// EventCallback — gomobile 导出的回调接口
type EventCallback interface {
OnConnected()
OnDisconnected(message string)
OnTextMessage(msg *TextMsg)
OnClientEnter(client *ServerClientView)
OnClientLeave(client *ServerClientView)
OnClientMoved(client *ClientMoved)
OnKicked(reason *ServerError)
OnTalkStatusChanged(talker *TalkStatusChange)
OnClientIDsDone()
OnServerError(error *ServerError)
}
// TSClient — gomobile 导出的桥接客户端
type TSClient struct {
client *teamspeak.Client
callback EventCallback
}
// 导出的数据结构(适配 gomobile 限制)
type ServerClientView struct { ... } // 频道成员视图
type ClientInfo struct { ... } // 完整客户端信息
type ChannelListItem struct { ... } // 频道列表项
type TextMsg struct { ... } // 文本消息
type ClientMoved struct { ... } // 客户端移动事件
type ServerError struct { ... } // 服务器错误
type TalkStatusChange struct { ... } // 说话状态变化
type ChannelListResult struct { ... } // 频道列表结果(含数组)
```
#### 关键设计决策
1. **字符串传递 ID**gomobile 不支持 `uint64` → Kotlin Long 的安全映射,统一用 `string`
2. **结构体包装**gomobile 不支持 `[]*T`,用 `ChannelListResult` 包装 `ChannelListItem[]`
3. **错误用字符串**gomobile 不支持 `error` 返回值,用空串=成功
4. **事件队列**SDK 内部 `evtQueue` + 单消费者 goroutine 串行分发,JNI 回调线程安全
### 3.2 Kotlin 友好封装层(TsClient.kt + TsModels.kt
**文件**`android/app/src/main/java/com/tsmobile/app/TsClient.kt``TsModels.kt`
这一层将 gomobile 生成的 Java 对象转为 Kotlin 友好接口,解决:
- 所有 API 为 getter/setter 而非属性
- `ChannelListItem[]` 需要手动转 `List<TsChannel>`
- 事件回调参数是 `Object` 类型需要强转
- group 类型是 `Int` 需要映射为 `ChanGroupType` 枚举
#### TsClient 对象
```kotlin
object TsClient {
// 连接
fun connect(identity, host, nickname, password, defaultChannel): Boolean
fun disconnect()
// 事件监听
fun setListener(listener: TsListener?)
// 查询(返回 Kotlin 友好类型)
fun getChannelList(): List<TsChannel>
fun getClientList(): List<TsClientInfo>
fun getClientId(): Long
fun getClientInfo(clientId: Long): TsClientInfo?
fun getChannelInfo(channelId: Long): TsChannel?
fun getSelf(): TsSelf
fun getChannelIdsByUid(uid: String, maxDepth: Int): List<Long>
// 属性
val serverVersion: String
val serverIp: String
val serverPlatform: String
val serverName: String
val serverCreated: Long
val serverUptime: Long
val maxClients: Int
val clientsOnline: Int
val channelsOnline: Int
// 操作
fun sendTextMessage(targetMode: Int, targetId: Long, msg: String): Boolean
fun clientMove(clientId: Long, channelId: Long, password: String): Boolean
fun clientPoke(clientId: Long, msg: String): Boolean
fun kickClient(clientId: Long, reasonId: Int, reasonMsg: String): Boolean
fun banClient(uid: String, timeInSeconds: Long, reasonMsg: String): Boolean
fun channelCreate(name: String, properties: Map<String, String>, permissions: List<TSPermission>): Long
fun channelUpdate(channelId: Long, properties: Map<String, String>): Boolean
fun channelDelete(channelId: Long, force: Boolean): Boolean
fun sendVoice(clientId: Long, codec: Int, data: ByteArray): Boolean
}
```
#### TsListener 接口
```kotlin
interface TsListener {
fun onConnected()
fun onDisconnected(error: String?)
fun onClientEnter(client: TsClientInfo)
fun onClientMoved(moved: TsClientMoved)
fun onClientLeave(client: ServerClientView)
fun onTalkStatusChanged(talker: TsTalker)
fun onClientIDsDone()
fun onTextMessage(msg: TsTextMessage)
fun onPoked(msg: TsTextMessage)
fun onKicked(reason: ServerError?)
fun onServerError(error: ServerError)
fun onChannelListChanged()
}
```
#### TsModels 数据类
```kotlin
// 频道
data class TsChannel(val channelListItem: ChannelListItem) {
val channelId get() = channelListItem.getChannelID()
val parentId get() = channelListItem.getParentChannelID()
val name get() = channelListItem.getName()
val order get() = channelListItem.getOrder()
val isPermanent get() = channelListItem.getIsPermanent()
val totalClients get() = channelListItem.getTotalClients()
// ... 更多属性
}
// 客户端
data class TsClientInfo(val serverClientView: ServerClientView) {
val clientId get() = serverClientView.getID()
val channelId get() = serverClientView.getChannelID()
val nickname get() = serverClientView.getNickname()
val uid get() = serverClientView.getUid()
val isTalker get() = serverClientView.getIsTalker()
// ... 更多属性
}
// 自身信息(可写)
class TsSelf internal constructor(
private val view: ServerClientView,
private val info: ClientInfo?
) {
var nickname
get() = view.getNickname()
set(value) { info?.setNickname(value) }
var isTalker
get() = view.getIsTalker()
set(value) { info?.setIsTalker(value) }
var inputMuted
get() = info?.getInputMuted() ?: false
set(value) { info?.setInputMuted(value) }
var outputMuted
get() = info?.getOutputMuted() ?: false
set(value) { info?.setOutputMuted(value) }
// ... 更多可写属性
}
// 移动事件
data class TsClientMoved(
val theClient: ServerClientView?,
val oldChannel: ChannelListItem?,
val newChannel: ChannelListItem?,
val visibility: Int
)
// 说话状态
data class TsTalker(
val client: ServerClientView?,
val isTalking: Boolean
)
// 文本消息
data class TsTextMessage(
val message: String,
val invokerUid: String,
val invokerName: String,
val invokerId: Long,
val targetMode: Int,
val targetClientId: Long,
val targetChannelId: Long
)
// 频道组/服务器组类型
enum class ChanGroupType(val value: Int) { ... }
enum class ChannelGroupType(val value: Int) { ... }
```
### 3.3 应用层桥接(TSBridge.kt
**文件**`android/app/src/main/java/com/tsmobile/app/TSBridge.kt`
TSBridge 是 ViewModel 层访问 Go 桥接的唯一入口,采用 `object` 单例模式。
#### 核心职责
1. **连接管理**:封装 `TsClient.connect()` / `disconnect()`
2. **监听注册**:在 `connect()` 时自动设置 `TsListener`
3. **查询转发**:所有查询方法委托给 `TsClient`
4. **操作转发**:所有操作方法委托给 `TsClient`
#### 完整 API
```kotlin
object TSBridge {
// === 连接管理 ===
fun connect(identity: Any, host: String, nickname: String,
password: String = "", defaultChannel: String = "",
listener: TSBridgeListener? = null): Boolean
fun disconnect()
// === 查询 ===
fun getChannelList(): List<TsChannel>
fun getClientList(): List<TsClientInfo>
fun getClientId(): Long
fun getSelf(): TsSelf
fun getChannelInfo(channelId: Long): TsChannel?
fun getClientInfo(clientId: Long): TsClientInfo?
fun getChannelIdsByUid(uid: String): List<Long>
// === 操作 ===
fun sendTextMessage(targetMode: Int, targetId: Long, msg: String): Boolean
fun clientMove(clientId: Long, channelId: Long, password: String = ""): Boolean
fun clientPoke(clientId: Long, msg: String): Boolean
fun kickClient(clientId: Long, reasonId: Int, reasonMsg: String): Boolean
fun banClient(uid: String, timeInSeconds: Long, reasonMsg: String): Boolean
fun channelCreate(name: String, properties: Map<String, String> = emptyMap(),
permissions: List<TSPermission> = emptyList()): Long
fun channelUpdate(channelId: Long, properties: Map<String, String>): Boolean
fun channelDelete(channelId: Long, force: Boolean = false): Boolean
fun sendVoice(clientId: Long, codec: Int, data: ByteArray): Boolean
// === 服务器属性 ===
val serverVersion: String
val serverIp: String
val serverPlatform: String
val serverName: String
// ...
}
// 回调接口(简化版,供 ViewModel 使用)
interface TSBridgeListener {
fun onConnected()
fun onDisconnected(error: String?)
fun onClientEnter(client: TsClientInfo)
fun onClientMoved(moved: TsClientMoved)
fun onClientLeave(client: ServerClientView)
fun onTalkStatusChanged(talker: TsTalker)
fun onClientIDsDone()
fun onTextMessage(msg: TsTextMessage)
fun onPoked(msg: TsTextMessage)
fun onKicked(reason: ServerError?)
fun onServerError(error: ServerError)
}
```
---
## 四、数据流转详解
### 4.1 连接流程
```
ViewModel: TSBridge.connect(identity, host, nickname, password)
TSBridge: TsClient.connect(identity, host, nickname, password)
TsClient: TSClient.connect(...) // gomobile Java 对象
Go: TSClient.Connect() → NewClient → registerHandlers → Connect → WaitConnected
Go SDK: 收到 welcome → 触发 OnConnected
Go Bridge: EventCallback.OnConnected() // JNI
TsClient: listener?.onConnected() // Kotlin 友好回调
TSBridge: listener?.onConnected()
ViewModel: _state.update { it.copy(connected = true) }
```
### 4.2 查询流程
```
ViewModel: TSBridge.getChannelList()
TSBridge: TsClient.getChannelList()
TsClient: TSClient.requestChannelList() // gomobile
TsClient: result.getChannels().map { TsChannel(it) } // 转为 Kotlin 类型
TSBridge: 返回 List<TsChannel>
ViewModel: _state.update { it.copy(channels = list) }
```
### 4.3 事件推送流程
```
TeamSpeak 服务器: notifyclientmoved
Go SDK: handleNotification → evtQueue → startEventLoop
Go Bridge: EventCallback.OnClientMoved(clientMoved)
↓ JNI
TsClient: TsListener.onClientMoved(TsClientMoved(view, old, new, vis))
TSBridge: listener?.onClientMoved(moved)
ViewModel: 处理移动事件,更新状态
```
---
## 五、gomobile 约束与应对
| 约束 | 影响 | 应对方案 |
| --- | --- | --- |
| 不能导出 `[]string` | 服务器组列表无法直接传递 | `TsClientInfo` 封装为逗号分隔字符串 |
| 不能导出 `[]*T` | 频道/客户端列表无法返回切片 | `ChannelListResult` 包装 + `TsClient``List` |
| 不能导出 `map[string]string` | 属性列表无法传递 | `channelCreate`/`channelUpdate` 接受 `Map`,内部转 gomobile 类型 |
| 不能导出 `error` | 方法无法返回错误 | `Boolean` 返回值(true=成功) |
| `uint64` 映射为 `long` | 频道 ID 可能溢出 | 统一用 `string` 传递 ID |
| 回调在 JNI 线程 | 不能直接操作 UI | `TsListener` 回调 → ViewModel + StateFlow 中转 |
| 所有字段为 getter/setter | Kotlin 不友好 | `TsModels` 包装为 `val`/`var` 属性 |
---
## 六、文件清单
| 文件 | 说明 |
| --- | --- |
| `go/teamspeak/bridge.go` | Go 侧桥接,导出 TSClient + EventCallback |
| `android/app/libs/teamspeak.aar` | gomobile 编译产物 |
| `android/app/src/main/java/com/tsmobile/app/TsClient.kt` | Kotlin 友好封装(TsClient 对象) |
| `android/app/src/main/java/com/tsmobile/app/TsModels.kt` | Kotlin 数据类(TsChannel, TsClientInfo 等) |
| `android/app/src/main/java/com/tsmobile/app/TSBridge.kt` | 应用层桥接(单例) |
---
## 七、验收标准
| # | 验证项 | 验证方法 |
| --- | --- | --- |
| 1 | bridge.go 可编译 | `cd go && go build ./teamspeak` 无报错 |
| 2 | gomobile 生成 AAR | `gomobile bind ...` 成功 |
| 3 | TsClient.connect() 可调用 | 编译通过,连接测试服务器成功 |
| 4 | TsListener 回调正常 | 连接后收到 `onConnected``onClientEnter` 等 |
| 5 | 查询返回 Kotlin 类型 | `getChannelList()` 返回 `List<TsChannel>` |
| 6 | 操作方法正常 | `sendTextMessage``clientMove` 等返回 true |
| 7 | 断开连接无崩溃 | `disconnect()` 后应用正常退出 |
---
## 八、参考文档
- `docs/sdk-bridge-api.md` — TsClient API 完整参考
- `docs/流程/00_总览.md` — 运行架构、三条核心通道
- `docs/流程/01_连接服务器.md` — 连接时序、事件依赖
- `CLAUDE.md` — gomobile 限制说明、JNI 线程注意事项
@@ -0,0 +1,871 @@
# 步骤 03:服务器配置页
> 实现服务器配置页 UI,包括输入验证、连接按钮状态机、最近连接列表。
> 对应流程:01 连接服务器(初始化配置部分)
> 依赖步骤:02Bridge 层)
---
## 一、目标
- [ ] ServerConfigScreen 三段式页面布局(品牌区 / 输入区 / 最近连接)
- [ ] 输入框组件(地址、昵称、密码)
- [ ] 输入验证逻辑(必填校验、格式校验)
- [ ] 连接按钮状态机(空闲 → 连接中 → 成功/失败/超时)
- [ ] 最近连接列表(DataStore 持久化,快速连接)
- [ ] ServerViewModel 状态管理
---
## 二、任务清单
### 3.1 数据模型
**文件**`android/app/src/main/java/com/tsmobile/app/data/Models.kt`
```kotlin
import kotlinx.serialization.Serializable
/**
* 服务器连接配置。
* 用于 ViewModel 状态和最近连接列表持久化。
*/
@Serializable
data class ServerConfig(
val address: String = "", // 服务器地址(域名/IP/TSDNS
val nickname: String = "", // 昵称
val password: String = "", // 服务器密码(可选)
val defaultChannel: String = "", // 默认频道(可选)
val defaultChannelPassword: String = "", // 默认频道密码(可选)
)
/**
* 最近连接记录。
* 点击可快速连接(复用 address/nickname/password)。
*/
@Serializable
data class RecentConnection(
val address: String,
val nickname: String,
val password: String = "",
val lastConnectedAt: Long = 0L, // 最后连接时间戳(epoch ms
val lastSucceeded: Boolean = false, // 上次连接是否成功
)
```
### 3.2 最近连接存储
**文件**`android/app/src/main/java/com/tsmobile/app/data/RecentConnectionsStore.kt`
使用 Jetpack DataStore Preferences 持久化最近连接列表。
```kotlin
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
// Context 扩展属性
private val Context.recentConnectionsDataStore: DataStore<Preferences>
by preferencesDataStore(name = "recent_connections")
class RecentConnectionsStore(private val context: Context) {
companion object {
private const val MAX_RECENT = 10
private val RECENTS_KEY = stringPreferencesKey("recents_json")
}
/**
* 观察最近连接列表(按 lastConnectedAt 倒序)。
*/
fun observeRecents(): Flow<List<RecentConnection>> {
return context.recentConnectionsDataStore.data.map { prefs ->
val json = prefs[RECENTS_KEY] ?: return@map emptyList()
try {
Json.decodeFromString<List<RecentConnection>>(json)
.sortedByDescending { it.lastConnectedAt }
} catch (_: Exception) {
emptyList()
}
}
}
/**
* 记录一次连接(成功或失败)。
* 相同 address + nickname 去重,保留最新记录。
*/
suspend fun addRecent(recent: RecentConnection) {
context.recentConnectionsDataStore.edit { prefs ->
val current = try {
Json.decodeFromString<List<RecentConnection>>(prefs[RECENTS_KEY] ?: "[]")
} catch (_: Exception) {
emptyList()
}.toMutableList()
// 去重:移除相同 address + nickname 的旧记录
current.removeAll { it.address == recent.address && it.nickname == recent.nickname }
current.add(0, recent) // 插入到头部
// 限制最多 MAX_RECENT 条
val trimmed = current.take(MAX_RECENT)
prefs[RECENTS_KEY] = Json.encodeToString(trimmed)
}
}
/**
* 删除单条记录。
*/
suspend fun removeRecent(address: String, nickname: String) {
context.recentConnectionsDataStore.edit { prefs ->
val current = try {
Json.decodeFromString<List<RecentConnection>>(prefs[RECENTS_KEY] ?: "[]")
} catch (_: Exception) {
emptyList()
}.toMutableList()
current.removeAll { it.address == address && it.nickname == nickname }
prefs[RECENTS_KEY] = Json.encodeToString(current)
}
}
/**
* 清空所有记录。
*/
suspend fun clearAll() {
context.recentConnectionsDataStore.edit { prefs ->
prefs.remove(RECENTS_KEY)
}
}
}
```
**存储方案选择**
| 方案 | 优缺点 | 结论 |
| --- | --- | --- |
| SharedPreferences | 简单,但已弃用 | ❌ |
| DataStore Preferences | 现代、协程友好、类型安全 | ✅ 采用 |
| Room DB | 过重,数据量小(最多 10 条) | ❌ |
### 3.3 页面布局
**文件**`android/app/src/main/java/com/tsmobile/app/ui/screens/ServerConfigScreen.kt`
#### 三段式结构
```
┌──────────────────────────────┐
│ 上:品牌区 │
│ [Logo] │
│ TeamSpeak Mobile │
│ 连接到你的 TeamSpeak 服务器 │
│ [🌙 主题] │ ← 右上角主题切换
├──────────────────────────────┤
│ 中:输入区 │
│ 服务器地址 │
│ ┌──────────────────────────┐│
│ │ ts.example.com ││
│ └──────────────────────────┘│
│ 昵称 │
│ ┌──────────────────────────┐│
│ │ 我的昵称 ││
│ └──────────────────────────┘│
│ 密码(可选) │
│ ┌──────────────────────────┐│
│ │ •••••• ││
│ └──────────────────────────┘│
│ ┌──────────────────────────┐│
│ │ 连接服务器 ││ ← 按钮状态见 3.5
│ └──────────────────────────┘│
├──────────────────────────────┤
│ 下:最近连接 │
│ 最近连接 │
│ ┌──────────────────────────┐│
│ │ 🟢 ts.myserver.com ││ ← 点击快速连接
│ │ MyNickname · 2小时前 ││
│ ├──────────────────────────┤│
│ │ 🔴 ts.other.com ││
│ │ Bob · 昨天 ││
│ └──────────────────────────┘│
│ [清空最近记录] │ ← 长按删除单条
└──────────────────────────────┘
```
#### Compose 结构
```kotlin
@Composable
fun ServerConfigScreen(
viewModel: ServerViewModel,
onNavigateToChannelList: () -> Unit, // 连接成功后跳转
) {
val state by viewModel.state.collectAsState()
val recents by viewModel.recents.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 上:品牌区
BrandSection(
onToggleTheme = { viewModel.toggleTheme() }
)
// 中:输入区
InputSection(
address = state.address,
nickname = state.nickname,
password = state.password,
onAddressChange = viewModel::updateAddress,
onNicknameChange = viewModel::updateNickname,
onPasswordChange = viewModel::updatePassword,
connectState = state.connectState,
errorMessage = state.errorMessage,
onConnect = { viewModel.connect() },
)
// 下:最近连接
RecentConnectionsSection(
recents = recents,
onConnectRecent = { recent -> viewModel.quickConnect(recent) },
onRemoveRecent = { recent -> viewModel.removeRecent(recent) },
onClearAll = { viewModel.clearRecents() },
)
}
// 连接成功后自动跳转
LaunchedEffect(state.connectState) {
if (state.connectState == ConnectState.SUCCESS) {
onNavigateToChannelList()
}
}
}
```
#### 品牌区组件
```kotlin
@Composable
private fun BrandSection(onToggleTheme: () -> Unit) {
Box(modifier = Modifier.fillMaxWidth().padding(top = 48.dp)) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Logo(使用 drawable 资源或 placeholder
Icon(
imageVector = Icons.Default.Dns, // 临时图标
contentDescription = "TeamSpeak",
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(12.dp))
Text(
text = "TeamSpeak Mobile",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "连接到你的 TeamSpeak 服务器",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// 主题切换按钮(右上角)
IconButton(
onClick = onToggleTheme,
modifier = Modifier.align(Alignment.TopEnd).padding(end = 8.dp),
) {
Icon(Icons.Default.DarkMode, contentDescription = "切换主题")
}
}
}
```
#### 输入区组件
```kotlin
@Composable
private fun InputSection(
address: String,
nickname: String,
password: String,
onAddressChange: (String) -> Unit,
onNicknameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
connectState: ConnectState,
errorMessage: String?,
onConnect: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
// 服务器地址
OutlinedTextField(
value = address,
onValueChange = onAddressChange,
label = { Text("服务器地址") },
placeholder = { Text("ts.example.com") },
singleLine = true,
isError = connectState == ConnectState.FAILED && address.isBlank(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
Spacer(Modifier.height(12.dp))
// 昵称
OutlinedTextField(
value = nickname,
onValueChange = onNicknameChange,
label = { Text("昵称") },
placeholder = { Text("我的昵称") },
singleLine = true,
isError = connectState == ConnectState.FAILED && nickname.isBlank(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
Spacer(Modifier.height(12.dp))
// 密码(可选)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text("密码(可选)") },
placeholder = { Text("••••••") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
)
Spacer(Modifier.height(24.dp))
// 连接按钮(状态机驱动)
ConnectButton(
state = connectState,
errorMessage = errorMessage,
onClick = onConnect,
)
}
}
```
#### 最近连接组件
```kotlin
@Composable
private fun RecentConnectionsSection(
recents: List<RecentConnection>,
onConnectRecent: (RecentConnection) -> Unit,
onRemoveRecent: (RecentConnection) -> Unit,
onClearAll: () -> Unit,
) {
if (recents.isEmpty()) return
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
) {
Text(
text = "最近连接",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
recents.forEach { recent ->
RecentConnectionItem(
recent = recent,
onClick = { onConnectRecent(recent) },
onLongClick = { onRemoveRecent(recent) },
)
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.height(8.dp))
TextButton(
onClick = onClearAll,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text("清空最近记录")
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RecentConnectionItem(
recent: RecentConnection,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// 状态指示灯
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (recent.lastSucceeded)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
shape = CircleShape,
),
)
Spacer(Modifier.width(12.dp))
Column {
Text(
text = recent.address,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = "${recent.nickname} · ${formatRelativeTime(recent.lastConnectedAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
```
### 3.4 输入验证逻辑
**验证规则**(依据 UI 架构设计 2.1 节):
| 字段 | 必填 | 验证规则 | 错误提示 |
| --- | --- | --- | --- |
| 服务器地址 | 是 | 非空,格式合法(域名/IP/TSDNS) | "请输入有效的服务器地址" |
| 昵称 | 是 | 非空,满足服务器命名规则 | "请输入昵称" |
| 密码 | 否 | 仅当服务器需要密码时必填 | "该服务器需要密码"(连接时由服务端返回) |
**验证时机**:用户点击"连接"时一次性校验,不实时校验(避免打断输入流)。
```kotlin
data class ValidationErrors(
val address: String? = null,
val nickname: String? = null,
)
fun validate(config: ServerConfig): ValidationErrors {
val errors = ValidationErrors()
// 地址验证:非空 + 基本格式(包含字母或数字,含可选端口)
if (config.address.isBlank()) {
errors.copy(address = "请输入服务器地址")
} else if (!isValidServerAddress(config.address)) {
errors.copy(address = "请输入有效的服务器地址")
}
// 昵称验证:非空
if (config.nickname.isBlank()) {
errors.copy(nickname = "请输入昵称")
}
return errors
}
/**
* 服务器地址格式验证。
* 支持:域名、IP(v4/v6)、TSDNS、带端口号。
*/
private fun isValidServerAddress(address: String): Boolean {
val trimmed = address.trim()
if (trimmed.isBlank()) return false
// 允许格式:
// - example.com
// - example.com:9987
// - 192.168.1.1
// - 192.168.1.1:9987
// - [::1]:9987
// - _ts3._udp.example.com (TSDNS SRV)
val ip4Pattern = Regex("""^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$""")
val domainPattern = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*(:\d+)?$""")
val ip6Pattern = Regex("""^\[?[a-fA-F0-9:]+\]?(:\d+)?$""")
return ip4Pattern.matches(trimmed) ||
domainPattern.matches(trimmed) ||
ip6Pattern.matches(trimmed)
}
```
### 3.5 连接按钮状态机
**状态定义**(依据 UI 架构设计 5.1 节):
```kotlin
enum class ConnectState {
IDLE, // 空闲:等待用户输入并点击
CONNECTING, // 连接中:Connect + WaitConnected 进行中
SUCCESS, // 连接成功:跳转频道列表页
FAILED, // 连接失败:显示错误信息和重试
TIMEOUT, // 连接超时:显示超时提示
}
```
**按钮外观对应**
| 状态 | 按钮文本 | 样式 | 可点击 |
| --- | --- | --- | --- |
| `IDLE` | "连接服务器" | Primary Filled | ✅ |
| `CONNECTING` | "连接中..." | Outlined + loading indicator | ❌ |
| `SUCCESS` | — | 自动跳转,按钮不显示 | — |
| `FAILED` | "连接失败,点击重试" | Error container 色 | ✅ |
| `TIMEOUT` | "连接超时,点击重试" | Orange container 色 | ✅ |
**错误信息分类**
| 错误类型 | 判断方式 | 提示信息 |
| --- | --- | --- |
| 密码错误 | 含 "password" 或 "密码" | "服务器密码错误" |
| 昵称冲突 | 含 "nickname" 或 "昵称" | "昵称已被使用,请更换" |
| 网络不可达 | 含 "timeout"、"unreachable"、"network" | "无法连接到服务器,请检查网络" |
| 地址无效 | 含 "resolve"、"dns"、"lookup" | "服务器地址无法解析" |
| 服务器满 | 含 "full"、"limit" | "服务器已满" |
| 其他 | 默认 | 原始错误信息 |
```kotlin
@Composable
fun ConnectButton(
state: ConnectState,
errorMessage: String?,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = state != ConnectState.CONNECTING,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = when (state) {
ConnectState.FAILED -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
ConnectState.TIMEOUT -> ButtonDefaults.buttonColors(
containerColor = Color(0xFFFFF3E0), // 橙色背景
contentColor = Color(0xFFE65100),
)
else -> ButtonDefaults.buttonColors()
},
) {
when (state) {
ConnectState.IDLE -> Text("连接服务器")
ConnectState.CONNECTING -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
Spacer(Modifier.width(8.dp))
Text("连接中...")
}
ConnectState.SUCCESS -> { /* 不会到达,自动跳转 */ }
ConnectState.FAILED -> {
Text(errorMessage ?: "连接失败,点击重试")
}
ConnectState.TIMEOUT -> Text("连接超时,点击重试")
}
}
}
```
### 3.6 ViewModel 状态管理
**文件**`android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt`
```kotlin
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
data class ServerScreenState(
val address: String = "",
val nickname: String = "",
val password: String = "",
val connectState: ConnectState = ConnectState.IDLE,
val errorMessage: String? = null,
val validationErrors: ValidationErrors = ValidationErrors(),
)
class ServerViewModel(application: Application) : AndroidViewModel(application) {
private val recentStore = RecentConnectionsStore(application)
// 页面状态
private val _state = MutableStateFlow(ServerScreenState())
val state: StateFlow<ServerScreenState> = _state.asStateFlow()
// 最近连接列表
val recents: StateFlow<List<RecentConnection>> =
recentStore.observeRecents()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// --- 输入更新 ---
fun updateAddress(value: String) {
_state.update { it.copy(address = value, errorMessage = null) }
}
fun updateNickname(value: String) {
_state.update { it.copy(nickname = value, errorMessage = null) }
}
fun updatePassword(value: String) {
_state.update { it.copy(password = value) }
}
// --- 连接 ---
fun connect() {
val current = _state.value
if (current.connectState == ConnectState.CONNECTING) return
// 输入验证
val config = ServerConfig(
address = current.address.trim(),
nickname = current.nickname.trim(),
password = current.password,
)
val errors = validate(config)
if (errors.address != null || errors.nickname != null) {
_state.update { it.copy(
validationErrors = errors,
connectState = ConnectState.IDLE,
errorMessage = errors.address ?: errors.nickname,
)}
return
}
// 进入连接中状态
_state.update { it.copy(
connectState = ConnectState.CONNECTING,
errorMessage = null,
validationErrors = ValidationErrors(),
)}
// 异步连接(调用 TSBridge
viewModelScope.launch {
val result = TSBridge.connect(
host = config.address,
nickname = config.nickname,
password = config.password,
callbacks = createBridgeCallbacks(),
)
if (result.isEmpty()) {
// 连接成功(实际成功由 onConnected 回调确认)
// 此处 Connect 已成功启动,等待 WaitConnected
} else {
// 连接失败
val errorMsg = classifyError(result)
_state.update { it.copy(
connectState = ConnectState.FAILED,
errorMessage = errorMsg,
)}
// 记录到最近连接(标记失败)
recentStore.addRecent(RecentConnection(
address = config.address,
nickname = config.nickname,
password = config.password,
lastConnectedAt = System.currentTimeMillis(),
lastSucceeded = false,
))
}
}
}
/**
* 最近连接快速连接。
* 自动填充所有字段并触发连接。
*/
fun quickConnect(recent: RecentConnection) {
_state.update { it.copy(
address = recent.address,
nickname = recent.nickname,
password = recent.password,
)}
connect()
}
fun removeRecent(recent: RecentConnection) {
viewModelScope.launch {
recentStore.removeRecent(recent.address, recent.nickname)
}
}
fun clearRecents() {
viewModelScope.launch {
recentStore.clearAll()
}
}
// --- Bridge 回调 ---
private fun createBridgeCallbacks(): TSBridge.Callbacks = object : TSBridge.Callbacks {
override fun onConnected() {
_state.update { it.copy(connectState = ConnectState.SUCCESS) }
// 记录到最近连接(标记成功)
viewModelScope.launch {
recentStore.addRecent(RecentConnection(
address = _state.value.address.trim(),
nickname = _state.value.nickname.trim(),
password = _state.value.password,
lastConnectedAt = System.currentTimeMillis(),
lastSucceeded = true,
))
}
}
override fun onDisconnected(message: String) {
// 连接阶段断开视为失败
if (_state.value.connectState == ConnectState.CONNECTING) {
_state.update { it.copy(
connectState = ConnectState.FAILED,
errorMessage = classifyError(message),
)}
}
}
override fun onTextMessage(msg: TextMsg) { /* 此阶段不处理 */ }
override fun onClientEnter(client: Client) { /* 此阶段不处理 */ }
override fun onClientLeave(id: Int, reasonMsg: String) { /* 此阶段不处理 */ }
override fun onClientMoved(id: Int, targetChannelID: String) { /* 此阶段不处理 */ }
override fun onKicked(reason: String) { /* 此阶段不处理 */ }
override fun onVoiceData(clientID: Int, data: ByteArray, codec: Int) { /* 此阶段不处理 */ }
}
// --- 错误分类 ---
private fun classifyError(raw: String): String {
val lower = raw.lowercase()
return when {
"password" in lower || "密码" in lower -> "服务器密码错误"
"nickname" in lower || "昵称" in lower -> "昵称已被使用,请更换"
"timeout" in lower || "unreachable" in lower || "network" in lower ->
"无法连接到服务器,请检查网络"
"resolve" in lower || "dns" in lower || "lookup" in lower ->
"服务器地址无法解析"
"full" in lower || "limit" in lower -> "服务器已满"
else -> raw
}
}
}
```
### 3.7 相对时间格式化
```kotlin
/**
* 格式化时间戳为相对时间描述。
* 例:刚刚、5分钟前、2小时前、昨天、3天前、2024-01-15
*/
fun formatRelativeTime(timestamp: Long): String {
if (timestamp <= 0) return ""
val now = System.currentTimeMillis()
val diff = now - timestamp
return when {
diff < 60_000L -> "刚刚"
diff < 3_600_000L -> "${diff / 60_000}分钟前"
diff < 86_400_000L -> "${diff / 3_600_000}小时前"
diff < 172_800_000L -> "昨天"
diff < 604_800_000L -> "${diff / 86_400_000}天前"
else -> {
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
sdf.format(java.util.Date(timestamp))
}
}
}
```
---
## 三、连接流程数据流
```
用户点击 "连接服务器"
ServerViewModel.connect()
├─ validate(config)
│ ├─ 失败 → 显示验证错误,状态保持 IDLE
│ └─ 通过 ↓
├─ state → CONNECTING(按钮显示 "连接中...",禁用)
├─ TSBridge.connect(host, nickname, password, callbacks)
│ │
│ ▼
│ Go: TSClient.Connect(...)
│ │
│ ├─ 返回 ""(启动成功)→ 等待回调
│ │ ├─ callbacks.onConnected() → state → SUCCESS → 跳转频道列表页
│ │ └─ callbacks.onDisconnected(msg) → state → FAILED → 显示错误
│ │
│ └─ 返回 "error msg"(启动失败)→ state → FAILED → 显示错误
├─ 记录到 RecentConnectionsStore
│ └─ 成功:lastSucceeded = true(绿色)
│ └─ 失败:lastSucceeded = false(红色)
└─ 用户可重试(点击按钮,state 回到 IDLE → 重新走连接流程)
```
---
## 四、文件清单
| 文件 | 说明 |
| --- | --- |
| `data/Models.kt` | ServerConfig、RecentConnection 数据类 |
| `data/RecentConnectionsStore.kt` | DataStore 持久化最近连接 |
| `ui/screens/ServerConfigScreen.kt` | 页面 Composable(品牌区 + 输入区 + 最近连接) |
| `ui/components/ConnectButton.kt` | 连接按钮状态机组件 |
| `viewmodel/ServerViewModel.kt` | 状态管理、验证、连接、错误分类 |
| `ui/navigation/NavGraph.kt` | 导航路由(步骤 01 已建,此处补充配置页路由) |
---
## 五、验收标准
| # | 验证项 | 验证方法 |
| --- | --- | --- |
| 1 | 页面布局正确 | 启动应用,确认三段式布局(品牌/输入/最近连接) |
| 2 | 输入验证生效 | 地址为空点击连接 → 提示 "请输入服务器地址";昵称为空 → 提示 "请输入昵称" |
| 3 | 按钮状态机正确 | 点击连接 → 按钮变为 "连接中..." 并禁用 → 成功跳转 / 失败显示错误 |
| 4 | 错误信息分类正确 | 输入错误密码连接 → 显示 "服务器密码错误" |
| 5 | 最近连接记录 | 连接成功/失败后返回配置页,列表显示对应记录 |
| 6 | 最近连接快速连接 | 点击最近连接条目 → 自动填充并触发连接 |
| 7 | 最近连接删除 | 长按条目 → 删除;点击 "清空" → 全部清空 |
| 8 | 最多 10 条记录 | 连接超过 10 个不同服务器,列表只保留最新 10 条 |
| 9 | 状态灯颜色 | 成功的记录显示绿色,失败的显示红色 |
| 10 | 主题切换 | 点击右上角 🌙 → 主题切换,状态持久化 |
---
## 六、参考文档
- `docs/UI架构设计.md` — 2.1 服务器配置页(布局、验证规则、最近连接)
- `docs/流程/01_连接服务器.md` — 初始化配置、状态树、连接时序
- `docs/implementation/02_Bridge层实现.md` — TSBridge API 接口
@@ -0,0 +1,552 @@
# 步骤 04:连接与首次同步
> 实现完整的连接流程:Identity 管理、Connect、WaitConnected、首次同步。
---
## 一、目标
- [ ] Identity 生成与持久化
- [ ] ClientOption 组装
- [ ] 事件处理器注册
- [ ] Connect + WaitConnected 流程
- [ ] 首次同步(ListChannels + ListClients + ClientID
- [ ] 连接状态与错误处理
---
## 二、任务清单
### 4.1 Identity 管理
**目标**:实现 TeamSpeak 加密身份的生成与持久化存储。
**任务**
1. **生成 Identity**
```kotlin
// Go 侧通过 Bridge 暴露生成接口
// Kotlin 侧调用生成并获取 Identity 字符串
val identity = TSBridge.generateIdentity()
```
2. **持久化存储**
- 使用 `SharedPreferences` 或 `DataStore` 存储 Identity
- Key 建议:`ts_identity`
- 首次启动时生成,后续启动时读取
3. **读取与恢复**
```kotlin
fun loadOrCreateIdentity(context: Context): String {
val prefs = context.getSharedPreferences("ts_config", Context.MODE_PRIVATE)
return prefs.getString("ts_identity", null)
?: TSBridge.generateIdentity().also {
prefs.edit().putString("ts_identity", it).apply()
}
}
```
**注意事项**
- Identity 是客户端加密身份,必须持久化,否则每次连接会被服务器视为新用户
- 生成后不可更改,丢失需重新生成(会丢失服务器端的权限关联)
### 4.2 连接流程实现
**目标**:实现完整的连接流程,从配置组装到连接成功。
**任务**
1. **连接参数数据类**
```kotlin
data class ConnectionConfig(
val address: String, // 服务器地址(IP/域名/TSDNS
val nickname: String, // 显示昵称
val password: String? = null, // 服务器密码(可选)
val defaultChannel: String? = null, // 默认频道(可选)
val defaultChannelPassword: String? = null // 默认频道密码(可选)
)
```
2. **Bridge 层连接接口封装**
```kotlin
// TSBridge.kt 中新增
fun connect(config: ConnectionConfig): Result<Unit> {
return try {
val identity = loadOrCreateIdentity(context)
// 调用 Go 侧 NewClient + Connect
tsClient.newClient(identity, config.address, config.nickname)
if (config.password != null) {
tsClient.setServerPassword(config.password)
}
if (config.defaultChannel != null) {
tsClient.setDefaultChannel(config.defaultChannel)
if (config.defaultChannelPassword != null) {
tsClient.setDefaultChannelPassword(config.defaultChannelPassword)
}
}
tsClient.connect()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
```
3. **WaitConnected 实现**
```kotlin
suspend fun waitConnected(timeout: Duration = 30.seconds): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
// Go 侧阻塞等待,支持 context 取消
tsClient.waitConnected(timeout.inWholeMilliseconds)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
```
4. **完整连接流程**
```kotlin
// ServerViewModel.kt
suspend fun connect(config: ConnectionConfig) {
_connectionState.value = ConnectionState.Connecting
// 1. 注册事件处理器(Connect 前必须完成)
registerEventHandlers()
// 2. 发起连接
val connectResult = TSBridge.connect(config)
if (connectResult.isFailure) {
_connectionState.value = ConnectionState.Failed(connectResult.exceptionOrNull()!!)
return
}
// 3. 等待连接就绪
val waitResult = TSBridge.waitConnected()
if (waitResult.isFailure) {
_connectionState.value = ConnectionState.Failed(waitResult.exceptionOrNull()!!)
return
}
// 4. 连接成功,等待 OnConnected 事件触发首次同步
}
```
**连接状态枚举**
```kotlin
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Connecting : ConnectionState()
object Connected : ConnectionState() // WaitConnected 成功
object Syncing : ConnectionState() // 首次同步中
object Ready : ConnectionState() // 业务就绪
data class Failed(val error: Throwable) : ConnectionState()
}
```
**时序约束**
- `Connect()` 成功只表示连接流程已启动
- 发送业务命令前必须等待 `WaitConnected()` 成功
- 事件处理器必须在 `Connect()` 前注册,避免早期事件丢失
### 4.3 事件注册
**目标**:在 Connect 前注册所有必要的事件处理器,确保不遗漏早期服务端推送。
**任务**
1. **事件处理器注册(Go Bridge 侧)**
```go
// bridge.go 中暴露注册接口
func (b *Bridge) RegisterEventHandlers() {
b.client.OnConnected(func() {
b.notifyEvent("connected", nil)
})
b.client.OnDisconnected(func(err error) {
b.notifyEvent("disconnected", map[string]interface{}{
"error": err.Error(),
})
})
b.client.OnClientEnter(func(info ClientInfo) {
data, _ := json.Marshal(info)
b.notifyEvent("client_enter", string(data))
})
b.client.OnClientLeave(func(event ClientLeftViewEvent) {
data, _ := json.Marshal(event)
b.notifyEvent("client_leave", string(data))
})
b.client.OnClientMoved(func(event ClientMovedEvent) {
data, _ := json.Marshal(event)
b.notifyEvent("client_moved", string(data))
})
b.client.OnTextMessage(func(msg TextMessage) {
data, _ := json.Marshal(msg)
b.notifyEvent("text_message", string(data))
})
b.client.OnPoked(func(event PokeEvent) {
data, _ := json.Marshal(event)
b.notifyEvent("poked", string(data))
})
b.client.OnKicked(func(reason string) {
b.notifyEvent("kicked", reason)
})
}
```
2. **Kotlin 侧事件监听**
```kotlin
// TSBridge.kt
fun setEventListener(listener: (String, String) -> Unit) {
// 接收 Go 侧通过 JNI 回调的事件
eventCallback = listener
}
// ServerViewModel.kt
fun registerEventHandlers() {
TSBridge.setEventListener { event, data ->
when (event) {
"connected" -> handleConnected()
"disconnected" -> handleDisconnected(data)
"client_enter" -> handleClientEnter(data)
"client_leave" -> handleClientLeave(data)
"client_moved" -> handleClientMoved(data)
"text_message" -> handleTextMessage(data)
"poked" -> handlePoked(data)
"kicked" -> handleKicked(data)
}
}
}
```
3. **事件处理器职责**
| 事件 | 处理器 | 职责 |
|------|--------|------|
| `connected` | `handleConnected()` | 触发首次同步流程 |
| `disconnected` | `handleDisconnected()` | 清理会话状态,更新 UI |
| `client_enter` | `handleClientEnter()` | 增量同步:添加成员到基线 |
| `client_leave` | `handleClientLeave()` | 增量同步:从基线移除成员 |
| `client_moved` | `handleClientMoved()` | 增量同步:更新成员频道位置 |
| `text_message` | `handleTextMessage()` | 消息归档:按 TargetMode 存储 |
| `poked` | `handlePoked()` | 显示 Poke 通知 |
| `kicked` | `handleKicked()` | 处理踢出,清理状态 |
**注意事项**
- 事件处理器必须在 `Connect()` 之前注册
- 事件回调在 Go 的事件循环 goroutine 中串行执行,不要做耗时操作
- 需要通过事件队列串行化 JNI 回调,避免并发问题
### 4.4 首次同步逻辑
**目标**:连接成功后,建立完整的频道基线和成员基线。
**触发时机**:收到 `OnConnected` 事件后立即执行。
**任务**
1. **并行请求三个数据源**
```kotlin
// ServerViewModel.kt
private suspend fun performInitialSync() {
_connectionState.value = ConnectionState.Syncing
try {
// 并行请求频道列表、成员列表、自身 ID
val channelsDeferred = async { TSBridge.listChannels() }
val clientsDeferred = async { TSBridge.listClients() }
val selfIdDeferred = async { TSBridge.getClientId() }
val channels = channelsDeferred.await()
val clients = clientsDeferred.await()
val selfId = selfIdDeferred.await()
// 原子提交到状态仓库
repository.updateBaseline(
channels = channels,
clients = clients,
selfClientId = selfId
)
_connectionState.value = ConnectionState.Ready
} catch (e: Exception) {
_connectionState.value = ConnectionState.SyncFailed(e)
// 允许重试,不进入业务就绪
}
}
```
2. **数据模型定义**
```kotlin
// data/Models.kt
data class ChannelInfo(
val id: Long,
val parentId: Long,
val name: String,
val order: Long = 0,
val isPassword: Boolean = false,
val isPermanent: Boolean = false,
val maxClients: Int = -1
)
data class ClientInfo(
val id: Int,
val nickname: String,
val channelId: Long,
val uid: String,
val type: Int = 0,
val serverGroups: List<String> = emptyList()
)
```
3. **状态仓库实现**
```kotlin
// data/Repository.kt
class ChannelRepository {
private val _channels = MutableStateFlow<List<ChannelInfo>>(emptyList())
val channels: StateFlow<List<ChannelInfo>> = _channels
private val _clients = MutableStateFlow<List<ClientInfo>>(emptyList())
val clients: StateFlow<List<ClientInfo>> = _clients
private val _selfClientId = MutableStateFlow<Int?>(null)
val selfClientId: StateFlow<Int?> = _selfClientId
// 按频道 ID 索引的成员列表
private val _channelClients = MutableStateFlow<Map<Long, List<ClientInfo>>>(emptyMap())
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = _channelClients
fun updateBaseline(channels: List<ChannelInfo>, clients: List<ClientInfo>, selfClientId: Int) {
_channels.value = channels
_clients.value = clients
_selfClientId.value = selfClientId
// 建立频道-成员索引
_channelClients.value = clients.groupBy { it.channelId }
}
}
```
4. **Bridge 层查询**(通过 TsClient 封装)
```kotlin
// TSBridge.kt — 直接返回 Kotlin 友好类型,无需 JSON 解析
fun getChannelList(): List<TsChannel> = TsClient.getChannelList()
fun getClientList(): List<TsClientInfo> = TsClient.getClientList()
fun getClientId(): Long = TsClient.getClientId()
```
**同步状态机**
```kotlin
sealed class SyncState {
object Unsynced : SyncState() // 已连接但尚无完整数据
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
object Synchronized : SyncState() // 列表基线可供 UI 使用
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
}
```
**原子提交原则**
- 频道列表、成员列表、自身 ID 必须全部成功才能提交
- 任一失败则不进入业务就绪状态
- 允许重试,避免在不完整数据上执行业务操作
### 4.5 错误处理与状态反馈
**目标**:实现完整的错误处理机制,确保用户能获得清晰的状态反馈。
**任务**
1. **连接错误分类**
```kotlin
sealed class ConnectionError : Exception() {
object InvalidAddress : ConnectionError() // 地址解析失败
object AuthenticationFailed : ConnectionError() // 密码错误
object ServerFull : ConnectionError() // 服务器满员
object Banned : ConnectionError() // 被封禁
object NetworkError : ConnectionError() // 网络问题
object Timeout : ConnectionError() // 连接超时
data class Other(val message: String) : ConnectionError()
}
```
2. **错误映射与处理**
```kotlin
fun mapConnectionError(error: Exception): ConnectionError {
val message = error.message?.lowercase() ?: ""
return when {
message.contains("resolve") || message.contains("address") ->
ConnectionError.InvalidAddress
message.contains("password") || message.contains("auth") ->
ConnectionError.AuthenticationFailed
message.contains("full") || message.contains("limit") ->
ConnectionError.ServerFull
message.contains("ban") ->
ConnectionError.Banned
message.contains("timeout") ->
ConnectionError.Timeout
message.contains("network") || message.contains("connection") ->
ConnectionError.NetworkError
else -> ConnectionError.Other(error.message ?: "Unknown error")
}
}
```
3. **状态反馈 UI**
```kotlin
@Composable
fun ConnectionStatusIndicator(state: ConnectionState) {
when (state) {
ConnectionState.Disconnected -> {
Text("未连接", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
ConnectionState.Connecting -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Text("正在连接...")
}
ConnectionState.Connected -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Text("已连接,正在同步...")
}
ConnectionState.Syncing -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
Text("正在同步数据...")
}
ConnectionState.Ready -> {
Icon(Icons.Default.CheckCircle, tint = Color.Green)
Text("就绪")
}
is ConnectionState.Failed -> {
Icon(Icons.Default.Error, tint = Color.Red)
Text("连接失败: ${state.error.getLocalizedMessage()}",
color = MaterialTheme.colorScheme.error)
Button(onClick = { /* 重试 */ }) {
Text("重试")
}
}
}
}
```
4. **同步失败重试机制**
```kotlin
// ServerViewModel.kt
private suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) {
var retryCount = 0
while (retryCount < maxRetries) {
try {
performInitialSync()
return // 成功则退出
} catch (e: Exception) {
retryCount++
if (retryCount >= maxRetries) {
_connectionState.value = ConnectionState.SyncFailed(e)
return
}
// 等待后重试
delay(1000L * retryCount)
}
}
}
```
5. **断开连接清理**
```kotlin
fun disconnect() {
// 1. 停止语音(如有)
voiceViewModel.stopVoice()
// 2. 清理会话状态
repository.clearSession()
// 3. 调用 SDK 断开
TSBridge.disconnect()
// 4. 更新状态
_connectionState.value = ConnectionState.Disconnected
}
```
**错误日志记录**
```kotlin
private fun logConnectionError(error: ConnectionError) {
Log.e(TAG, "Connection error: ${error::class.simpleName}", error)
// 可选:上报到崩溃分析服务
}
```
---
## 三、验收标准
### 功能验收
- [ ] **Identity 持久化**
- 首次启动自动生成 Identity 并存储
- 后续启动读取已有 Identity,不重复生成
- 清除应用数据后能重新生成
- [ ] **连接流程**
- 输入有效地址、昵称后能成功连接服务器
- 输入错误密码时显示明确错误提示
- 连接超时时(30秒)显示超时错误
- 无网络时显示网络错误
- [ ] **首次同步**
- 连接成功后自动执行首次同步
- 频道列表正确显示(包含所有频道)
- 成员列表正确显示(包含所有在线用户)
- 自己的客户端 ID 正确识别
- [ ] **状态反馈**
- 连接过程中显示加载状态
- 同步过程中显示同步状态
- 就绪后显示就绪状态
- 错误时显示错误信息和重试按钮
- [ ] **断开连接**
- 主动断开后状态正确重置
- 被动断开(网络中断)能检测并提示
- 被踢出时显示踢出原因
### 性能验收
- [ ] 首次同步在 3 秒内完成(标准服务器,< 100 频道,< 500 用户)
- [ ] 连接建立时间 < 5 秒(正常网络环境)
### 代码质量验收
- [ ] 所有网络操作在 IO 线程执行
- [ ] 事件回调通过事件队列串行化
- [ ] 无内存泄漏(正确取消协程)
- [ ] 错误处理覆盖所有已知异常场景
### 测试用例
| 场景 | 输入 | 预期结果 |
|------|------|----------|
| 正常连接 | 有效地址、昵称、无密码 | 连接成功,频道列表显示 |
| 密码保护服务器 | 有效地址、昵称、正确密码 | 连接成功 |
| 错误密码 | 有效地址、昵称、错误密码 | 显示"密码错误"提示 |
| 无效地址 | 无效地址 | 显示"地址解析失败"提示 |
| 网络断开 | 断开网络后连接 | 显示"网络错误"提示 |
| 服务器满员 | 满员服务器 | 显示"服务器已满"提示 |
| 被封禁 | 被封禁的 UID | 显示"已被封禁"提示 |
| 连接超时 | 阻断 UDP 30 秒 | 显示"连接超时"提示 |
| 断开重连 | 断开后重新连接 | 状态正确重置,可重新连接 |
---
## 四、参考文档
- `docs/流程/01_连接服务器.md` - 完整生命周期、时序图
- `docs/流程/08_状态同步.md` - ① 首次同步
- `docs/sdk文档-go.md` - 连接管理 API
File diff suppressed because it is too large Load Diff
+711
View File
@@ -0,0 +1,711 @@
# 步骤 06:频道切换
> 实现频道切换流程:频道点击处理、密码弹窗、ClientMove 命令发送、等待 OnClientMoved 服务端确认、自身频道状态更新。
> 对应流程:`docs/流程/03_切换频道.md`
> 依赖步骤:05(频道列表页)
---
## 一、目标
- [ ] 频道点击事件处理(区分有密码/无密码频道)
- [ ] 密码输入弹窗组件
- [ ] ChannelSwitchState 状态机(idle → requesting → waitingServerEvent → idle/failed
- [ ] ClientMove 命令发送(通过 TSBridge.MoveToChannel
- [ ] 等待 OnClientMoved 服务端事实确认
- [ ] 自身频道状态更新(④ 自身状态同步)
- [ ] 错误处理与用户反馈
---
## 二、任务清单
### 6.1 频道点击处理
**目标**:在频道列表页中处理频道点击事件,区分有密码和无密码频道。
**前置条件**
- 步骤 05 的 ChannelListScreen 已实现
- ChannelRow 组件已支持点击事件
**任务**
1. **频道点击入口**
```kotlin
// ChannelListScreen.kt - ChannelTreeContent 中的 onChannelClick 回调
@Composable
fun ChannelTreeContent(
channelViewModel: ChannelViewModel,
onChannelClick: (ChannelInfo) -> Unit,
onClientClick: (ClientInfo) -> Unit,
onNavigateToChat: () -> Unit
) {
// ... 已有实现 ...
}
```
2. **频道点击逻辑(ChannelViewModel**
```kotlin
// ChannelViewModel.kt
// 密码弹窗状态
private val _showPasswordDialog = MutableStateFlow(false)
val showPasswordDialog: StateFlow<Boolean> = _showPasswordDialog
// 待切换的目标频道
private val _pendingSwitchChannel = MutableStateFlow<ChannelInfo?>(null)
val pendingSwitchChannel: StateFlow<ChannelInfo?> = _pendingSwitchChannel
/**
* 处理频道点击事件
* 对应 UI架构设计.md 频道树交互规则
*/
fun onChannelClicked(channel: ChannelInfo) {
// 如果是当前频道,忽略
if (channel.id == repository.selfChannelId.value) {
Log.d(TAG, "Already in channel ${channel.id}, ignoring click")
return
}
// 检查是否正在切换中
if (_switchState.value != ChannelSwitchState.Idle) {
Log.w(TAG, "Channel switch already in progress, ignoring click")
return
}
if (channel.isPassword) {
// 有密码频道:弹出密码输入框
_pendingSwitchChannel.value = channel
_showPasswordDialog.value = true
} else {
// 无密码频道:直接发起切换
viewModelScope.launch {
performChannelSwitch(channel.id, "")
}
}
}
```
### 6.2 密码弹窗组件
**目标**:实现频道密码输入弹窗,支持密码错误重试。
**任务**
1. **密码弹窗 Composable**
```kotlin
// ui/components/ChannelPasswordDialog.kt
@Composable
fun ChannelPasswordDialog(
channelName: String,
onConfirm: (password: String) -> Unit,
onDismiss: () -> Unit,
isError: Boolean = false,
errorMessage: String = "密码错误,请重试"
) {
var password by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(isError) }
// 当 isError 变化时更新本地状态
LaunchedEffect(isError) {
showError = isError
if (isError) {
password = "" // 清空输入框
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "该频道需要密码",
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column {
Text(
text = "频道:$channelName",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = {
password = it
showError = false
},
label = { Text("输入频道密码") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = showError,
supportingText = if (showError) {
{ Text(errorMessage, color = MaterialTheme.colorScheme.error) }
} else null,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(password) },
enabled = password.isNotEmpty()
) {
Text("进入")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
```
2. **在 ChannelListScreen 中集成密码弹窗**
```kotlin
// ChannelListScreen.kt
@Composable
fun ChannelListScreen(
channelViewModel: ChannelViewModel,
serverViewModel: ServerViewModel,
voiceViewModel: VoiceViewModel,
onNavigateToChat: () -> Unit,
onNavigateToServerConfig: () -> Unit,
onOpenServerDetail: () -> Unit,
onOpenChannelDetail: (channelId: Long) -> Unit,
onOpenVoiceCard: () -> Unit
) {
val showPasswordDialog by channelViewModel.showPasswordDialog.collectAsState()
val pendingChannel by channelViewModel.pendingSwitchChannel.collectAsState()
val switchState by channelViewModel.switchState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// ... 已有布局 ...
}
// 密码弹窗
if (showPasswordDialog && pendingChannel != null) {
ChannelPasswordDialog(
channelName = pendingChannel!!.name,
onConfirm = { password ->
channelViewModel.confirmPasswordAndSwitch(password)
},
onDismiss = {
channelViewModel.dismissPasswordDialog()
},
isError = switchState is ChannelSwitchState.Failed,
errorMessage = (switchState as? ChannelSwitchState.Failed)?.error ?: "密码错误,请重试"
)
}
}
```
3. **密码确认与取消逻辑**
```kotlin
// ChannelViewModel.kt
/**
* 用户确认密码,发起切换
*/
fun confirmPasswordAndSwitch(password: String) {
val channel = _pendingSwitchChannel.value ?: return
_showPasswordDialog.value = false
viewModelScope.launch {
performChannelSwitch(channel.id, password)
}
}
/**
* 用户取消密码输入
*/
fun dismissPasswordDialog() {
_showPasswordDialog.value = false
_pendingSwitchChannel.value = null
_switchState.value = ChannelSwitchState.Idle
}
```
### 6.3 ChannelSwitchState 状态机
**目标**:实现频道切换的状态管理,确保命令响应与事件事实分离。
**状态定义**
```kotlin
// data/Models.kt 或 ChannelViewModel.kt
/**
* 频道切换状态机
* 对应 docs/流程/03_切换频道.md 中的状态转换
*
* 状态转换:
* Idle → Requesting:用户发起切换请求
* Requesting → WaitingServerEventClientMove 命令成功
* Requesting → FailedClientMove 命令失败
* WaitingServerEvent → Idle:收到自己的 OnClientMoved 事件
* Failed → Idle:用户重试或取消
*/
sealed class ChannelSwitchState {
/** 空闲状态,可以发起新的切换 */
object Idle : ChannelSwitchState()
/** 正在发送 ClientMove 命令 */
object Requesting : ChannelSwitchState()
/** ClientMove 命令成功,等待服务端 OnClientMoved 事件确认 */
data class WaitingServerEvent(val targetChannelId: Long) : ChannelSwitchState()
/** 切换失败(命令被拒绝或超时) */
data class Failed(val error: String) : ChannelSwitchState()
}
```
**状态机实现**
```kotlin
// ChannelViewModel.kt
// 切换状态
private val _switchState = MutableStateFlow<ChannelSwitchState>(ChannelSwitchState.Idle)
val switchState: StateFlow<ChannelSwitchState> = _switchState
// 等待服务端确认的超时 Job
private var switchTimeoutJob: Job? = null
/**
* 执行频道切换
* 对应 docs/流程/03_切换频道.md 时序图
*/
private suspend fun performChannelSwitch(targetChannelId: Long, password: String) {
Log.d(TAG, "Requesting channel switch to $targetChannelId")
// 状态改为 requesting
_switchState.value = ChannelSwitchState.Requesting
try {
// 发送 ClientMove 命令
// TSBridge.MoveToChannel 返回空串表示成功,否则返回错误信息
val error = TSBridge.moveSelfToChannel(targetChannelId.toString(), password)
if (error.isEmpty()) {
// 命令成功,等待服务端事件确认
_switchState.value = ChannelSwitchState.WaitingServerEvent(targetChannelId)
// 启动超时检测(10秒)
switchTimeoutJob?.cancel()
switchTimeoutJob = viewModelScope.launch {
delay(10_000)
// 超时:如果还在等待状态,视为失败
if (_switchState.value is ChannelSwitchState.WaitingServerEvent) {
Log.w(TAG, "Channel switch timeout waiting for server event")
_switchState.value = ChannelSwitchState.Failed("等待服务端确认超时")
}
}
Log.d(TAG, "ClientMove command accepted, waiting for server event")
} else {
// 命令被拒绝
Log.w(TAG, "ClientMove command rejected: $error")
_switchState.value = ChannelSwitchState.Failed(mapMoveError(error))
}
} catch (e: Exception) {
Log.e(TAG, "ClientMove command failed", e)
_switchState.value = ChannelSwitchState.Failed("切换失败:${e.message}")
}
}
/**
* 映射 MoveToChannel 错误信息为用户友好的提示
*/
private fun mapMoveError(error: String): String {
return when {
error.contains("password", ignoreCase = true) -> "密码错误"
error.contains("permission", ignoreCase = true) -> "权限不足"
error.contains("full", ignoreCase = true) -> "频道已满"
error.contains("banned", ignoreCase = true) -> "你已被该频道封禁"
else -> "切换失败:$error"
}
}
```
### 6.4 自身状态同步(④)
**目标**:处理自己的 OnClientMoved 事件,确认频道切换完成。
**关键原则**
- 命令响应(ClientMove nil)不等于状态已提交
- 必须等待服务端推送的 OnClientMoved 事件才能更新本地频道事实
- 通过比对 ClientID == selfID 识别自己的移动事件
**任务**
1. **处理 OnClientMoved 事件(区分自己和他人)**
```kotlin
// ChannelViewModel.kt
/**
* 处理客户端移动事件
* 对应 docs/流程/08_状态同步.md ② 增量同步 + ④ 自身状态同步
*
* @param clientId 移动的客户端 ID
* @param targetChannelId 目标频道 ID(字符串形式)
*/
fun handleClientMoved(clientId: Int, targetChannelId: Long) {
Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
val selfId = repository.selfClientId.value
if (clientId == selfId) {
// ④ 自身状态同步:这是自己的移动事件
handleSelfMoved(targetChannelId)
} else {
// ② 增量同步:这是其他用户的移动事件
handleOtherClientMoved(clientId, targetChannelId)
}
}
/**
* 处理自己的移动事件
* 对应 docs/流程/03_切换频道.md 中等待 OnClientMoved 确认的分支
*/
private fun handleSelfMoved(targetChannelId: Long) {
val currentState = _switchState.value
// 更新自身频道事实
repository.updateSelfChannel(targetChannelId)
// 清除目标频道的未读标记
clearUnread(targetChannelId)
when (currentState) {
is ChannelSwitchState.WaitingServerEvent -> {
// 正常流程:确认切换完成
Log.d(TAG, "Channel switch confirmed by server: target=$targetChannelId")
switchTimeoutJob?.cancel()
_switchState.value = ChannelSwitchState.Idle
_pendingSwitchChannel.value = null
}
is ChannelSwitchState.Requesting -> {
// 罕见情况:事件先于命令响应到达
Log.d(TAG, "Server event arrived before command response")
switchTimeoutJob?.cancel()
_switchState.value = ChannelSwitchState.Idle
_pendingSwitchChannel.value = null
}
else -> {
// 非切换流程中的移动(例如被管理员移动)
Log.d(TAG, "Self moved by external action to channel $targetChannelId")
_switchState.value = ChannelSwitchState.Idle
}
}
}
/**
* 处理其他用户的移动事件(增量同步)
*/
private fun handleOtherClientMoved(clientId: Int, targetChannelId: Long) {
val existingClient = repository.getClientById(clientId)
if (existingClient != null) {
// 成员存在:更新频道位置
repository.updateClientChannel(clientId, targetChannelId)
} else {
// 成员不存在:触发补偿同步
Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
viewModelScope.launch { compensateClientList() }
}
}
```
2. **切换超时处理**
```kotlin
// ChannelViewModel.kt
/**
* 重试频道切换(失败后)
*/
fun retryChannelSwitch() {
val channel = _pendingSwitchChannel.value ?: return
_switchState.value = ChannelSwitchState.Idle
viewModelScope.launch {
performChannelSwitch(channel.id, "")
}
}
/**
* 取消频道切换
*/
fun cancelChannelSwitch() {
switchTimeoutJob?.cancel()
_switchState.value = ChannelSwitchState.Idle
_pendingSwitchChannel.value = null
_showPasswordDialog.value = false
}
```
### 6.5 切换状态 UI 反馈
**目标**:在频道列表页显示切换状态,提供用户反馈。
**任务**
1. **切换中指示器**
```kotlin
// ui/components/SwitchingIndicator.kt
@Composable
fun ChannelSwitchingIndicator(
state: ChannelSwitchState,
onRetry: () -> Unit,
onCancel: () -> Unit
) {
when (state) {
is ChannelSwitchState.Requesting -> {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
is ChannelSwitchState.WaitingServerEvent -> {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
is ChannelSwitchState.Failed -> {
Snackbar(
action = {
TextButton(onClick = onRetry) {
Text("重试")
}
TextButton(onClick = onCancel) {
Text("取消")
}
}
) {
Text(state.error)
}
}
else -> { /* Idle: 不显示任何指示 */ }
}
}
```
2. **在 ChannelListScreen 中集成**
```kotlin
// ChannelListScreen.kt
@Composable
fun ChannelListScreen(
// ... 参数 ...
) {
val switchState by channelViewModel.switchState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 头部
ChannelListHeader(/* ... */)
// 切换状态指示器
if (switchState != ChannelSwitchState.Idle) {
ChannelSwitchingIndicator(
state = switchState,
onRetry = { channelViewModel.retryChannelSwitch() },
onCancel = { channelViewModel.cancelChannelSwitch() }
)
}
// 中部:频道树
Box(modifier = Modifier.weight(1f)) {
// ... 已有实现 ...
}
// ... 其余布局 ...
}
}
```
### 6.6 事件处理器注册
**目标**:确保 ChannelViewModel 的事件处理方法被 ServerViewModel 正确调用。
**任务**
```kotlin
// ServerViewModel.kt - 在 registerEventHandlers 中添加
fun registerEventHandlers() {
TSBridge.setCallbacks(object : TSBridge.Callbacks {
// ... 已有回调 ...
override fun onClientMoved(id: Int, targetChannelID: String) {
val targetId = targetChannelID.toLongOrNull() ?: return
channelViewModel.handleClientMoved(id, targetId)
}
// ... 其他回调 ...
})
}
```
---
## 三、状态与数据流
### 3.1 频道切换状态机
```
┌──────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄───────────────────────────────┤
└────┬────┘ │
│ 用户点击频道 │
▼ │
┌─────────────┐ │
│ Requesting │ │
└──────┬──────┘ │
│ │
┌───────────┴───────────┐ │
│ │ │
▼ ▼ │
┌───────────┐ ┌──────────┐ │
│ Failed │ │ Waiting │ │
│ │ │ Server │ │
└─────┬─────┘ │ Event │ │
│ └────┬─────┘ │
│ │ │
│ ┌─────────────────┤ │
│ │ │ │
│ ▼ ▼ │
│ 超时 OnClientMoved │
│ │ (selfID match) │
│ │ │ │
└───┴─────────────────┴─────────────────────────┘
```
### 3.2 数据流向
```
用户操作 ChannelViewModel TSBridge/Go 服务端
│ │ │ │
│ 点击频道 │ │ │
├───────────────────→│ │ │
│ │ │ │
│ │ 有密码? │ │
│ ├─→ 显示密码弹窗 │ │
│ 输入密码 │ │ │
├───────────────────→│ │ │
│ │ │ │
│ │ MoveToChannel(id, pwd) │ │
│ ├──────────────────────────→│ clientmove │
│ │ ├─────────────────→│
│ │ │ │
│ │ │ 命令响应 │
│ │ │←─────────────────┤
│ │ error == "" ? │ │
│ │←──────────────────────────┤ │
│ │ │ │
│ │ 状态 → WaitingServerEvent │ │
│ │ │ │
│ │ │ notifyclientmoved│
│ │ │←─────────────────┤
│ │ OnClientMoved(selfID) │ │
│ │←──────────────────────────┤ │
│ │ │ │
│ │ 更新自身频道 │ │
│ │ 状态 → Idle │ │
│ │ │ │
```
### 3.3 命令响应与事件事实的区分
**关键原则**(对应 `docs/流程/03_切换频道.md`):
| 概念 | 含义 | 处理方式 |
|------|------|----------|
| ClientMove 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed |
| ClientMove 返回 nil | 命令被服务器接受 | 状态 → WaitingServerEvent,继续等待 |
| OnClientMoved(selfID) | 服务器确认移动完成 | 更新本地频道事实,状态 → Idle |
**为什么不能用命令响应直接更新频道?**
- 命令响应只表示服务器接受了请求
- 实际移动可能因权限、密码、容量等原因被延迟拒绝
- 只有服务端推送的 `notifyclientmoved` 事件才是最终事实
---
## 四、验收标准
### 功能验收
- [ ] **无密码频道切换**
- 点击无密码频道 → 直接发送 ClientMove
- 显示切换中进度指示
- 收到 OnClientMoved 后切换完成
- 当前频道栏更新为目标频道
- [ ] **有密码频道切换**
- 点击有密码频道 → 弹出密码输入框
- 输入密码后发送 ClientMove
- 密码错误 → 显示错误提示,清空输入框,允许重试
- 点击取消 → 关闭弹窗,不发送命令
- [ ] **切换状态管理**
- 切换中禁止发起新的切换
- 切换超时(10秒)显示失败提示
- 失败后可重试或取消
- 被管理员移动时正确更新状态
- [ ] **自身状态同步**
- 只有匹配 selfID 的 OnClientMoved 才更新自身频道
- 命令响应不直接提交频道事实
- 切换完成后清除目标频道的未读标记
### 错误处理验收
| 错误场景 | 预期行为 |
|----------|----------|
| 密码错误 | 弹窗显示错误,清空输入框 |
| 频道已满 | Snackbar 提示"频道已满" |
| 权限不足 | Snackbar 提示"权限不足" |
| 网络超时 | 10秒后显示超时提示,可重试 |
| 被管理员移动 | 静默更新当前频道 |
### 性能验收
- [ ] 切换响应时间 < 100msUI 反馈)
- [ ] 服务端确认时间 < 3s(正常网络)
- [ ] 密码弹窗弹出/关闭动画流畅
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 无密码切换 | 点击无密码频道 | 进度条 → 切换完成 → 当前频道更新 |
| 有密码切换 | 点击有密码频道 → 输入密码 → 点击进入 | 密码弹窗 → 进度条 → 切换完成 |
| 密码错误 | 输入错误密码 | 弹窗显示错误,清空输入框 |
| 取消密码 | 点击取消 | 弹窗关闭,无网络请求 |
| 切换超时 | 断网后切换 | 10秒后显示超时提示 |
| 重试切换 | 失败后点击重试 | 重新发送 ClientMove |
| 被管理员移动 | 管理员移动你到其他频道 | 当前频道静默更新 |
| 重复点击 | 快速点击多个频道 | 只处理第一次点击 |
| 切换中点击 | 切换进行中点击其他频道 | 忽略点击 |
---
## 五、参考文档
- `docs/流程/03_切换频道.md` - 时序图、状态机、事件依赖
- `docs/流程/08_状态同步.md` - ④ 自身状态同步
- `docs/UI架构设计.md` - 2.2 频道列表页交互、4.2 密码弹窗
- `docs/sdk文档-go.md` - ClientMove API、OnClientMoved 事件
- `docs/implementation/02_Bridge层实现.md` - MoveToChannel、onClientMoved 回调
- `docs/implementation/05_频道列表页.md` - ChannelViewModel、ChannelListScreen
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,701 @@
# 步骤 10:状态同步进阶
> 实现高级状态同步:增量同步完善、补偿同步触发、重连全量同步、同步失败处理、数据一致性校验。
> 依赖:步骤 09(断开连接)已完成,首次同步(①)已在步骤 04 实现。
---
## 一、目标
- [ ] 增量同步(②)完善 — OnClientEnter / OnClientMoved / OnClientLeave 幂等归并
- [ ] 补偿同步(③)触发机制 — 未知实体引用时自动修复基线
- [ ] 重连全量同步(⑥) — 清理旧状态 → 重新执行首次同步
- [ ] 同步失败处理(⑦) — 状态机流转、重试、降级
- [ ] 数据一致性校验 — 周期性校验与频道列表刷新
---
## 二、任务清单
### 10.1 增量同步完善
**目标**:将步骤 04 中注册的事件处理器补全为完整的幂等归并逻辑,确保成员实体表在持续事件流中保持一致。
**前置条件**
- 步骤 04 已实现首次同步(ListChannels + ListClients + ClientID
- 步骤 04 已注册 OnClientEnter / OnClientLeave / OnClientMoved 事件处理器
- `ChannelRepository` 已有 `_clients: MutableStateFlow<List<ClientInfo>>``_channelClients: MutableStateFlow<Map<Long, List<ClientInfo>>>`
**任务**
1. **成员实体表改造为 Map 结构**
将成员存储从 `List<ClientInfo>` 改为 `Map<Int, ClientInfo>`,以 ClientID 为 key 实现 O(1) 查询和幂等更新。
```kotlin
// data/Repository.kt
class ChannelRepository {
// 成员实体表:ClientID → ClientInfo
private val _clientMap = MutableStateFlow<Map<Int, ClientInfo>>(emptyMap())
val clientMap: StateFlow<Map<Int, ClientInfo>> = _clientMap
// 派生:按频道 ID 索引的成员列表(由 clientMap 自动计算)
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> =
_clientMap.map { map ->
map.values.groupBy { it.channelId }
}.stateIn(scope, SharingStarted.WhileSubscribed(), emptyMap())
// 派生:成员列表(兼容旧接口)
val clients: StateFlow<List<ClientInfo>> =
_clientMap.map { it.values.toList() }
.stateIn(scope, SharingStarted.WhileSubscribed(), emptyList())
/** 首次同步:原子替换整个成员表 */
fun setClientBaseline(clients: List<ClientInfo>) {
_clientMap.value = clients.associateBy { it.id }
}
/** 增量更新:按 ID 幂等插入或覆盖 */
fun upsertClient(client: ClientInfo) {
_clientMap.update { it + (client.id to client) }
}
/** 增量更新:按 ID 幂等删除(重复删除为 no-op) */
fun removeClient(clientId: Int) {
_clientMap.update { it - clientId }
}
/** 增量更新:移动成员到目标频道 */
fun moveClient(clientId: Int, targetChannelId: Long) {
_clientMap.update { map ->
val existing = map[clientId] ?: return@update map // 不存在则 no-op
if (existing.channelId == targetChannelId) return@update map // 相同频道则 no-op
map + (clientId to existing.copy(channelId = targetChannelId))
}
}
/** 查询成员是否存在 */
fun hasClient(clientId: Int): Boolean = _clientMap.value.containsKey(clientId)
/** 查询频道是否存在 */
fun hasChannel(channelId: Long): Boolean = _channels.value.any { it.id == channelId }
/** 清理会话数据 */
fun clearSession() {
_clientMap.value = emptyMap()
_channels.value = emptyList()
_selfClientId.value = null
}
}
```
2. **OnClientEnter 归并逻辑**
```kotlin
// ChannelViewModel.kt
fun handleClientEnter(data: String) {
val client = Json.decodeFromString<ClientInfo>(data)
// 按 ID 覆盖,重复事件不会重复计数
repository.upsertClient(client)
}
```
**关键约束**
- 按 ClientInfo.ID 覆盖,**禁止**使用 `+1` 增量累加频道人数
- 频道人数由 `channelClients[channelId].size` 实时派生
- 已有基线时,进入事件覆盖旧数据;无基线时,插入新条目
3. **OnClientMoved 归并逻辑**
```kotlin
// ChannelViewModel.kt
fun handleClientMoved(data: String) {
val event = Json.decodeFromString<ClientMovedEvent>(data)
// 判断是否为自己
if (event.clientId == repository.selfClientId.value) {
repository.updateSelfChannel(event.targetChannelId)
}
// 更新成员位置
if (repository.hasClient(event.clientId)) {
repository.moveClient(event.clientId, event.targetChannelId)
} else {
// 成员不存在 → 触发补偿同步(见 10.2)
triggerClientCompensationSync()
}
}
```
**关键约束**
- 目标频道 ID 是移动后的服务器事实,直接覆盖旧的 ChannelID
- 相同目标频道视为 no-op
- 未知成员**不得**静默忽略,必须触发补偿同步
4. **OnClientLeave 归并逻辑**
```kotlin
// ChannelViewModel.kt
fun handleClientLeave(data: String) {
val event = Json.decodeFromString<ClientLeftViewEvent>(data)
if (event.isSelf) {
// 自己被踢出或离开 → 由步骤 09 处理
handleKicked(event.reasonMessage)
return
}
// 按 ID 删除,重复删除安全地保持 no-op
repository.removeClient(event.clientId)
}
```
**关键约束**
- 重复删除必须为 no-opMap.remove 天然满足)
- 不使用 `-1` 减量维护频道人数
- `IsSelf` 为 true 时走踢出/断开流程,不从成员表删除
5. **ClientMovedEvent / ClientLeftViewEvent 数据类**
```kotlin
// data/Models.kt
data class ClientMovedEvent(
val clientId: Int,
val targetChannelId: Long,
val reasonId: Int = 0,
val invokerId: Int = 0,
val invokerName: String = "",
val invokerUid: String = ""
)
data class ClientLeftViewEvent(
val clientId: Int,
val reasonId: Int = 0, // 0=正常离开, 4=频道踢, 5=服务器踢
val reasonMessage: String = "",
val isSelf: Boolean = false
)
```
### 10.2 补偿同步机制
**目标**:当增量事件引用了本地不存在的实体(ClientID 或 ChannelID),自动触发完整列表请求修复基线。
**触发条件**
| 场景 | 检测方式 | 补偿动作 |
|------|----------|----------|
| OnClientMoved 引用未知 ClientID | `!repository.hasClient(event.clientId)` | 重新调用 ListClients |
| OnClientLeave 引用未知 ClientID | `!repository.hasClient(event.clientId)` | 重新调用 ListClients(可选,删除本身是 no-op |
| 成员引用未知 ChannelID | `!repository.hasChannel(member.channelId)` | 重新调用 ListChannels |
**任务**
1. **补偿同步触发器**
```kotlin
// ChannelViewModel.kt
private var compensationSyncJob: Job? = null
/**
* 触发成员基线补偿同步。
* 使用防抖:连续多个未知实体事件只触发一次 ListClients。
*/
private fun triggerClientCompensationSync() {
compensationSyncJob?.cancel()
compensationSyncJob = viewModelScope.launch {
delay(300) // 防抖 300ms
performCompensationSync()
}
}
private suspend fun performCompensationSync() {
try {
Log.w(TAG, "Compensation sync: rebuilding client baseline")
val clients = TSBridge.listClients()
repository.setClientBaseline(clients)
Log.i(TAG, "Compensation sync completed: ${clients.size} clients")
} catch (e: Exception) {
Log.e(TAG, "Compensation sync failed", e)
// 补偿同步失败不阻塞业务,等待下次触发
}
}
```
2. **频道基线补偿同步**
```kotlin
// ChannelViewModel.kt
private fun triggerChannelCompensationSync() {
viewModelScope.launch {
try {
Log.w(TAG, "Compensation sync: rebuilding channel baseline")
val channels = TSBridge.listChannels()
repository.setChannelBaseline(channels)
Log.i(TAG, "Compensation sync completed: ${channels.size} channels")
} catch (e: Exception) {
Log.e(TAG, "Channel compensation sync failed", e)
}
}
}
```
3. **补偿同步与增量事件的协调**
```kotlin
// 在 handleClientMoved 中集成
fun handleClientMoved(data: String) {
val event = Json.decodeFromString<ClientMovedEvent>(data)
if (event.clientId == repository.selfClientId.value) {
repository.updateSelfChannel(event.targetChannelId)
}
if (repository.hasClient(event.clientId)) {
repository.moveClient(event.clientId, event.targetChannelId)
} else {
// 检测到未知成员,触发补偿同步
Log.w(TAG, "Unknown client ${event.clientId} in move event, triggering compensation")
triggerClientCompensationSync()
}
}
```
**关键约束**
- 补偿同步使用**完整列表替换**,不是增量合并
- 使用防抖避免事件风暴时重复调用 ListClients
- 补偿同步失败不阻塞业务,等待下次事件触发重试
- 补偿同步完成后,UI 通过 StateFlow 自动刷新
**时序**(对应流程文档 §六):
```
OnClientMoved(未知 ClientID)
→ 检测到不一致
→ 调用 ListClients()
→ 完整成员列表返回
→ 替换成员基线(setClientBaseline
→ UI 自动刷新
```
### 10.3 重连流程
**目标**:断开重连后,清空旧会话状态,重新执行完整首次同步,确保数据与服务器完全一致。
**触发条件**
- 网络恢复后自动重连
- 用户手动触发重连
- 被踢出后重新连接
**任务**
1. **重连状态机**
```kotlin
// ServerViewModel.kt
sealed class ReconnectState {
object Idle : ReconnectState()
object Detecting : ReconnectState() // 检测到断开
object Cleaning : ReconnectState() // 清理旧会话
object Reconnecting : ReconnectState() // 重新连接中
object Syncing : ReconnectState() // 首次同步中
object Ready : ReconnectState() // 恢复就绪
data class Failed(val error: Throwable) : ReconnectState()
}
```
2. **重连流程实现**
```kotlin
// ServerViewModel.kt
private suspend fun performReconnect(config: ConnectionConfig) {
_reconnectState.value = ReconnectState.Cleaning
// 1. 清理旧会话数据(频道、成员、Pending 全部移除)
repository.clearSession()
_connectionState.value = ConnectionState.Disconnected
// 2. 断开旧连接(确保资源释放)
try { TSBridge.disconnect() } catch (_: Exception) {}
_reconnectState.value = ReconnectState.Reconnecting
// 3. 重新执行连接流程(参见步骤 04)
registerEventHandlers()
val connectResult = TSBridge.connect(config)
if (connectResult.isFailure) {
_reconnectState.value = ReconnectState.Failed(connectResult.exceptionOrNull()!!)
return
}
val waitResult = TSBridge.waitConnected()
if (waitResult.isFailure) {
_reconnectState.value = ReconnectState.Failed(waitResult.exceptionOrNull()!!)
return
}
// 4. OnConnected 事件将自动触发首次同步(步骤 04 已实现)
_reconnectState.value = ReconnectState.Syncing
}
```
3. **断开事件处理中的重连触发**
```kotlin
// ServerViewModel.kt
fun handleDisconnected(data: String) {
val error = Json.decodeFromString<DisconnectedEvent>(data)
when {
error.isKicked -> {
// 被踢出:显示原因,不自动重连
_kickReason.value = error.reasonMessage
repository.clearSession()
_connectionState.value = ConnectionState.Disconnected
}
isAutoReconnectEnabled -> {
// 网络断开:尝试自动重连
viewModelScope.launch {
delay(RECONNECT_DELAY) // 等待网络恢复
performReconnect(lastConfig)
}
}
else -> {
// 手动断开或不自动重连
repository.clearSession()
_connectionState.value = ConnectionState.Disconnected
}
}
}
```
4. **清理会话数据的完整性**
```kotlin
// data/Repository.kt
fun clearSession() {
_clientMap.value = emptyMap()
_channels.value = emptyList()
_selfClientId.value = null
_currentChannelId.value = null
// 清除所有待确认状态
_pendingChannelMove.value = null
}
```
**关键约束**
- 重连前**必须**清空旧会话状态,防止旧成员、频道和 Pending 污染新连接
- 清理操作在连接断开后执行,避免竞态
- 重连后的首次同步复用步骤 04 的 `performInitialSync()` 逻辑
- 被踢出不自动重连,由用户决定
**时序**(对应流程文档 §七):
```
检测到断开
→ 状态改为 Cleaning
→ 清理旧会话数据(频道、成员、Pending)
→ 断开旧连接
→ 重新 Connect + WaitConnected
→ OnConnected 触发首次同步
→ ListChannels + ListClients + ClientID
→ 原子提交新基线
→ 恢复业务就绪
```
### 10.4 同步失败处理
**目标**:当 ListChannels 或 ListClients 请求失败时,正确流转同步状态,允许重试,防止在不完整数据上执行业务操作。
**任务**
1. **同步状态机完善**
```kotlin
// data/Models.kt
sealed class SyncState {
object Unsynced : SyncState() // 已连接但尚无完整数据
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
object Synchronized : SyncState() // 列表基线可供 UI 使用
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
}
```
**状态流转**
```
Unsynced → Syncing → Synchronized(正常路径)
Syncing → SyncFailed → Syncing(重试路径)
Synchronized → Syncing(补偿同步或重连)
```
2. **首次同步失败处理**
```kotlin
// ServerViewModel.kt
private suspend fun performInitialSync() {
_syncState.value = SyncState.Syncing
try {
// 并行请求
val channelsDeferred = async { TSBridge.listChannels() }
val clientsDeferred = async { TSBridge.listClients() }
val selfIdDeferred = async { TSBridge.getClientId() }
val channels = channelsDeferred.await()
val clients = clientsDeferred.await()
val selfId = selfIdDeferred.await()
// 原子提交
repository.setChannelBaseline(channels)
repository.setClientBaseline(clients)
repository.setSelfClientId(selfId)
_syncState.value = SyncState.Synchronized
_connectionState.value = ConnectionState.Ready
} catch (e: Exception) {
Log.e(TAG, "Initial sync failed", e)
_syncState.value = SyncState.SyncFailed(e)
// 不进入业务就绪,允许重试
}
}
```
3. **重试机制**
```kotlin
// ServerViewModel.kt
private suspend fun performSyncWithRetry(maxRetries: Int = 3) {
var retryCount = 0
while (retryCount < maxRetries) {
performInitialSync()
if (_syncState.value is SyncState.Synchronized) {
return // 成功
}
retryCount++
if (retryCount < maxRetries) {
Log.w(TAG, "Sync retry $retryCount/$maxRetries")
delay(1000L * retryCount) // 递增延迟
}
}
Log.e(TAG, "Sync failed after $maxRetries retries")
// 保持 SyncFailed 状态,UI 显示重试按钮
}
```
4. **手动重试入口**
```kotlin
// ServerViewModel.kt
fun retrySync() {
viewModelScope.launch {
performSyncWithRetry()
}
}
```
5. **同步失败时的 UI 保护**
```kotlin
// 在业务操作前检查同步状态
fun switchChannel(channelId: Long, password: String? = null) {
if (_syncState.value !is SyncState.Synchronized) {
_error.value = "数据未同步,请等待同步完成或点击重试"
return
}
// 执行频道切换...
}
```
**关键约束**
- 任一核心请求(ListChannels / ListClients / ClientID)失败则不标记业务就绪
- 允许重试,避免在不完整数据上执行业务操作
- 同步失败期间,业务操作(切换频道、发消息等)应被阻止
- 补偿同步失败不进入 SyncFailed,仅记录日志等待下次触发
### 10.5 数据一致性
**目标**:在长期运行中,检测并修复可能的数据不一致(如频道列表过期、成员数据漂移)。
**任务**
1. **频道列表过期检测**
SDK 不提供频道创建/更新/删除事件,因此频道列表可能随时间过期。
```kotlin
// ChannelViewModel.kt
private var lastChannelRefreshTime: Long = 0
/** 检查频道列表是否需要刷新 */
private fun isChannelListStale(): Boolean {
val elapsed = System.currentTimeMillis() - lastChannelRefreshTime
return elapsed > CHANNEL_LIST_STALE_THRESHOLD // 建议 5 分钟
}
companion object {
const val CHANNEL_LIST_STALE_THRESHOLD = 5 * 60 * 1000L // 5 分钟
}
```
2. **被动刷新策略**
在用户执行关键操作时,检查并刷新过期数据:
```kotlin
// ChannelViewModel.kt
suspend fun refreshChannelsIfNeeded() {
if (isChannelListStale()) {
try {
val channels = TSBridge.listChannels()
repository.setChannelBaseline(channels)
lastChannelRefreshTime = System.currentTimeMillis()
} catch (e: Exception) {
Log.e(TAG, "Channel refresh failed", e)
// 不阻塞业务,使用旧数据
}
}
}
/** 浏览频道时刷新 */
fun onChannelListVisible() {
viewModelScope.launch { refreshChannelsIfNeeded() }
}
/** 切换频道前刷新 */
suspend fun beforeChannelSwitch() {
refreshChannelsIfNeeded()
}
```
3. **成员数据校验**
当成员引用了本地不存在的频道时,触发频道基线刷新:
```kotlin
// ChannelViewModel.kt
fun validateMemberData() {
val channels = repository.channels.value.map { it.id }.toSet()
val clients = repository.clientMap.value.values
val unknownChannelIds = clients
.map { it.channelId }
.filter { it !in channels }
.toSet()
if (unknownChannelIds.isNotEmpty()) {
Log.w(TAG, "Found clients referencing unknown channels: $unknownChannelIds")
triggerChannelCompensationSync()
}
}
```
4. **后台一致性检查(可选)**
```kotlin
// ServerViewModel.kt
private var consistencyCheckJob: Job? = null
fun startConsistencyCheck() {
consistencyCheckJob = viewModelScope.launch {
while (isActive) {
delay(CONSISTENCY_CHECK_INTERVAL)
if (_syncState.value is SyncState.Synchronized) {
channelViewModel.validateMemberData()
}
}
}
}
fun stopConsistencyCheck() {
consistencyCheckJob?.cancel()
consistencyCheckJob = null
}
companion object {
const val CONSISTENCY_CHECK_INTERVAL = 60 * 1000L // 1 分钟
}
```
**关键约束**
- `ListChannels` 是频道目录的**唯一权威来源**(SDK 无频道变更事件)
- 不能假设频道列表依靠 `On*` 事件永久保持最新
- 刷新失败时使用旧数据,不阻塞业务操作
- 一致性检查为低优先级,不影响正常事件流的实时性
---
## 三、验收标准
### 功能验收
- [ ] **增量同步**
- OnClientEnter 事件正确添加成员到基线,重复事件不重复计数
- OnClientMoved 事件正确更新成员频道位置,相同目标频道为 no-op
- OnClientLeave 事件正确删除成员,重复删除为 no-op
- 自己的移动事件正确更新自身频道位置
- 频道人数由成员实体表实时派生,不使用独立计数器
- [ ] **补偿同步**
- OnClientMoved 引用未知 ClientID 时自动触发 ListClients
- 补偿同步使用完整列表替换成员基线
- 连续多个未知实体事件只触发一次补偿同步(防抖)
- 补偿同步失败不阻塞业务
- [ ] **重连全量同步**
- 重连前清空旧会话数据(频道、成员、Pending)
- 重连后自动执行首次同步
- 同步完成后恢复业务就绪
- 被踢出不自动重连
- [ ] **同步失败处理**
- 首次同步失败进入 SyncFailed 状态
- 同步失败期间业务操作被阻止
- 提供手动重试入口
- 重试最多 3 次,递增延迟
- [ ] **数据一致性**
- 频道列表超过 5 分钟未刷新时,关键操作前自动刷新
- 成员引用未知频道时触发频道基线补偿同步
- 刷新失败时使用旧数据,不阻塞业务
### 性能验收
- [ ] 增量同步单次事件处理 < 10ms
- [ ] 补偿同步(ListClients)在 3 秒内完成
- [ ] 重连全量同步在 5 秒内完成
- [ ] 防抖机制避免事件风暴时的重复请求
### 代码质量验收
- [ ] 成员实体表操作线程安全(StateFlow + immutable Map
- [ ] 补偿同步防抖使用协程取消,无泄漏
- [ ] 所有网络操作在 IO 线程执行
- [ ] 日志覆盖关键状态转换和异常
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 正常增量流 | 其他用户进入/移动/离开 | 成员表实时更新,频道人数正确 |
| 重复进入事件 | 同一用户连续两次 OnClientEnter | 成员表只有一条记录,无重复计数 |
| 未知成员移动 | OnClientMoved 引用不存在的 ClientID | 自动触发 ListClients 补偿同步 |
| 补偿同步防抖 | 连续 5 个未知实体事件 | 只触发 1 次 ListClients |
| 正常重连 | 网络断开后恢复 | 清理旧数据 → 重连 → 同步 → 就绪 |
| 被踢后重连 | 被服务器踢出后手动重连 | 显示踢出原因 → 清理 → 重连 → 同步 |
| 同步失败重试 | 首次同步网络超时 | 进入 SyncFailed → 点击重试 → 成功 |
| 同步失败阻塞 | 同步失败时切换频道 | 显示"数据未同步"提示 |
| 频道列表过期 | 5 分钟后切换频道 | 自动刷新频道列表再切换 |
| 成员引用未知频道 | 成员的 ChannelID 在本地不存在 | 触发频道基线补偿同步 |
---
## 四、参考文档
- `docs/流程/08_状态同步.md` - 完整同步机制(②③⑥⑦)
- `docs/流程/02_浏览频道.md` - 成员实体状态树、事件依赖矩阵
- `docs/sdk文档-go.md` - OnClientEnter / OnClientLeave / OnClientMoved 事件处理器、ListClients / ListChannels API
- `docs/implementation/04_连接与首次同步.md` - 首次同步实现、SyncState 状态机
- `docs/implementation/09_断开连接.md` - 断开连接清理逻辑
File diff suppressed because it is too large Load Diff
+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` - 测试说明