首次推送
This commit is contained in:
@@ -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 callback(Go 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 TSClient,JSON 传递复杂数据 |
|
||||
| 桥接层 | TSBridge (单例) | 直接包装 gomobile TSClient,JNI 回调转 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/Moved,debounce 刷新)
|
||||
├── 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.0(gomobile 要求最低 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 3:Go → 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 4:Android → 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` — 整体架构设计
|
||||
@@ -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 连接服务器(初始化配置部分)
|
||||
> 依赖步骤:02(Bridge 层)
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
- [ ] 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
@@ -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 → WaitingServerEvent:ClientMove 命令成功
|
||||
* Requesting → Failed:ClientMove 命令失败
|
||||
* 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秒后显示超时提示,可重试 |
|
||||
| 被管理员移动 | 静默更新当前频道 |
|
||||
|
||||
### 性能验收
|
||||
|
||||
- [ ] 切换响应时间 < 100ms(UI 反馈)
|
||||
- [ ] 服务端确认时间 < 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-op(Map.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
@@ -0,0 +1,998 @@
|
||||
# 步骤 12:主题与收尾
|
||||
|
||||
> 实现暗色主题、边缘情况处理、稳定性优化。
|
||||
> 对应设计:`docs/UI架构设计.md` - 4.4 主题切换
|
||||
> 依赖步骤:11(卡片与全局交互)
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
- [ ] 暗色/亮色主题切换 — 全局 Material 3 动态主题
|
||||
- [ ] 主题持久化 — DataStore 保存用户选择,启动时自动应用
|
||||
- [ ] 边缘情况处理 — 空状态、异常输入、极端场景覆盖
|
||||
- [ ] 内存泄漏检查 — ViewModel / 协程 / 回调 / 音频资源释放
|
||||
- [ ] 性能优化 — 列表滚动、重组范围、图片/动画优化
|
||||
- [ ] 最终集成验证 — 全链路冒烟测试
|
||||
|
||||
---
|
||||
|
||||
## 二、任务清单
|
||||
|
||||
### 12.1 主题系统
|
||||
|
||||
**目标**:实现 Material 3 暗色/亮色主题切换,全局生效并持久化用户选择。
|
||||
|
||||
**对应设计**:`docs/UI架构设计.md` 4.4 主题切换:
|
||||
- 入口位置:服务器配置页右上角 🌙 图标
|
||||
- 切换方式:点击在亮色/暗色主题间切换
|
||||
- 持久化:选择保存到本地配置,下次启动自动应用
|
||||
- 影响范围:全局所有页面和卡片
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **ThemeMode 枚举与 DataStore 持久化**
|
||||
|
||||
```kotlin
|
||||
// ui/theme/ThemeMode.kt
|
||||
|
||||
enum class ThemeMode {
|
||||
LIGHT, // 亮色
|
||||
DARK, // 暗色
|
||||
SYSTEM // 跟随系统(默认)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// data/ThemePreferences.kt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "settings")
|
||||
|
||||
class ThemePreferences(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取主题模式(默认跟随系统)
|
||||
*/
|
||||
val themeMode: Flow<ThemeMode> = context.dataStore.data.map { prefs ->
|
||||
when (prefs[THEME_MODE_KEY]) {
|
||||
ThemeMode.LIGHT.name -> ThemeMode.LIGHT
|
||||
ThemeMode.DARK.name -> ThemeMode.DARK
|
||||
else -> ThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存主题模式
|
||||
*/
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_MODE_KEY] = mode.name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **ThemeViewModel — 主题状态管理**
|
||||
|
||||
```kotlin
|
||||
// viewmodel/ThemeViewModel.kt
|
||||
|
||||
class ThemeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val themePreferences = ThemePreferences(application)
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> = themePreferences.themeMode
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = ThemeMode.SYSTEM
|
||||
)
|
||||
|
||||
/**
|
||||
* 切换主题模式
|
||||
*
|
||||
* 对应 docs/UI架构设计.md 4.4:
|
||||
* "点击在亮色/暗色主题间切换"
|
||||
* "选择保存到本地配置"
|
||||
*/
|
||||
fun toggleTheme() {
|
||||
viewModelScope.launch {
|
||||
val next = when (themeMode.value) {
|
||||
ThemeMode.SYSTEM -> ThemeMode.LIGHT
|
||||
ThemeMode.LIGHT -> ThemeMode.DARK
|
||||
ThemeMode.DARK -> ThemeMode.SYSTEM
|
||||
}
|
||||
themePreferences.setThemeMode(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Material 3 主题配置**
|
||||
|
||||
```kotlin
|
||||
// ui/theme/Theme.kt
|
||||
|
||||
@Composable
|
||||
fun TSMobileTheme(
|
||||
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = if (darkTheme) {
|
||||
darkColorScheme(
|
||||
primary = Color(0xFF90CAF9),
|
||||
onPrimary = Color(0xFF003258),
|
||||
primaryContainer = Color(0xFF00497D),
|
||||
onPrimaryContainer = Color(0xFFD1E4FF),
|
||||
secondary = Color(0xFFBBC7DB),
|
||||
onSecondary = Color(0xFF263141),
|
||||
surface = Color(0xFF1A1C1E),
|
||||
onSurface = Color(0xFFE3E2E6),
|
||||
surfaceVariant = Color(0xFF43474E),
|
||||
onSurfaceVariant = Color(0xFFC3C6CF),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005)
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = Color(0xFF1565C0),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD1E4FF),
|
||||
onPrimaryContainer = Color(0xFF001D36),
|
||||
secondary = Color(0xFF535F70),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
surface = Color(0xFFFDFBFF),
|
||||
onSurface = Color(0xFF1A1C1E),
|
||||
surfaceVariant = Color(0xFFE0E3EC),
|
||||
onSurfaceVariant = Color(0xFF43474E),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
4. **MainActivity 集成**
|
||||
|
||||
```kotlin
|
||||
// MainActivity.kt
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val themeViewModel: ThemeViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val themeMode by themeViewModel.themeMode.collectAsState()
|
||||
|
||||
TSMobileTheme(themeMode = themeMode) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainApp(
|
||||
themeViewModel = themeViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **服务器配置页主题切换按钮**
|
||||
|
||||
对应 `docs/UI架构设计.md` 2.1 布局 — 右上角主题图标:
|
||||
|
||||
```kotlin
|
||||
// ui/screens/ServerConfigScreen.kt
|
||||
|
||||
@Composable
|
||||
fun ServerConfigScreen(
|
||||
serverViewModel: ServerViewModel,
|
||||
themeViewModel: ThemeViewModel
|
||||
) {
|
||||
val themeMode by themeViewModel.themeMode.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 品牌区
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Logo + 描述
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(top = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Logo
|
||||
Icon(
|
||||
imageVector = Icons.Default.Headset,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "TeamSpeak Mobile",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Text(
|
||||
text = "连接到你的 TeamSpeak 服务器",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 主题切换按钮(右上角)
|
||||
IconButton(
|
||||
onClick = { themeViewModel.toggleTheme() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (themeMode) {
|
||||
ThemeMode.LIGHT -> Icons.Default.LightMode
|
||||
ThemeMode.DARK -> Icons.Default.DarkMode
|
||||
ThemeMode.SYSTEM -> Icons.Default.SettingsBrightness
|
||||
},
|
||||
contentDescription = "切换主题",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ... 输入区 + 最近连接 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 边缘情况
|
||||
|
||||
**目标**:覆盖各种边缘场景,确保应用在异常输入、极端数据、特殊字符等情况下不崩溃。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **空状态处理**
|
||||
|
||||
为所有列表和数据展示区域提供空状态 UI:
|
||||
|
||||
```kotlin
|
||||
// ui/components/EmptyStateView.kt
|
||||
|
||||
@Composable
|
||||
fun EmptyStateView(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String = "",
|
||||
actionText: String? = null,
|
||||
onAction: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
if (actionText != null && onAction != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(onClick = onAction) {
|
||||
Text(actionText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
各场景空状态:
|
||||
|
||||
| 场景 | 图标 | 标题 | 副标题 |
|
||||
|------|------|------|--------|
|
||||
| 频道列表为空 | FolderOpen | 暂无频道 | 服务器没有任何频道 |
|
||||
| 当前频道无成员 | PersonOutline | 频道内无人 | 你是第一个进入的 |
|
||||
| 消息列表为空 | ChatBubbleOutline | 暂无消息 | 发送第一条消息吧 |
|
||||
| 最近连接为空 | History | 暂无记录 | 连接服务器后会在这里显示 |
|
||||
| 语音卡无人发言 | VolumeOff | 暂无发言 | — |
|
||||
|
||||
2. **输入验证加固**
|
||||
|
||||
```kotlin
|
||||
// data/InputValidator.kt
|
||||
|
||||
object InputValidator {
|
||||
|
||||
/**
|
||||
* 服务器地址验证
|
||||
* 支持:域名、IP(v4/v6)、TSDNS、带端口
|
||||
*/
|
||||
fun validateServerAddress(input: String): ValidationResult {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ValidationResult(false, "请输入服务器地址")
|
||||
}
|
||||
// 去除协议前缀
|
||||
val addr = trimmed
|
||||
.removePrefix("ts3server://")
|
||||
.removePrefix("ts3://")
|
||||
.trimEnd('/')
|
||||
|
||||
// 基本格式检查:不能包含空格、必须有合法字符
|
||||
if (addr.contains(' ') || addr.length > 256) {
|
||||
return ValidationResult(false, "地址格式不正确")
|
||||
}
|
||||
|
||||
// 端口检查(如果有)
|
||||
val parts = addr.split(":")
|
||||
if (parts.size == 2) {
|
||||
val port = parts[1].toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
return ValidationResult(false, "端口范围 1-65535")
|
||||
}
|
||||
} else if (parts.size > 2) {
|
||||
// IPv6 地址 — 必须包含在 [] 中
|
||||
if (!addr.startsWith("[")) {
|
||||
return ValidationResult(false, "IPv6 地址需要用 [] 包裹")
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationResult(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 昵称验证
|
||||
*/
|
||||
fun validateNickname(input: String): ValidationResult {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ValidationResult(false, "请输入昵称")
|
||||
}
|
||||
if (trimmed.length > 30) {
|
||||
return ValidationResult(false, "昵称最长 30 个字符")
|
||||
}
|
||||
// 检查非法字符(TeamSpeak 限制)
|
||||
val illegalChars = listOf("\\", "/", "|", "\n", "\r", "\t")
|
||||
for (ch in illegalChars) {
|
||||
if (trimmed.contains(ch)) {
|
||||
return ValidationResult(false, "昵称包含非法字符: '$ch'")
|
||||
}
|
||||
}
|
||||
return ValidationResult(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道密码验证
|
||||
*/
|
||||
fun validateChannelPassword(input: String): ValidationResult {
|
||||
if (input.isEmpty()) {
|
||||
return ValidationResult(false, "请输入频道密码")
|
||||
}
|
||||
if (input.length > 100) {
|
||||
return ValidationResult(false, "密码过长")
|
||||
}
|
||||
return ValidationResult(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息验证
|
||||
*/
|
||||
fun validateMessage(input: String): ValidationResult {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ValidationResult(false, "消息不能为空")
|
||||
}
|
||||
if (trimmed.length > 1024) {
|
||||
return ValidationResult(false, "消息最长 1024 个字符")
|
||||
}
|
||||
return ValidationResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
data class ValidationResult(
|
||||
val isValid: Boolean,
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
```
|
||||
|
||||
3. **频道名/成员名特殊字符处理**
|
||||
|
||||
```kotlin
|
||||
// ui/components/TextExtensions.kt
|
||||
|
||||
/**
|
||||
* 安全显示频道名/成员名
|
||||
* 处理:空名称、超长名称、特殊字符
|
||||
*/
|
||||
@Composable
|
||||
fun SafeDisplayName(
|
||||
name: String,
|
||||
fallback: String = "未知",
|
||||
maxLength: Int = 50,
|
||||
style: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
maxLines: Int = 1
|
||||
) {
|
||||
val displayName = when {
|
||||
name.isBlank() -> fallback
|
||||
name.length > maxLength -> name.take(maxLength) + "…"
|
||||
else -> name
|
||||
}
|
||||
Text(
|
||||
text = displayName,
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
4. **极端数据场景处理**
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 频道数 > 100 | 使用 LazyColumn 虚拟化,避免一次性渲染 |
|
||||
| 成员数 > 500 | LazyColumn 虚拟化 + 分页加载 |
|
||||
| 消息数 > 1000 | 限制内存中保留最近 200 条,其余从归档加载 |
|
||||
| 频道名为空 | 显示 "(未命名频道)" |
|
||||
| 成员昵称为空 | 显示 "未知用户" |
|
||||
| 消息内容为空 | 不显示该消息,记录日志 |
|
||||
| 服务器返回异常 JSON | try-catch + 默认值,不崩溃 |
|
||||
| SDK 方法调用超时 | 设置 5 秒超时,超时后显示错误提示 |
|
||||
| 服务器满/密码错/被封禁 | 显示对应错误信息,不自动重连 |
|
||||
|
||||
5. **全局异常捕获**
|
||||
|
||||
```kotlin
|
||||
// App.kt
|
||||
|
||||
class App : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// 全局未捕获异常处理
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Log.e("App", "Uncaught exception in ${thread.name}", throwable)
|
||||
|
||||
// 写入崩溃日志文件(可用于后续分析)
|
||||
writeCrashLog(throwable)
|
||||
|
||||
// 交给默认处理器(系统弹窗)
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeCrashLog(throwable: Throwable) {
|
||||
try {
|
||||
val file = File(getExternalFilesDir(null), "crash.log")
|
||||
file.appendText(
|
||||
buildString {
|
||||
appendLine("=== ${java.util.Date()} ===")
|
||||
appendLine(throwable.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("App", "Failed to write crash log", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 稳定性优化
|
||||
|
||||
**目标**:确保资源正确释放、协程不泄漏、音频设备正确管理,提升应用稳定性。
|
||||
|
||||
**任务**:
|
||||
|
||||
1. **ViewModel 生命周期管理**
|
||||
|
||||
```kotlin
|
||||
// viewmodel/VoiceViewModel.kt — 资源释放示例
|
||||
|
||||
class VoiceViewModel : ViewModel() {
|
||||
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var voiceJob: Job? = null
|
||||
|
||||
/**
|
||||
* ViewModel 销毁时释放所有资源
|
||||
*/
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d(TAG, "onCleared: releasing voice resources")
|
||||
|
||||
// 停止语音
|
||||
stopVoice()
|
||||
|
||||
// 释放音频资源
|
||||
audioTrack?.release()
|
||||
audioTrack = null
|
||||
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
|
||||
// 取消协程
|
||||
voiceJob?.cancel()
|
||||
voiceJob = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **TSBridge 回调生命周期管理**
|
||||
|
||||
```kotlin
|
||||
// viewmodel/ServerViewModel.kt
|
||||
|
||||
class ServerViewModel : ViewModel() {
|
||||
|
||||
/**
|
||||
* 注册回调(连接时调用)
|
||||
*/
|
||||
fun registerCallbacks() {
|
||||
TSBridge.setCallbacks(createCallbacks())
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销回调(断开时调用)
|
||||
*
|
||||
* 防止断开后仍然收到回调导致状态混乱
|
||||
*/
|
||||
fun unregisterCallbacks() {
|
||||
TSBridge.setCallbacks(null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
unregisterCallbacks()
|
||||
reconnectJob?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **协程作用域安全**
|
||||
|
||||
```kotlin
|
||||
// 所有 ViewModel 中的协程调用
|
||||
|
||||
// ✅ 正确:使用 viewModelScope,自动在 ViewModel 销毁时取消
|
||||
fun fetchServerInfo() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val json = TSBridge.getServerInfoJSON()
|
||||
_serverInfo.value = Json.decodeFromString(json)
|
||||
} catch (e: CancellationException) {
|
||||
throw e // 不要吞掉 CancellationException
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "fetchServerInfo failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误:使用 GlobalScope,不会随 ViewModel 销毁取消
|
||||
// GlobalScope.launch { ... }
|
||||
```
|
||||
|
||||
4. **音频设备切换与焦点管理**
|
||||
|
||||
```kotlin
|
||||
// viewmodel/VoiceViewModel.kt
|
||||
|
||||
/**
|
||||
* 请求音频焦点
|
||||
* 进入语音频道时调用
|
||||
*/
|
||||
private fun requestAudioFocus() {
|
||||
val audioManager = application.getSystemService(AudioManager::class.java)
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
// 永久丢失焦点 → 停止语音
|
||||
stopVoice()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
// 暂时丢失 → 暂停发送
|
||||
pauseTransmit()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
// 重新获得焦点 → 恢复
|
||||
resumeTransmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放音频焦点
|
||||
* 离开语音频道时调用
|
||||
*/
|
||||
private fun abandonAudioFocus() {
|
||||
val audioManager = application.getSystemService(AudioManager::class.java)
|
||||
audioManager.abandonAudioFocusRequest(focusRequest)
|
||||
}
|
||||
```
|
||||
|
||||
5. **音频录制/播放设备异常处理**
|
||||
|
||||
```kotlin
|
||||
// viewmodel/VoiceViewModel.kt
|
||||
|
||||
/**
|
||||
* 安全初始化音频录制
|
||||
*/
|
||||
private fun initAudioRecord(): Boolean {
|
||||
return try {
|
||||
val bufferSize = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize * 2
|
||||
)
|
||||
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord failed to initialize")
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Microphone permission denied", e)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "AudioRecord init failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全初始化音频播放
|
||||
*/
|
||||
private fun initAudioTrack(): Boolean {
|
||||
return try {
|
||||
val bufferSize = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
audioTrack = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(bufferSize * 2)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "AudioTrack init failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. **列表性能优化**
|
||||
|
||||
```kotlin
|
||||
// ui/screens/ChannelListScreen.kt
|
||||
|
||||
@Composable
|
||||
fun ChannelTreeList(
|
||||
channelTree: List<ChannelNode>,
|
||||
currentChannelId: Long,
|
||||
onChannelClick: (ChannelInfo) -> Unit,
|
||||
onChannelLongClick: (ChannelInfo) -> Unit
|
||||
) {
|
||||
// 使用 key 优化重组
|
||||
LazyColumn {
|
||||
items(
|
||||
items = channelTree,
|
||||
key = { node -> "channel_${node.channel.id}_${node.depth}" }
|
||||
) { node ->
|
||||
ChannelTreeItem(
|
||||
node = node,
|
||||
isCurrentChannel = node.channel.id == currentChannelId,
|
||||
onClick = { onChannelClick(node.channel) },
|
||||
onLongClick = { onChannelLongClick(node.channel) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageList(
|
||||
messages: List<TextMsg>,
|
||||
selfClientId: Int
|
||||
) {
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
reverseLayout = true // 新消息在底部
|
||||
) {
|
||||
items(
|
||||
items = messages,
|
||||
key = { msg -> "${msg.senderID}_${msg.timestamp}" }
|
||||
) { msg ->
|
||||
MessageItem(
|
||||
message = msg,
|
||||
isSelf = msg.senderID == selfClientId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.4 测试与验证
|
||||
|
||||
**目标**:对全部功能进行端到端冒烟测试,确保各流程正常工作。
|
||||
|
||||
**冒烟测试清单**:
|
||||
|
||||
| 编号 | 测试场景 | 操作步骤 | 预期结果 |
|
||||
|------|----------|----------|----------|
|
||||
| T01 | 首次连接 | 输入地址/昵称 → 点击连接 | 连接成功,频道列表显示 |
|
||||
| T02 | 快速连接 | 点击最近连接记录 | 自动填充并连接 |
|
||||
| T03 | 频道树浏览 | 展开/折叠子频道 | 频道树正确展开/折叠 |
|
||||
| T04 | 频道切换 | 点击无密码频道 | 切换成功,当前频道栏更新 |
|
||||
| T05 | 密码频道 | 点击有密码频道 → 输入密码 | 密码正确则进入,错误则提示 |
|
||||
| T06 | 发送消息 | 输入消息 → 点击发送 | 消息显示在列表中 |
|
||||
| T07 | 接收消息 | 其他成员发送消息 | 消息实时显示,未读指示更新 |
|
||||
| T08 | PTT 发言 | 按住 PTT 按钮 → 松开 | 发言指示出现/消失 |
|
||||
| T09 | 静音切换 | 点击静音按钮 | 图标切换,语音停止/恢复 |
|
||||
| T10 | 服务器卡 | 点击头部左侧按钮 | 卡片弹出,信息正确 |
|
||||
| T11 | 断开连接 | 服务器卡 → 断开 → 确认 | 断开成功,返回配置页 |
|
||||
| T12 | 被踢处理 | 被管理员踢出 | 全屏提示,可重连/返回 |
|
||||
| T13 | 网络断开 | 断开网络 | 重连横幅,自动重连 |
|
||||
| T14 | 主题切换 | 点击右上角主题图标 | 主题切换,重启后保持 |
|
||||
| T15 | Poke | 长按成员 → Poke → 发送 | 对方收到通知 |
|
||||
| T16 | 语音卡 | 点击展开按钮 | 卡片显示,控制正常 |
|
||||
| T17 | 长时间运行 | 连接后静置 30 分钟 | 无崩溃、无内存持续增长 |
|
||||
|
||||
**性能指标**:
|
||||
|
||||
| 指标 | 目标 | 测量方法 |
|
||||
|------|------|----------|
|
||||
| 首次启动到可交互 | < 2 秒 | 手动计时 |
|
||||
| 连接建立 | < 5 秒 | Logcat 时间戳 |
|
||||
| 首次同步完成 | < 3 秒 | Logcat 时间戳 |
|
||||
| 频道列表滚动 FPS | ≥ 55 FPS | GPU 过度绘制 / Profiler |
|
||||
| 消息列表滚动 FPS | ≥ 55 FPS | GPU 过度绘制 / Profiler |
|
||||
| 内存占用(空闲) | < 80 MB | Android Profiler |
|
||||
| 内存占用(语音中) | < 120 MB | Android Profiler |
|
||||
| APK 大小 | < 30 MB | 构建产物大小 |
|
||||
| ANR 发生率 | 0 | Monkey 测试 / 手动测试 |
|
||||
|
||||
---
|
||||
|
||||
## 三、状态与数据流
|
||||
|
||||
### 3.1 主题状态流
|
||||
|
||||
```
|
||||
用户点击主题按钮 ThemeViewModel ThemePreferences (DataStore) UI
|
||||
│ │ │ │
|
||||
│ toggleTheme() │ │ │
|
||||
├───────────────────→│ │ │
|
||||
│ │ setThemeMode(next) │ │
|
||||
│ ├───────────────────────→│ │
|
||||
│ │ │ 持久化到磁盘 │
|
||||
│ │ │ │
|
||||
│ │ themeMode Flow 发出新值 │ │
|
||||
│ │←───────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ │ TSMobileTheme 重组 │
|
||||
│ │ │ 全局颜色方案切换 │
|
||||
│ │ │ │
|
||||
│ 界面切换主题 │ │ │
|
||||
│←───────────────────────────────────────────────────────────────────→│
|
||||
```
|
||||
|
||||
### 3.2 主题模式循环
|
||||
|
||||
```
|
||||
┌─────────┐ 点击 ┌─────────┐ 点击 ┌─────────┐
|
||||
│ SYSTEM │ ──────────→ │ LIGHT │ ──────────→ │ DARK │
|
||||
│ 跟随系统 │ │ 亮色 │ │ 暗色 │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
↑ │
|
||||
│ 点击 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、与其他步骤的集成
|
||||
|
||||
### 4.1 与服务器配置页集成(步骤 03)
|
||||
|
||||
- 主题切换按钮在品牌区右上角
|
||||
- 主题模式变更实时反映在输入框、按钮、最近连接列表样式上
|
||||
|
||||
### 4.2 与频道列表页集成(步骤 05)
|
||||
|
||||
- 频道树的图标、文字、背景跟随主题色
|
||||
- 未读指示的红点/数字 badge 在暗色主题下可见
|
||||
|
||||
### 4.3 与聊天页集成(步骤 07)
|
||||
|
||||
- 消息气泡颜色区分:自己 vs 他人,亮/暗色方案不同
|
||||
- 时间戳、发送者名称的颜色适配
|
||||
|
||||
### 4.4 与卡片集成(步骤 11)
|
||||
|
||||
- 所有 BottomSheet 卡片的背景、文字、按钮跟随主题
|
||||
- Poke 通知的容器颜色适配
|
||||
|
||||
### 4.5 与断开连接集成(步骤 09)
|
||||
|
||||
- 重连横幅的颜色使用 errorContainer / onErrorContainer
|
||||
- 被踢全屏提示的颜色适配
|
||||
|
||||
---
|
||||
|
||||
## 五、验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- [ ] **主题切换**
|
||||
- 服务器配置页右上角图标可切换主题
|
||||
- 切换后全局所有页面/卡片立即生效
|
||||
- 切换模式循环:跟随系统 → 亮色 → 暗色 → 跟随系统
|
||||
- 图标随模式变化(LightMode / DarkMode / SettingsBrightness)
|
||||
|
||||
- [ ] **主题持久化**
|
||||
- 选择的主题模式保存到 DataStore
|
||||
- 关闭应用后重新启动,主题模式保持
|
||||
- 首次安装默认跟随系统
|
||||
|
||||
- [ ] **边缘情况 — 空状态**
|
||||
- 频道列表为空时显示空状态提示
|
||||
- 消息列表为空时显示空状态提示
|
||||
- 最近连接为空时显示空状态提示
|
||||
|
||||
- [ ] **边缘情况 — 输入验证**
|
||||
- 服务器地址为空 → 提示 "请输入服务器地址"
|
||||
- 服务器地址格式错误 → 提示 "地址格式不正确"
|
||||
- 昵称为空 → 提示 "请输入昵称"
|
||||
- 昵称包含非法字符 → 提示包含非法字符
|
||||
- 消息为空 → 发送按钮置灰
|
||||
- 消息超长 → 提示 "消息最长 1024 个字符"
|
||||
|
||||
- [ ] **边缘情况 — 极端数据**
|
||||
- 100+ 频道时列表滚动流畅
|
||||
- 500+ 成员时列表滚动流畅
|
||||
- 频道名/成员名为空时显示兜底文本
|
||||
- 服务器返回异常 JSON 时不崩溃
|
||||
|
||||
- [ ] **稳定性 — 资源释放**
|
||||
- 断开连接后音频资源释放
|
||||
- ViewModel 销毁后协程取消
|
||||
- 断开后回调注销,不收到旧事件
|
||||
- 音频焦点正确请求/释放
|
||||
|
||||
- [ ] **稳定性 — 异常处理**
|
||||
- 全局未捕获异常写入日志
|
||||
- SDK 方法调用超时不导致 ANR
|
||||
- 权限拒绝(麦克风)不崩溃,显示提示
|
||||
|
||||
### 性能验收
|
||||
|
||||
- [ ] 首次启动到可交互 < 2 秒
|
||||
- [ ] 连接建立 < 5 秒
|
||||
- [ ] 频道列表滚动 FPS ≥ 55
|
||||
- [ ] 消息列表滚动 FPS ≥ 55
|
||||
- [ ] 内存占用(空闲) < 80 MB
|
||||
- [ ] 内存占用(语音中) < 120 MB
|
||||
- [ ] 无 ANR 发生
|
||||
- [ ] 无内存泄漏(LeakCanary 或 Profiler 检测)
|
||||
|
||||
### 代码质量验收
|
||||
|
||||
- [ ] 所有 ViewModel 在 onCleared 中释放资源
|
||||
- [ ] 所有协程使用 viewModelScope
|
||||
- [ ] 所有 SDK 调用有 try-catch 保护
|
||||
- [ ] 所有 JSON 解析有异常处理和默认值
|
||||
- [ ] 无硬编码的字符串资源(使用 strings.xml)
|
||||
- [ ] 无硬编码的颜色值(使用主题色)
|
||||
|
||||
### 测试用例
|
||||
|
||||
| 场景 | 操作 | 预期结果 |
|
||||
|------|------|----------|
|
||||
| 亮色主题 | 切换到亮色 | 全局亮色,图标为太阳 |
|
||||
| 暗色主题 | 切换到暗色 | 全局暗色,图标为月亮 |
|
||||
| 跟随系统 | 切换到跟随系统 | 跟随系统设置,图标为亮度自动 |
|
||||
| 主题持久化 | 切换主题 → 杀掉应用 → 重启 | 主题保持上次选择 |
|
||||
| 空频道 | 连接无频道服务器 | 显示空状态提示 |
|
||||
| 长消息 | 输入 1000 字符发送 | 发送成功,正常显示 |
|
||||
| 特殊字符名 | 昵称含 emoji/特殊符号 | 正常显示,不崩溃 |
|
||||
| 快速切换频道 | 连续快速点击频道 | 无崩溃,最终停留在正确频道 |
|
||||
| 快速发送消息 | 连续快速点击发送 | 消息按序发送,无丢失 |
|
||||
| 语音中切换主题 | 发言中切换暗色/亮色 | 主题切换,语音不中断 |
|
||||
| 内存检查 | 连接 → 断开 → 重复 10 次 | 内存无持续增长 |
|
||||
| 崩溃日志 | 触发未捕获异常 | crash.log 文件生成 |
|
||||
|
||||
---
|
||||
|
||||
## 六、参考文档
|
||||
|
||||
- `docs/UI架构设计.md` - 2.1 服务器配置页(主题切换按钮)、4.4 主题切换
|
||||
- `docs/implementation/03_服务器配置页.md` - ServerConfigScreen 集成点
|
||||
- `docs/implementation/05_频道列表页.md` - ChannelTreeList 性能优化
|
||||
- `docs/implementation/07_聊天页.md` - MessageList 性能优化
|
||||
- `docs/implementation/09_断开连接.md` - 资源释放、回调注销
|
||||
- `docs/implementation/11_卡片与全局交互.md` - 卡片主题适配
|
||||
- `CLAUDE.md` - 测试说明
|
||||
Reference in New Issue
Block a user