refactor(ws): 进程内共享一条 ChatSocket,访问 N 个会话不再留 N+2 条连接

改了什么
- AppContainer:删掉「每次调用都新建」的 createChatSocket(),改成进程内唯一的 val chatSocket
  (by lazy)。三个调用方全部改取它,工厂方法一并删掉——不留「还能再造一份」的入口。
- ChatViewModel / SessionsViewModel / AdminViewModel:原本各自建一条连接,现在都改为订阅同一条。
- 连接的开与关收敛成唯一一处主人:SessionsViewModel 的登录态订阅
  (LoggedIn 则 connect()、LoggedOut 则 close())。
  * ChatViewModel 不再 connect()(它随切会话就被销毁,不该掌握连接的生死);
  * AdminViewModel 不再 connect() / close(),包括原来「viewer 时关掉自己那条连接」那条分支——
    这三帧服务端本就 fail-closed,本类另有逐帧角色门,断开连接多挡不掉什么,却会掐掉别的屏幕。
  * AdminViewModel 登出仍然只丢日志基线(行为不变)。
- 三个 VM 的 onCleared 一律不再 close(共享连接不能被任何 VM 关闭,否则切会话 / 关管理页会把
  主会话流式、抽屉未读、管理页三帧一起掐断)。
- KDoc 同步改:AppContainer(为什么可以共享 / 为什么必须唯一 / 开与关的主人是谁)、ChatSocket
  (它不是某个屏幕的私有连接、谁都不许 close;events 的 replay 恒为 0 从「安全」升为「必需」)、
  三个 VM 的类注释与 onCleared。

为什么行为不变
- 服务端广播、不按会话订阅:改前 N 条连接各收全部帧,改后一条连接收全部帧,每个 VM 收到的帧集合
  逐帧相同——这是本次重构安全的唯一依据。isForSession、各事件分支、重连与退避逻辑、下行帧语义、
  UI 一个字都没动。
- baseUrl / token 每次重连现读,connect() 幂等(wantConnection 已置位即返回),所以登出重登、
  换账号都不需要重建实例。
- 连接数量:改前访问 N 个会话 = N+2 条连接、N+2 个 OkHttpClient(每个连接自带一个),改后恒为 1 条。

如实记一处非逐帧等价的细节(已核过,无功能影响):改前每条连接各自握手,服务端每次都会回一帧
history;改后只有首次连接会收到。晚于连接建立才创建的 VM 因此少收一帧 history,而该帧对非主会话
经 historyForSession 过滤后本来就是空(只用来收掉 loading),对主会话与 REST 首屏是同一份内容的
冗余来源,且新 VM 的 pending 为空、reconcilePending 是空操作。

验证:assembleDebug + testDebugUnitTest 全绿(891 个用例,0 失败 0 错误 0 跳过)。
This commit is contained in:
AskaEth committed 2026-10-05 08:03:09 +08:00
1 parent 6c2cc57cdb
commit 276b01113b
5 files changed
+138 -54

No files matched your search

@@ -32,13 +32,11 @@ import kotlin.random.Random
* Cyrene 实时通道客户端(`GET /ws/chat?token=<JWT>`,api-reference §6)。
*
* ## 用法
* 进程内只有**一个**实例,由 `AppContainer.chatSocket` 持有;上层要收帧就取它,**不要自己 new**
* (见下面「它不是某个屏幕的私有连接」):
* ```
* val socket = ChatSocket(baseUrlProvider = { cfg.baseUrl }, tokenProvider = { auth.token },
* onAuthFailed = { auth.logout() })
* socket.connect()
* // 收集 socket.events,观察 socket.state
* socket.sendChat(sessionId = "web", text = "你好", deviceId = deviceId)
* socket.close()
* val socket = container.chatSocket
* // 收集 socket.events,观察 socket.state;发送用 socket.sendChat / socket.sendInterrupt
* ```
*
* ## 会话归属
@@ -46,6 +44,20 @@ import kotlin.random.Random
* (见 [WsEvent.sessionId]),不补值。服务端广播**不按会话订阅**:同一条连接会收到**所有**
* 会话的帧,因此收集 [events] 的上层**必须**按 [WsEvent.isForSession] 收敛到自己的会话。
*
* ## 它不是某个屏幕的私有连接:谁都不许关它
* 本实例是**进程级共享**的一条连接——所有需要收帧的 ViewModel 订阅的是同一份 [events] / [state]。
* 之所以能共享,正是靠上面那条广播语义:「一条连接收到全部帧」与「N 条连接各收全部帧」对上层是
* **同一个语义**,每个 VM 收到的帧集合逐帧相同,所以共享改的只是「连接有几条」(连带每个连接
* 各带一个 `OkHttpClient` 的成本),不是「帧怎么走」——各 VM 的会话过滤逻辑**一个字都不用动**。
*
* 由此推出两条硬约束:
* 1. **[close] 只归应用级的那条登出 / 会话失效路径**(目前唯一调用点:`SessionsViewModel` 的
* 登录态订阅,见 `AppContainer.chatSocket`)。任何 ViewModel 在 `onCleared` 里关它、页面退出时
* 关它,都会把**别的屏幕**一起掐断——切会话会掐掉抽屉未读与管理页三帧,关管理页会掐掉主会话的
* 流式。连接的生命周期是**进程 + 登录态**,不是屏幕。
* 2. **[events] 的 `replay` 恒为 0**(理由见那里的 KDoc):共享之后,新建的 VM 会立刻订阅同一份流,
* replay > 0 就等于把**历史**下行帧投给一个新来的订阅者。
*
* ## 心跳与重连
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
@@ -84,7 +96,10 @@ class ChatSocket(
/**
* 下行事件流。
*
* `replay = 0`:重放旧 delta 会让新订阅者重复渲染,会话状态应由 `history` / REST 重建。
* **`replay` 恒为 0,这条不是调优而是必需**:重放旧 delta 会让新订阅者重复渲染,会话状态应由
* `history` / REST 重建;而连接**进程内共享**之后,新建的 VM(切到一个没访问过的会话)会**立刻**
* 订阅同一份流——replay > 0 等于把历史帧投给它,轻则重复渲染,重则会话串台。
*
* `extraBufferCapacity = 512 + DROP_OLDEST`:发送方是 OkHttp 读线程,不能阻塞;溢出时丢最旧的
* 增量帧(服务端发送缓冲 32 本就会丢帧,`stream_end` / `history` 仍可兜底)。
*/
@@ -114,7 +129,12 @@ class ChatSocket(
private var scope: CoroutineScope? = null
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
/**
* 开始连接。**幂等**:已经在连接 / 重连中([wantConnection] 已置位)时直接返回。
*
* 因为幂等,任何一个 VM 都可以在需要时调它做兜底;但**决定它该不该在**的只有登录态那条路径
* (见类注释的硬约束 1),本方法只负责「接上」。
*/
fun connect() {
if (wantConnection) return
wantConnection = true
@@ -123,7 +143,14 @@ class ChatSocket(
sessionJob = ensureScope().launch { runConnectionLoop() }
}
/** 主动断开并停掉重连;之后可再次 [connect]。 */
/**
* 主动断开并停掉重连;之后可再次 [connect]。
*
* ⚠️ 本实例是**进程级共享连接**,不是某个屏幕的私有连接:**只有应用级的登出 / 会话失效路径可以
* 调它**(目前唯一调用点:`SessionsViewModel` 的登录态订阅)。ViewModel 的 `onCleared`、
* 页面退出、切会话都**不许**调——那会把别的屏幕正在用的连接一起掐断。理由见类注释
* 「它不是某个屏幕的私有连接:谁都不许关它」。
*/
fun close() {
wantConnection = false
val socket = webSocket