账号切换后的数据粘连,表面看是“退出 A 账号后还能看到 A 的昵称、订单、消息或缓存列表”,本质上是旧账号的内存状态、异步任务、持久化缓存或后台回调越过了新的身份边界。
一句话结论:把账号切换当成一次全局 Session 版本变更来处理,让 UI 状态、请求任务、数据库、缓存、文件和后台任务都携带账号命名空间,并在切换瞬间取消旧任务、清理旧内存、拒绝旧结果回写。
本文以 AndroidX Lifecycle 2.x、Kotlin Coroutine 1.x、Room 2.x、WorkManager 2.x 的概念模型说明问题;具体 API 行为请以项目当前依赖版本为准。文中的类名多为架构锚点,不代表某个框架的稳定内部实现。
1. 先看一条真实粘连链路
很多项目第一次遇到粘连,是这样的场景:
这不是单纯的“退出登录时清缓存没清干净”,而是旧任务、旧缓存、旧状态没有被账号边界拦住。
#mermaid-svg-T39mSTT5kKOlby3Q{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T39mSTT5kKOlby3Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T39mSTT5kKOlby3Q .error-icon{fill:#552222;}#mermaid-svg-T39mSTT5kKOlby3Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T39mSTT5kKOlby3Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T39mSTT5kKOlby3Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T39mSTT5kKOlby3Q .marker.cross{stroke:#333333;}#mermaid-svg-T39mSTT5kKOlby3Q svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T39mSTT5kKOlby3Q p{margin:0;}#mermaid-svg-T39mSTT5kKOlby3Q .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-T39mSTT5kKOlby3Q .cluster-label text{fill:#333;}#mermaid-svg-T39mSTT5kKOlby3Q .cluster-label span{color:#333;}#mermaid-svg-T39mSTT5kKOlby3Q .cluster-label span p{background-color:transparent;}#mermaid-svg-T39mSTT5kKOlby3Q .label text,#mermaid-svg-T39mSTT5kKOlby3Q span{fill:#333;color:#333;}#mermaid-svg-T39mSTT5kKOlby3Q .node rect,#mermaid-svg-T39mSTT5kKOlby3Q .node circle,#mermaid-svg-T39mSTT5kKOlby3Q .node ellipse,#mermaid-svg-T39mSTT5kKOlby3Q .node polygon,#mermaid-svg-T39mSTT5kKOlby3Q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T39mSTT5kKOlby3Q .rough-node .label text,#mermaid-svg-T39mSTT5kKOlby3Q .node .label text,#mermaid-svg-T39mSTT5kKOlby3Q .image-shape .label,#mermaid-svg-T39mSTT5kKOlby3Q .icon-shape .label{text-anchor:middle;}#mermaid-svg-T39mSTT5kKOlby3Q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T39mSTT5kKOlby3Q .rough-node .label,#mermaid-svg-T39mSTT5kKOlby3Q .node .label,#mermaid-svg-T39mSTT5kKOlby3Q .image-shape .label,#mermaid-svg-T39mSTT5kKOlby3Q .icon-shape .label{text-align:center;}#mermaid-svg-T39mSTT5kKOlby3Q .node.clickable{cursor:pointer;}#mermaid-svg-T39mSTT5kKOlby3Q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T39mSTT5kKOlby3Q .arrowheadPath{fill:#333333;}#mermaid-svg-T39mSTT5kKOlby3Q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T39mSTT5kKOlby3Q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T39mSTT5kKOlby3Q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T39mSTT5kKOlby3Q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T39mSTT5kKOlby3Q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T39mSTT5kKOlby3Q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T39mSTT5kKOlby3Q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T39mSTT5kKOlby3Q .cluster text{fill:#333;}#mermaid-svg-T39mSTT5kKOlby3Q .cluster span{color:#333;}#mermaid-svg-T39mSTT5kKOlby3Q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-T39mSTT5kKOlby3Q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T39mSTT5kKOlby3Q rect.text{fill:none;stroke-width:0;}#mermaid-svg-T39mSTT5kKOlby3Q .icon-shape,#mermaid-svg-T39mSTT5kKOlby3Q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T39mSTT5kKOlby3Q .icon-shape p,#mermaid-svg-T39mSTT5kKOlby3Q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T39mSTT5kKOlby3Q .icon-shape .label rect,#mermaid-svg-T39mSTT5kKOlby3Q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T39mSTT5kKOlby3Q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T39mSTT5kKOlby3Q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T39mSTT5kKOlby3Q :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
用户 A 请求订单
旧协程任务
网络返回
切换到用户 B
新 Session
写入共享缓存
用户 B 页面读取
展示 A 的数据
这张图回答了粘连的四个关键问题:旧任务由页面或仓库创建,旧任务在切换后仍然存活,结果写进了不区分账号的共享缓存,新页面又从共享缓存读取到了错误数据。
2. 粘连不是一个 bug,而是一类边界问题
账号数据可能粘在很多地方:
| 内存状态 | 首页头像切成 B,角标还是 A | 单例、全局变量、StateFlow replay 未重置 |
| 网络请求 | A 的接口返回后刷新 B 页面 | 切换时未取消旧请求,结果未校验账号 |
| Repository 缓存 | B 页面读到 A 的列表 | cache key 没带userId 或 sessionId |
| SharedPreferences 或 DataStore | B 拿到 A 的开关和草稿 | 持久化 key 没有账号命名空间 |
| Room 数据库 | B 查询出 A 的本地数据 | 表没有userId 字段,或查询条件漏加 |
| Paging 列表 | 切号后列表闪现旧数据 | PagingData、RemoteMediator、数据源未失效 |
| 图片和文件 | B 头像或附件显示 A 缓存 | 文件路径、磁盘缓存 key 未按账号隔离 |
| WorkManager | 旧账号同步任务继续跑 | 后台任务没有 tag 或唯一任务名隔离 |
| Push 和 IM | 退出后收到 A 的消息 | 设备 token、通道、长连接未解绑或重连 |
| 埋点和风控 | B 行为带上 A 的 userId | 埋点上下文捕获旧身份 |
所以治理思路不能只写一句 clearCache()。真正需要的是一套从入口到执行边界的身份传播模型。
3. 正确流程:账号切换是一场受控的状态迁移
推荐把账号切换设计成一个明确的协调流程,而不是散落在登录页、首页、仓库和各个工具类里的临时清理。
#mermaid-svg-kcNKcDzJ3uA2rOiu{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kcNKcDzJ3uA2rOiu .error-icon{fill:#552222;}#mermaid-svg-kcNKcDzJ3uA2rOiu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kcNKcDzJ3uA2rOiu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .marker.cross{stroke:#333333;}#mermaid-svg-kcNKcDzJ3uA2rOiu svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kcNKcDzJ3uA2rOiu p{margin:0;}#mermaid-svg-kcNKcDzJ3uA2rOiu .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster-label text{fill:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster-label span{color:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster-label span p{background-color:transparent;}#mermaid-svg-kcNKcDzJ3uA2rOiu .label text,#mermaid-svg-kcNKcDzJ3uA2rOiu span{fill:#333;color:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .node rect,#mermaid-svg-kcNKcDzJ3uA2rOiu .node circle,#mermaid-svg-kcNKcDzJ3uA2rOiu .node ellipse,#mermaid-svg-kcNKcDzJ3uA2rOiu .node polygon,#mermaid-svg-kcNKcDzJ3uA2rOiu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .rough-node .label text,#mermaid-svg-kcNKcDzJ3uA2rOiu .node .label text,#mermaid-svg-kcNKcDzJ3uA2rOiu .image-shape .label,#mermaid-svg-kcNKcDzJ3uA2rOiu .icon-shape .label{text-anchor:middle;}#mermaid-svg-kcNKcDzJ3uA2rOiu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .rough-node .label,#mermaid-svg-kcNKcDzJ3uA2rOiu .node .label,#mermaid-svg-kcNKcDzJ3uA2rOiu .image-shape .label,#mermaid-svg-kcNKcDzJ3uA2rOiu .icon-shape .label{text-align:center;}#mermaid-svg-kcNKcDzJ3uA2rOiu .node.clickable{cursor:pointer;}#mermaid-svg-kcNKcDzJ3uA2rOiu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .arrowheadPath{fill:#333333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kcNKcDzJ3uA2rOiu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kcNKcDzJ3uA2rOiu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kcNKcDzJ3uA2rOiu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster text{fill:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu .cluster span{color:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kcNKcDzJ3uA2rOiu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kcNKcDzJ3uA2rOiu rect.text{fill:none;stroke-width:0;}#mermaid-svg-kcNKcDzJ3uA2rOiu .icon-shape,#mermaid-svg-kcNKcDzJ3uA2rOiu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kcNKcDzJ3uA2rOiu .icon-shape p,#mermaid-svg-kcNKcDzJ3uA2rOiu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kcNKcDzJ3uA2rOiu .icon-shape .label rect,#mermaid-svg-kcNKcDzJ3uA2rOiu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kcNKcDzJ3uA2rOiu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kcNKcDzJ3uA2rOiu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kcNKcDzJ3uA2rOiu :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
入口:点击切换账号
加切换锁
停止旧账号任务
清理内存状态
切换持久化命名空间
写入新 Session
预热新账号数据
通知 UI 重建状态
进入新账号首页
这里谁创建、谁持有、何时触发、结果交给谁要讲清楚:
| 入口 API | 发起退出、登录、切换账号动作 | LoginViewModel、AccountActivity |
| 协调者 | 串联取消任务、清状态、写 session、预热数据 | AccountSwitchCoordinator |
| Session 检索器 | 提供当前账号、token、版本号 | SessionStore、SessionProvider |
| 生命周期持有者 | 管理 UI 状态和页面级任务 | ViewModel、LifecycleOwner |
| 任务调度器 | 跟踪并取消旧账号任务 | CoroutineScope、JobRegistry、WorkManager |
| 执行与缓存边界 | 发请求、查库、读写缓存 | Repository、DAO、HTTP Client、Disk Cache |
一个稳的切换流程至少包含三件事:
- 切换前:阻止重复切换,取消旧账号可取消任务。
- 切换中:生成新的 Session 版本,清理内存态,切换缓存命名空间。
- 切换后:新页面只接收新 Session 的结果,旧结果即使返回也不能写入。
4. 核心设计:Session 不只是 token
很多粘连来自一个错误心智:认为账号信息只有 token。实际工程里,Session 至少要包含:
data class UserSession(
val userId: String,
val token: String,
val version: Long
)
userId 解决“数据属于谁”,token 解决“请求怎么鉴权”,version 解决“这次结果是否仍属于当前登录态”。
为什么只用 userId 不够?因为同一个用户可能退出后重新登录,旧请求仍然可能返回。version 能把“同一个用户的旧登录态”和“当前登录态”区分开。
简化判断如下:
fun isStillCurrent(
captured: UserSession,
current: UserSession?
): Boolean {
return current != null &&
captured.userId == current.userId &&
captured.version == current.version
}
生产建议:
- Session 由单一来源维护,例如 SessionStore。
- 不要在 Repository 初始化时永久捕获 token。
- 每次请求前读取当前 token,或为请求显式传入本次捕获的 Session。
- 所有账号私有数据的 cache key 至少带 userId,高风险回写再带 version。
- UI 展示前也校验当前 Session,不要只在网络层校验。
5. 入口层:不要让切换动作散落到各处
账号切换常见入口有:
- 登录页切换账号。
- 设置页退出登录。
- token 失效后跳登录。
- 多账号列表里点击另一个账号。
- 后台风控要求强制下线。
这些入口最终应该收敛到一个协调者,而不是每个页面自己清一点。
class AccountViewModel(
private val accountSwitch: AccountSwitchCoordinator
) : ViewModel() {
fun switchAccount(target: LoginCredential) {
viewModelScope.launch {
accountSwitch.switchTo(target)
}
}
}
这段代码是简化伪代码。真实项目还需要处理登录失败、二次确认、页面导航、loading、防重复点击和风控弹窗。
入口层只表达“我要切换到谁”。真正的任务取消、缓存清理和状态重建应该下沉到协调者,否则每新增一个入口,就多一种漏清理路径。
6. 生命周期层:旧页面不能继续消费旧结果
AndroidX 的 viewModelScope 会随 ViewModel 清理而取消内部协程;repeatOnLifecycle 会根据 Lifecycle 状态启动和停止收集。这些是生命周期绑定的事实,但它们不会自动帮你处理账号切换。
如果账号切换后页面没有销毁,或者某个 ViewModel 被导航栈保留,旧状态仍然可能存在。
#mermaid-svg-1PiIEeY3IYauKqXW{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1PiIEeY3IYauKqXW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1PiIEeY3IYauKqXW .error-icon{fill:#552222;}#mermaid-svg-1PiIEeY3IYauKqXW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1PiIEeY3IYauKqXW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1PiIEeY3IYauKqXW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1PiIEeY3IYauKqXW .marker.cross{stroke:#333333;}#mermaid-svg-1PiIEeY3IYauKqXW svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1PiIEeY3IYauKqXW p{margin:0;}#mermaid-svg-1PiIEeY3IYauKqXW .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-1PiIEeY3IYauKqXW .cluster-label text{fill:#333;}#mermaid-svg-1PiIEeY3IYauKqXW .cluster-label span{color:#333;}#mermaid-svg-1PiIEeY3IYauKqXW .cluster-label span p{background-color:transparent;}#mermaid-svg-1PiIEeY3IYauKqXW .label text,#mermaid-svg-1PiIEeY3IYauKqXW span{fill:#333;color:#333;}#mermaid-svg-1PiIEeY3IYauKqXW .node rect,#mermaid-svg-1PiIEeY3IYauKqXW .node circle,#mermaid-svg-1PiIEeY3IYauKqXW .node ellipse,#mermaid-svg-1PiIEeY3IYauKqXW .node polygon,#mermaid-svg-1PiIEeY3IYauKqXW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1PiIEeY3IYauKqXW .rough-node .label text,#mermaid-svg-1PiIEeY3IYauKqXW .node .label text,#mermaid-svg-1PiIEeY3IYauKqXW .image-shape .label,#mermaid-svg-1PiIEeY3IYauKqXW .icon-shape .label{text-anchor:middle;}#mermaid-svg-1PiIEeY3IYauKqXW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1PiIEeY3IYauKqXW .rough-node .label,#mermaid-svg-1PiIEeY3IYauKqXW .node .label,#mermaid-svg-1PiIEeY3IYauKqXW .image-shape .label,#mermaid-svg-1PiIEeY3IYauKqXW .icon-shape .label{text-align:center;}#mermaid-svg-1PiIEeY3IYauKqXW .node.clickable{cursor:pointer;}#mermaid-svg-1PiIEeY3IYauKqXW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1PiIEeY3IYauKqXW .arrowheadPath{fill:#333333;}#mermaid-svg-1PiIEeY3IYauKqXW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1PiIEeY3IYauKqXW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1PiIEeY3IYauKqXW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1PiIEeY3IYauKqXW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1PiIEeY3IYauKqXW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1PiIEeY3IYauKqXW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1PiIEeY3IYauKqXW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1PiIEeY3IYauKqXW .cluster text{fill:#333;}#mermaid-svg-1PiIEeY3IYauKqXW .cluster span{color:#333;}#mermaid-svg-1PiIEeY3IYauKqXW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1PiIEeY3IYauKqXW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1PiIEeY3IYauKqXW rect.text{fill:none;stroke-width:0;}#mermaid-svg-1PiIEeY3IYauKqXW .icon-shape,#mermaid-svg-1PiIEeY3IYauKqXW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1PiIEeY3IYauKqXW .icon-shape p,#mermaid-svg-1PiIEeY3IYauKqXW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1PiIEeY3IYauKqXW .icon-shape .label rect,#mermaid-svg-1PiIEeY3IYauKqXW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1PiIEeY3IYauKqXW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1PiIEeY3IYauKqXW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1PiIEeY3IYauKqXW :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
ViewModel 持有状态
StateFlow replay
页面重新收集
账号切换
是否重置
旧状态继续展示
进入空态或加载态
这张图的重点是:StateFlow 的 replay 能让新收集者马上拿到最后一次状态,这对正常页面恢复很有用,但在账号边界变化时必须重置或重新创建。
推荐做法:
- 切换账号时清理应用级 UI 状态,例如角标、用户资料、购物车概览。
- 页面级 ViewModel 监听 Session 变化,发现账号变化后重置自身状态。
- 导航层可以回到登录后首页,并清空需要隔离的历史栈。
- 对多账号共存场景,不要全局清空,而是按 userId 切换状态容器。
7. 任务层:取消旧任务,并拒绝旧结果回写
只取消页面协程不够,因为请求可能由 Repository、同步服务、WebSocket、Push、WorkManager 或全局轮询创建。
可以建立一个账号级任务登记器:
class AccountJobRegistry {
private val jobs = mutableMapOf<Long, MutableSet<Job>>()
fun track(sessionVersion: Long, job: Job) {
jobs.getOrPut(sessionVersion) { linkedSetOf() }.add(job)
job.invokeOnCompletion {
jobs[sessionVersion]?.remove(job)
}
}
fun cancelBefore(currentVersion: Long) {
val oldVersions = jobs.keys.filter { it < currentVersion }
oldVersions.forEach { version ->
jobs.remove(version)?.forEach { it.cancel() }
}
}
}
这是简化伪代码,只说明“按 Session 版本追踪任务”的思路。生产项目需要考虑线程安全、结构化并发、异常记录和组件生命周期。
但即使取消了旧任务,也要防止取消失败或网络库已经回调:
suspend fun <T> runForCurrentSession(
sessionStore: SessionStore,
block: suspend (UserSession) -> T
): T? {
val captured = sessionStore.current() ?: return null
val result = block(captured)
val current = sessionStore.current()
return if (isStillCurrent(captured, current)) result else null
}
关键点是两段校验:
- 执行前捕获本次 Session。
- 写回前确认它仍然是当前 Session。
这个判断应该靠近结果写入处,例如更新内存缓存、写数据库、发 UI state 之前。只在网络请求前校验是不够的。
8. 缓存层:所有账号私有数据都必须带命名空间
缓存粘连的根因通常是 key 设计错了。
不安全示例:
cache["home_feed"] = feeds
cache["order_list"] = orders
cache["user_profile"] = profile
更好的 key:
fun accountKey(session: UserSession, rawKey: String): String {
return "u:${session.userId}:$rawKey"
}
如果是容易被旧请求回写的数据,可以加版本:
fun sessionKey(session: UserSession, rawKey: String): String {
return "u:${session.userId}:v:${session.version}:$rawKey"
}
怎么选择 userId 还是 version?
| 用户资料、订单、消息 | userId | 属于账号,重新登录后仍可复用 |
| 登录中间态、一次性校验 | version | 旧登录态结果不应该复用 |
| 请求去重、页面加载状态 | version | 切号后旧任务必须失效 |
| 草稿、搜索历史 | 视业务而定 | 有些需要随账号,有些属于设备 |
| 公共配置、AB 实验 | 不带账号或带租户 | 取决于服务端下发维度 |
不要把所有缓存都在退出时粗暴清空。正确做法是分类:
- 账号私有缓存:按账号隔离,退出时可选择保留或清除。
- 登录态缓存:按 session 版本隔离,切号立即失效。
- 设备级缓存:不随账号清理,例如主题、语言、设备能力。
- 敏感缓存:退出必须删除,例如 token、私密附件、支付临时信息。
9. 数据库层:按账号字段隔离,或按账号库隔离
Room 或 SQLite 里的粘连很隐蔽,因为 UI 可能只是查了一句:
SELECT * FROM orders ORDER BY createdAt DESC
如果表里没有 userId,那就根本没法区分账号。如果有 userId 但 DAO 忘了加条件,也会串。
常见方案有两种:
| 单库多账号字段 | 每张账号私有表带userId | 多账号切换频繁,数据量中等 | DAO 必须强制带条件 |
| 每账号独立库 | app_user_<id>.db | 强隔离、数据大、隐私要求高 | 迁移、打开关闭和磁盘管理更复杂 |
单库方案要特别注意:
- 账号私有表统一加 userId。
- DAO 查询方法必须显式接收 userId。
- 复合索引通常要包含 userId。
- 删除账号数据时按 userId 删除,不扫全库。
- 数据迁移时要给旧数据补账号归属,否则宁可丢弃不可靠缓存。
示例:
@Dao
interface OrderDao {
@Query(
"SELECT * FROM orders " +
"WHERE userId = :userId " +
"ORDER BY createdAt DESC"
)
fun observeOrders(userId: String): Flow<List<OrderEntity>>
}
生产建议:对高风险 DAO 做代码审查或静态检查,禁止账号私有表出现不带 userId 条件的查询。
10. 网络层:token 不能被旧对象永久捕获
有些项目会在创建 API Client 时把 token 塞进去:
class ApiClient(token: String) {
private val authHeader = "Bearer $token"
}
如果这个对象是单例,切号后它可能继续带旧 token。更稳的方式是每次请求从 SessionStore 读取当前 token:
class AuthInterceptor(
private val sessionStore: SessionStore
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val session = sessionStore.currentBlocking()
val request = chain.request().newBuilder()
.apply {
if (session != null) {
header("Authorization", "Bearer ${session.token}")
header("X-Session-Version", session.version.toString())
}
}
.build()
return chain.proceed(request)
}
}
这是简化伪代码。真实项目要避免在拦截器里做慢 I/O,currentBlocking() 应来自内存快照,而不是每次读磁盘。
网络层还要处理:
- 401 后刷新 token 时,确认刷新结果仍属于当前账号。
- 上传、下载、长轮询切号时取消。
- WebSocket 或 IM 长连接切号时断开旧连接并用新身份重连。
- 请求重试队列里的旧任务要清空或重新绑定新 Session。
11. WorkManager、Push、IM:后台链路也会串号
账号切换不是只发生在前台页面。后台同步任务、推送通道和 IM 长连接如果不处理,也会把旧账号数据带回来。
WorkManager 2.x 的常见做法是给账号任务加唯一任务名或 tag,切号时取消旧账号任务:
val workName = "sync_user_${session.userId}"
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
syncRequest
)
切号时:
WorkManager.getInstance(context)
.cancelAllWorkByTag("account:${oldSession.userId}")
生产建议:
- 后台任务 inputData 带 userId 和 sessionVersion。
- Worker 开始执行时读取当前 Session 并比对。
- Worker 写库前再次比对。
- 退出登录时解绑 push alias、关闭 IM 连接、清空旧账号消息队列。
- 新账号登录成功后再绑定新 alias、重建连接、重新拉取未读数。
后台链路的判断原则和前台一样:旧结果可以返回,但不能写进新账号的状态空间。
12. 和主题切换、语言切换有什么不同
账号切换容易被误类比成主题切换或语言切换,但它更接近“安全域切换”。
| 主题切换 | 大部分业务数据 | UI 样式状态 | 重建 UI 或刷新资源 |
| 语言切换 | 用户数据、业务缓存 | 文案资源、部分配置 | Locale 和资源刷新 |
| 账号切换 | 设备级配置、公共资源 | 私有数据、任务、token、消息 | 身份边界和数据隔离 |
Android 里像 ViewModel、WorkManager、Room、图片库、网络库都提供了各自的生命周期或缓存能力,但它们不知道你的业务账号边界。账号隔离必须由应用架构显式表达。
13. 最小可落地实现
下面是一套可改造的最小模型,重点演示三件事:统一 SessionStore、切号协调者、Repository 写回前校验。
data class UserSession(
val userId: String,
val token: String,
val version: Long
)
interface SessionStore {
fun current(): UserSession?
fun update(session: UserSession?)
}
class InMemorySessionStore : SessionStore {
@Volatile
private var session: UserSession? = null
override fun current(): UserSession? = session
override fun update(session: UserSession?) {
this.session = session
}
}
class AccountSwitchCoordinator(
private val sessionStore: SessionStore,
private val jobRegistry: AccountJobRegistry,
private val memoryCache: AccountMemoryCache,
private val authApi: AuthApi
) {
private val switchMutex = Mutex()
suspend fun switchTo(credential: LoginCredential) {
switchMutex.withLock {
val oldSession = sessionStore.current()
val nextVersion = System.currentTimeMillis()
oldSession?.let {
jobRegistry.cancelBefore(nextVersion)
memoryCache.clearSession(it)
}
val loginResult = authApi.login(credential)
val newSession = UserSession(
userId = loginResult.userId,
token = loginResult.token,
version = nextVersion
)
sessionStore.update(newSession)
memoryCache.prepare(newSession)
}
}
}
class OrderRepository(
private val sessionStore: SessionStore,
private val api: OrderApi,
private val dao: OrderDao
) {
suspend fun refreshOrders(): List<Order> {
val captured = sessionStore.current() ?: return emptyList()
val remote = api.fetchOrders(captured.token)
val current = sessionStore.current()
if (current?.userId != captured.userId ||
current.version != captured.version
) {
return emptyList()
}
dao.replaceOrders(
userId = captured.userId,
orders = remote.map { it.toEntity(captured.userId) }
)
return remote.map { it.toDomain() }
}
}
这段代码是简化实现,故意省略了依赖注入、持久化 Session、token 刷新、错误分类、线程安全容器、数据库事务、WorkManager 取消、Push 解绑和导航栈清理。它的目的不是生产可直接复制,而是把关键机制说清楚:捕获身份、执行任务、写回前复核身份。
14. 排查清单:怎么定位现有项目的粘连
如果项目已经出现粘连,可以按这个顺序查:
15. 测试策略:粘连问题必须做竞态测试
只测“退出后登录成功”不够,必须测旧请求晚返回。
推荐用例:
| A 请求未返回时切 B | A 结果不能写入 B UI |
| A 列表已有缓存时切 B | B 首屏不能闪现 A 列表 |
| A 后台同步中切 B | A Worker 被取消或写回被拒绝 |
| 同一账号退出后重登 | 旧 version 结果不能覆盖新 version |
| B 登录失败 | A 的敏感态不能被错误恢复 |
| 多账号快速连续切换 | 最终状态只属于最后一次 session |
可以用 fake API 控制返回顺序:
@Test
fun old_result_should_not_update_new_session() = runTest {
val sessionStore = InMemorySessionStore()
val api = FakeOrderApi()
val dao = FakeOrderDao()
val repository = OrderRepository(sessionStore, api, dao)
sessionStore.update(UserSession("A", "tokenA", 1))
val job = async { repository.refreshOrders() }
sessionStore.update(UserSession("B", "tokenB", 2))
api.completeFor("A", listOf(OrderDto("orderA")))
job.await()
assertTrue(dao.ordersOf("B").isEmpty())
}
这是单元测试级别的简化样例。真实项目还要测 UI 状态、数据库事务、Flow 收集、Worker 执行和导航栈。
16. AI 协作时怎么约束这类改造
账号粘连涉及安全和隐私,不适合让 AI 自由发挥。可以给它明确边界:
请排查并修复 Android 项目中账号切换后的数据粘连问题。
范围:
– 只分析登录、SessionStore、订单列表、用户资料和后台同步链路。
– 不修改支付、风控、协议签名和生产环境配置。
– 不升级依赖版本。
目标:
– 所有账号私有缓存 key 必须带 userId。
– 旧 session 的网络结果写回前必须校验 version。
– 切换账号时取消旧账号 WorkManager 任务。
– 为旧请求晚返回的场景补测试。
验证:
– 说明每个粘连点位于内存、网络、数据库、后台任务还是 UI。
– 给出最小测试命令。
– 如果需要扩大修改范围,先停止并说明风险。
更硬的约束要放进自动化:
- 静态扫描账号私有 DAO 是否缺少 userId 参数。
- 单元测试覆盖旧请求晚返回。
- CI 检查敏感缓存 key 是否符合命名规范。
- Code Review checklist 强制检查 token、cache、worker、push、IM。
提示词能让 AI 更守规矩,但真正兜底的仍然是测试、静态检查和评审。
17. 最后给一份决策 checklist
账号切换方案是否可靠,可以看这几个问题:
- 当前登录态是否有唯一 Session 版本?
- 所有账号私有数据是否都有 userId 命名空间?
- 旧请求返回时,写 UI、写库、写缓存前是否复核当前 Session?
- 切换账号时,页面任务、全局任务、后台任务是否能取消?
- StateFlow、LiveData、Paging、Adapter 是否会 replay 旧状态?
- Room 查询是否强制带账号条件?
- Push、IM、WebSocket 是否解绑旧账号并重连新账号?
- token、草稿、附件、支付临时数据是否按敏感级别清理?
- 是否有“旧请求晚返回”的自动化测试?
- 是否有 CI 或评审规则阻止新增无账号维度缓存?
账号切换的数据粘连,本质上是身份边界没有被工程化。不要把它当成某个页面的小 bug,也不要只靠退出时清空几个变量。把 Session 作为全局状态迁移的核心,让每条异步链路都能识别“这是不是当前账号的结果”,项目才会从偶发串号走向长期可靠。






