From bbc47dbd314fb96ab599fbbd0ed868813c4d1986 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:56:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=B8=B8=E9=A9=BB=20ChatViewMode?= =?UTF-8?q?l=20=E5=8A=A0=20LRU=20=E5=9B=9E=E6=94=B6=EF=BC=88S4=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=80=E6=A0=BC=E4=B8=80=E4=BB=BD=20Vie?= =?UTF-8?q?wModelStore=EF=BC=8C=E6=9C=80=E8=BF=91=203=20=E6=A0=BC=E4=B9=8B?= =?UTF-8?q?=E5=A4=96=E6=95=B4=E6=A0=BC=E4=BA=A4=E5=87=BA=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S4 的病根:每个访问过的会话各留一个常驻 VM(都订阅那条共享连接 ⇒ 每次重连各自发一轮 after 补拉)、各留一份消息列表(含内联 base64 图片),而删会话也不回收。此前**没有按会话 交出一格的手段**:所有 VM 塞在 Activity 级那一个 ViewModelStore 里,而它只有整体 clear(), W48 只能做到「让 key 换一代、旧实例继续留着」—— 旧实例照样活着、照样收帧。 这次把「哪几格留着」这件事单独拎出来: - 新增 `core/session/ChatSessionSlots.kt`(进程级,挂在 AppContainer 上,与 GoneSessions 同一层):一格一份 ViewModelStore。`ChatScreen` 改成 `viewModel(viewModelStoreOwner = <槽位>, key = chatViewModelKey(...))`,所以「交出去一格」 就是那一份 store 自己的 `clear()` ⇒ 那一格的 `onCleared()` + `viewModelScope` 取消 (订阅、补拉、在飞的请求一起停)。共享连接的生死一个字节都没碰:主人仍是 SessionsViewModel,`ChatViewModel.onCleared` 照旧**不** close(现在它会真的被调到, 所以这句注释也一并写成事实)。 - 新增判据 `core/session/chatSessionEvictions`(**纯函数**,VM 与测试共用): 「最近」的口径 = **最后一次成为当前会话的时刻**(由 ChatScreen 在组合到某个会话时写入); 候选要同时满足「不是主会话 ∧ 不是屏幕上那一格 ∧ 没有没交代完的事」,按最旧的先走, 保留最近 `keep` = 3 格(主人口径,含被豁免的那些占的名额——不然「最多 3 格」会变成 3 + 豁免数)。两条豁免名额之外的豁免各自都有硬理由:屏幕那一格死了会「不再更新、连 发送都静默不发」,还有没交代完的事那一格(草稿 / 排队等连接的发送)死了内容**回不来**。 - 删会话时那一格当场交出去(`SessionsViewModel.deleteSession`,与 W51 的 `goneSessions.mark` 相邻:一个是「别发注定 404 的补拉」,一个是「这一格本来就不该留」); 登出 / 换账号时 `clear()` 一格不留(与快照 / 已读游标 / GoneSessions 同档纪律)。 - 「还有没交代完的事」收的是**现读的探针**而不是打开那一刻的快照:回收发生在别的会话被 打开的那一刻,而那正是用户可能刚在前一格打了草稿、还没切走的时候。 刻意不动的红线:W48 的 `chatViewModelKey` 拼法一个字没改(新槽位的 key 里同样带账号指纹, 两道闸门各管一段);W49 的节流判据、W50 的 ProactiveIngest 判据、W51 的 reconnectPull 判据都没碰,也没有把「连接恢复」做成整表重拉。 测试:新增 `ChatSessionEvictionTest`(14 例,穷举判据的每一条边界:N=3、最旧先走、主会话 / 屏幕上那一格 / 有草稿的豁免、豁免占名额、并列退回账本顺序、结论是带账号指纹的槽位 key) 与 `ChatSessionSlotsTest`(9 例,钉住「没被收的一格还是原来那一格(切回来状态还在)」与 「被收的一格下次拿到的是新的一格」、空槽位会被下一次回收扫掉、探针现读的效果)。 同批把已经说错的注释改成事实:GoneSessions / AppContainer.chatSocket / ChatHomeScreen / ChatScreen 里那几处「VM 活在 Activity 级 ViewModelStore 里、切走 / 删会话都不回收」。 --- .../top/yeij/cyrene/app/AppContainer.kt | 29 ++- .../core/session/ChatSessionEviction.kt | 100 ++++++++ .../cyrene/core/session/ChatSessionSlots.kt | 184 ++++++++++++++ .../yeij/cyrene/core/session/GoneSessions.kt | 16 +- .../top/yeij/cyrene/ui/chat/ChatHomeScreen.kt | 9 +- .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 59 +++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 49 +++- .../cyrene/ui/sessions/SessionsViewModel.kt | 10 + .../core/session/ChatSessionEvictionTest.kt | 230 ++++++++++++++++++ .../core/session/ChatSessionSlotsTest.kt | 144 +++++++++++ 10 files changed, 794 insertions(+), 36 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionEviction.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionSlots.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionEvictionTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionSlotsTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index d93959d..cba6061 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -6,6 +6,7 @@ import top.yeij.cyrene.core.auth.SessionStore import top.yeij.cyrene.core.prefs.AppSettings import top.yeij.cyrene.core.prefs.EnumPreferences import top.yeij.cyrene.core.prefs.SessionPreferences +import top.yeij.cyrene.core.session.ChatSessionSlots import top.yeij.cyrene.core.session.GoneSessions import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory @@ -15,6 +16,7 @@ import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.UploadApi +import top.yeij.cyrene.data.api.dto.SessionSummaryDto import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.DocumentRepository @@ -41,11 +43,25 @@ class AppContainer(context: Context) { * 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。 * * 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是 - * **每一个** `ChatViewModel`(它们各留一个常驻 VM,删会话不回收)。它拦下的是那一轮注定 - * `404 session_not_found` 的补拉 —— 理由与「为什么不持久化」见 [GoneSessions] 的类注释。 + * **每一个还活着的** `ChatViewModel`(它们在 [chatSessionSlots] 没回收之前都活着、都订阅着 + * 那条共享连接)。它拦下的是那一轮注定 `404 session_not_found` 的补拉 —— 理由与 + * 「为什么不持久化」见 [GoneSessions] 的类注释。 */ val goneSessions: GoneSessions = GoneSessions() + /** + * 会话槽位账本(**进程级**):本端为一个账号留着的聊天状态,一格一份 `ViewModelStore`。 + * + * 它把 S4 那笔技术债收在一处:`ChatViewModel` 是按会话建的,此前访问过多少个会话就有多少份 + * 常驻内存(消息列表含内联 base64 图片)与多少份对共享连接的订阅;现在最多留 + * [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,超出的按「最后一次成为当前会话」 + * 从旧到新交出去(判据是纯函数 `core.session.chatSessionEvictions`)。 + * + * 与 [goneSessions] 同一层、同一批读者(写它的是 `ChatScreen` 与 `SessionsViewModel`)。 + * 主会话 id 在这里注入,是为了让 `core.session` 那两层不认识 `ui.chat` 的那个常量。 + */ + val chatSessionSlots: ChatSessionSlots = ChatSessionSlots(SessionSummaryDto.MAIN_SESSION_ID) + private var cachedBaseUrl: String? = null private var cachedSessionsApi: SessionsApi? = null @@ -226,9 +242,12 @@ class AppContainer(context: Context) { * 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前 * `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、 * [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、 - * 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`;而 VM 活在 Activity 级 - * `ViewModelStore` 里,**切走 / 删会话都不回收**。现在这里就是唯一的持有者(`by lazy`,只此一份), - * 调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。 + * 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`。现在这里就是唯一的 + * 持有者(`by lazy`,只此一份),调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。 + * + * **每个会话那个 VM 常驻的那一半**(S4)由 [chatSessionSlots] 收:本端最多留 + * [top.yeij.cyrene.core.session.CHAT_SESSION_SLOT_KEEP] 格,多出来的整格交出去。这一条与 + * 本段说的是**两件事**:连接只有一条,而订阅它的 VM 有几格 —— 前者与本类同一层,后者在账本里。 * * ## 开与关只有一个主人 * [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、 diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionEviction.kt b/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionEviction.kt new file mode 100644 index 0000000..8cf0cbd --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionEviction.kt @@ -0,0 +1,100 @@ +package top.yeij.cyrene.core.session + +/** + * 本端**同时**为一个账号留几个会话槽位(N=3,[chatSessionEvictions] 的默认 `keep`)。 + * + * 为什么是 3(**主人拍板的口径**):它要在「切回去时那几屏东西还在」与「不留着一堆没人再看的 + * 消息列表」之间取一个数。驱动这个数的是**内存**——每个槽位留着一整页已落库消息(次级会话里可能 + * 是内联 base64 图片),而 S4 登记的正是「访问过的会话一个都不回收」。不跟着会话数走的理由也在 + * 这里:它是内存上限,不是「用户可能有多少会话」。 + */ +const val CHAT_SESSION_SLOT_KEEP = 3 + +/** + * 一格槽位在**被判定那一刻**的样子([chatSessionEvictions] 的入参,**纯值**)。 + * + * 三项都由调用方现读现喂,判据自己不看 VM、不读表、不碰时钟(先例:`ui.sessions.silentRefreshStep` + * 只回结论,账本与时钟都由调用方喂)——所以它在 JVM 上可以穷举。 + * + * @param key 槽位的稳定标识(`账号指纹|会话 id`)。它就是**结论的载体**:判据只把它原样列出来, + * 「拿这个 key 去哪里找槽位」是调用方的事。 + * @param sessionId 会话 id。**它和 [key] 不是同一件事**:key 里还带着账号指纹(换账号之后同一个 + * 会话 id 是另一格),而这一位只回答一个问题——**它是不是主会话**(主会话永不回收,见 + * [chatSessionEvictions] 第 1 条)。两样都留着是刻意的:把「主会话」这件事换个说法搬出判据, + * 它就再也钉不住了(那是本任务的红线之一)。 + * @param lastUsedAt **最后一次成为当前会话**的时刻(毫秒)。「最近」的口径就是它,理由见 + * [chatSessionEvictions]。`0` = 建出来还没打开过(组合被丢弃时留下的空槽位)。 + * @param holdsUnsentWork 这一格里还有**本端唯一一份**的内容(没发出去的草稿 / 没上路的发送)。 + * 它必须一路传到这里,是因为「回收」是一条**回不来**的动作:判据要在动手之前就能拦下 + * 「把用户还没发出去的字一起收掉」——那比「多留一格」严重得多(红线:不许静默丢内容)。 + */ +internal data class ChatSessionUsage( + val key: String, + val sessionId: String, + val lastUsedAt: Long, + val holdsUnsentWork: Boolean, +) + +/** + * 这一刻该交出去哪几格(**纯函数**,[ChatSessionSlots] 与测试共用)。返回**要回收的槽位 key**, + * 最该走的在最前(最旧的最先);没有要回收的就返回空表。 + * + * ## 「最近」的口径:最后一次**成为当前会话**的时刻 + * 三选一里选的它,理由是另外两个都答不对这个问题: + * - 「最后一次收到这个会话的帧」**不是用户的活动**:共享连接上别的会话的帧一直在来(服务端不按 + * 会话订阅),一条后台广播就能把一个用户根本没再打开过的会话顶成「最近」。 + * - 「最后一次有新消息」同理,而且是**别人**的活动(她在次级会话里说一句,本端就要为它留一格)。 + * - 「最后一次成为当前会话」才是「用户切回去时希望看到什么」这件事本身:槽位存在的意义就是 + * 那一屏不用重新加载,而**用户看得见哪个会话**只有这一个时刻能表达。 + * + * 它由 `ChatScreen` 在**组合到某个会话时**写入(那正是「它成了当前会话」这一刻),所以判据这边 + * 只需要比大小,不需要任何时钟:谁的时刻新,谁就留下。同一毫秒并列时按**账本里的先后**退回 + * ([usages] 的顺序 = 槽位被建出来的顺序,见 [ChatSessionSlots]),也就是「谁先建谁先走」。 + * + * ## 判据与优先级(自上而下,先命中先返回) + * 1. **主会话永不回收**:它是抽屉与主流程的锚——用户随时会退回它,而它那一格正是「一进 App + * 就有东西看」的来处。这一条**不看** `keep`,也不占「最近」的名额:主会话就是主会话, + * 不看它多久没被打开过。 + * 2. **屏幕上那一格永不回收**(`key == [currentKey]`):它的 VM 一死,屏幕上那一段就再也不会更新 + * (帧收不到、`viewModelScope` 已经取消 ⇒ 连发送都会静默不发),要等下一次重组才会自己修回来 + * —— 那是「回收」绝不能带来的一种状态。这一条与主会话同属「按身份豁免」,与时刻无关 + * ([currentKey] 那一格通常本来就是最新的,但不靠这件事成立)。 + * 3. **还有没交代完的事的槽位不回收**([ChatSessionUsage.holdsUnsentWork]):那些内容只在本端 + * 这一份(草稿、排队等连接的发送),回收掉就**回不来**了。这一条压过 `keep` 的语义—— + * 它宁可让本端多留一格,也不做一件不可逆的事。 + * 4. 其余按 [ChatSessionUsage.lastUsedAt] 从新到旧留 `keep` 格,**连上面三条占住的一起算**: + * `keep` 是「本端同时保留几格」这个内存目标,所以被豁免的那些**占名额**——不然「最多 3 格」 + * 在豁免生效时会变成 3 + 豁免数,那个数就没有上限了。(真实使用里豁免的那些几乎总是 + * 「刚刚还在用的」,本来就在最近 3 个里,这条只在极端情况下才真的挤掉谁。) + * + * 判据里除了这些入参什么都不看,才能穷举、才能在 JVM 上单测(先例:`ui.chat.reconnectPull`、 + * `ui.sessions.silentRefreshStep`)。 + * + * ## ⚠️ 它**只**回答「交出去哪几格」 + * 「交出去」具体是什么动作(清掉那份 `ViewModelStore`、于是这一格的 VM 随 `onCleared` 一起走) + * 是调用方的事,而且**必须**是那份 store 自己的 `clear()`:连接是全 App 共享的那一条,它的生死 + * 只由登录态表达(主人是 `SessionsViewModel`),回收一格**绝不能**碰它——回收一格的状态与 + * 「别人的流、别人的未读」是两件事,这条判据里连一个字节的连接概念都没有。 + * + * @param usages 账本里的全部槽位(顺序有意义,见「同一毫秒并列」那一句)。 + * @param mainSessionId 主会话的 id(由装配处注入,判据不认识那个常量本身)。 + * @param currentKey **此刻屏幕上**那一格的 key([ChatSessionSlots] 刚记完账的那一格)。 + * @param keep 本端**同时**保留几格(含主会话、屏幕上那一格与「还有没交代完的事」的那些), + * 默认 [CHAT_SESSION_SLOT_KEEP]。 + */ +internal fun chatSessionEvictions( + usages: List, + mainSessionId: String, + currentKey: String, + keep: Int = CHAT_SESSION_SLOT_KEEP, +): List { + val reclaimable = usages + .filter { + it.sessionId != mainSessionId && it.key != currentKey && !it.holdsUnsentWork + } + .sortedBy { it.lastUsedAt } + val budget = keep - (usages.size - reclaimable.size) + val surplus = reclaimable.size - budget + if (surplus <= 0) return emptyList() + return reclaimable.take(surplus).map { it.key } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionSlots.kt b/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionSlots.kt new file mode 100644 index 0000000..d15fcc1 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/session/ChatSessionSlots.kt @@ -0,0 +1,184 @@ +package top.yeij.cyrene.core.session + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +/** + * 本端留着的**会话槽位**(`账号指纹|会话 id` → 那一格的聊天状态)。S4 的解药。 + * + * ## 它回答的是哪一个问题 + * `ChatViewModel` 是**按会话**建的(`sessionId` 是构造参数),于是「切走 / 删会话都不回收」意味着 + * 访问过 N 个会话就常驻 N 份消息列表(含内联 base64 图片)、N 份对共享连接的订阅、以及**每次重连 + * 各自一轮** `after` 补拉。这里就是那个「N」的闸门:**一格一个 `ViewModelStore`**,超出的按 + * [chatSessionEvictions] 的结论交出整格。 + * + * ## 为什么一格一个 `ViewModelStore`(而不是给 key 加一代号) + * 回收的本体动作是「让那一格的 VM 真正死掉」:`ViewModelStore.clear()` 会走 `ViewModel.onCleared()` + * 并取消 `viewModelScope`——于是**订阅、补拉、正在飞的那些请求一起停下**。此前每一格都塞在 + * Activity 级那个 store 里,而它**只有整体 `clear()`**,没有「按 key 交出一格」的入口:那种情况下 + * 只能让 key 换一代、把旧实例**留在 store 里**(它照样活着、照样收帧——那正是 S4 的另一半没修掉 + * 的样子)。一格一份 store 之后,`clear()` 的粒度就正好是「这一格」。 + * + * ## 回收**不碰**共享连接 + * 连接是全 App 唯一的那一条(`AppContainer.chatSocket`),它的生死只由登录态表达 + * (主人是 `SessionsViewModel`)。本类碰的只有「自己那一格的 store」:掐掉的是这一格的订阅, + * 别的会话的流、别的会话的未读、以及那条连接本身一个字节都不动。 + * + * ## 谁碰它、什么时候(全部在主线程上) + * - **`ChatScreen`**:组合到某个会话时 [slotFor] 取(或建)槽位——VM 必须有地方建,而槽位就是 + * 那一格的存放处,所以这一步没法挪到组合之后;组合完成之后 [onOpened] 记账 + 回收。 + * - **`SessionsViewModel`**:本端**唯一**的删除入口,删成功后 [reclaim](那一格跟着会话一起走); + * 登出 / 换账号时 [clear](与快照 / 已读游标 / [GoneSessions] 同一档:换代之后本端什么都不知道)。 + * - 三者都在主线程上(组合、`viewModelScope` 的默认派发器),所以账本本身不加锁——它的每一个 + * 读写点都在同一个线程上。 + * + * ## 为什么不持久化 + * 槽位里装的是**进程内存里的状态**(消息列表、草稿、流式缓冲),它没有可以落盘的形式,而它的 + * 内容全部另有来源(服务端的 `GET /messages` 那一页 + 服务端的幂等键)。重启之后本端重新拉, + * 与 [GoneSessions] 「不持久化」是同一个理由。 + */ +class ChatSessionSlots(private val mainSessionId: String) { + + /** + * 账本。用 `LinkedHashMap` 只为一件事:**同一毫秒并列**时 [chatSessionEvictions] 的稳定排序会 + * 退回这个插入顺序(先建出来的先走)。除此之外顺序不表达任何东西——「谁最近」一律看 + * [ChatSessionSlot] 里的 `lastUsedAt`。 + */ + private val slots = linkedMapOf() + + /** + * 取这个会话的槽位(没有就建一个)。**组合期间**调用。 + * + * 它**不记账**:谁最近用过由 [onOpened] 写。所以就算这一段组合被丢弃(组合没走完),留下的也 + * 只是一个 `lastUsedAt` 还是 `0` 的空槽位,下一次回收就会把它扫掉(它必然是最旧的那一格)。 + */ + fun slotFor(account: String, sessionId: String): ChatSessionSlot = + slots.getOrPut(keyOf(account, sessionId)) { ChatSessionSlot(sessionId) } + + /** + * 这个会话**刚刚成为当前会话**:记下它是「最近」的那一格,然后按 [chatSessionEvictions] 回收。 + * + * 记账与回收在同一处,是因为回收的判据就是这份账本本身(先例:`ui.sessions.silentRefreshStep` + * 把「该做什么」与「账本走成什么样」一起回):拆开就会给出「判了却忘了推进账本」的机会, + * 而那正好会让最该留下的那一格被当成最旧的收掉。同一处也才拿得到那个 key —— 判据要 + * **按身份**豁免的就是刚记完账这一格(`chatSessionEvictions` 的 `currentKey`)。 + * + * @param holdsUnsentWork 现读的一格真相(见 [ChatSessionUsage.holdsUnsentWork])。收的是一个 + * **探针**而不是一个布尔快照,是因为回收发生在**别的**会话被打开的那一刻 —— 而那一刻正是 + * 用户可能刚在前一个会话里打了草稿、还没切走的时候。快照会把它读成「干净」,把没发出去的 + * 字一起回收掉(那是一条回不来的动作)。 + */ + fun onOpened( + account: String, + sessionId: String, + holdsUnsentWork: () -> Boolean, + nowMillis: Long, + ) { + val key = keyOf(account, sessionId) + slotFor(account, sessionId).opened(holdsUnsentWork, nowMillis) + sweep(currentKey = key) + } + + /** + * 定点回收一格(删会话):把那份 store 交出去,那个 VM 随 `onCleared` 一起走,本类不再引用它。 + * + * 与 [GoneSessions] 的关系:那是「本端已知它没了」这件事的**第一半**(让还活着的 VM 别再去发 + * 注定 404 的补拉),这里是**第二半**(那一格本来就该跟着会话一起消失)。两半分开写在两处, + * 是因为它们回答的问题不同——一个答「还值不值得发请求」,一个答「还留不留这一格状态」, + * 而后者要动 VM 的生命周期,不该被前者拽住(`ui.chat.reconnectPull` 只读前者)。 + */ + fun reclaim(account: String, sessionId: String) { + slots.remove(keyOf(account, sessionId))?.clear() + } + + /** + * 换账号 / 登出:本端**一格都不留**。 + * + * 与快照 / 已读游标 / [GoneSessions] 是同一档纪律(`SessionsViewModel` 的登出分支一处清完): + * 槽位里的东西全部属于**上一个环境**,留着只会在下一次登录后变成一份没人认领的常驻内存 + * (而它带着的那个 VM 还在订阅连接、还会在重连时补拉)。这一条也顺带把 [ChatSessionSlot] 的 + * 那条「主会话永不回收」的例外收干净:换账号时连主会话那一格也不留。 + * + * 代价说清楚:登出(含令牌失效那条路径)之后**本端未发出的草稿会跟着没了**。这与既有口径一致 + * ——登出时快照、已读游标、已删会话的结论都是当场清掉的(那些也都没落盘)。 + */ + fun clear() { + slots.values.forEach { it.clear() } + slots.clear() + } + + /** + * 按 [chatSessionEvictions] 的结论交出超出的那几格(「最近」的口径见那个函数的 KDoc)。 + * + * @param currentKey 刚记完账、此刻在屏幕上那一格的 key(它按身份豁免,见判据第 2 条)。 + */ + private fun sweep(currentKey: String) { + val usages = slots.map { (key, slot) -> slot.usage(key) } + chatSessionEvictions(usages, mainSessionId, currentKey) + .forEach { key -> slots.remove(key)?.clear() } + } + + /** 与聊天页的 VM key 无关:这是**槽位**的身份,账号指纹在里面是为了换账号时天然分格。 */ + private fun keyOf(account: String, sessionId: String): String = "$account|$sessionId" +} + +/** + * 一个会话的槽位:**它自己的那份 `ViewModelStore`**,外加这项回收策略要的两个记账位。 + * + * 装在这里的 VM 由 `ChatScreen` 经它取出(`viewModel(viewModelStoreOwner = 槽位, key = <账号指纹|会话 id>)`): + * key 仍然是 `chatViewModelKey` 那一份口径,只是现在**配着一份按会话分的 store** 用。 + * + * 它**不持有** VM 的类型:本类只知道「怎么把这一格交出去」([clear])与「它还有没有没交代完的事」 + * ([opened] 收下的那个探针)。这样 `core.session` 不必认识 `ui.chat` 的任何类型,而两件事 + * ——「槽位怎么算最近」与「VM 里哪一位算没交代完」——各自待在自己那一层。 + */ +class ChatSessionSlot internal constructor( + /** 本槽位的会话 id(槽位身份的一半:账号指纹在 [ChatSessionSlots] 的账本 key 里)。 */ + val sessionId: String, +) : ViewModelStoreOwner { + + private val store = ViewModelStore() + + override val viewModelStore: ViewModelStore + get() = store + + /** + * 最后一次成为当前会话的时刻(毫秒);`0` = 建出来还没打开过。**只增不改**。 + * + * 由 `ChatSessionSlots.onOpened` 写(那里是「它成了当前会话」的唯一表达处),本类不自己读时钟。 + * 不进 [usage] 以外的任何地方——「最近」的判据只认它,而判据只吃 [ChatSessionUsage]。 + */ + private var lastUsedAt: Long = 0L + + /** + * 这一格里「还有没有没交代完的事」的**现读**探针(见 [ChatSessionSlot.opened] 的说明)。 + * 默认恒 `false`:还没打开过的一格当然没有。 + */ + private var holdsUnsentWork: () -> Boolean = { false } + + /** 这一格成了当前会话:记下该问谁「还有没有没交代完的事」,并更新「最近」这一位。 */ + internal fun opened(holdsUnsentWork: () -> Boolean, nowMillis: Long) { + this.holdsUnsentWork = holdsUnsentWork + lastUsedAt = nowMillis + } + + /** 交给判据的样子(判据不认识本类,见 [ChatSessionUsage])。 */ + internal fun usage(key: String): ChatSessionUsage = ChatSessionUsage( + key = key, + sessionId = sessionId, + lastUsedAt = lastUsedAt, + holdsUnsentWork = holdsUnsentWork(), + ) + + /** + * 交出这一格:`ViewModelStore.clear()` ⇒ 那一格的 `ViewModel.onCleared()` + `viewModelScope` + * 取消(订阅、补拉、在飞的请求一起停),随后**松开探针**。 + * + * 松开探针不能省:它捕获着那个 VM(否则就成了一份「已经交出去、却还被人拎着」的引用, + * 那一格的消息列表会跟着这条引用活到进程结束——正是本类要断掉的那根线)。 + */ + internal fun clear() { + store.clear() + holdsUnsentWork = { false } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt b/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt index a987186..2482cba 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/session/GoneSessions.kt @@ -7,16 +7,21 @@ import kotlinx.coroutines.flow.update * 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。 * * ## 它回答的是哪一个问题 - * 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 每个访问过的会话各留一个常驻 VM - * (切走 / 删会话都不回收),而它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉一轮**。会话 - * 在本端被删掉之后,那个 VM 并不知道(它没有别的渠道知道),于是**每一个**重连都发一次注定 - * `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。 + * 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 是按会话建的,本端**同时**留着其中几格 + * (W52A 起是 `ChatSessionSlots` 的 N 格),它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉 + * 一轮**。会话在本端被删掉之后,那个 VM 自己并不知道(它没有别的渠道知道),于是**每一个**重连 + * 都发一次注定 `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。 * * 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了, * 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」 * 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求 * 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。 * + * W52A 又给这件事加了一半:删会话时那一格**当场就被回收**(`ChatSessionSlots.reclaim`),所以 + * 大多数情况下这里拦的已经是一个不会再发生的补拉。它仍然留着,因为两件事回答的不是一个问题 + * ——这一位答「还值不值得发请求」,槽位答「还留不留这一格状态」,而**本端删掉一个会话**与 + * **那个会话在别处被删掉**(只能靠 404 发现)是两条不同的路,后者压根不经过这里。 + * * ## 为什么是进程级、为什么**不**持久化 * - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel` * (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。 @@ -25,7 +30,8 @@ import kotlinx.coroutines.flow.update * 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。 * - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端 * 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符 - * 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。 + * 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。同一处也清聊天页的槽位 + * (`ChatSessionSlots.clear`),两件事属同一档纪律。 */ class GoneSessions { diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt index 56d807c..04a4499 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatHomeScreen.kt @@ -28,11 +28,10 @@ private const val SESSION_GONE_NOTICE_MILLIS = 8_000L * 会话在别处被删掉时那句如实告知。 * * 它必须由**这一层**持有,不能挂在 `ChatViewModel` 上:`onSessionGone` 一被调用, - * `activeSessionId` 就会被收敛回主会话,ChatScreen 的 VM key(含会话 id)随之变化 —— - * 变的是 key,于是**承载横幅的那段组合被销毁重建**;那个 VM 实例本身住在 Activity 级的 - * ViewModelStore 里,**仍然活着、继续收帧**(连登出都不回收它,见 `chatViewModelKey` 的 KDoc)。 - * 横幅挂在 VM 状态上的话活不过这一帧、一个字都来不及显示(真机实测过)。 - * 这一层的 `remember` 活得比它久。 + * `activeSessionId` 就会被收敛回主会话,ChatScreen 的槽位(与 VM key 里的会话 id)随之变化 —— + * 于是**承载横幅的那段组合被销毁重建**;而那个 VM 这会儿正被交出去(本端删除那条路走 + * `SessionsViewModel.deleteSession` 里的 `ChatSessionSlots.reclaim`,别处删除这条走 404 收敛), + * 挂在它状态上的横幅连这一帧都活不过。这一层的 `remember` 活得比它久。 */ private const val SESSION_GONE_MESSAGE = "这个会话在服务端已经不存在了(可能已在别处删除),已切回主会话" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index 6b08fe7..2b58832 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.withContext import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.core.prefs.accountFingerprint +import top.yeij.cyrene.core.session.ChatSessionSlots import top.yeij.cyrene.data.repository.DocumentCacheStore import top.yeij.cyrene.data.repository.ImageCacheStore import top.yeij.cyrene.data.ws.WsState @@ -112,11 +113,15 @@ private val InputBottomSpacer = 22.dp /** * 聊天页那个 [ChatViewModel] 的 key(纯函数):**账号指纹 + 会话 id**。 * - * `viewModel(key = ...)` 的 owner 是 **Activity 级的 ViewModelStore**,而登出只把本组合移出、 - * **不清理那个 store**([ChatViewModel.onCleared] 是空的,全仓也没有 `ViewModelStore.clear()`)。 - * key 里只放 sessionId 的话,换账号/换服务器再进来时,`viewModel()` 会把**上一个环境** - * 那个还活着的实例原样取回来——它会继续收那个环境的帧,屏幕上摆的就是别的服务器的消息。 - * 指纹进 key 之后,环境一变就取到新实例;同一个环境里切会话仍然各留一份状态(key 未变)。 + * 它现在是**一格槽位之内**的名字(owner 是那一格自己的 `ViewModelStore`,见 [ChatSessionSlots]), + * 但它要挡的那件事一个字没变:owner 换了(换账号 / 换服务器之后再进来)而 key 不变的话, + * `viewModel()` 会把**上一个环境**那个还活着的实例原样取回来——它会继续收那个环境的帧, + * 屏幕上摆的就是别的服务器的消息。所以指纹**必须**在里面。 + * + * 两道闸门现在是**各管一段**的:账号指纹进**槽位**的 key(`ChatSessionSlots.keyOf`,换账号天然 + * 分格),同一个指纹再进**这一格 store 里**的 key(本函数)——同一格 store 只会装一个 VM, + * 但两处都写着同一份口径,任何一处将来被单独改掉都还挡得住(先例:`SessionPreferences` 的快照 + * 与 `SessionsViewModel` 的 `pulledForIdentity` 也是同一份指纹的两处用法)。 * * [accountFingerprint] 与 [SessionPreferences] / `SessionsViewModel` 是**同一份**口径: * 别在这里另拼一套字符串——两套判据早晚会分叉,而分叉的那一侧就是漏出去的那一侧。 @@ -163,28 +168,54 @@ fun ChatScreen( onSessionGone: () -> Unit = {}, sessionNotice: String? = null, ) { - // 账号指纹:**必须进下面那两个 VM 的 key**。登出只是把本组合移出(见 CyreneApp 的 when), - // 而这两个 VM 住在 Activity 级的 ViewModelStore 里、**不会随登出回收**——key 里不带指纹的话, - // 换账号/换服务器再进来会按同一个 key 取回上一个环境那个还活着的实例,屏幕上摆的就是 - // 别的服务器的消息。`remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建), - // 所以不会拿着上一份指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。 + // 账号指纹:**必须进下面那两个 VM 的 key**(聊天那个 VM 还要进它那一格槽位的 key,见 + // `ChatSessionSlots.keyOf`)。登出只是把本组合移出(见 CyreneApp 的 when),key 里不带指纹的话, + // 换账号/换服务器再进来时 `viewModel()` 会把**上一个环境**那个还活着的实例原样取回来,屏幕上 + // 摆的就是别的服务器的消息(下面那个搜索 VM 就是这样:它没有人回收,只靠 key 分开)。 + // `remember` 每次进入本组合重算一次(换环境必经登出 ⇒ 本组合被销毁重建),所以不会拿着上一份 + // 指纹;不在每次重组时读加密存储是因为流式期间重组非常频繁。 val account = remember { accountFingerprint(container.authManager.currentBaseUrl, container.sessionStore.load().username) } + // 这个会话的**槽位**:这一格的聊天状态住在这里面那份 `ViewModelStore` 里,所以它能被**单独** + // 回收(S4;此前每格都塞在 Activity 级那一个 store 里,而它只有整体 clear,收回一格的办法 + // 只能是「让 key 换一代、旧实例继续留着」——那正是 S4 没修掉的那一半)。 + // + // 组合期间只做这一件与账本有关的事:取(或建)槽位。VM 必须在组合里就拿出来,而槽位就是它的 + // 存放处,所以这一步没法挪到组合之后;「谁最近」与「超了要不要收」都在下面那个 effect 里。 + val sessionSlot = remember(account, sessionId) { + container.chatSessionSlots.slotFor(account, sessionId) + } val viewModel: ChatViewModel = viewModel( + viewModelStoreOwner = sessionSlot, key = chatViewModelKey(account, sessionId), factory = viewModelFactory { initializer { ChatViewModel(container, sessionId) } }, ) val state by viewModel.state.collectAsState() val listState = rememberLazyListState() + // 这个会话**刚刚成为当前会话**:这是「最近」的唯一表达处(口径见 `chatSessionEvictions`), + // 顺手按账本回收超出的格数。放在组合之后是刻意的——槽位可能刚在这一帧建出来,而账本不该在 + // 组合期间被改动(组合被丢弃时不该留下「它被打开过」的结论)。 + // + // `holdsUnsentWork` 收的是**现读的探针**:回收发生在别的会话被打开的那一刻,而那正是用户 + // 可能刚在前一个会话里打了草稿、还没切走的时候(理由写在 `ChatSessionUsage`)。 + LaunchedEffect(account, sessionId) { + container.chatSessionSlots.onOpened( + account = account, + sessionId = sessionId, + holdsUnsentWork = { viewModel.holdsUnsentWork }, + nowMillis = System.currentTimeMillis(), + ) + } + // 这个会话在服务端已经没了(`404 session_not_found`,契约 v0.63):交给上层把用户带回主会话。 // 只有**非主会话**会置这个位(主会话回 404 说明部署出了问题,那时该显示的是普通错误横幅), // 所以这里不会出现「已经主会话还往上跳一次」的回环。 // // ⚠️ **那句解释不能挂在这个 VM 上**:`onSessionGone` 一被调用,上层就会把 activeSessionId 收敛回 - // 主会话,VM key 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**。(变的是 key, - // 不是 VM:那个实例仍在 Activity 级的 ViewModelStore 里活着、继续收帧——见上面 key 的 KDoc。) + // 主会话,VM 里的会话 id 随之变化 ⇒ **承载它的那段组合被销毁重建**(换的是槽位与 key,不是 + // 那一格 store 本身:它这会儿正被上一层的删除路径 `ChatSessionSlots.reclaim` 交出去)。 // 挂在 VM 状态上的横幅活不过这一帧,一个字都来不及显示(真机实测过:切回主会话干净利落, // 用户完全不知道为什么)。所以文案由 [sessionNotice] 从上层注入(那个状态活得比本 VM 久)。 LaunchedEffect(state.sessionGone) { @@ -372,8 +403,8 @@ fun ChatScreen( // **不可恢复**的 `Offline` 那一档时说 [SHARE_OFFLINE_NOTICE](「重新登录 / 检查服务器地址」)—— // 那一档不会自动重连,「连上后自动发出」等的是一个永远不会到来的连接。哪档配哪句是纯函数 // [shareWaitingNotice],两档的落地时机一模一样。 - // - **只有当前会话的 ChatScreen 在组合**:已访问过的会话的 VM 都还活着(Activity 级 - // ViewModelStore),由 UI 层认领才不会让旧会话把内容抢走。 + // - **只有当前会话的 ChatScreen 在组合**:别的会话的 VM 也可能还活着(各自那一格槽位里, + // 见 `ChatSessionSlots`),由 UI 层认领才不会让旧会话把内容抢走。 // - [ShareIntake.take] 是原子出队:同一条内容不会被发两次。重组 / 切会话 / 连接变化都会重跑这个 // effect,哪怕其中一次拿着**已经过期的**快照进来,`take()` 给出的也只是队列里的下一条或 null。 val pendingShares by ShareIntake.pending.collectAsState() diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index c40a59d..2bdf873 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -801,6 +801,34 @@ class ChatViewModel( private val _state = MutableStateFlow(ChatUiState()) val state: StateFlow = _state.asStateFlow() + /** + * 这一格里还有**本端唯一一份**、回收掉就回不来的东西:草稿(正文 / 图 / 附件)与还没上路的发送。 + * + * 它只服务一件事:`core.session.ChatSessionSlots` 回收一格之前**先问这一位**(判据的入参 + * `ChatSessionUsage.holdsUnsentWork`)——「最多留几格」是内存目标,而「用户打了还没发出去的字」 + * 是一条回不来的动作,后者压过前者。 + * + * 三个来源都在 [ChatUiState] 里,口径就是它们的原义: + * - [ChatUiState.draft] / [ChatUiState.draftImages] / [ChatUiState.draftDocuments]:输入栏里 + * 还没发出去的东西(发送失败会**退回**这几个字段,所以「失败等着重发」也算在这里); + * - [ChatUiState.pending]:已经上屏、正在投递(或正等着补发)的那几条——它们的正文/图片/附件 + * **只**存在这份状态里,而投递那一段协程会随这一格一起被取消; + * - [ChatUiState.queuedForConnection]:一次都还没上过路、在等连接的那几条(内容挂在 `pending` + * 那条气泡上,两者同生共死;这里两样都写,是为了将来只改其中一处时仍然拦得住)。 + * + * 流式中的回复([ChatUiState.streams])**刻意不算**:那是服务端正在生成的那一份的本地投影, + * 本端这一格没了那一轮照旧跑完、照旧落库,重进会话会重新拉到;它也不具备「本端唯一」这条性质 + * (而它正是「回收能省下多少」的大头之一,把它算进来等于让回收在这最该省的时候不动手)。 + */ + val holdsUnsentWork: Boolean + get() = _state.value.let { + it.draft.isNotBlank() || + it.draftImages.isNotEmpty() || + it.draftDocuments.isNotEmpty() || + it.pending.isNotEmpty() || + it.queuedForConnection.isNotEmpty() + } + private var socket: ChatSocket? = null private var repository: MessageRepository? = null private var oldestLoadedAt: Long? = null @@ -921,8 +949,11 @@ class ChatViewModel( * 出去」这种情形,那正是要静默的那一种。 * * 落在 VM 实例字段上:VM 是按会话建的(`sessionId` 是构造参数),所以这一格天然**按会话**, - * 切走会话就随 VM 一起没了。**不做全局单例**:「这条会话的这批消息连着几次没发出去」与别的 - * 会话毫无关系,共用一个计数器只会让一个会话的反复失败把另一个会话的告知也掐掉。 + * 切走会话之后它仍在这**一格**里(W52A 起本端同时留几格,见 `ChatSessionSlots`),那一格真被 + * 回收(或登出)时才随 VM 一起没了——而**队列里还有东西的格子不会被回收**(判据的入参 + * [holdsUnsentWork]),所以这套记账不会在「还没发出去」的时候被回收顺手抹掉。 + * **不做全局单例**:「这条会话的这批消息连着几次没发出去」与别的会话毫无关系,共用一个计数器 + * 只会让一个会话的反复失败把另一个会话的告知也掐掉。 * * ## 计数与清零(主人 2026-10-06 裁决:**按事故复位**) * - `+1`:补发**没能上路、内容仍留在队列里**([drainQueuedSends])——只有这种会反复发生。 @@ -965,8 +996,8 @@ class ChatViewModel( // 连接是**进程内共享**的那一条(`AppContainer.chatSocket`),本 VM 只订阅它。开与关归 // 「登录态」那条路径(唯一主人是 `SessionsViewModel`,见它的类注释):`connect()` 虽然幂等, - // 但主人只有一个——本 VM 的 key 一换(切会话 / 换账号)就只是另起一个实例,旧实例仍留在 - // Activity 级的 `ViewModelStore` 里,让它插手就等于让一个屏幕的生死去掐全 App 的连接。 + // 但主人只有一个——本 VM 只是**订阅者之一**,而且随时可能被 `ChatSessionSlots` 交出去 + // (LRU 回收 / 删会话 / 登出),让它插手就等于让**一格的生死**去掐全 App 的连接。 val ws = container.chatSocket socket = ws viewModelScope.launch { ws.state.collect(::onConnectionChanged) } @@ -3001,10 +3032,14 @@ class ChatViewModel( /** * **刻意不关连接**:`socket` 是**全 App 共享**的那一条(`AppContainer.chatSocket`),而本 VM - * 并不随切会话 / 登出消失——它的 key 一换只是另起一个实例,旧实例仍留在 Activity 级的 - * `ViewModelStore` 里(见 `chatViewModelKey` 的 KDoc),`onCleared` 要等整个 Activity 结束才来。 - * 在 `onCleared` 里 close 会连抽屉的未读、管理页的三帧、别的会话的流式一起掐断——连接的 + * 会随**这一格**被交出去(`ChatSessionSlots` 的 LRU 回收 / 删会话 / 登出)——`onCleared` 因此 + * 是**真会来**的(W52A 之前它要等整个 Activity 结束)。在 `onCleared` 里 close 会连抽屉的未读、 + * 管理页的三帧、别的会话的流式一起掐断,而且现在**每一次回收**都会掐一次:连接的 * 开与关只由**登录态**表达(主人是 `SessionsViewModel`,见它的类注释)。 + * + * 反过来说,`onCleared` 一到就说明这一格的订阅要停了——这正是 S4 要的那一半(回收之后它不再 + * 收帧、不再在重连时自己发一轮补拉)。取消 `viewModelScope` 由 `ViewModelStore.clear()` 做, + * 这里不需要额外动作。 */ override fun onCleared() { super.onCleared() diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt index 2dc2f0e..b8ce266 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/sessions/SessionsViewModel.kt @@ -173,6 +173,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { // 「本端已知已删」也一样作废:它说的是**上一个环境**里那些 id 的结论, // 换账号之后本端什么都不知道(与快照 / 已读游标同一档,见 GoneSessions 的类注释)。 container.goneSessions.clear() + // 聊天页的槽位(W52A)同样一格不留:它们里面是上一个环境的消息列表与草稿, + // 留着就是一份没人认领的常驻内存(而那一格里的 VM 还在订阅连接、还会在重连时 + // 补拉)。这一条把 `ChatSessionSlots` 那条「主会话永不回收」的例外也一起收干净 + // ——登出连主会话那一格也不留(代价:没发出去的草稿跟着没了,与上面几项同档)。 + container.chatSessionSlots.clear() // 手里这份列表与它的节流账本都属于上一个身份,一起作废:留着账本会让 // 重登后那一次必要的重拉被上一轮的窗口挡住;留着补拉任务会让它带着一个 // 已经没有令牌的身份去发请求(必然 401,还会在抽屉里挂一行错误)。 @@ -394,6 +399,11 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() { // 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知 // (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。 container.goneSessions.mark(sessionId) + // 同一件事的另一半(W52A):把那一格**交出去**。上面那一笔只让「还活着的 VM」 + // 别再发补拉,而这一格本来就该跟着会话一起消失 —— 它的消息列表(含内联 base64 + // 图片)没有任何理由继续占着内存。两半都在这里,是因为这里是本端**唯一**的删除 + // 入口(别处删掉的会话只能靠 404 那一档发现,那一档的收敛在 VM 自己身上)。 + container.chatSessionSlots.reclaim(fingerprint(), sessionId) if (_state.value.activeSessionId == sessionId) { select(SessionSummaryDto.MAIN_SESSION_ID) } diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionEvictionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionEvictionTest.kt new file mode 100644 index 0000000..0f5ca49 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionEvictionTest.kt @@ -0,0 +1,230 @@ +package top.yeij.cyrene.core.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「留最近几格、其余交出去」这条判据([chatSessionEvictions])的口径。 + * + * 钉住四件事,它们各自都是一条会出事的边界: + * 1. **「最近」的口径是 `lastUsedAt`**(最后一次成为当前会话)——谁新谁留下,最旧的先走; + * 2. **主会话与屏幕上那一格永不回收**,而且它们**占** `keep` 的名额(`keep` 是「本端同时留几格」 + * 的内存目标,不是「除它们之外再留几格」); + * 3. **还有没交代完的事的格子不回收**(草稿 / 排队等连接的发送:回收=内容回不来),同样占名额 + * ——不然「最多 3 格」在豁免生效时会变成「3 + 豁免数」,那个数就没有上限了; + * 4. 结论是**槽位的 key**(账号指纹 + 会话 id),不是会话 id:同一个会话 id 在两个账号下是两格。 + * + * 每个用例都把 `currentKey` 显式指出来,取值按真实情形(屏幕上那一格就是刚刚打开的那一格)。 + */ +class ChatSessionEvictionTest { + + private val main = "web" + + /** 一个槽位的样子;[sessionId] 单列是刻意的(key 里另带着账号指纹,见 [ChatSessionUsage])。 */ + private fun usage( + sessionId: String, + at: Long, + unsent: Boolean = false, + account: String = "acct", + ) = ChatSessionUsage( + key = "$account|$sessionId", + sessionId = sessionId, + lastUsedAt = at, + holdsUnsentWork = unsent, + ) + + /** 账本 key(免得每个断言里手拼 `acct|` 前缀时写错一处)。 */ + private fun key(sessionId: String, account: String = "acct") = "$account|$sessionId" + + @Test + fun `the owner decision is three slots`() { + // N=3 是主人拍板的口径,不是随手取的一个数(改动它会同时改掉本端常驻的内存上限)。 + assertEquals(3, CHAT_SESSION_SLOT_KEEP) + } + + @Test + fun `nothing is reclaimed while the budget is not exceeded`() { + // 主会话 + 两个次级会话 = 正好 3 格。它是最常见的形态,也是「什么都不该发生」的那一档。 + val evicted = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3)), + mainSessionId = main, + currentKey = key("web:b"), + ) + assertTrue(evicted.isEmpty()) + } + + @Test + fun `an empty ledger reclaims nothing`() { + assertTrue( + chatSessionEvictions(emptyList(), mainSessionId = main, currentKey = key(main)).isEmpty(), + ) + } + + @Test + fun `the oldest slots go first and the verdict lists them oldest first`() { + // 主会话(最旧)+ 三个次级会话:名额 3 里主会话与屏幕上那一格各占一个 ⇒ 只能再留 1 个 + // ⇒ 最旧的那个走。(屏幕上那一格通常就是最新的那个,见下面的用例。) + val evicted = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4)), + mainSessionId = main, + currentKey = key("web:c"), + ) + assertEquals(listOf(key("web:a")), evicted) + + // 再打开一个新会话 ⇒ 又该走一个,而且结论里**先列最旧的**(调用方按这个顺序交出去)。 + val more = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4), usage("web:d", 5)), + mainSessionId = main, + currentKey = key("web:d"), + ) + assertEquals(listOf(key("web:a"), key("web:b")), more) + } + + @Test + fun `a slot that was never opened is the first to go`() { + // lastUsedAt = 0 是「建出来还没打开过」的空槽位(组合被丢弃时留下的那种):它必然最旧。 + val evicted = chatSessionEvictions( + listOf(usage(main, 10), usage("web:abandoned", 0), usage("web:a", 11), usage("web:b", 12)), + mainSessionId = main, + currentKey = key("web:b"), + ) + assertEquals(listOf(key("web:abandoned")), evicted) + } + + @Test + fun `the main session is never reclaimed even when it is the oldest`() { + val evicted = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4), usage("web:d", 5)), + mainSessionId = main, + currentKey = key("web:d"), + ) + // 主会话是最旧的那一格,但它一个字节都不许出现在结论里(名额照旧被它占着)。 + assertTrue(main !in evicted.map { it.substringAfter('|') }) + assertEquals(listOf(key("web:a"), key("web:b")), evicted) + } + + @Test + fun `the session on screen is never reclaimed`() { + // 屏幕上那一格**可能**是账本里最旧的一格(它刚被打开、而别的格子刚收过心跳;或者时钟跳过)。 + // 它照样不能收:收掉它 = 正在看的那一段从此不再更新(帧收不到、`viewModelScope` 已取消 + // ⇒ 连发送都会静默不发),要等下一次重组才自己修回来。 + val evicted = chatSessionEvictions( + listOf( + usage(main, 1), + usage("web:on-screen", 2), + usage("web:a", 3), + usage("web:b", 4), + usage("web:c", 5), + ), + mainSessionId = main, + currentKey = key("web:on-screen"), + ) + assertEquals(listOf(key("web:a"), key("web:b")), evicted) + } + + @Test + fun `a session holding unsent work is never reclaimed`() { + // a 是最旧的次级会话,但它手里有还没发出去的字(草稿 / 排队等连接的发送)——回收是 + // 一条回不来的动作,所以该走的是**下一个**最旧的。 + val evicted = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2, unsent = true), usage("web:b", 3), usage("web:c", 4)), + mainSessionId = main, + currentKey = key("web:c"), + ) + assertEquals(listOf(key("web:b")), evicted) + } + + @Test + fun `a protected slot still eats one place of the budget`() { + // 主会话 + 两格有草稿 + 屏幕上那一格,就已经把「同时留 3 格」用掉了 ⇒ **干净的**那一格 + // 才是该走的(豁免压过 keep,但豁免不会凭空多给名额)。 + val evicted = chatSessionEvictions( + listOf( + usage(main, 1), + usage("web:a", 2, unsent = true), + usage("web:b", 3, unsent = true), + usage("web:c", 4), + usage("web:on-screen", 5), + ), + mainSessionId = main, + currentKey = key("web:on-screen"), + ) + assertEquals(listOf(key("web:c")), evicted) + } + + @Test + fun `keep counts the protected slots, so a tight budget reclaims every reclaimable one`() { + // keep = 1 而主会话与屏幕上那一格各占一个名额 ⇒ 可回收的全部走 + //(`take` 的越界不报错,也不多收)。 + val evicted = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3)), + mainSessionId = main, + currentKey = key("web:b"), + keep = 1, + ) + assertEquals(listOf(key("web:a")), evicted) + } + + @Test + fun `the default budget is the owner caliber`() { + val usages = listOf(usage(main, 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4)) + assertEquals( + chatSessionEvictions(usages, main, key("web:c"), keep = CHAT_SESSION_SLOT_KEEP), + chatSessionEvictions(usages, main, key("web:c")), + ) + } + + @Test + fun `ties fall back to the ledger order`() { + // 同一毫秒并列(真机上切会话在同一毫秒内发生):退回账本顺序(先建出来的先走), + // 于是判据对同一份账本永远给同一个结论——不会在两次打开之间随机决定收谁。 + val first = chatSessionEvictions( + listOf(usage(main, 1), usage("web:a", 5), usage("web:b", 5), usage("web:c", 6)), + mainSessionId = main, + currentKey = key("web:c"), + ) + assertEquals(listOf(key("web:a")), first) + + val flipped = chatSessionEvictions( + listOf(usage(main, 1), usage("web:b", 5), usage("web:a", 5), usage("web:c", 6)), + mainSessionId = main, + currentKey = key("web:c"), + ) + assertEquals(listOf(key("web:b")), flipped) + } + + @Test + fun `the verdict carries the slot key so two accounts never share a slot`() { + // 换账号之后同一个会话 id 是**另一格**(key 里的账号指纹分得开),而两格的 sessionId + // 都是主会话 ⇒ 两格都受「主会话永不回收」保护,能收的只有次级会话。 + val usages = listOf( + usage(main, 1, account = "A"), + usage("web:s1", 2, account = "A"), + usage(main, 3, account = "B"), + usage("web:s1", 4, account = "B"), + usage("web:s2", 5, account = "B"), + ) + assertEquals( + listOf(key("web:s1", "A"), key("web:s1", "B")), + chatSessionEvictions(usages, mainSessionId = main, currentKey = key("web:s2", "B")), + ) + } + + @Test + fun `the main session id is what the caller says it is`() { + // 判据不认识那个常量本身(主会话 id 由装配处注入):换成别的 id 时口径跟着换, + // 说明它比的是**入参**,而不是某处写死的 "web"。 + val usages = listOf(usage("web", 1), usage("web:a", 2), usage("web:b", 3), usage("web:c", 4)) + // 换成别的 id 之后,最旧的 `web` 那一格就不再受保护了(它是第一个该走的)。 + assertEquals( + listOf(key("web")), + chatSessionEvictions(usages, mainSessionId = "something-else", currentKey = key("web:c")), + ) + // 而按真主会话的口径比,它一个字节都不许出现在结论里。 + assertEquals( + listOf(key("web:a")), + chatSessionEvictions(usages, mainSessionId = main, currentKey = key("web:c")), + ) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionSlotsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionSlotsTest.kt new file mode 100644 index 0000000..be194a5 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/session/ChatSessionSlotsTest.kt @@ -0,0 +1,144 @@ +package top.yeij.cyrene.core.session + +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * 会话槽位账本的粘合行为([ChatSessionSlots]):它只负责两件事——**给出去的那一格是不是同一格** + * (切回来时状态还在)与**什么时候把一格交出去**(判断本身在 [chatSessionEvictions],这里钉的是 + * 「判断的结论真的落到了那一格上」)。 + * + * 观测方式刻意只用 `slotFor(...) === 原来的那个对象`:槽位被交出去之后,同样的账号 + 会话 id + * 会拿到一个**新**槽位(新的 `ViewModelStore` ⇒ 里面那个 VM 是重头建的),而没被交出去的一格 + * 必须还是原来那个对象(否则「切回来还在」就没了)。这样测的是行为,不需要为测试开任何后门。 + * + * 这里**不往 store 里放 VM**:本测试只跑在 JVM 上,构造 / 清空一份 `ViewModelStore` 不碰任何 + * Android 与协程设施(回收那一半——`ViewModelStore.clear()` 会走 `onCleared`、取消 `viewModelScope` + * ——靠走查:那一行是库自己的语义,项目里没有能在 JVM 上安全建起来的 VM)。 + */ +class ChatSessionSlotsTest { + + private val account = "http://host-a:10942/|testuser" + + private val slots = ChatSessionSlots(mainSessionId = "web") + + /** 打开一个会话(= `ChatScreen` 记的那一笔账),并顺手返回它的槽位。 */ + private fun open( + sessionId: String, + at: Long, + holdsUnsentWork: () -> Boolean = { false }, + ): ChatSessionSlot = slots.slotFor(account, sessionId).also { + slots.onOpened(account, sessionId, holdsUnsentWork, at) + } + + @Test + fun `the same account and session always yields the same slot`() { + assertSame(slots.slotFor(account, "web:a"), slots.slotFor(account, "web:a")) + // 另一个账号 / 另一个会话各是各的格子(账号指纹就在槽位 key 里)。 + assertNotSame(slots.slotFor(account, "web:a"), slots.slotFor("other-account", "web:a")) + assertNotSame(slots.slotFor(account, "web:a"), slots.slotFor(account, "web:b")) + } + + @Test + fun `taking a slot does not reclaim anything by itself`() { + // slotFor 只是「取或建」:组合期间碰它不该有任何回收动作(回收的触发点是 onOpened)。 + val a = slots.slotFor(account, "web:a") + val b = slots.slotFor(account, "web:b") + val c = slots.slotFor(account, "web:c") + slots.slotFor(account, "web:d") + assertSame(a, slots.slotFor(account, "web:a")) + assertSame(b, slots.slotFor(account, "web:b")) + assertSame(c, slots.slotFor(account, "web:c")) + } + + @Test + fun `opening one session beyond the budget gives the oldest slot back`() { + val main = open("web", 1) + val a = open("web:a", 2) + val b = open("web:b", 3) + val c = open("web:c", 4) + + // 主会话(最旧)占一个名额 ⇒ 次级会话里最旧的 a 让位,其余原样。 + assertSame(main, slots.slotFor(account, "web")) + assertNotSame(a, slots.slotFor(account, "web:a")) + assertSame(b, slots.slotFor(account, "web:b")) + assertSame(c, slots.slotFor(account, "web:c")) + } + + @Test + fun `the slot on screen keeps its place even when every other slot holds unsent work`() { + // 屏幕上那一格按**身份**豁免(判据第 2 条)。没有它的话:四个格子都「不可回收」时预算 + // 会变成负数,刚打开的这一格反而成了唯一可回收的那个——正在看的那一段从此不再更新。 + open("web", 1) + open("web:a", 2) { true } + open("web:b", 3) { true } + open("web:c", 4) { true } + val onScreen = open("web:d", 5) + + assertSame(onScreen, slots.slotFor(account, "web:d")) + } + + @Test + fun `a slot created but never opened is swept away by the next reclaim`() { + // 组合被丢弃时会留下「建出来、从来没打开过」的空槽位:它 lastUsedAt 仍是 0 ⇒ 必然最旧。 + open("web", 1) + val abandonedA = slots.slotFor(account, "web:abandoned-1") + val abandonedB = slots.slotFor(account, "web:abandoned-2") + val kept = open("web:kept", 2) + val kept2 = open("web:kept2", 3) + + // 打开两个会话之后,两个空槽位先后被扫掉(每打开一格才收一格)。 + assertNotSame(abandonedA, slots.slotFor(account, "web:abandoned-1")) + assertNotSame(abandonedB, slots.slotFor(account, "web:abandoned-2")) + assertSame(kept, slots.slotFor(account, "web:kept")) + assertSame(kept2, slots.slotFor(account, "web:kept2")) + } + + @Test + fun `a slot holding unsent work is spared until it is clean`() { + var typing = true + val withDraft = open("web:a", 2) { typing } + open("web", 1) + val b = open("web:b", 3) + val c = open("web:c", 4) + + // a 有还没发出去的字(虽然它在可回收的那些里最旧):让位的是下一个最旧的 b。 + assertSame(withDraft, slots.slotFor(account, "web:a")) + assertSame(c, slots.slotFor(account, "web:c")) + + // 探针是**现读的**:草稿发出去之后,再打开一个会话时它就该让位了。 + typing = false + open("web:d", 5) + assertNotSame(withDraft, slots.slotFor(account, "web:a")) + assertNotSame(b, slots.slotFor(account, "web:b")) + assertSame(c, slots.slotFor(account, "web:c")) + } + + @Test + fun `reclaim gives exactly one slot back`() { + val a = open("web:a", 1) + val b = open("web:b", 2) + slots.reclaim(account, "web:a") + + assertNotSame(a, slots.slotFor(account, "web:a")) + assertSame(b, slots.slotFor(account, "web:b")) + } + + @Test + fun `reclaim of another account leaves this one alone`() { + val a = open("web:a", 1) + slots.reclaim("other-account", "web:a") + assertSame(a, slots.slotFor(account, "web:a")) + } + + @Test + fun `clear gives every slot back`() { + val main = open("web", 1) + val a = open("web:a", 2) + slots.clear() + + assertNotSame(main, slots.slotFor(account, "web")) + assertNotSame(a, slots.slotFor(account, "web:a")) + } +}