第14篇|退出登录要清干净状态:把持久化 key、水合和清理流程集中管理

摘要:退出登录不是删掉 token 就结束。HarmonyOS 应用里,AppStorage、本地 Preferences、页面内存状态、最近访问缓存和导航栈只要有一层没归零,下一位用户进入时就可能看到旧头像、旧收藏、旧统计,甚至按返回回到需要登录的页面。稳定做法是先列清持久化 key 和运行态 key,启动时统一水合默认值,退出时由服务层同时清持久层、重置运行态、回收导航栈。

我遇到过一个账号切换问题:用户退出后登录页已经出现,但首页收藏数量还停在旧账号的 18;重新打开应用时,旧昵称先闪了一下才变成未登录。继续在退出按钮里补 this.favoriteCount = 0 没用,因为旧数据并不只在这个页面里。真正要修的是“状态从哪里来、谁负责清、页面何时重新读”。

在这里插入图片描述

这篇文章解决四个实际问题:

  1. 退出登录时哪些状态必须清,哪些只是页面临时状态。
  2. 新增 @StorageLink key 后,为什么启动水合和退出重置必须一起补。
  3. 如何让 Repository 清持久层,Service 编排运行态重置,页面只处理交互。
  4. 如何用切账号、重启、返回栈和清空数据路径验证旧状态没有回潮。

在这里插入图片描述
在这里插入图片描述

适用场景和边界

本文讨论 Stage 模型下的本地账号状态清理。示例用 key-value 存储适配器表达持久化层,用 AppStorage 表达运行态协调,用 NavPathStack 表达页面栈回收。真实项目可以把持久层换成 Preferences、文件或 RDB,但分工不要变。

状态类型 示例 退出时怎么处理
认证状态 token、refresh token、当前用户 id 删除持久层,运行态设为空
用户域数据 收藏 id、最近浏览、草稿缓存 按用户域 key 清理或切换命名空间
运行态摘要 首页统计、红点、当前头像 AppStorage.setOrCreate 重置默认值
页面临时态 输入框内容、loading、Toast 页面销毁或路由重置时自然释放
导航状态 已登录页面栈 清空栈并回到登录入口

退出登录的难点在于这些状态分散在不同层。只改按钮上的几行代码,很容易当前页面看起来好了,换入口或重启后又看到旧值。

先把旧状态回潮路径找出来

退出登录问题可以先用“旧状态从哪里回来的”来排查。不同路径指向的根因不一样。

现象 优先怀疑 说明
刚退出当前页还显示旧昵称 运行态没重置 AppStorage 或页面 @State 还保留旧值
重启后旧数据又出现 持久层没删干净 Preferences / 文件 / RDB 仍有用户域数据
登录新账号后看到旧收藏 key 没按用户域隔离 收藏、最近浏览等共享了旧账号 key
按返回回到旧页面 导航栈没回收 退出后仍能回到登录态之外的页面
清空数据后首次进入崩溃 默认值没水合 @StorageLink 首次读取到未准备值

这个表能帮助你少做无效修复。比如重启后旧数据回来,重点查持久层;退出瞬间 UI 没变,重点查运行态和页面刷新;按返回能回旧页面,就先看导航栈。

集中登记持久化 key 和运行态 key

不要让页面各写各的字符串 key。退出登录、账号注销、清空数据、测试重置都应该使用同一份 key 清单。

export const PersistKeys = {
  sessionToken: 'session_token',
  currentUser: 'current_user',
  favoriteCourseIds: 'favorite_course_ids',
  recentCourseIds: 'recent_course_ids',
  draftCourseNote: 'draft_course_note'
} as const

export type PersistKey = typeof PersistKeys[keyof typeof PersistKeys]

export const UserScopedPersistKeys: PersistKey[] = [
  PersistKeys.sessionToken,
  PersistKeys.currentUser,
  PersistKeys.favoriteCourseIds,
  PersistKeys.recentCourseIds,
  PersistKeys.draftCourseNote
]

export const RuntimeKeys = {
  sessionToken: PersistKeys.sessionToken,
  currentUser: PersistKeys.currentUser,
  favoriteCourseIds: PersistKeys.favoriteCourseIds,
  recentCourseIds: PersistKeys.recentCourseIds,
  homeRefreshVersion: 'home_refresh_version'
} as const

这里把“持久层 key”和“运行态 key”放在一起看,是为了避免新增一个 @StorageLink 后只在页面里使用,却忘了启动默认值和退出重置。key 一旦跨页面,就不再属于某个页面。

启动阶段先水合默认值

页面首次挂载时,运行态可能还没有从持久层恢复完成。尤其是 @StorageLink 数组或对象,如果没有安全默认值,Builder 里一调用 .length 或字符串插值就可能出错。

export function hydrateSessionRuntimeKeys(): void {
  AppStorage.setOrCreate<string>(RuntimeKeys.sessionToken, '')
  AppStorage.setOrCreate<string>(RuntimeKeys.currentUser, '')
  AppStorage.setOrCreate<string[]>(RuntimeKeys.favoriteCourseIds, [])
  AppStorage.setOrCreate<string[]>(RuntimeKeys.recentCourseIds, [])
  AppStorage.setOrCreate<number>(RuntimeKeys.homeRefreshVersion, 0)
}

export class EntryAbility extends UIAbility {
  onCreate(): void {
    hydrateSessionRuntimeKeys()
  }
}

这段代码只做一件事:让运行态 key 在页面出现前有安全默认值。它不负责读取真实用户信息,也不决定是否登录。真实恢复逻辑可以放到 SessionService,但默认值必须足够早。

Repository 只处理持久层删除

持久层清理要集中在 Repository 或存储适配器里。它不弹 Toast,不跳页面,也不关心首页怎么展示。

export interface LocalKeyValueStore {
  remove(key: string): Promise<void>
  putString(key: string, value: string): Promise<void>
}

export class SessionRepository {
  constructor(private readonly store: LocalKeyValueStore) {}

  async clearUserScopedData(): Promise<void> {
    for (const key of UserScopedPersistKeys) {
      await this.store.remove(key)
    }
  }

  async markLoggedOut(): Promise<void> {
    await this.store.putString(PersistKeys.sessionToken, '')
    await this.store.putString(PersistKeys.currentUser, '')
  }
}

clearUserScopedData() 处理收藏、最近浏览、草稿这类用户域数据,markLoggedOut() 处理认证状态兜底。真实项目里如果某些缓存要保留到设备级,也应该从 UserScopedPersistKeys 里排除,而不是在退出流程里临时判断。

Service 编排持久层和运行态一起归零

退出按钮不应该直接调用十几个清理方法。它只发起退出动作,Service 负责按顺序编排。

export interface LogoutResult {
  success: boolean
  message: string
}

export class LogoutService {
  constructor(private readonly repository: SessionRepository) {}

  async logout(): Promise<LogoutResult> {
    try {
      await this.repository.clearUserScopedData()
      await this.repository.markLoggedOut()
      this.resetRuntimeState()
      return { success: true, message: '已退出登录' }
    } catch (err) {
      return { success: false, message: '退出登录失败,请重试' }
    }
  }

  private resetRuntimeState(): void {
    AppStorage.setOrCreate<string>(RuntimeKeys.sessionToken, '')
    AppStorage.setOrCreate<string>(RuntimeKeys.currentUser, '')
    AppStorage.setOrCreate<string[]>(RuntimeKeys.favoriteCourseIds, [])
    AppStorage.setOrCreate<string[]>(RuntimeKeys.recentCourseIds, [])
    AppStorage.setOrCreate<number>(RuntimeKeys.homeRefreshVersion, Date.now())
  }
}

顺序上先清持久层,再重置运行态。这样就算页面马上读取 AppStorage,也只能读到空状态;应用重启后,持久层也不会把旧状态恢复回来。homeRefreshVersion 用来提醒首页、我的页、收藏页重新读取空状态。

页面只触发动作,不直接清 key

页面可以处理按钮 loading、错误提示和跳转,但不要自己删除持久化 key。否则退出、注销、账号过期、清空数据会走出多套逻辑。

@Component
struct AccountSettingsPage {
  @State loggingOut: boolean = false
  @State errorText: string = ''

  private logoutService: LogoutService = createLogoutService()
  private accountNavigator: AccountNavigator = createAccountNavigator()

  private async handleLogout(): Promise<void> {
    if (this.loggingOut) {
      return
    }
    this.loggingOut = true
    const result = await this.logoutService.logout()
    this.loggingOut = false

    if (!result.success) {
      this.errorText = result.message
      return
    }
    this.accountNavigator.backToLogin()
  }
}

这段页面代码只有交互职责:防止重复点击、展示失败、成功后跳转。清理范围不在页面里决定,后面新增“账号注销”也可以复用同一个 Service。

Builder 里读取运行态要给安全出口

@StorageLink 值可能在首次挂载、清空数据、退出瞬间处于默认状态。页面展示时不要在 Builder 里直接假设数组或对象一定有业务值。

@Component
struct ProfileHeader {
  @StorageLink(RuntimeKeys.currentUser) currentUser: string = ''
  @StorageLink(RuntimeKeys.favoriteCourseIds) favoriteCourseIds: string[] = []

  private getUserName(): string {
    return this.currentUser.length > 0 ? this.currentUser : '未登录'
  }

  private getFavoriteCount(): number {
    return this.favoriteCourseIds?.length ?? 0
  }

  build() {
    Column({ space: 8 }) {
      Text(this.getUserName())
      Text(`收藏课程 ${this.getFavoriteCount()}`)
    }
  }
}

这里用普通方法返回安全默认值,不用复杂 getter 参与 Builder 状态读取。这样退出瞬间、清空数据后首次打开、账号状态恢复前,页面都能展示可预期内容。

退出后要回收导航栈

状态清干净之后,如果导航栈还保留已登录页面,用户按返回仍可能回到旧页面。退出成功后应该清空栈,再进入登录页或未登录首页。

export class AccountNavigator {
  constructor(private readonly navStack: NavPathStack) {}

  backToLogin(): void {
    this.navStack.clear()
    this.navStack.pushPathByName('LoginPage', undefined)
  }
}

如果项目里有底部 Tab 或多个导航容器,要确认真正清的是当前可见容器对应的栈。不要只改一个入口,另一个 Tab 里仍然保留旧页面。

清空数据和账号注销也要复用同一套链路

退出登录不是唯一会清状态的入口。账号注销、token 过期、清空本地数据、切换账号,都应该尽量复用相同的 key 清单和运行态重置。

入口 是否清持久层 是否重置运行态 是否回收导航
主动退出登录
token 过期 认证状态必须清
账号注销 用户域数据必须清
清空本地数据 按用户选择清 视入口决定
切换账号 旧用户域隔离或清理 进入新账号首屏

把这些入口放在一起看,能发现很多遗漏。比如主动退出清了收藏,但 token 过期只跳登录页,没有清运行态,旧收藏仍可能显示。

用反向搜索清掉旧路径

改完后要搜索旧 key、直接写存储、页面内清理和绕过 Service 的入口。

rg "session_token|current_user|favorite_course_ids|recent_course_ids" entry features common
rg "AppStorage\\.set|AppStorage\\.setOrCreate|@StorageLink|remove\\(" entry features common
rg "logout|clearUserScopedData|backToLogin|NavPathStack" entry features common

第一条确认 key 是否还有散落字符串;第二条找页面或组件是否直接操作运行态;第三条确认退出、清理和导航回收是否形成同一条链路。命中不代表一定错误,但每一处都要能说清 owner。

验证路径要覆盖切账号和重启

退出登录问题不能只点一次按钮。至少要走下面这些路径:

  1. 账号 A 登录后收藏两门课程,确认首页和我的页都有统计。
  2. 点击退出登录,确认头像、昵称、收藏数量、最近记录都立即回到未登录态。
  3. 按系统返回键,确认不能回到账号 A 的页面。
  4. 关闭应用再打开,确认旧头像和旧收藏不会闪现。
  5. 登录账号 B,确认看不到账号 A 的收藏、草稿和最近浏览。
  6. 清空应用数据后首次进入,确认 @StorageLink 字符串、数组都有安全默认值。
  7. 人为制造退出失败,确认页面不会半清状态并且有重试入口。

如果第 2 步失败,先查运行态;如果第 4 步失败,先查持久层;如果第 3 步失败,先查导航栈。把失败归类后,修起来会快很多。

风险提示和回退方案

退出链路改动面不小,适合分阶段落地。

风险 触发场景 处理方式
清错设备级数据 把主题、引导页状态也放进用户域 key 区分用户域 key 和设备级 key
当前页半更新 退出失败后已经重置运行态 先清持久层成功,再重置运行态
首页旧值闪现 启动先渲染旧缓存 启动水合默认值,恢复完成后再展示用户态
新增 key 漏清 新页面新增 @StorageLink 同步加入 key 清单、启动水合、退出重置
返回旧页面 只跳登录页没清栈 使用 NavPathStack.clear() 后再进入登录页

如果已经上线,不建议一次性重写所有持久化。可以先把退出链路中最危险的认证状态、当前用户、用户域缓存收口,再逐步把页面里的散落 key 迁出来。

常见问题和处理方式

现象 常见原因 处理方式
退出后当前页还有旧昵称 AppStorage 没重置 Service 中统一 setOrCreate 默认值
重启后旧收藏回来 持久层 key 没删 Repository 按 UserScopedPersistKeys 清理
新账号看到旧草稿 用户域数据共用 key 清理旧用户域或按 userId 隔离
清空数据后页面崩溃 @StorageLink 默认值缺失 启动阶段水合字符串、数组和对象
退出后返回旧页面 导航栈保留已登录页面 清栈后进入登录页

这些问题看似是 UI 现象,根因通常在状态边界。先把持久层、运行态、页面状态和导航栈分清楚,再决定改哪里。

复查清单

改完后我会按这份清单收尾:

  1. 用户域持久化 key 已集中到 UserScopedPersistKeys
  2. 新增的 @StorageLink key 已在启动阶段 setOrCreate 默认值。
  3. 退出登录由 Service 编排,页面没有直接删持久化 key。
  4. 持久层清理和运行态重置同时发生,不只改其中一层。
  5. 首页、我的页、收藏页都能在退出后读到未登录态。
  6. 导航栈已回收,系统返回不会回到旧页面。
  7. 切账号、重启、清空数据、退出失败路径都走过。

这次问题给我的复盘

退出登录最容易被误修成按钮问题。按钮只是入口,真正的链路包含 key 清单、默认值水合、持久层删除、运行态重置、页面安全读取和导航回收。任何一层漏掉,旧状态都会从另一个入口回来。

如果重新处理一次,我会先列出所有用户域 key,再看哪些页面通过 @StorageLink 读取它们,接着把清理动作收到 Service,最后用切账号和重启验证。这个顺序比在页面上补零慢一点,但能把问题一次收成一类。

小结:退出登录是一条状态归零链路

退出登录要清干净,关键不是多写几个 set(''),而是让状态有清晰 owner。Repository 删除持久层,Service 重置运行态,页面只触发动作和展示结果,导航层回收已登录页面栈。新增一个跨页面 key,就同步补启动水合、退出重置和验证路径。这样账号切换、重启和清空数据后,旧状态才不会回潮。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐