工程改进:跨语言契约固化、日志脱敏、状态模型收敛
依据 docs/项目工程改进方案.md 实施(第五梯队 CI/协作除外)。
安全性
- Release 剥离 Log.v/d/i/w(-assumenosideeffects),保留 Log.e
- 全量频道/成员 dump、消息正文、发送者 UID 改为 BuildConfig.DEBUG 保护
(实测 release dex 中 "Channel: id=" / "Client: id=" / selfId= 等均为 0 命中)
构建产物体积
- .gitignore 补 *.a(保留 .opus/lib 四个 ABI 预编译库)、.opus/install、
app/libs/*.aar、dnn/torch
- git rm --cached 除名 AAR、.gradle、.opus/{build,install}、dnn/torch
跟踪体积 50MB → 17.4MB,工作区文件不受影响
跨语言契约(本方案核心)
- 新增 go/teamspeak/contract.go:字段名常量 + BridgeContractVersion 单一事实来源
- 新增契约测试:反射断言常量与 struct tag 一致;Go/Kotlin 共用 golden 样本
- 新增 BridgeContract.kt:启动校验 AAR 契约版本,不匹配则阻止连接
- 修正文档:channel_order 是前驱频道 ID(链表指针)而非排序权重,
按它数值排序会打乱频道树;ChannelInfo.order 在 Go 侧不存在
状态模型
- ConnectionState 增 Idle 取代 null 编码,connectionState 不再可空
- applyClients 改为按频道差分更新,避免全量刷新导致频道树整体重组
- 频道数据 freshness 统一由 Repository 维护,修复 ViewModel 与 Repository
两份时间戳不同步导致的重复刷新
UI
- 修复 collectAsState() 在参数位置调用导致的 isSelf 快照失效
- 消除 9 处 !! 断言(ChannelListScreen)
- 硬编码 24.5/14.5dp 与 depth*24 收敛到 UiTokens.Spacing,缩进加 4 层上限
测试
- Kotlin 测试 7 → 106;Go 契约测试新增 24 个用例
- 各覆盖 InputValidator、消息送达确认、僵尸会话过滤、频道差分、
频道顺序语义、错误分类
其他修复
- classifyError 提取为纯函数并补测试;修复 too many clones (id=521)
未识别导致英文原文直接暴露给用户
版本号提升至 1.0.13(versionCode 13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
# 《TS Mobile UI / UX 重构设计规范》审查报告
|
||||
|
||||
审查对象:`E:\MyProject\ts-mobile-go\docs\TS Mobile UI UX 重构设计规范.md`(47 章)
|
||||
交叉参照:
|
||||
- `E:\MyProject\ts-mobile-go\docs\UI架构设计.md`
|
||||
- `E:\MyProject\ts-mobile-go\docs\流程\00_总览.md` ~ `09_EventBus架构.md`
|
||||
- `E:\MyProject\ts-mobile-go\docs\implementation\00_实施总览.md` ~ `12_主题与收尾.md`
|
||||
- `E:\MyProject\ts-mobile-go\CLAUDE.md`(已知 SDK 限制)
|
||||
- Android 实现:`android/app/src/main/java/com/tsmobile/app/`(Models、Repository、NavGraph、UiTokens、Shapes、SemanticColors、TSBridge、各 ViewModel 与组件)
|
||||
|
||||
审查结论:**方向正确,但当前版本不具备落地条件。**
|
||||
|
||||
"Server 是工作空间 + Current Channel 是核心上下文 + Chat/Channels 只是视图"这个模型判断是对的,比现有的三页跳转(server_config → channel_list → chat)更贴合 TeamSpeak 的使用心智。但文档停留在"理念陈述"层面:状态模型与代码不符、多个核心组件缺数据来源、异常路径几乎空白、Design Token 与现有实现两套命名并存。按此文档开工,实现者会在第 3 天卡住。
|
||||
|
||||
---
|
||||
|
||||
## 一、阻断级缺陷(不补齐无法开工)
|
||||
|
||||
### A1. 顶层状态模型与代码不一致,且漏掉必然出现的中间态
|
||||
|
||||
文档第 3 章只定义 `Disconnected / Connecting / Connected` 三态,并断言"不应该存在 Connected but no channel"。
|
||||
|
||||
实际 `data/Models.kt` 的 `ConnectionState` 是 5 态:
|
||||
|
||||
| 文档 | 代码实际 |
|
||||
| --- | --- |
|
||||
| Disconnected | `Disconnected(reason, wasKicked)` |
|
||||
| Connecting | 无独立态(连接中由 ViewModel 的 `isConnecting` 类标志表达) |
|
||||
| Connected | `Connected` |
|
||||
| — 未定义 | `Disconnecting` |
|
||||
| — 未定义 | `Reconnecting(attempt, maxAttempts, reason)` |
|
||||
| — 未定义 | `WaitingForNetwork(since)` |
|
||||
|
||||
更关键的是:**"Connected 但没有频道"在真实链路上必然存在**,文档把它当成不该出现的状态,但它不是 UI 设计问题,是协议时序问题:
|
||||
|
||||
1. `ConnectionState.Connected` 之后还有独立的第二层状态 `SyncState`(`ChannelViewModel`:`Unsynced / Syncing / Synchronized / SyncFailed`)。首次同步期间频道树是空的,`UI架构设计.md` 2.2 节为此专门设计了"正在同步服务器数据..."加载态。新文档完全没提这一层。
|
||||
2. `Repository.currentChannelId` 初值就是 `"0"`;`NavGraph.kt` 里存在兜底逻辑——当 `currentChannelId <= 0` 时改用 `Repository.getSelfChannelId()` 从客户端列表反查。这说明 `GetChannelID` 失败是已知的真实场景。
|
||||
|
||||
**需要补的规格**:Connected 之后的完整状态矩阵,至少覆盖
|
||||
`Connected × {Unsynced, Syncing, Synchronized, SyncFailed}` × `currentChannelId ∈ {有效, "0", 反查失败}`,
|
||||
并明确每种组合下 ServerHeader / CurrentChannelBar / Content 区各显示什么(骨架屏?占位名?"正在同步"?)。这是全文档最大的空洞——CurrentChannelBar 被称为"最重要的 UI 组件",却没有定义它在数据未就绪时的样子。
|
||||
|
||||
### A2. 成员状态系统缺数据来源,且未列入任何优先级
|
||||
|
||||
文档第 16 章要求 8 种成员状态:`Online / Idle / Away / Muted / Deafened / Speaking / Server Muted / Disconnected`。
|
||||
|
||||
实际数据能力:
|
||||
- `ClientInfo` 字段仅有 `id / nickname / uid / channelId / serverGroups / isSelf`——**没有 away、idle、muted、deafened、serverMuted、talkPower 任何一项**。
|
||||
- `TSBridge` 回调只有 `onClientSpeaking(clientID, speaking)`(Go 侧判定,Kotlin 不得自行从 PCM 推断)。
|
||||
- `setRemoteClientMuted` 是本地静音远端,不等于对方的 Muted 状态。
|
||||
|
||||
也就是说 8 种状态里只有 Speaking 有数据支撑,其余 6 种(Online 可由在场推断)需要改 `go/teamspeak/bridge.go`、扩展 `getClientsJSON` 字段、**重新编译 AAR**。文档第 40~44 章的重构方向和 P0/P1/P2 优先级里完全没有"需要改协议层"这一项,会让排期严重失真。
|
||||
|
||||
**需要补**:每个状态标注「数据已具备 / 需扩展 bridge / 本期不做」,并把 bridge 改造单列为 P0 前置项。
|
||||
|
||||
### A3. Avatar 体系凭空出现,无任何数据来源与降级规格
|
||||
|
||||
文档第 7、15、16、31、39 章大量依赖头像:CurrentChannelBar 的 `● ● ● ● +8`、Member Item 的 `[Avatar] Alex`、Speaking 的 Avatar Ring、第 31 章还定义了三档尺寸令牌(32/40/56dp)。
|
||||
|
||||
实际情况:TeamSpeak 协议**没有用户头像**。`ServerInfo.iconId` 和 `ChannelDetailInfo.iconId` 是服务器图标 / 频道图标,不是客户端头像;代码中不存在任何 avatar 相关实现。
|
||||
|
||||
文档从头到尾没有说明:头像内容从哪来?无头像时显示什么(昵称首字母?固定图形?服务器组图标?)?头像是否可上传(TS 无此能力)?
|
||||
|
||||
这不是细节问题——CurrentChannelBar 的成员预览、Member Item 的主视觉、Speaking 的主要表达方式全部建立在头像上。**必须先定义"无头像时的 Avatar 替代方案",否则第 7、15、16 章的示意图无法实现。**
|
||||
|
||||
建议:改为首字母色块(昵称首字符 + 由 UID 哈希出的稳定背景色),Avatar Ring 表达 Speaking,并把"服务器组徽章"作为唯一的真实图形来源。
|
||||
|
||||
### A4. PTT 在最终结构里没有位置
|
||||
|
||||
文档第 19 章说"PTT 是语音 UI 的核心操作",第 20 章专门定义 PTT 状态,第 39 章组件树里有 `VoiceStatus`。
|
||||
|
||||
但第 38 章"主页面最终结构"的两张完整示意图(Chat / Channels)里**没有任何语音或 PTT 元素**,第 5 章的 Server Space 结构图里也只有 `Current Content`。
|
||||
|
||||
同时文档第 18 章否定了 Voice 独立页面,第 19 章又把 Voice UI 画成一个大卡片(含 🎙 / ● Connected / [PTT] / Mic / Speaker)——这个卡片挂在哪一层?常驻还是弹出?与底部 BottomNavigation 如何共存?现状 `UI架构设计.md` 是"底部语音控制栏常驻所有页面 + 展开为语音卡(BottomSheet)",新文档既没说保留也没说取消。
|
||||
|
||||
**核心操作缺失布局定义,属于必须补齐的阻断项。**
|
||||
|
||||
附带三个未定义问题:
|
||||
- PTT 触发方式(屏内按钮 / 悬浮窗 / 音量键 / 声控)一个都没指定。
|
||||
- **App 在后台或锁屏时如何发言**——全文未涉及后台场景,但代码有 `ConnectionService` 前台服务与 keepalive 通知,说明后台常驻是真实需求,PTT 的后台可用性是移动端语音客户端的关键设计点。
|
||||
- 第 20 章的 `Idle → Pressed → Speaking` 与代码 `VoiceState`(`Idle / Transmitting / Blocked(reason)`)不匹配:文档漏了 `Blocked`(未连接、采集失败、发送异常)及其 UI 表现;且 `Speaking` 在代码语义里指**远端**用户在说话(`onClientSpeaking`),文档却把它当作自己 PTT 的第三态,概念混用需澄清。
|
||||
|
||||
### A5. Chat 范围收窄会砍掉私聊与服务器聊天,文档未声明取舍
|
||||
|
||||
文档第 9 章把 Chat 定义为"当前频道的文字消息流",第 41 章 `ServerSpaceState` 只有单个 `chatMessages`,第 46 章 `Current View = Chat or Channels`。
|
||||
|
||||
实际现有能力覆盖三种会话:`ChatMessage.targetMode` 为 `1=私聊 / 2=频道 / 3=服务器`;`Repository` 按 `"${targetMode}_${targetId}"` 分档归档(每会话上限 500 条);`ServerViewModel.sendMessageNotification` 会区分标题"频道消息"/"私聊消息";`MainActivity.DeepLinkAction` 携带 `targetMode/targetId`,通知点击可直达指定会话。
|
||||
|
||||
新架构里私聊和服务器聊天**没有任何入口**。这会直接导致:
|
||||
- 系统通知点击 → 跳转会话的 deep link 断链;
|
||||
- Repository 的多会话归档能力被闲置;
|
||||
- 从 Member Action Menu 发起私聊的路径消失(第 17 章的菜单里也只有 Poke / Copy / Move / Kick)。
|
||||
|
||||
**必须明确写**:本期是否支持私聊与服务器聊天?若不支持,通知 deep link 如何降级?若支持,入口在哪(Drawer?Member Menu?第三个 Tab?会话列表?),`ServerSpaceState` 需改为会话集合而非单个 `chatMessages`。
|
||||
|
||||
---
|
||||
|
||||
## 二、与现有文档 / 代码的直接冲突(需裁决,否则三套规范并存)
|
||||
|
||||
| # | 冲突点 | 新规范 | 现状 |
|
||||
| --- | --- | --- | --- |
|
||||
| B1 | 断线重连 | 第 33 章 `Reconnecting → Warning`、第 35 章推荐 Reconnect banner 动画 | `UI架构设计.md` 5.4 为顶部横幅不阻塞页面、最多 5 次递增重连;代码实际是 `ReconnectOverlay` **全屏遮罩**;`CLAUDE.md` 又写明"自动重连默认禁用,频繁重连会触发服务端限流/封禁"。三方不一致,新文档未裁决 |
|
||||
| B2 | 头部是否显示地址 | 第 6 章"不建议长期显示 server.example.com:9987" | `UI架构设计.md` 1.2 与 `AppTopBar(subtitle=...)` 明确显示 `192.168.1.1:9987`。文档未说明这是有意变更;且服务器重名时地址是唯一辨识信息,第 24 章 Recent Servers 又显示地址,自相矛盾 |
|
||||
| B3 | 端口输入 | 第 23 章把 Port 拆为独立输入框 | `InputValidator.validateServerAddress` 支持 `address:port` 一体式并校验 1..65535;`ServerConfig` / `RecentConnection` 只有 `address` 字段;TSDNS 地址本身可能含端口。第 24 章 Recent Servers 又写成一体式 |
|
||||
| B4 | 连接页字段 | 第 23 章表单无 Password、无默认频道 | 第 3 章 Disconnected 明确列了 Password;`ServerConfig` 有 `password / defaultChannel / defaultChannelPassword` |
|
||||
| B5 | 被踢路径 | 第 26 章 Disconnect 流程只有 `Server Space → Disconnected → Connection Screen` | 代码有 `Disconnected(wasKicked=true)` → `Routes.KICKED` → `KickedScreen`(重连 / 返回主页)。流程图漏了这条已实现的路径 |
|
||||
| B6 | 成员状态是否显示文字 | 第 15 章"正常状态不要一直显示文字" | `UI架构设计.md` 与现有实现用文字标注;纯颜色/圆点表达会降低可访问性(见 D4) |
|
||||
| B7 | Drawer 条目 | 第 21 章:Server Info / Voice / Notifications / Appearance / Settings / Disconnect | 第 4 章信息架构:Server Info / Voice Settings / Notifications / Appearance / Disconnect。同一文档内两处条目不同(Settings 有无、Voice vs Voice Settings) |
|
||||
| B8 | CurrentChannelBar 位置 | 第 38 章放在 Header 之下(顶部) | 第 5 章、第 8 章结构图放在 Content 之下、Tab 之上(底部)。同一文档内位置矛盾 |
|
||||
| B9 | 主题切换入口 | 第 21 章移入 Server Drawer → Appearance | 现状在服务器配置页右上角,未连接时也可切换。移入 Drawer 后**未连接状态无法切换主题**,属交互回退,文档未察觉 |
|
||||
| B10 | 频道切换语义 | 第 27 章"本质上只是 currentChannelId 发生变化" | 代码是异步 `ClientMove` + `ChannelSwitchState` 等服务端确认,可能因密码 / 满员 / 权限 / talkPower 失败(`mapMoveError`)。按文档描述实现会做成无回滚的乐观更新 |
|
||||
|
||||
**文档治理问题(最需要先解决的一条)**:项目已有 `UI架构设计.md`(3 页 3 卡)、`docs/流程/00~09`、`docs/implementation/00~12` 三套体系,新文档**没有版本、日期、作者、适用范围、变更记录,也没有声明它与上述文档的关系**——是取代还是补充?冲突以谁为准?不明确这一点,上表 10 处冲突会全部变成实现期的反复拉扯。
|
||||
|
||||
建议在文档开头加一节「本文档效力」:明确取代 `UI架构设计.md` 的哪些章节、保留哪些、`docs/流程` 作为行为权威源不变。
|
||||
|
||||
---
|
||||
|
||||
## 三、覆盖缺口(现有功能在新规范里消失)
|
||||
|
||||
新规范描述的是一个"理想晴天的正常路径"。以下已实现或已设计的能力在 47 章中**一次都没出现**:
|
||||
|
||||
**异常与错误态**(现状代码已有对应实现,文档全部丢失)
|
||||
- 连接失败 6 类错误分类与文案(`ServerViewModel.classifyError`:密码错误 / 昵称冲突 / 网络不可达 / 地址无效 / 超时 / 服务器满)
|
||||
- 首次同步失败与重试(`SyncState.SyncFailed`)
|
||||
- 频道切换失败(`ChannelSwitchState` / `mapMoveError`)、密码频道输入(`ChannelPasswordDialog`)、频道确认(`ChannelConfirmDialog`)
|
||||
- 消息发送失败与重试(`MessageSendState.Failed` / `MessageDeliveryState.FAILED` / 10 秒超时)
|
||||
- 空状态(`EmptyStateView`:空频道、无消息)
|
||||
- 网络丢失等待(`WaitingForNetwork`)
|
||||
- 应用更新提示(`UpdateChecker` / `UpdateBanner` / `UpdateCheckDialog`)在 Server Space 中的位置
|
||||
|
||||
**已存在的 Dialog / Menu 全部未纳入规范**
|
||||
第 28 章只抽象地说 Dialog 用于"确认 / 危险操作 / 输入",但没有清单。代码中已有:`ChannelPasswordDialog`、`ChannelConfirmDialog`、`DisconnectConfirmDialog`、`PokeDialog`、`ClientActionMenu`、`MessageContextMenu`、`UpdateCheckDialog`、`PokeNotification`。规范应逐个给出触发条件、内容、按钮、危险等级。
|
||||
|
||||
**Poke**
|
||||
现状是核心交互(三个入口 + 顶部气泡 + 系统通知 + `App.POKE_CHANNEL_ID`)。新文档只在第 17 章 Member Action 里出现一次 "Poke",未定义接收端表现。
|
||||
|
||||
**权限与降级**
|
||||
第 17 章要求 Move / Kick from Channel / Kick from Server,但没定义**无权限时的表现**(隐藏 / 置灰 / 点击后报错)。TS 的权限由服务器组与频道组决定(`ServerInfo.defaultServerGroup / defaultChannelGroup`、`ChannelDetailInfo.neededTalkPower`),这是必须定义的分支。
|
||||
|
||||
**Recent Servers 管理**
|
||||
第 24 章只给了两行卡片示意,缺:最多 10 条、按时间倒序、长按删除、清空、成功/失败标记(这些在 `UI架构设计.md` 2.1 有),以及**密码的存储安全规范**(`RecentConnectionsStore` 持久化了 password,文档未涉及是否加密、是否用 Keystore)。
|
||||
|
||||
**文件与富文本消息**
|
||||
代码有 `Bbcode.kt`、`FileMessageMeta`(图片 / MyTS 文件)、`EmojiMapper`。文档第 10 章讲消息合并、第 11 章说"不要把 Emoji / 文件 / 图片放一级 UI",但没说明**接收侧**这些内容如何渲染。"不放到一级输入 UI"和"能不能收发"是两件事,当前表述容易被误读为砍功能。
|
||||
|
||||
**后台与生命周期**
|
||||
前台服务、keepalive 通知、`NetworkMonitor`、进程被杀后的状态恢复、`connectionGeneration` 防止陈旧回调——移动端语音客户端的核心体验,文档零覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 四、Design Token 章节(29~34)的具体问题
|
||||
|
||||
### D1. 与现有 Token 两套命名并存
|
||||
|
||||
第 29 章给 `xs=4 sm=8 md=12 lg=16 xl=24`,代码 `UiTokens.Spacing` 已是 `None=0 ExtraSmall=4 Small=8 Medium=12 Large=16 ExtraLarge=24 Huge=32`。文档说"建议继续强化现有体系",却给了一套新命名,且漏了 `None` 和 `Huge`。照文档写会出现 `Spacing.sm` 与 `Spacing.Small` 并存。
|
||||
→ 直接引用现有名称,补上 None / Huge。
|
||||
|
||||
### D2. 用区间代替确定值,等于没有约束
|
||||
|
||||
- 第 30 章 Radius:`List Item 8~12dp`、`Card 12~16dp`、`Floating Card 16~20dp`
|
||||
- 第 32 章 Typography:`Title 16~20sp`、`Body 14~16sp`、`Secondary 12~14sp`、`Caption 11~12sp`
|
||||
- 第 31 章:`List Item 52~56dp`
|
||||
|
||||
给区间意味着两个开发者会各选一端,最终仍然不统一——这正是文档第 31 章想避免的"14.5dp / 17dp 局部修正值"问题的另一种形式。
|
||||
→ 规范应给确定映射,并复用代码已有档位:`Shapes`(extraSmall 4 / small 8 / medium 12 / large 16 / extraLarge 24)、`UiTokens.Size`(TouchTarget 48 / ControlHeight 40 / AppBarHeight 56 / Icon 16-20-24)、`MaterialTheme.typography`(`AppTopBar` 已用 `titleMedium`)。文档自建的 Title/Body/Secondary/Caption 与 M3 的 display/headline/title/body/label 是两套体系,未给映射关系。
|
||||
|
||||
### D3. Semantic Colors 与代码不匹配
|
||||
|
||||
第 33 章列 `Primary / Accent / Success / Warning / Error / Muted / Disabled / Speaking`;代码 `SemanticColors` 只有 `success / warning / info` 三组(各含 on / container 变体)。差异:
|
||||
- 缺 `accent / muted / disabled / speaking` 四组,需明确新增字段
|
||||
- `Primary / Error` 在 M3 `colorScheme` 里已有(`primary / error / errorContainer`),文档另立一份会造成双源
|
||||
- `info` 在代码里有、文档没提
|
||||
- `Accent` 与 M3 的 `tertiary` 关系未定义;`Speaking → Accent` 与 `Primary Action` 同色会削弱第 37 章的视觉层级
|
||||
- 未给出 onXxx 前景色与 container 变体,而代码现有 token 都是成组定义的
|
||||
|
||||
### D4. 状态仅靠颜色区分,缺冗余编码
|
||||
|
||||
第 33~34 章把 Connected/Speaking/Reconnecting/Disconnected 全部映射到颜色,没有要求形状、图标或文字的第二重编码。这违反 WCAG 1.4.1(不能仅用颜色传达信息),对色觉障碍用户不可用。
|
||||
第 15 章"正常状态不要一直显示文字"进一步削弱了这一点。
|
||||
→ 建议规定:颜色 + 图标形状(如 Speaking 用波形而非仅高亮环)双重编码;纯装饰性状态点可只用颜色。
|
||||
|
||||
### D5. 暗色主题规范完全缺失
|
||||
|
||||
代码有 `ThemeMode.kt` / `ThemePreferences.kt`,`UI架构设计.md` 4.4 定义了主题切换与持久化。新文档第 21 章列了 Appearance、第 29 章提了 Theme,但**全文没有一条暗色规格**:Semantic Colors 在暗色下的取值、Speaking 高亮的对比度、第 37 章 Background/Surface/Elevated Surface 三级层级在暗色下如何区分(暗色主题通常靠 tonal elevation 而非阴影)。
|
||||
→ 第 33/34/37 章均需给出亮/暗双套取值,或明确引用 M3 tonal palette 的生成规则。
|
||||
|
||||
### D6. 动画规范无数值
|
||||
|
||||
第 35 章只说"短时、低幅度",没有时长、easing、可打断性、并发规则。代码现状已有具体值(`tween(340/320/280/240, FastOutSlowInEasing)`)。
|
||||
→ 给出 duration token(如 `Fast=150ms / Normal=250ms / Slow=350ms`)与 easing token,并说明是否沿用现有值。同时缺"系统开启减弱动效时如何降级"。
|
||||
|
||||
---
|
||||
|
||||
## 五、组件级设计漏洞
|
||||
|
||||
### E1. CurrentChannelBar(第 7 章,自称最重要组件)缺规格
|
||||
- 成员头像预览的**排序规则**未定义(说话者优先?加入时间?权限高低?)
|
||||
- 折叠阈值未定义(几个之后显示 `+N`)
|
||||
- 点击行为未定义(跳 Channels Tab?弹 Member 列表 Sheet?)
|
||||
- **缺变体定义**:第 38 章 Channels 视图里它被简化为 `# General 12`,第 7 章却是含头像的完整版。同一组件两种形态,正文未说明,也无变体命名
|
||||
- 与 Channels Tab 的 Channel Tree 成员列表**信息重复**:Channels 视图下当前频道成员会出现两次
|
||||
- 数据刷新抖动:`CLAUDE.md` 说明 client enter/leave 会触发**全量 clientlist 刷新**(因 `notifycliententerview` 的 ChannelID 不可靠),头像预览会整组重排。第 35 章讲动画却没考虑这个高频刷新场景
|
||||
|
||||
### E2. Channel Tree(第 12~14 章)缺关键规格
|
||||
- **嵌套缩进上限**未定义。TS 频道可深层嵌套,窄屏 5 层以上必然溢出。需要规定最大缩进层级、超限处理(截断缩进 / 横向滚动 / 面包屑)
|
||||
- 排序规则未定义(`ChannelInfo.order` 字段存在但文档未提)
|
||||
- 默认展开策略未定义(全部折叠?只展开当前频道路径?)
|
||||
- 展开状态在切 Tab / 切频道后是否保持
|
||||
- 大量频道时的性能要求(LazyColumn、key 稳定性)
|
||||
- 实时性假设不成立:`CLAUDE.md` 明确 SDK **无 channel create/update/delete 事件**,频道列表 5 分钟陈旧或切换前才刷新。第 12 章把它当实时树来设计,需注明刷新时机与"数据可能滞后"的表达
|
||||
|
||||
### E3. 消息合并(第 10 章)缺规则
|
||||
- 合并的**时间窗口**未定义(跨 5 分钟还合并吗?)
|
||||
- 合并后时间戳如何显示
|
||||
- 系统消息(`MessageType.SYSTEM`)是否打断合并
|
||||
- 合并组内单条消息的 `deliveryState`(PENDING/SENT/FAILED)如何展示、失败重试入口在哪
|
||||
|
||||
### E4. 用 emoji 充当设计规格
|
||||
第 13 章 `▼ 🎙 General 12`、示意图中的 `☰ ⋮ ● ▼ ▶ 💬 🎤 🔒 🔇`。emoji 跨设备渲染不一致,且与第 36 章"Low Decoration / 不是 Material 3 Demo"的诉求冲突。
|
||||
→ 应指定 Material Icons 具体图标名,并补一张「频道类型 → 图标」映射表。文档提到"Channel Type"但从未枚举 TS 实际的频道属性(默认 / 密码 / 永久 / 半永久 / 临时 / codec / neededTalkPower / maxClients),而这些字段 `ChannelInfo` 和 `ChannelDetailInfo` 里都有。
|
||||
|
||||
### E5. 触控目标低于平台标准
|
||||
第 31 章 `Small Button 40dp`。Android 与 M3 推荐最小触控目标 48dp(代码 `UiTokens.Size.TouchTarget = 48`)。视觉高度 40dp 可以,但必须规定用 padding 补足到 48dp 命中区——文档未说明,实现者会直接做成 40dp 命中区。
|
||||
|
||||
### E6. 第 28 章 Sheet/Dialog/Drawer 分层规则不完整
|
||||
规则本身合理(避免 BottomSheet → Dialog → BottomSheet 链),但缺:
|
||||
- Bottom Sheet 的档位(半屏 / 全屏 / 拖拽行为),现状语音卡是可拖拽半屏
|
||||
- 系统返回键在每种容器上的行为
|
||||
- 多层容器同时存在时的层级与遮罩规则
|
||||
- 键盘弹起时 Dialog / Sheet 内输入框的避让(现状 `UI架构设计.md` 2.3 有键盘联动规格,新文档丢失)
|
||||
|
||||
---
|
||||
|
||||
## 六、可访问性与适配(全文零覆盖)
|
||||
|
||||
一份移动端 UI/UX 重构规范里没有以下任何一项:
|
||||
|
||||
- **无障碍语义**:contentDescription 规范、状态变化的 `liveRegion` 声明。Speaking 是纯视觉高频状态,TalkBack 用户完全无法感知谁在说话——这对语音客户端是严重问题
|
||||
- **字体缩放**:全用 sp 定义字号,但第 31 章的固定 dp 高度(Input 52 / List Item 52~56)在大字号下会截断,需定义最小/最大缩放下的行为
|
||||
- **横屏 / 平板 / 折叠屏**:全文只有一种窄屏竖版布局。Compose 项目大概率会跑在平板上,Server Space 是否用双栏(左频道树 + 右内容)?未提
|
||||
- **对比度**:第 33/37 章定义颜色与层级,无任何对比度要求(WCAG AA 4.5:1)
|
||||
- **减弱动效**:第 35 章无降级方案
|
||||
- **单手可达性**:PTT、发送、Tab 都在底部是对的,但 CurrentChannelBar 按第 38 章在顶部,未讨论可达性
|
||||
|
||||
---
|
||||
|
||||
## 七、实时性能约束缺失
|
||||
|
||||
对一个语音客户端,这是不该缺的一章:
|
||||
|
||||
- Speaking 状态由 Go 侧 400ms 静音超时判定,属高频变化。第 16 章要求 Avatar Ring + 状态点 + 音量指示器**三种同时表达**,12 人频道会引发成员列表大范围重组
|
||||
- 文档未规定重组边界(`derivedStateOf`、稳定 key、把高频状态下推到最小 composable)
|
||||
- 未规定 Speaking 指示的更新节流(是否需要 100ms 级去抖)
|
||||
- 未规定频道树全量刷新时避免闪烁的策略(结合 `CLAUDE.md` 的"client enter/leave 触发全量 clientlist 刷新")
|
||||
|
||||
---
|
||||
|
||||
## 八、文档形式与可执行性
|
||||
|
||||
### F1. 信噪比过低
|
||||
25KB / 47 章中大量是把一两个词包进 ```text 代码块(如单独一行的 `currentChannelId`、`Chat`、`Channels`)。真正的规格信息(确定数值、状态枚举、交互规则)占比很小。同样的内容可以压缩到 1/3 篇幅且更有用。
|
||||
|
||||
### F2. 措辞无约束力
|
||||
"推荐"约 20 次、"建议"约 25 次、"不要/避免"若干。规范里全是建议就无法作为验收依据。
|
||||
→ 改用 MUST / SHOULD / MAY(或 必须 / 应当 / 可以),并对每条给出可检验的判据。
|
||||
|
||||
### F3. 缺规范应有的载体
|
||||
没有:组件规格表(尺寸/间距/状态/交互的确定值)、状态枚举表、Do/Don't 对照、验收清单、视觉稿或线框图链接。ASCII 示意图只能表达布局意图,无法表达规格。
|
||||
|
||||
### F4. 术语不统一
|
||||
- `Client` / `Member` / `User` 混用:代码是 `ClientInfo` / `ClientActionMenu` / `ClientItem`,文档第 15~17 章叫 Member,第 40 章目录又叫 `client/`
|
||||
- `Channel Tree` / `Channels` / `Channel List` 混用
|
||||
- `Server Space` / 服务器工作空间(第 2 章)混用
|
||||
→ 加一节术语表,锁定唯一名称(建议跟随代码用 Client)。
|
||||
|
||||
### F5. P0/P1/P2 不可排期
|
||||
第 42~44 章的优先级只有条目名,缺:工作量、依赖关系、验收标准、**是否需要改 Go 协议层**。按 A2/A3 的分析,成员状态与头像都需要先改 `bridge.go` 并重编 AAR,这必须是 P0 前置项,但文档把它归到了 P1(Member Item)和未提及(Avatar)。
|
||||
|
||||
### F6. 缺迁移映射
|
||||
第 40 章给了目标目录结构,但没有"现有文件 → 新结构"的对照表。当前实现是 4 路由(`server_config / channel_list / chat / kicked`)+ 独立 Screen;目标是 Server Space 单容器 + 内部 Tab。这需要重写 `NavGraph.kt`(含 deep link、`BackHandler`、`LaunchedEffect` 导航、`ReconnectOverlay` 与 `PokeNotification` 全局覆盖层),是本次重构成本最高的部分,文档一句未提。
|
||||
|
||||
建议补一张迁移表,例如:
|
||||
| 现有 | 目标 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| `AppTopBar` | `server/ServerHeader` | 改造(增加状态与在线数) |
|
||||
| `ChannelListScreen` | `ServerSpace` + `channel/ChannelTree` | 拆分 |
|
||||
| `ChatScreen` | `chat/ChatView` | 降级为视图 |
|
||||
| `VoiceCard` + `VoiceControlBar` + `PTTButton` | `voice/*` | 合并(需先定 A4) |
|
||||
| `ServerDetailCard` | `server/ServerDrawer` | 形态从 BottomSheet 改 Drawer |
|
||||
| `NavGraph` 4 路由 | 2 路由(Connection / ServerSpace)+ Tab | 重写 |
|
||||
| `KickedScreen` | ? | 文档未定义(见 B5) |
|
||||
|
||||
---
|
||||
|
||||
## 九、优化建议:文档结构重组
|
||||
|
||||
按当前 47 章平铺结构,实现者无法按需查阅。建议重组为 6 部分:
|
||||
|
||||
1. **范围与效力**(新增,最高优先):版本 / 日期 / 适用模块 / 与 `UI架构设计.md`、`docs/流程`、`docs/implementation` 的取代关系 / 冲突裁决规则 / 术语表
|
||||
2. **模型与状态**(现第 2~4、26~27、41 章):连接状态机(补齐 5 态 + SyncState + currentChannelId 就绪态)、频道切换的异步语义与失败回滚
|
||||
3. **信息架构与导航**(现第 5、8、28、38、39 章):Server Space 布局、Tab 规则、容器分层、迁移映射表
|
||||
4. **组件规格**(现第 6~7、9~25 章):每个组件一节,统一模板 = 结构 / 尺寸 token / 状态枚举 / 交互 / 数据源 / 异常态 / 无障碍 / 变体
|
||||
5. **视觉与 Token**(现第 29~37 章):确定值映射到现有代码 token,亮暗双套,对比度要求,动画数值
|
||||
6. **横切规范**(新增):异常与错误态全集、空态、后台与生命周期、实时性能约束、可访问性、多窗口适配、凭据存储安全
|
||||
|
||||
另外两条具体建议:
|
||||
- **把"数据可用性"作为每个组件规格的必填字段**。本次审查中 A2(成员状态)、A3(头像)、E2(频道实时性)三处阻断问题的共同根因,是文档从 UI 效果倒推、没有先核对协议层能提供什么。建议在每个组件规格里强制填「数据源 / 是否需改 bridge / 数据未就绪时的表现」。
|
||||
- **P0 之前先做一次"数据能力对齐"**:列出 `bridge.go` 当前导出的全部字段与事件,标出规范中依赖但缺失的项,形成 bridge 改造清单。这一步不做,P0 的 CurrentChannelBar 和 Voice 都只能做成半成品。
|
||||
|
||||
---
|
||||
|
||||
## 十、总体评价
|
||||
|
||||
| 维度 | 评价 |
|
||||
| --- | --- |
|
||||
| 产品模型判断 | 好。Server Space + Current Channel 上下文的方向正确,优于现有三页跳转 |
|
||||
| 与代码现状的吻合度 | 差。状态机、成员数据、头像、会话范围四处脱节 |
|
||||
| 完整性 | 不足。异常路径、后台、可访问性、暗色、性能基本空白 |
|
||||
| 可执行性 | 不足。区间值代替确定值、"建议"代替约束、无迁移映射、无验收标准 |
|
||||
| 文档治理 | 缺失。无版本信息,未声明与既有三套文档的关系 |
|
||||
| 内部一致性 | 有 4 处自相矛盾(B3/B4/B7/B8) |
|
||||
|
||||
**建议处理方式**:不要按当前版本直接进入实现。先补三样东西——① 效力声明与冲突裁决(第二节表格逐条定调);② 数据能力对齐清单(Go bridge 现有字段 vs 规范依赖字段);③ 状态矩阵(A1)。这三项完成后,第 40 章的组件重构才有确定的输入。其余缺口可在各组件规格细化时逐个补齐。
|
||||
@@ -0,0 +1,218 @@
|
||||
# Go ↔ Kotlin Bridge 契约
|
||||
|
||||
> **状态:权威** | 最后核对:2026-09-10 | 契约版本:`1`
|
||||
>
|
||||
> 单一事实来源:`go/teamspeak/contract.go`(字段名常量 + `BridgeContractVersion`)
|
||||
> Kotlin 侧守卫:`android/app/src/main/java/com/tsmobile/app/data/BridgeContract.kt`
|
||||
> 契约测试:`go/teamspeak/contract_test.go`、`contract_golden_test.go`、
|
||||
> `android/app/src/test/java/com/tsmobile/app/data/BridgeContractTest.kt`
|
||||
|
||||
---
|
||||
|
||||
## 为什么需要这份文档
|
||||
|
||||
gomobile 有三条硬限制(`CLAUDE.md` 已记录):不能导出 `[]string`、`[]*T`、Go `error`。
|
||||
项目用 **JSON 字符串**绕过它们,代价是:
|
||||
|
||||
> **Go 侧改一个字段名,Kotlin 侧不会编译失败,只会在运行时静默拿到默认值。**
|
||||
|
||||
叠加 `Repository.kt` 的 `Json { ignoreUnknownKeys = true }`,字段对不上时
|
||||
`ChannelInfo` 直接填 `""` / `0` / `false`:频道列表看起来是空的,
|
||||
或者密码频道显示成公开频道,而日志里什么都没有。
|
||||
|
||||
本契约用三道机制钉住这个边界:
|
||||
|
||||
| 机制 | 防止的问题 |
|
||||
| --- | --- |
|
||||
| `contract.go` 字段名常量 | 集中声明,marshal 处不写字面量 |
|
||||
| `contract_test.go` 反射断言 | 常量与 struct tag 漂移 → 测试红 |
|
||||
| golden 样本(Go/Kotlin 共用) | 样本与 tag 漂移 → 两侧测试都红 |
|
||||
| `BridgeContract.verifyOrDescribeError()` | AAR 与 App 版本不匹配 → 连接前明确报错 |
|
||||
|
||||
---
|
||||
|
||||
## 边界数据类型分级
|
||||
|
||||
不是所有跨边界数据都同样脆弱。**改代码前先确认属于哪一类**:
|
||||
|
||||
| 类别 | 保护强度 | 类型 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| **A 类** | 编译期检查 | `TextMsg`、`PokeEvent`、`Channel`、`Client` | gomobile 导出的 struct,字段是真实属性。Go 改名 → Kotlin 编译失败。**无需常量保护。** |
|
||||
| **B 类** | 无(靠契约测试) | `channelJSON`、`clientJSON`、`serverInfoJSON`、文件下载 JSON | JSON 字符串。**本契约的主要保护对象。** |
|
||||
| **C 类** | 无(无消费方) | `channelDetailedJSON`、`clientDetailedInfoJSON`、`initialSyncJSON` | 已导出但 Kotlin 未解析。字段名已声明以钉住形状;将来要消费时先跑契约测试。 |
|
||||
|
||||
---
|
||||
|
||||
## B 类契约明细
|
||||
|
||||
图例:**✓** = 已有 Kotlin 消费方 | **—** = 当前未消费
|
||||
|
||||
### 1. 频道列表 — `GetChannelsJSON()` → `Repository.fetchChannels()`
|
||||
|
||||
Go:`channelJSON`(`bridge.go`)| Kotlin:`ChannelInfo`(`Models.kt`)
|
||||
|
||||
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `ID` | `id` | `id` | `String` | 否 | `""` — **字符串化数字**,见下方注意 |
|
||||
| `Name` | `name` | `name` | `String` | 否 | `""` |
|
||||
| `ParentID` | `parentId` | `parentId` | `String` | 否 | `"0"` — 顶级频道的约定值 |
|
||||
| `Description` | `description` | `description` | `String` | 否 | `""`(Go 侧实际不填充) |
|
||||
| `IsPassword` | `isPassword` | `isPassword` | `Boolean` | 否 | `false` |
|
||||
|
||||
> ⚠️ **`id` / `parentId` 是字符串不是数字。** TeamSpeak 的频道 ID 是无符号 64 位,
|
||||
> 超出 Kotlin `Int` 范围。`contract_test.go` 的 `TestChannelJSON_IDIsStringType`
|
||||
> 钉住了这一点——改成数值类型会让 Kotlin 在运行时抛序列化异常。
|
||||
|
||||
> ⚠️ **`ChannelInfo.order`(`Int`)在 Go 侧不存在。** `channelJSON` 没有 `order` 字段,
|
||||
> 所以该值恒为默认 `0`,`ChannelViewModel.buildChannelTree` 的 `.sortedBy { it.order }`
|
||||
> 实际是不产生效果的 no-op(`sortedBy` 稳定,全等值等于保持原序)。
|
||||
>
|
||||
> **频道顺序目前由服务器返回顺序决定,且这是正确的**——TS3 的 `channellist`
|
||||
> 响应按频道树顺序返回,子频道紧跟父频道。显示顺序经真机验证与响应顺序一致。
|
||||
>
|
||||
> 📌 **不要"按 order 排序"**:`channel_order` 不是排序权重,而是
|
||||
> **前驱频道的 ID**(链表指针,`0` = 排在本层最前)。见
|
||||
> `docs/teamspeak-sdk-3.5.2/doc/client/channel-sort.html`:
|
||||
>
|
||||
> > The channel order is the ID of the predecessor channel after which the
|
||||
> > given channel should be sorted. An order of 0 means the channel is
|
||||
> > sorted on the top of its hirarchy.
|
||||
>
|
||||
> 官方示例:`Subsubchannel_2 ( ID = 7 , order = 6 )` 表示"排在 ID=6 之后",
|
||||
> `Subchannel_2 ( ID = 5 , order = 4 )` 表示"排在 ID=4 之后"。
|
||||
> 若按该值**数值排序**会得到 `1,4,6,2,3,5,7`,正好把树结构打乱。
|
||||
>
|
||||
> 若将来确实需要在客户端重建顺序(例如不再依赖响应顺序),
|
||||
> 正确做法是消费 `channelDetailedJSON.order` 后按前驱指针**串链**,
|
||||
> 而不是排序。注意 API 侧 `ChannelDetailInfo` 里 `pid` 取自
|
||||
> `item["channel_order"]`(`api.go:265`),该处取值可疑,使用前需先核实。
|
||||
|
||||
### 2. 客户端列表 — `GetClientsJSON()` → `Repository.fetchClients()`
|
||||
|
||||
Go:`clientJSON` | Kotlin:`ClientInfo`
|
||||
|
||||
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `ID` | `id` | `id` | `Int` | 否 | `0` |
|
||||
| `Nickname` | `nickname` | `nickname` | `String` | 否 | `""` |
|
||||
| `UID` | `uid` | `uid` | `String` | 否 | `""` — **永久身份标识,等同账号 ID** |
|
||||
| `ChannelID` | `channelId` | `channelId` | `String` | 否 | `"0"` — 字符串化 |
|
||||
| `ServerGroups` | `serverGroups` | `serverGroups` | `List<String>` | 否 | `[]` |
|
||||
| `IsSelf` | `isSelf` | `isSelf` | `Boolean` | 否 | `false` |
|
||||
|
||||
> `uid` 是敏感数据:`Repository.performInitialSync` 中的全量 dump 已改为
|
||||
> `BuildConfig.DEBUG` 保护 + `proguard-rules.pro` 剥离 `Log.d`。
|
||||
|
||||
### 3. 服务器信息 — `GetServerInfoJSON()` → `ServerViewModel` / `Repository`
|
||||
|
||||
Go:`serverInfoJSON` | Kotlin:`ServerInfo`
|
||||
|
||||
| Go 字段 | JSON 字段 | Kotlin 字段 | 类型 | 可空 | 默认值语义 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `Name` | `name` | `name` | `String` | 否 | `""` |
|
||||
| `WelcomeMessage` | `welcomeMessage` | `welcomeMessage` | `String` | 否 | `""` |
|
||||
| `MaxClients` | `maxClients` | `maxClients` | `Int` | 否 | `0` |
|
||||
| `ClientsOnline` | `clientsOnline` | `clientsOnline` | `Int` | 否 | `0` |
|
||||
| `ChannelsOnline` | `channelsOnline` | `channelsOnline` | `Int` | 否 | `0` |
|
||||
| `Uptime` | `uptime` | `uptime` | `String` | 否 | `""` — **字符串化秒数** |
|
||||
| `Version` | `version` | `version` | `String` | 否 | `""` |
|
||||
| `Platform` | `platform` | `platform` | `String` | 否 | `""` |
|
||||
| `Created` | `created` | `created` | `String` | 否 | `""` — **字符串化秒级时间戳** |
|
||||
| `IconID` | `iconId` | `iconId` | `String` | 否 | `""` |
|
||||
| `DefaultServerGroup` | `defaultServerGroup` | `defaultServerGroup` | `Int` | 否 | `0` |
|
||||
| `DefaultChannelGroup` | `defaultChannelGroup` | `defaultChannelGroup` | `Int` | 否 | `0` |
|
||||
|
||||
> ⚠️ `uptime` / `created` 是**字符串**。`TestGolden_ServerInfoTypes` 钉住了类型——
|
||||
> 改成数值会让 Kotlin 的 `String` 字段静默变空。
|
||||
|
||||
### 4. 文件下载 — `DownloadFileBytesJSON()` → `FileDownloadManager`
|
||||
|
||||
Go:`kotlin_api.go`(内联 map)| Kotlin:`TSBridge.downloadFileBytes()`
|
||||
|
||||
| JSON 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `data` | `String` | base64 编码的文件内容 |
|
||||
| `size` | `Int` | 解码后字节数 |
|
||||
|
||||
> **失败约定与其它接口不同**:失败返回 `"{}"`(而非 `"[]"`),消费方以 `data` 为空判定失败。
|
||||
> 空字符串返回表示「未连接」,`"{}"` 表示「连接正常但下载失败」——两者不可混为一谈。
|
||||
|
||||
---
|
||||
|
||||
## 事件契约(A 类,编译期检查)
|
||||
|
||||
这些类型由 gomobile 直接导出为 Java 类,字段访问经编译器校验,**不需要也不应该**
|
||||
为它们添加 JSON 字段常量。
|
||||
|
||||
### `TextMsg`(`EventCallback.OnTextMessage`)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `TargetMode` | `Int` | 见下方 targetMode 表 |
|
||||
| `TargetID` | `String` | 目标 ID(字符串化) |
|
||||
| `InvokerID` | `Int` | 发送者 clid;旧 AAR 可能为 `0` |
|
||||
| `InvokerName` | `String` | 发送者昵称 |
|
||||
| `InvokerUID` | `String` | 发送者 UID |
|
||||
| `Message` | `String` | 消息内容(纯文本或文件消息 JSON) |
|
||||
|
||||
### `PokeEvent`(`EventCallback.OnPoked`)
|
||||
|
||||
| 字段 | 类型 |
|
||||
| --- | --- |
|
||||
| `InvokerID` | `Int` |
|
||||
| `InvokerName` | `String` |
|
||||
| `InvokerUID` | `String` |
|
||||
| `Message` | `String` |
|
||||
|
||||
---
|
||||
|
||||
## targetMode 收发能力对照(重要)
|
||||
|
||||
`TargetMode` 的**接收**与**发送**能力不对称,读代码时极易误判为「三种都支持」:
|
||||
|
||||
| 值 | 含义 | 接收 | 发送 |
|
||||
| --- | --- | --- | --- |
|
||||
| `1` | 私聊 | ✅ 支持 | ❌ **未实现** |
|
||||
| `2` | 频道 | ✅ 支持 | ✅ 支持 |
|
||||
| `3` | 服务器 | ✅ 支持 | ❌ **未实现** |
|
||||
|
||||
`TSBridge.sendTextMessage` 仅实现 `mode=2`;其余分支返回显式错误串
|
||||
`"暂不支持该消息类型"`(按桥接约定:非空 = 错误),由 `ChatViewModel` 映射为
|
||||
`MessageSendState.Failed` 并将消息标记为 `FAILED`。
|
||||
|
||||
**当前状态:接收侧支持三种会话(通知 deep link 可跳转到指定会话),发送侧只有频道消息。**
|
||||
私聊能收到、能弹通知,但无法在 App 内回复。
|
||||
|
||||
---
|
||||
|
||||
## 版本升级流程
|
||||
|
||||
任何**不兼容**变更(字段改名、类型变更、字段删除)必须:
|
||||
|
||||
1. 递增 `go/teamspeak/contract.go` 的 `BridgeContractVersion`
|
||||
2. 同步 `BridgeContract.kt` 的 `EXPECTED_VERSION`
|
||||
3. 更新本文档
|
||||
4. 重编 AAR(`build.bat` 第 1 步)
|
||||
|
||||
只**新增可选字段**(Kotlin 侧有默认值)不需要递增版本。
|
||||
|
||||
> 版本校验不匹配时,`ServerViewModel.connect()` 会以 `ConnectState.FAILED`
|
||||
> 明确报错并阻止连接——而不是带着错配的字段名继续跑。
|
||||
> 旧 AAR(未导出 `getContractVersion()`)不阻断,仅记录 warning。
|
||||
|
||||
---
|
||||
|
||||
## 跑契约测试
|
||||
|
||||
```bash
|
||||
# Go 侧:字段名常量 + golden 样本一致性
|
||||
cd go && go test ./teamspeak/ -run "Contract|Golden" -v
|
||||
|
||||
# Kotlin 侧:反序列化 + 失效模式
|
||||
cd android && ./gradlew testDebugUnitTest --tests '*BridgeContractTest*'
|
||||
```
|
||||
|
||||
**golden 样本位于** `android/app/src/test/resources/contract/`,
|
||||
由 Go 与 Kotlin **共用**:Go 测试断言样本字段集合 == struct tag,
|
||||
Kotlin 测试断言样本能正确反序列化。
|
||||
字段漂移时两侧都会变红——这是「改了字段名却无人发现」的兜底。
|
||||
@@ -0,0 +1,480 @@
|
||||
# TS Mobile 工程改进方案
|
||||
|
||||
> 编写依据:对 `android/`、`go/`、`.git` 的实测,以及 `CLAUDE.md`、`docs/` 现有文档的交叉核对。
|
||||
> 立场:本文不复述通用 Android 最佳实践,只针对这个项目因**双层跨语言架构**而产生的真实风险。
|
||||
> 所有结论均附实测证据,未验证的地方标注为「推测」。
|
||||
|
||||
---
|
||||
|
||||
## 一、我的核心判断
|
||||
|
||||
这个项目最大的风险不在 UI,也不在 Kotlin 代码质量,而在**Go 与 Kotlin 之间那道没有类型保护的边界**。
|
||||
|
||||
架构是:Go 协议层 → gomobile 编译成 `teamspeak.aar` → Kotlin 通过 JNI 调用。gomobile 有三条硬限制(`CLAUDE.md` 已记录):不能导出 `[]string`、不能导出 `[]*T`、不能导出 Go `error`。为了绕过它们,项目选择了**用 JSON 字符串传所有复杂数据**。
|
||||
|
||||
这个选择的代价是:
|
||||
|
||||
> **Go 侧改一个字段名,Kotlin 侧不会编译失败,只会在运行时静默拿到默认值。**
|
||||
|
||||
`Repository.kt:18` 的 `Json { ignoreUnknownKeys = true }` 让这个失效模式更隐蔽——字段名对不上时不报错,`ChannelInfo` 直接用默认值 `""` / `0` / `false` 填充。结果是频道列表看起来是空的,或者密码频道显示成公开频道,而日志里什么都没有。
|
||||
|
||||
而这条边界目前是**完全无保护**的:
|
||||
|
||||
| 保护手段 | 现状 |
|
||||
| --- | --- |
|
||||
| 编译期类型检查 | 无(JSON 字符串) |
|
||||
| 契约文档 | 无(`docs/sdk-bridge-api.md` 存在但未核对与 `bridge.go` 是否同步) |
|
||||
| 契约测试 | 无 |
|
||||
| Schema 校验 | 无 |
|
||||
| 版本标识 | 无(AAR 无版本号,`bridge.go` 无契约版本常量) |
|
||||
|
||||
叠加第二个事实:**重编 AAR 是手工两步流程,且产物入了 git**。`CLAUDE.md` 专门用一整节记录「忘记加 `-linkmode=external` 会 SIGSEGV」「忘记 `-Wl,--hash-style=both` 会 dlopen 失败」——这两个坑被写进文档,说明它们已经真实咬过人。而 `android/app/libs/teamspeak.aar`(23.13 MB)被 git 跟踪,意味着**你无法从提交历史判断某个 APK 用的是哪一版协议层**。
|
||||
|
||||
所以我的改进主线是一条:
|
||||
|
||||
> **先把跨语言契约固化成可验证的东西,再谈架构解耦,最后才谈 UI。**
|
||||
|
||||
顺序不能反。在一个契约不可靠的地基上拆 ViewModel、重构 Compose,只会把不确定性扩散到更多文件里。
|
||||
|
||||
---
|
||||
|
||||
## 二、实测到的问题分级
|
||||
|
||||
### 第一梯队 · 止血(半天内可完成,阻断其他改进)
|
||||
|
||||
#### 1.1 Release 包泄漏完整成员列表与 UID
|
||||
|
||||
`proguard-rules.pro` 全文只有 3 行:
|
||||
|
||||
```
|
||||
-keep class teamspeak.** { *; }
|
||||
-keep class com.tsmobile.app.TSBridge { *; }
|
||||
```
|
||||
|
||||
没有任何日志剥离规则。而全项目有 **197 处 `Log.*` 调用**(实测统计),其中 `Repository.performInitialSync` 在 `Repository.kt:89-94` 逐条打印:
|
||||
|
||||
```kotlin
|
||||
for (ch in channels) {
|
||||
android.util.Log.d("Repository", "Channel: id=${ch.id}, name=${ch.name}, parentId=${ch.parentId}...")
|
||||
}
|
||||
for (cl in clients) {
|
||||
android.util.Log.d("Repository", "Client: id=${cl.id}, nickname=${cl.nickname}, channelId=${cl.channelId}")
|
||||
}
|
||||
```
|
||||
|
||||
`applyClients` 还打印 UID 过滤细节。这些在 release 包里全部保留——任何人 `adb logcat` 就能拿到服务器完整的频道结构和所有成员的昵称 + UID。UID 在 TeamSpeak 里是永久身份标识,等同于账号 ID。
|
||||
|
||||
**改法**(`proguard-rules.pro` 追加):
|
||||
|
||||
```proguard
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static int v(...);
|
||||
public static int d(...);
|
||||
public static int i(...);
|
||||
public static int w(...);
|
||||
}
|
||||
```
|
||||
|
||||
保留 `Log.e`。注意 `-assumenosideeffects` 只删调用,不删参数表达式的求值——`Repository.kt` 里这些日志的参数是字符串模板,含 `channels` 遍历,删除调用后循环体变空,R8 会一并消除。
|
||||
|
||||
同时应把 `performInitialSync` 里的全量 dump 改成 debug-only 或只打印数量。
|
||||
|
||||
**验收**:`assembleRelease` 后反编译 `classes.dex`,grep 不到 `"Channel: id="` 字面量。
|
||||
**成本**:低(~1 小时)。**风险**:低。这是本方案性价比最高的一项。
|
||||
|
||||
#### 1.2 构建产物入库,`.git` 已 236 MB
|
||||
|
||||
实测被 git 跟踪的二进制:
|
||||
|
||||
| 文件 | 大小 | 问题 |
|
||||
| --- | --- | --- |
|
||||
| `android/app/libs/teamspeak.aar` | 23.13 MB | 每次重编 AAR 都产生新版本,历史永久膨胀 |
|
||||
| `android/.gradle/8.11.1/executionHistory/executionHistory.bin` | 9.98 MB | Gradle 本地构建缓存,纯垃圾 |
|
||||
| `go/teamspeak/.opus/{lib,install,build}/{abi}/libopus.a` | ~3.3 MB × 4 ABI × 3 份 | 同一份库存了三次 |
|
||||
| `third_party/opus/dnn/torch/osce/resources/training_files.txt` | 5.32 MB | PyTorch DNN 训练资源,Android 构建完全用不到 |
|
||||
|
||||
`.gitignore` 里已经写了 `android/.gradle/` 和 `go/teamspeak/.opus/build/`,但**对已跟踪文件无效**——需要显式 `git rm --cached`。另外 `.gitignore` 有 `*.so`、`*.dylib`,**唯独漏了 `*.a`**,这是 libopus.a 全量入库的直接原因。
|
||||
|
||||
这里有一个需要你决策的权衡,我不替你定:
|
||||
|
||||
- `libopus.a` 入库是**有意的**(`CLAUDE.md` 写明「Pre-built static libraries live in ... for all four Android ABIs」,避免每次交叉编译 opus)。但 `lib/`、`install/`、`build/` 三份内容相同,**至少可以去掉两份**,省 ~26 MB。
|
||||
- `teamspeak.aar` 入库则是纯负担。它应该由 `build.bat` 现场生成。如果你担心 clone 后没装 gomobile 无法构建,正确做法是 Git LFS 或把 AAR 挂到 Release 附件,而不是塞进 git 对象库。
|
||||
|
||||
**改法**:
|
||||
```
|
||||
# .gitignore 追加
|
||||
*.a
|
||||
```
|
||||
```
|
||||
git rm --cached android/.gradle -r
|
||||
git rm --cached go/teamspeak/.opus/build -r
|
||||
git rm --cached go/teamspeak/.opus/install -r
|
||||
git rm --cached "android/app/src/main/cpp/third_party/opus/dnn" -r
|
||||
```
|
||||
|
||||
`git rm --cached` 只从索引移除,**不删工作区文件**,构建不受影响。
|
||||
|
||||
至于清理已有的 236 MB 历史(`git filter-repo`),那是**不可逆的破坏性操作**,会重写所有 commit hash、要求所有克隆重新拉取。除非仓库已经大到影响日常操作,我不建议现在做。先止住增量即可。
|
||||
|
||||
**验收**:`git ls-files | ForEach-Object { ... }` 统计跟踪文件总体积,从当前 ~50 MB 降到 ~25 MB;新 clone 后 `build.bat` 能正常产出 APK。
|
||||
**成本**:低(~1 小时)。**风险**:中——`third_party/opus/dnn` 是否真的不参与构建需要你确认一次(如果 `CMakeLists.txt` 引用了它,删掉会编译失败)。
|
||||
|
||||
#### 1.3 顺带确认:密钥管理是正确的
|
||||
|
||||
我特意查了这一项,结论是**没问题,不用改**:`keystore/`、`*.jks`、`*.keystore`、`android/local.properties` 都在 `.gitignore` 里,且 `git ls-files keystore/` 返回空——签名密钥从未入库。`build.gradle.kts:28-32` 通过 `providers.gradleProperty()` 读密码而非硬编码,也是对的。
|
||||
|
||||
写在这里是为了避免你在后续清理时误以为这里也有问题。
|
||||
|
||||
---
|
||||
|
||||
### 第二梯队 · 契约固化(本方案的核心,1~2 周)
|
||||
|
||||
#### 2.1 给 bridge 建立单一事实来源
|
||||
|
||||
**现状证据**:`TSBridge.kt:255-258` 有一个已经存在的契约缺口:
|
||||
|
||||
```kotlin
|
||||
fun sendTextMessage(targetMode: Int, targetId: String, message: String): String {
|
||||
// TODO: 重新编译 AAR 后使用 client?.sendTextMessage(targetMode, targetId, message)
|
||||
// 当前 AAR 只有 sendChannelMessage,对应 targetMode=2
|
||||
return when (targetMode) {
|
||||
```
|
||||
|
||||
也就是说:**接收侧支持三种会话**(`ChatMessage.targetMode` 为 1=私聊 / 2=频道 / 3=服务器,`ServerViewModel.sendMessageNotification` 还会把标题区分为"频道消息"/"私聊消息",通知 deep link 还能跳转到指定会话),但**发送侧只实现了频道消息**。私聊能收到、能弹通知,但用户无法回复——收发能力不对称。
|
||||
|
||||
需要说明的是,这里**没有**静默失败:`else` 分支返回的是显式错误串 `"暂不支持该消息类型"`,按桥接约定(非空=错误)会被上层识别为失败。这个处理是正确的。
|
||||
|
||||
但它暴露了真正的问题:**这个能力缺口只存在于代码注释里**。`docs/` 的 27 份文档中没有任何一处记录「发送仅支持 mode=2」,`ChatMessage.targetMode` 的字段注释还写着 `1=私聊, 2=频道, 3=服务器`,读起来像三种都支持。下一个人(或下一个 AI 助手)在实现私聊 UI 时,会假设发送链路是通的。
|
||||
|
||||
> 待核实:该错误串在 UI 上的呈现路径我没有追到底——`ChatViewModel.sendMessage` 是否把它映射成 `MessageSendState.Failed` 并让用户看见,需要实测确认。如果只落日志不弹提示,用户会以为消息发出去了。
|
||||
|
||||
这类「文档与代码不同步」的缺口只能靠显式契约发现。建议:
|
||||
|
||||
1. 在 `go/teamspeak/` 下新建 `contract.go`,用一个常量集中声明契约版本与所有 JSON 字段名:
|
||||
```go
|
||||
const BridgeContractVersion = "1"
|
||||
|
||||
// 字段名常量,禁止在 marshal 处写字面量
|
||||
const (
|
||||
FieldChannelID = "id"
|
||||
FieldChannelParentID = "parentId"
|
||||
FieldChannelName = "name"
|
||||
// ...
|
||||
)
|
||||
```
|
||||
导出 `GetContractVersion()`,Kotlin 启动时校验,不匹配就明确报错而不是静默降级。
|
||||
|
||||
2. 在 `docs/` 下建 `bridge-contract.md`,逐个结构体列出:Go 类型 → JSON 字段 → Kotlin 数据类字段 → 是否可空 → 默认值语义。**每个字段标注双向支持状态**(如 `sendTextMessage: mode=2 已实现 / mode=1,3 未实现`)。
|
||||
|
||||
3. 把 `TSBridge.kt` 的 TODO 转成显式失败:未实现的 `targetMode` 返回明确错误串,让 `ChatViewModel` 能把消息标成 `FAILED` 而不是 `PENDING` 挂着。
|
||||
|
||||
**验收**:`bridge-contract.md` 覆盖 `bridge.go` 导出的全部结构体;启动时有契约版本校验;私聊发送失败会在 UI 上可见。
|
||||
**成本**:中(2~4 天,主要是梳理)。**风险**:低(纯增量,不改行为)。
|
||||
|
||||
#### 2.2 给契约加测试
|
||||
|
||||
这是整个方案里我认为**最该投入**的一项。
|
||||
|
||||
现状:Kotlin 侧只有 2 个测试文件(`EmojiMapperTest.kt`、`MixedPcmFrameTest.kt`),Go 侧自有测试只有 `receive_audio_test.go`(343 行,其余 19 个 `_test.go` 属 vendored 的 teamspeak-go,不算你的资产)。
|
||||
|
||||
而核心逻辑规模是:`ServerViewModel.kt` ~1355 行、`bridge.go` 1542 行、`kotlin_api.go` 625 行、`ChannelViewModel.kt` 613 行、`Repository.kt` 325 行——**这些全部零测试**。
|
||||
|
||||
不需要追求覆盖率。只需要针对「跨边界 + 纯逻辑」这两类写测试,投入产出比最高:
|
||||
|
||||
**A. 契约往返测试(Go 侧)**——最高优先
|
||||
```go
|
||||
func TestChannelInfoJSON_ContractStable(t *testing.T) {
|
||||
// 固定输入 → 断言 JSON 字段名与类型
|
||||
// 任何人改字段名,这个测试立刻红
|
||||
}
|
||||
```
|
||||
|
||||
**B. 反序列化健壮性(Kotlin 侧)**
|
||||
用 Go 侧产出的真实 JSON 样本作为 test resource,断言 `ChannelInfo` / `ClientInfo` / `ServerInfo` 能正确解析,且缺字段时的默认值是预期的。**特别是 `ignoreUnknownKeys = true` 掩盖的那些场景**。
|
||||
|
||||
**C. 纯逻辑单元测试**——这些现在就能写,无需 mock Android:
|
||||
- `InputValidator`(4 个校验函数,含 `address:port` 解析与 1..65535 边界)
|
||||
- `Repository.confirmMessageDelivery` / `markPendingMessagesFailed`(消息送达确认的匹配逻辑,含 `senderId=0` 降级路径)
|
||||
- `ServerViewModel.classifyError`(6 类错误映射)
|
||||
- `ChannelViewModel.mapMoveError`
|
||||
- 僵尸会话过滤 `applyClients`(`Repository.kt:172-187`,同 UID 不同 clid 的过滤规则,这是重连正确性的关键)
|
||||
|
||||
`Repository` 是 `object` 单例,直接依赖 `TSBridge`,不好测。改法是抽出纯函数:把 `applyClients` 的过滤逻辑提成 `internal fun filterZombieSessions(clients: List<ClientInfo>, selfId: Int): List<ClientInfo>`,单独测。这类重构成本极低但立刻可测。
|
||||
|
||||
**验收**:CI 上跑通;改动 `bridge.go` 任一字段名会导致测试失败。
|
||||
**成本**:中(3~5 天)。**风险**:低。
|
||||
|
||||
#### 2.3 拆掉 ViewModel 之间的手工交叉引用
|
||||
|
||||
`NavGraph.kt:66-71`:
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(Unit) {
|
||||
serverViewModel.channelViewModel = channelViewModel
|
||||
serverViewModel.chatViewModel = chatViewModel
|
||||
serverViewModel.voiceViewModel = voiceViewModel
|
||||
channelViewModel.chatViewModel = chatViewModel
|
||||
}
|
||||
```
|
||||
|
||||
配合 `ServerViewModel.kt:85-89` 的三个 `@Volatile var ...ViewModel?`。
|
||||
|
||||
这是**用可变引用做依赖注入**,问题有三:
|
||||
1. 赋值发生在 `LaunchedEffect` 里,即首帧之后。在此之前的任何 bridge 回调都会走 `?:` 兜底分支——`ServerViewModel.kt:1206/1213/1220` 三处 `?: viewModelScope.launch { Repository.refreshClientList() }` 就是这个兜底。
|
||||
2. ViewModel 生命周期由 `viewModel()` 在 NavGraph 作用域创建,理论上同生共死,但这个不变量没有任何机制保证,靠注释维系。
|
||||
3. 状态所有权模糊:`Repository` 是真相源,但 `ChannelViewModel` 也持有 `currentChannelId`、`channels`、`clients` 的镜像 StateFlow,两边都要同步。
|
||||
|
||||
**根因**是 Go 回调只有一个入口(`createBridgeCallbacks()`,`ServerViewModel.kt:993` 起,一个函数吞掉所有事件再手工分发)。
|
||||
|
||||
**改法**(渐进,不需要引入 DI 框架):
|
||||
1. 新建 `BridgeEventBus`(单例 object,内部 `MutableSharedFlow<BridgeEvent>`)。
|
||||
2. `TSBridge.Callbacks` 的实现从 `ServerViewModel` 移到一个独立的 `BridgeDispatcher`(由 `App` 或 `ConnectionService` 持有,生命周期与应用一致,而非与 ViewModel 一致)。
|
||||
3. `BridgeDispatcher` 只做一件事:把 JNI 回调转成 sealed class 事件投递到 bus。
|
||||
4. 各 ViewModel 自己 `collect` 关心的事件,不再互相持有引用。
|
||||
|
||||
这一步同时解决了一个潜在 bug:**当前 ViewModel 被清除后(如配置变更、进程重建),bridge 回调就没人接了**。把 dispatcher 挂到应用级生命周期更稳。
|
||||
|
||||
**验收**:`ServerViewModel` 中不再出现其他 ViewModel 的类型引用;grep `@Volatile var.*ViewModel` 无结果。
|
||||
**成本**:高(4~7 天)。**风险**:中——涉及所有回调路径,**必须在 2.2 的测试到位之后做**。这是梯队顺序不能反的具体原因。
|
||||
|
||||
#### 2.4 拆分 ServerViewModel
|
||||
|
||||
~1355 行,实测承担 **9 类职责**:连接生命周期、重连状态机(含 `reconnectLock` / `reconnectAttempt` / `reconnectJob` / `lastConnectParams` / `lastChannelId` 五个并发字段)、网络监控、系统通知构建与发送(`sendPokeSystemNotification` / `sendMessageNotification` / `clearNotifications` / `triggerVibration`)、Poke 气泡、主题切换、应用更新检测(`checkForUpdate` / `manualCheckUpdate` / `closeUpdateDialog` / `openReleaseUrl` / `dismissUpdate`)、表单状态、bridge 回调分发、错误分类。
|
||||
|
||||
其中**更新检测和主题切换与"服务器"毫无关系**,纯粹是历史堆积。
|
||||
|
||||
**改法**(按可独立性排序,前三项可立刻做,互不阻塞):
|
||||
| 抽出 | 目标 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| 更新检测(5 个方法 + `UpdateInfo` + `updatePreferences`) | `UpdateViewModel` | 无,完全独立 |
|
||||
| 主题(`toggleTheme` + `themeMode` + `themePreferences`) | `SettingsViewModel` 或 `CompositionLocal` | 无 |
|
||||
| 通知构建 | `NotificationHelper`(非 ViewModel,纯工具类) | 需 `Application` context |
|
||||
| 重连状态机 | `ReconnectController` | 依赖 2.3 完成 |
|
||||
| bridge 回调分发 | `BridgeDispatcher` | 即 2.3 |
|
||||
|
||||
拆完 `ServerViewModel` 应只剩连接生命周期 + 表单状态,预计 ~350 行。
|
||||
|
||||
**成本**:中(前三项 2~3 天,后两项依赖 2.3)。**风险**:低~中。
|
||||
|
||||
---
|
||||
|
||||
### 第三梯队 · 状态模型收敛(1 周)
|
||||
|
||||
#### 3.1 七个状态机,两处定义,语义重叠
|
||||
|
||||
实测项目里的状态机:
|
||||
|
||||
| 状态机 | 位置 | 取值 |
|
||||
| --- | --- | --- |
|
||||
| `ConnectionState` | `Models.kt:160` | Connected / Disconnecting / Reconnecting(attempt,max,reason) / WaitingForNetwork(since) / Disconnected(reason,wasKicked) |
|
||||
| `ConnectState` | `ServerViewModel.kt:34` | IDLE / CONNECTING / SUCCESS / FAILED / TIMEOUT |
|
||||
| `SyncState` | `ChannelViewModel.kt:20` | Unsynced / Syncing / Synchronized / SyncFailed |
|
||||
| `ChannelSwitchState` | `Models.kt` | Requesting / Failed / ... |
|
||||
| `VoiceState` | `Models.kt:196` | Idle / Transmitting / Blocked(reason) |
|
||||
| `MessageSendState` | `Models.kt:138` | Idle / Sending / Failed |
|
||||
| `MessageDeliveryState` | `Models.kt:72` | PENDING / SENT / FAILED |
|
||||
|
||||
`ConnectState` 和 `ConnectionState` 是**同一件事的两套表达**:一个是 enum 一个是 sealed class,一个管按钮外观一个管导航。`NavGraph.kt:103` 用 `connectionState` 驱动导航,`ServerConfigScreen` 用 `state.connectState` 驱动按钮,两者的转换点分散在 `doConnect()` 里。这就是「Connected 但 UI 还停在 CONNECTING」这类 bug 的温床。
|
||||
|
||||
`connectionState` 还用了 `StateFlow<ConnectionState?>`,`null` 表示"未进入频道列表页"(`ServerViewModel.kt:101-103` 注释)——**用 null 编码一个业务状态**,每个消费点都要先判空。
|
||||
|
||||
**改法**:
|
||||
1. 删除 `ConnectState`,`ServerScreenState` 直接引用 `ConnectionState` 的派生值。按钮外观由 `when(connectionState)` 计算,不作为独立状态存储。
|
||||
2. `ConnectionState` 增加 `object Idle`(或 `data class Initial`)替代 `null`。
|
||||
3. 把 `SyncState` 合并进 `ConnectionState`——「已连接但数据未就绪」本来就是一个连接阶段,不该是另一个维度。建议:
|
||||
```kotlin
|
||||
sealed class ConnectionState {
|
||||
object Idle
|
||||
data class Connecting(val target: String)
|
||||
data class Syncing(val progress: SyncPhase) // ← 原 SyncState
|
||||
object Connected // 数据已就绪
|
||||
data class Reconnecting(val attempt: Int, val maxAttempts: Int, val reason: String)
|
||||
data class WaitingForNetwork(val since: Long)
|
||||
object Disconnecting
|
||||
data class Disconnected(val reason: String, val wasKicked: Boolean)
|
||||
}
|
||||
```
|
||||
这样「不存在 Connected but no channel」从一个**需要靠 UI 层自律维持的约定**,变成**类型系统保证的不变量**——只有 `Syncing` 完成后才可能是 `Connected`。
|
||||
|
||||
这一条直接消除了现有设计里最容易出错的一块,而且让 `NavGraph` 的导航逻辑从「监听多个状态 + 判空」简化为「单一 `when`」。
|
||||
|
||||
**验收**:grep 不到 `ConnectState`;`connectionState` 类型不再是可空。
|
||||
**成本**:中(3~5 天)。**风险**:中——影响导航与所有 UI 消费点,需 2.2 的测试兜底。
|
||||
|
||||
#### 3.2 频道切换的乐观更新语义
|
||||
|
||||
`CLAUDE.md` 记录了两个 SDK 硬限制:
|
||||
- 无 channel create/update/delete 事件,频道列表靠「5 分钟陈旧或切换前刷新」
|
||||
- `notifycliententerview` 的 ChannelID 不可靠,client enter/leave 触发**全量 clientlist 刷新**
|
||||
|
||||
这两条对 UI 有直接影响,但代码里没有体现为显式设计:
|
||||
- 全量刷新意味着 `_channelClients` 整个 Map 被替换(`Repository.kt:185`),成员列表会整体重组。高频进出场景(公共服务器)下这是持续的重组压力。
|
||||
- 频道树数据可能滞后 5 分钟,UI 却没有任何「数据可能不是最新」的表达。
|
||||
|
||||
**改法**:
|
||||
1. `applyClients` 改为**差分更新**:对比新旧列表,只对变化的 channelId 更新 `_channelClients` 的对应条目,而不是 `groupBy` 全量替换。
|
||||
2. 给频道数据加 `fetchedAt` 时间戳,超过阈值时在 UI 上标注(如频道页下拉刷新 + "数据更新于 X 分钟前")。
|
||||
3. 频道切换必须是**悲观**的:`ChannelSwitchState.Requesting` 期间禁用其他频道点击,等服务端确认或超时失败后回滚。现有 `ChannelConfirmDialog` + `switchState is ChannelSwitchState.Failed` 已经是这个思路,需要确认超时路径完整(**推测**:未见明确超时,若服务端不响应可能永久停在 Requesting——需实测)。
|
||||
|
||||
**成本**:中。**风险**:中(差分更新容易引入不一致,必须有测试)。
|
||||
|
||||
---
|
||||
|
||||
### 第四梯队 · UI 层(1~2 周,可与第三梯队并行)
|
||||
|
||||
#### 4.1 一个真实的 Compose 误用
|
||||
|
||||
`ChannelListScreen.kt:276`:
|
||||
|
||||
```kotlin
|
||||
ClientActionMenu(
|
||||
client = selectedClient!!,
|
||||
isSelf = selectedClient!!.id == channelViewModel.selfClientId.collectAsState().value,
|
||||
```
|
||||
|
||||
`collectAsState()` 在**参数表达式位置**调用。虽然 `ClientActionMenu` 是 `@Composable`,参数在 composable 作用域内求值,所以订阅能生效——但 `.value` 立即取值再传入,导致 `isSelf` 是**一次性快照**,`selfClientId` 变化时 `ClientActionMenu` 不会重组(它收到的是 Boolean,不是 State)。
|
||||
|
||||
同文件内 `selectedClient!!` 出现 9 次非空断言,配合 `var selectedClient by remember { mutableStateOf<ClientInfo?>(null) }`。这是典型的「用 !! 绕过可空性」,一旦 `onDismiss` 与回调时序交错就会崩。
|
||||
|
||||
**改法**:
|
||||
```kotlin
|
||||
selectedClient?.let { client ->
|
||||
val selfId by channelViewModel.selfClientId.collectAsState()
|
||||
ClientActionMenu(client = client, isSelf = client.id == selfId, ...)
|
||||
}
|
||||
```
|
||||
用 `?.let` 消掉全部 `!!`,`collectAsState()` 提到函数体顶部。
|
||||
|
||||
**成本**:低(~1 小时)。**风险**:低。建议优先做,这是可复现的正确性问题。
|
||||
|
||||
#### 4.2 拆分 838 行的 ChannelListScreen
|
||||
|
||||
该文件含 12 个 `@Composable`/私有函数,单函数最长约 200 行(`ChannelListScreen` 本体,含 4 个弹窗的编排)。13 个 `collectAsState()` 集中在顶部——**任何一个状态变化都会重组整个屏幕**,包括频道树。
|
||||
|
||||
**改法**:
|
||||
1. 把 4 个弹窗(`ChannelDetailCard` / `ClientActionMenu` / `PokeDialog` / `ChannelConfirmDialog`)提成一个 `ChannelListDialogs` composable,状态用参数传入。
|
||||
2. `ChannelTreeContent` 已经独立(`:464`),但它自己 `collectAsState` 了 `channels` / `clients` / `expandedChannelIds` / `selfClientId` 四个流——保持这样是对的,不要提到父级。
|
||||
3. `ChannelRow` / `ClientRow` 加 `key`(LazyColumn 已用 `channelTreeNodeItems` 扩展,需确认 key 稳定性)。
|
||||
4. 高频变化的 Speaking 状态应下推到 `ClientRow` 内部订阅,不经过父级。
|
||||
|
||||
**成本**:中(2~3 天)。**风险**:低。
|
||||
|
||||
#### 4.3 硬编码尺寸
|
||||
|
||||
`ChannelListScreen.kt:48-55`:
|
||||
|
||||
```kotlin
|
||||
private val TREE_LEFT_INDENT = 24.5.dp
|
||||
private val TREE_RIGHT_INDENT = 24.5.dp
|
||||
private val FOOTER_LEFT_INDENT = 14.5.dp
|
||||
private val FOOTER_RIGHT_INDENT = 14.5.dp
|
||||
```
|
||||
|
||||
以及 `:569` 的 `depth * 24).dp` 内联计算、`:565` / `:645` 的 `padding(vertical = 12.dp)`。
|
||||
|
||||
`UiTokens.Spacing` 已经有完整档位(None/ExtraSmall/Small/Medium/Large/ExtraLarge/Huge),这些 24.5 / 14.5 是绕开 token 的局部修正值。**24.5dp 这种半像素值在非整数密度屏幕(如 2.75x)上会产生子像素对齐问题**,导致缩进线模糊。
|
||||
|
||||
另外 `depth * 24` 无上限——TeamSpeak 频道可深层嵌套,5 层以上在窄屏必然溢出。需要定 `maxIndentDepth`(建议 4)并对超限部分做截断或横向滚动。
|
||||
|
||||
**改法**:全部替换为 `UiTokens.Spacing.*`,缩进改为 `UiTokens.Spacing.Large * min(depth, 4)`。
|
||||
|
||||
**成本**:低。**风险**:低。
|
||||
|
||||
#### 4.4 EmojiMapper.kt 1989 行
|
||||
|
||||
项目最大的 Kotlin 文件,是手写 emoji 映射表。这类**纯数据**不该占编译单元:它拖慢增量编译(改一行重编 1989 行),且无法被非 Kotlin 侧复用。
|
||||
|
||||
**改法**:移到 `assets/emoji-map.json`,启动时懒加载;或用 KSP 生成。有 `EmojiMapperTest.kt` 兜底,迁移风险可控。
|
||||
|
||||
**成本**:低~中。**风险**:低。
|
||||
|
||||
---
|
||||
|
||||
### 第五梯队 · 门禁与协作(持续)
|
||||
|
||||
#### 5.1 没有 CI
|
||||
|
||||
实测:仓库内无 `.github/workflows/`(`third_party/opus/.gitlab-ci.yml` 是上游 opus 自带的,与本项目无关),无 detekt/ktlint 配置,无 `lint.xml`。
|
||||
|
||||
构建全靠本地 `build.bat` / `build.sh`,而这两个脚本封装的正是 `CLAUDE.md` 里记录过会踩坑的手工两步流程。**没有 CI 意味着那两个 SIGSEGV / DT_HASH 坑只能靠人记住。**
|
||||
|
||||
**改法**:加一个最小 GitHub Actions(或任何你用的 CI):
|
||||
```yaml
|
||||
jobs:
|
||||
go: # cd go && go vet ./teamspeak/ && go test ./teamspeak/
|
||||
kotlin: # cd android && ./gradlew lint testDebug assembleDebug
|
||||
aar: # 用固定 flags 重编 AAR,校验产物可被 dlopen(这条最关键)
|
||||
```
|
||||
第三项最有价值:把 `build.bat` 里的 `-ldflags` 写成 CI 里的唯一真源,让「忘记加 flag」变成不可能。
|
||||
|
||||
同时加 `detekt` + `ktlint`(Gradle 插件,配置成本低),并把 `hardcoded dp/sp` 之类规则打开——这能从机制上防止 4.3 的问题复发,比写进规范文档有效得多。
|
||||
|
||||
**成本**:中(1~2 天搭好)。**风险**:低。
|
||||
|
||||
#### 5.2 文档体系需要收口
|
||||
|
||||
`docs/` 下现有:`UI架构设计.md`(3 页 3 卡)、`状态控制设计.md`、`sdk-bridge-api.md`、`sdk文档-go.md`、`TS Mobile UI UX 重构设计规范.md`、`流程/00~09`(10 份)、`implementation/00~12`(13 份)——**27 份文档,总计约 400 KB**。
|
||||
|
||||
问题不是文档少,是**没有权威源声明**。`UI架构设计.md` 描述的三页三卡架构与 `TS Mobile UI UX 重构设计规范.md` 的 Server Space 架构直接冲突,而代码实现的是前者。一个新加入的人(或一个 AI 助手)无法判断该信哪份。
|
||||
|
||||
**改法**:在 `docs/README.md` 建一个索引表,每份文档标注:状态(权威 / 历史 / 草案)、覆盖范围、最后核对日期、与代码的一致性。冲突的明确标注「已被 X 取代」。
|
||||
|
||||
`CLAUDE.md` 是目前**唯一与代码高度一致**的文档(Known SDK Limitations、Thread Safety、Critical Build Details 三节我逐条核对过,全部属实)。建议把它提升为文档体系的入口,其余文档从它链接出去。
|
||||
|
||||
**成本**:低(半天)。**风险**:无。
|
||||
|
||||
---
|
||||
|
||||
## 三、明确不建议做的事
|
||||
|
||||
这部分和上面同样重要——避免你把力气花在错的地方。
|
||||
|
||||
1. **不要现在引入 Hilt / Koin**。项目是单人维护规模(`versionCode 12`、`versionName 1.0.12`),ViewModel 交叉引用的问题用 2.3 的事件总线就能解决,引入 DI 框架会带来 KSP 编译开销和学习成本,收益不成比例。
|
||||
|
||||
2. **不要重写 Compose UI**。`ui/components/` 下 20 个组件的粒度基本合理(多数 100~300 行),真正失控的只有 `ChannelListScreen`。局部拆分即可。
|
||||
|
||||
3. **不要动 Go 音频管线**。`receive_audio.go` 是全项目唯一有像样测试的模块(343 行测试对 281 行实现),且 `CLAUDE.md` 记录了 jitter buffer / Opus 解码 / 立体声混音的完整设计与「Speaking 检测由 Go 侧负责,Kotlin 不得从 PCM 推断」的所有权划分。这块是项目质量最高的部分,改它风险最大收益最小。
|
||||
|
||||
4. **不要清理 git 历史**(除非仓库已影响日常操作)。见 1.2,先止住增量。
|
||||
|
||||
5. **不要在契约固化之前做 2.3 / 3.1**。这两项都会大范围移动回调与状态流转路径。没有测试网兜着做,等于在无保护状态下重写核心链路。**这是我把它排在第二梯队而非第一梯队的唯一原因**——不是它不重要,是它有前置依赖。
|
||||
|
||||
6. **不要追求测试覆盖率指标**。这个项目该测的是「跨边界的契约」和「纯逻辑的状态转换」,UI 层用 Compose Preview + 手工验证足够。定一个 60% 覆盖率目标会让你去测 getter/setter。
|
||||
|
||||
---
|
||||
|
||||
## 四、落地顺序
|
||||
|
||||
按依赖关系排,不是按重要性排:
|
||||
|
||||
```
|
||||
第 1 步(半天) 1.1 Release 剥离日志 ← 隐私问题,独立,立刻做
|
||||
第 2 步(半天) 1.2 构建产物出库 ← 独立,立刻做
|
||||
第 3 步(1 小时) 4.1 修 collectAsState 误用 ← 独立,正确性问题
|
||||
第 4 步(1 天) 5.1 CI 最小门禁 ← 独立,且为后续所有步骤提供安全网
|
||||
─────────────────────────────────────────
|
||||
第 5 步(2~4 天) 2.1 契约文档 + 版本常量 ← 后续步骤的地基
|
||||
第 6 步(3~5 天) 2.2 契约测试 + 纯逻辑测试 ← 2.3 / 3.1 的前置条件
|
||||
─────────────────────────────────────────
|
||||
第 7 步(2~3 天) 2.4 拆出 Update / Theme / Notification ← 独立,可提前做
|
||||
第 8 步(4~7 天) 2.3 事件总线,消除 ViewModel 交叉引用
|
||||
第 9 步(3~5 天) 3.1 状态模型收敛
|
||||
─────────────────────────────────────────
|
||||
第 10 步(并行) 4.2 / 4.3 / 4.4 UI 层清理
|
||||
第 11 步(并行) 3.2 差分更新 + 频道数据时效
|
||||
第 12 步(半天) 5.2 文档收口
|
||||
```
|
||||
|
||||
第 1~4 步互不依赖,可以在一天内全部完成,且立刻消除一个隐私泄漏、一个正确性 bug,并建立安全网。**建议就从这四步开始。**
|
||||
|
||||
第 5~6 步是投资,做完之后第 8~9 步的风险从「高」降到「中」。
|
||||
|
||||
---
|
||||
|
||||
## 五、如果只做一件事
|
||||
|
||||
如果时间只够做一项,我会选 **2.2 的契约往返测试**。
|
||||
|
||||
理由:这个项目所有的结构性风险——JSON 字段静默失效、收发能力不对称却无处记录、AAR 与源码不同步、重编 flag 遗漏——本质上都是**同一个问题的不同表现:跨语言边界上没有可执行的验证**。而契约测试是唯一能用最小成本(一个 Go 测试文件 + 几个 Kotlin 测试)把这个边界钉住的手段。
|
||||
|
||||
其他所有改进都是在已知问题上打补丁;只有这一项能防止**你还不知道的问题**继续产生。
|
||||
Reference in New Issue
Block a user