14-退出登录要清干净状态-把持久化键水合和清理流程集中管理
第14篇|退出登录要清干净状态:把持久化 key、水合和清理流程集中管理
摘要:退出登录不是删掉 token 就结束。HarmonyOS 应用里,AppStorage、本地 Preferences、页面内存状态、最近访问缓存和导航栈只要有一层没归零,下一位用户进入时就可能看到旧头像、旧收藏、旧统计,甚至按返回回到需要登录的页面。稳定做法是先列清持久化 key 和运行态 key,启动时统一水合默认值,退出时由服务层同时清持久层、重置运行态、回收导航栈。
我遇到过一个账号切换问题:用户退出后登录页已经出现,但首页收藏数量还停在旧账号的 18;重新打开应用时,旧昵称先闪了一下才变成未登录。继续在退出按钮里补this.favoriteCount = 0没用,因为旧数据并不只在这个页面里。真正要修的是“状态从哪里来、谁负责清、页面何时重新读”。
这篇文章解决四个实际问题:
- 退出登录时哪些状态必须清,哪些只是页面临时状态。
- 新增
@StorageLinkkey 后,为什么启动水合和退出重置必须一起补。 - 如何让 Repository 清持久层,Service 编排运行态重置,页面只处理交互。
- 如何用切账号、重启、返回栈和清空数据路径验证旧状态没有回潮。
适用场景和边界
本文讨论 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 清单。
exportconstPersistKeys={sessionToken:'session_token',currentUser:'current_user',favoriteCourseIds:'favorite_course_ids',recentCourseIds:'recent_course_ids',draftCourseNote:'draft_course_note'}asconstexporttypePersistKey=typeofPersistKeys[keyoftypeofPersistKeys]exportconstUserScopedPersistKeys:PersistKey[]=[PersistKeys.sessionToken,PersistKeys.currentUser,PersistKeys.favoriteCourseIds,PersistKeys.recentCourseIds,PersistKeys.draftCourseNote]exportconstRuntimeKeys={sessionToken:PersistKeys.sessionToken,currentUser:PersistKeys.currentUser,favoriteCourseIds:PersistKeys.favoriteCourseIds,recentCourseIds:PersistKeys.recentCourseIds,homeRefreshVersion:'home_refresh_version'}asconst这里把“持久层 key”和“运行态 key”放在一起看,是为了避免新增一个@StorageLink后只在页面里使用,却忘了启动默认值和退出重置。key 一旦跨页面,就不再属于某个页面。
启动阶段先水合默认值
页面首次挂载时,运行态可能还没有从持久层恢复完成。尤其是@StorageLink数组或对象,如果没有安全默认值,Builder 里一调用.length或字符串插值就可能出错。
exportfunctionhydrateSessionRuntimeKeys():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)}exportclassEntryAbilityextendsUIAbility{onCreate():void{hydrateSessionRuntimeKeys()}}这段代码只做一件事:让运行态 key 在页面出现前有安全默认值。它不负责读取真实用户信息,也不决定是否登录。真实恢复逻辑可以放到 SessionService,但默认值必须足够早。
Repository 只处理持久层删除
持久层清理要集中在 Repository 或存储适配器里。它不弹 Toast,不跳页面,也不关心首页怎么展示。
exportinterfaceLocalKeyValueStore{remove(key:string):Promise<void>putString(key:string,value:string):Promise<void>}exportclassSessionRepository{constructor(privatereadonlystore:LocalKeyValueStore){}asyncclearUserScopedData():Promise<void>{for(constkeyofUserScopedPersistKeys){awaitthis.store.remove(key)}}asyncmarkLoggedOut():Promise<void>{awaitthis.store.putString(PersistKeys.sessionToken,'')awaitthis.store.putString(PersistKeys.currentUser,'')}}clearUserScopedData()处理收藏、最近浏览、草稿这类用户域数据,markLoggedOut()处理认证状态兜底。真实项目里如果某些缓存要保留到设备级,也应该从UserScopedPersistKeys里排除,而不是在退出流程里临时判断。
Service 编排持久层和运行态一起归零
退出按钮不应该直接调用十几个清理方法。它只发起退出动作,Service 负责按顺序编排。
exportinterfaceLogoutResult{success:booleanmessage:string}exportclassLogoutService{constructor(privatereadonlyrepository:SessionRepository){}asynclogout():Promise<LogoutResult>{try{awaitthis.repository.clearUserScopedData()awaitthis.repository.markLoggedOut()this.resetRuntimeState()return{success:true,message:'已退出登录'}}catch(err){return{success:false,message:'退出登录失败,请重试'}}}privateresetRuntimeState():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。否则退出、注销、账号过期、清空数据会走出多套逻辑。
@Componentstruct AccountSettingsPage{@StateloggingOut:boolean=false@StateerrorText:string=''privatelogoutService:LogoutService=createLogoutService()privateaccountNavigator:AccountNavigator=createAccountNavigator()privateasynchandleLogout():Promise<void>{if(this.loggingOut){return}this.loggingOut=trueconstresult=awaitthis.logoutService.logout()this.loggingOut=falseif(!result.success){this.errorText=result.messagereturn}this.accountNavigator.backToLogin()}}这段页面代码只有交互职责:防止重复点击、展示失败、成功后跳转。清理范围不在页面里决定,后面新增“账号注销”也可以复用同一个 Service。
Builder 里读取运行态要给安全出口
@StorageLink值可能在首次挂载、清空数据、退出瞬间处于默认状态。页面展示时不要在 Builder 里直接假设数组或对象一定有业务值。
@Componentstruct ProfileHeader{@StorageLink(RuntimeKeys.currentUser)currentUser:string=''@StorageLink(RuntimeKeys.favoriteCourseIds)favoriteCourseIds:string[]=[]privategetUserName():string{returnthis.currentUser.length>0?this.currentUser:'未登录'}privategetFavoriteCount():number{returnthis.favoriteCourseIds?.length??0}build(){Column({space:8}){Text(this.getUserName())Text(`收藏课程${this.getFavoriteCount()}门`)}}}这里用普通方法返回安全默认值,不用复杂 getter 参与 Builder 状态读取。这样退出瞬间、清空数据后首次打开、账号状态恢复前,页面都能展示可预期内容。
退出后要回收导航栈
状态清干净之后,如果导航栈还保留已登录页面,用户按返回仍可能回到旧页面。退出成功后应该清空栈,再进入登录页或未登录首页。
exportclassAccountNavigator{constructor(privatereadonlynavStack: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。
验证路径要覆盖切账号和重启
退出登录问题不能只点一次按钮。至少要走下面这些路径:
- 账号 A 登录后收藏两门课程,确认首页和我的页都有统计。
- 点击退出登录,确认头像、昵称、收藏数量、最近记录都立即回到未登录态。
- 按系统返回键,确认不能回到账号 A 的页面。
- 关闭应用再打开,确认旧头像和旧收藏不会闪现。
- 登录账号 B,确认看不到账号 A 的收藏、草稿和最近浏览。
- 清空应用数据后首次进入,确认
@StorageLink字符串、数组都有安全默认值。 - 人为制造退出失败,确认页面不会半清状态并且有重试入口。
如果第 2 步失败,先查运行态;如果第 4 步失败,先查持久层;如果第 3 步失败,先查导航栈。把失败归类后,修起来会快很多。
风险提示和回退方案
退出链路改动面不小,适合分阶段落地。
| 风险 | 触发场景 | 处理方式 |
|---|---|---|
| 清错设备级数据 | 把主题、引导页状态也放进用户域 key | 区分用户域 key 和设备级 key |
| 当前页半更新 | 退出失败后已经重置运行态 | 先清持久层成功,再重置运行态 |
| 首页旧值闪现 | 启动先渲染旧缓存 | 启动水合默认值,恢复完成后再展示用户态 |
| 新增 key 漏清 | 新页面新增@StorageLink | 同步加入 key 清单、启动水合、退出重置 |
| 返回旧页面 | 只跳登录页没清栈 | 使用NavPathStack.clear()后再进入登录页 |
如果已经上线,不建议一次性重写所有持久化。可以先把退出链路中最危险的认证状态、当前用户、用户域缓存收口,再逐步把页面里的散落 key 迁出来。
常见问题和处理方式
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 退出后当前页还有旧昵称 | AppStorage没重置 | Service 中统一setOrCreate默认值 |
| 重启后旧收藏回来 | 持久层 key 没删 | Repository 按UserScopedPersistKeys清理 |
| 新账号看到旧草稿 | 用户域数据共用 key | 清理旧用户域或按 userId 隔离 |
| 清空数据后页面崩溃 | @StorageLink默认值缺失 | 启动阶段水合字符串、数组和对象 |
| 退出后返回旧页面 | 导航栈保留已登录页面 | 清栈后进入登录页 |
这些问题看似是 UI 现象,根因通常在状态边界。先把持久层、运行态、页面状态和导航栈分清楚,再决定改哪里。
复查清单
改完后我会按这份清单收尾:
- 用户域持久化 key 已集中到
UserScopedPersistKeys。 - 新增的
@StorageLinkkey 已在启动阶段setOrCreate默认值。 - 退出登录由 Service 编排,页面没有直接删持久化 key。
- 持久层清理和运行态重置同时发生,不只改其中一层。
- 首页、我的页、收藏页都能在退出后读到未登录态。
- 导航栈已回收,系统返回不会回到旧页面。
- 切账号、重启、清空数据、退出失败路径都走过。
这次问题给我的复盘
退出登录最容易被误修成按钮问题。按钮只是入口,真正的链路包含 key 清单、默认值水合、持久层删除、运行态重置、页面安全读取和导航回收。任何一层漏掉,旧状态都会从另一个入口回来。
如果重新处理一次,我会先列出所有用户域 key,再看哪些页面通过@StorageLink读取它们,接着把清理动作收到 Service,最后用切账号和重启验证。这个顺序比在页面上补零慢一点,但能把问题一次收成一类。
小结:退出登录是一条状态归零链路
退出登录要清干净,关键不是多写几个set(''),而是让状态有清晰 owner。Repository 删除持久层,Service 重置运行态,页面只触发动作和展示结果,导航层回收已登录页面栈。新增一个跨页面 key,就同步补启动水合、退出重置和验证路径。这样账号切换、重启和清空数据后,旧状态才不会回潮。