ArkTS 登录态持久化:Preferences 三个易踩的坑与一套可复用封装
端云一体的鸿蒙应用里,"记住登录态"看起来是十行代码就能做完的事:登录成功后把用户信息存起来,下次冷启动读出来决定跳首页还是登录页。但真正上线后,用户反馈最多的偏偏是"明明登录过,一打开又让我登录""退出登录后重启又自动登录了"。这类问题几乎都出在 Preferences 的使用姿势上,而不是登录接口本身。
本文按一次冷启动的真实调用顺序,拆三个最容易踩的坑,并给出一份可以直接搬到工程里的封装。
坑一:onCreate 里的异步竞态
Preferences 的读取是异步的:getPreferences 返回 Promise,get 同样返回 Promise。常见写法是在 EntryAbility.onCreate 里发起读取但不等待,随后 onWindowStageCreate 直接 loadContent。Ability 生命周期不会等你那个 Promise,于是首帧渲染时 AppStorage 里还是默认值 null,首页按"未登录"处理,用户就看到一次登录页闪回。
// 反例:读取未完成就放行首帧
onCreate() {
preferences.getPreferences(this.context, 'session').then(s => {
s.get('user', '').then(v => AppStorage.setOrCreate('user', v));
});
}
onWindowStageCreate(ws: window.WindowStage) {
ws.loadContent('pages/Index'); // 此时 AppStorage 大概率还是空
}
正确做法是把"恢复会话"作为冷启动的一个显式前置阶段:在 onCreate 里 await 完成并写入 AppStorage,再让窗口阶段根据结果决定落地页。这样路由判断依赖的状态在首帧之前一定就绪。
async onCreate(want: Want, param: AbilityConstant.LaunchParam) {
const store = await SessionStore.get(this.context);
AppStorage.setOrCreate('sessionUser', await store.restore());
}
async onWindowStageCreate(ws: window.WindowStage) {
const u = AppStorage.get<SessionUser | null>('sessionUser');
await ws.loadContent(u ? 'pages/Index' : 'pages/Login');
}
坑二:把敏感凭据当普通字段存
Preferences 本质是应用沙箱内的持久化键值文件,内容并非加密存储。把 access token、refresh token 甚至密码明文塞进去,等于把长期凭据摊在磁盘上;一旦设备被 root、应用被备份导出、或日志里误打了整个 store,风险就从"本地状态"升级成"账号被盗"。
更稳的边界划分是:
- 只存非敏感的展示态:userId、昵称、头像 URL、会话过期时间。这些丢了最多是重新登录一次。
- 凭据交给服务端会话:token 放在服务端 session 或短期令牌里,端侧只持有一个可随时失效的会话标识;需要本地保护时使用系统密钥库能力,而不是自己往 Preferences 写密文。
- 给本地状态加 TTL:哪怕服务端没通知失效,本地也要有 expireAt,超时一律按未登录处理。
这条边界还有个额外好处:换设备、改密码、管理员踢下线时,服务端一处失效即可,不依赖端侧清理逻辑是否跑到位。
坑三:flush 时机与多实例
put 只改内存中的副本,flush 才真正落盘。很多"退出登录后杀掉进程,重启又登录了"的怪现象,就是退出时只 put 了空值、没 flush,进程被系统回收后磁盘上仍是旧值。写操作和清理操作之后都应显式 flush()。
另外,同名的 Preferences 会复用同一个实例,跨 Ability、跨页面各自 getPreferences 并不会得到独立副本。与其在各处散落调用,不如用全局单例统一收口,既避免重复加载,也让"读—写—落盘"的顺序只有一处实现。
一套可复用的 SessionStore
下面这层封装把上面三件事一次做完:单例加载、带 TTL 的恢复、写后 flush、退出时清理,并与 AppStorage 同步,方便 UI 侧用 @StorageLink 直接响应登录态变化。
// SessionStore.ets
import { preferences } from '@kit.ArkData';
const STORE_NAME = 'session_store';
const KEY_USER = 'user';
const KEY_EXPIRE = 'expire_at';
export interface SessionUser {
userId: string;
nickName: string;
}
export class SessionStore {
private static inst: SessionStore | null = null;
private store: preferences.Preferences | null = null;
static async get(ctx: Context): Promise<SessionStore> {
if (!SessionStore.inst) {
const s = new SessionStore();
s.store = await preferences.getPreferences(ctx, STORE_NAME);
SessionStore.inst = s;
}
return SessionStore.inst;
}
async restore(): Promise<SessionUser | null> {
const expire = await this.store?.get(KEY_EXPIRE, 0) as number;
if (!expire || Date.now() > expire) return null;
const raw = await this.store?.get(KEY_USER, '') as string;
if (!raw) return null;
return JSON.parse(raw) as SessionUser;
}
async save(user: SessionUser, ttlMs: number): Promise<void> {
await this.store?.put(KEY_USER, JSON.stringify(user));
await this.store?.put(KEY_EXPIRE, Date.now() + ttlMs);
await this.store?.flush(); // 关键:立即落盘
AppStorage.setOrCreate('sessionUser', user);
}
async clear(): Promise<void> {
await this.store?.delete(KEY_USER);
await this.store?.delete(KEY_EXPIRE);
await this.store?.flush(); // 退出同样要落盘
AppStorage.setOrCreate('sessionUser', null);
}
}
调用侧只剩两行:登录成功后 await store.save(user, 7 * 24 * 3600 * 1000),退出时 await store.clear() 再跳登录页。业务页面不需要知道 Preferences 的存在。
验证清单
改完之后,建议按这五个场景各跑一遍,能覆盖绝大多数回归:
- 登录后冷启动(进程完全杀掉):应直达首页,无登录页闪帧。
- 登录后强杀进程再启动:状态仍在,验证 flush 生效。
- 手动把设备时间调过 TTL,或把 expire_at 改小:应回落登录页。
- 退出登录后立即强杀进程再启动:不应"复活"会话。
- 从通知 / 深链接拉起另一个 Ability:读到的应是同一份会话,不出现双份状态。
小结
登录态持久化真正要解决的不是"怎么存",而是"什么时候读完了才允许渲染"以及"哪些东西根本不该存在本地"。把异步时序收进生命周期、把敏感凭据挡在端侧之外、把 flush 与单例收进一层封装,这三个决定比选哪个存储 API 更影响稳定性。封装好之后,后续接生物识别、多账号切换或会话续期,也只需要改这一个文件。
本文为原创实践笔记,代码为便于说明做了精简,实际工程请按所用 SDK 版本调整类型声明与异常处理。
- 点赞
- 收藏
- 关注作者
评论(0)