ArkTS 登录态持久化:Preferences 三个易踩的坑与一套可复用封装

举报
yd_274993192 发表于 2026/11/06 13:57:04 2026/11/06
【摘要】 端云一体应用常把登录态写进 Preferences,但异步加载竞态、敏感字段明文落盘、flush 时机错位最易引发线上问题。本文沿 EntryAbility 冷启动链路拆解这三个坑,给出一份可直接复用的 SessionStore 封装与验证清单。

端云一体的鸿蒙应用里,"记住登录态"看起来是十行代码就能做完的事:登录成功后把用户信息存起来,下次冷启动读出来决定跳首页还是登录页。但真正上线后,用户反馈最多的偏偏是"明明登录过,一打开又让我登录""退出登录后重启又自动登录了"。这类问题几乎都出在 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 的存在。

验证清单

改完之后,建议按这五个场景各跑一遍,能覆盖绝大多数回归:

  1. 登录后冷启动(进程完全杀掉):应直达首页,无登录页闪帧。
  2. 登录后强杀进程再启动:状态仍在,验证 flush 生效。
  3. 手动把设备时间调过 TTL,或把 expire_at 改小:应回落登录页。
  4. 退出登录后立即强杀进程再启动:不应"复活"会话。
  5. 从通知 / 深链接拉起另一个 Ability:读到的应是同一份会话,不出现双份状态。

小结

登录态持久化真正要解决的不是"怎么存",而是"什么时候读完了才允许渲染"以及"哪些东西根本不该存在本地"。把异步时序收进生命周期、把敏感凭据挡在端侧之外、把 flush 与单例收进一层封装,这三个决定比选哪个存储 API 更影响稳定性。封装好之后,后续接生物识别、多账号切换或会话续期,也只需要改这一个文件。

本文为原创实践笔记,代码为便于说明做了精简,实际工程请按所用 SDK 版本调整类型声明与异常处理。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。