欢迎光临
我们一直在努力

Android中账号切换数据粘连问题解决思路

账号切换后的数据粘连,表面看是“退出 A 账号后还能看到 A 的昵称、订单、消息或缓存列表”,本质上是旧账号的内存状态、异步任务、持久化缓存或后台回调越过了新的身份边界。
一句话结论:把账号切换当成一次全局 Session 版本变更来处理,让 UI 状态、请求任务、数据库、缓存、文件和后台任务都携带账号命名空间,并在切换瞬间取消旧任务、清理旧内存、拒绝旧结果回写。

本文以 AndroidX Lifecycle 2.x、Kotlin Coroutine 1.x、Room 2.x、WorkManager 2.x 的概念模型说明问题;具体 API 行为请以项目当前依赖版本为准。文中的类名多为架构锚点,不代表某个框架的稳定内部实现。


1. 先看一条真实粘连链路

很多项目第一次遇到粘连,是这样的场景:

  • 用户 A 打开订单页,页面发起订单列表请求。
  • 请求还没返回,用户退出 A 并登录 B。
  • 首页已经显示 B 的头像,但订单页旧请求返回了。
  • 旧请求把 A 的订单写进全局缓存或 StateFlow。
  • B 再进入订单页,看到了 A 的列表。
  • 这不是单纯的“退出登录时清缓存没清干净”,而是旧任务、旧缓存、旧状态没有被账号边界拦住。

    #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. 排查清单:怎么定位现有项目的粘连

    如果项目已经出现粘连,可以按这个顺序查:

  • 找到切换入口:登录、退出、token 失效、多账号切换是否走同一套协调流程。
  • 搜索全局状态:object、单例、MutableStateFlow、LiveData、全局 cache 是否带账号维度。
  • 搜索缓存 key:是否存在 home、profile、orders 这类不带 userId 的 key。
  • 检查 DAO:账号私有表是否有 userId 字段,查询是否强制带条件。
  • 检查请求回调:回写 UI、写库、写缓存前是否校验当前 Session。
  • 检查后台任务:WorkManager、定时器、轮询、下载上传是否能按账号取消。
  • 检查长连接:Push alias、IM、WebSocket 是否在退出时解绑或重连。
  • 检查列表和分页:切号后 PagingSource 是否 invalidate,Adapter 是否提交空态。
  • 检查图片和文件:账号私有文件路径是否按账号隔离,退出是否清敏感文件。
  • 检查埋点:全局 userId 上下文是否在切号时同步更新。

  • 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 作为全局状态迁移的核心,让每条异步链路都能识别“这是不是当前账号的结果”,项目才会从偶发串号走向长期可靠。

    赞(0)
    未经允许不得转载:171主机测评 » Android中账号切换数据粘连问题解决思路
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址