ArkTS 用 Preferences 存 App 配置的正确姿势 —— 从踩坑到 singleton 封装

上周三晚上,我在 breathing 这个项目里补一个"多语言持久化"的功能。原本以为半小时能搞定的活,前后弄了差不多两个多小时。不是因为多语言复杂,是因为我第一次开始怀疑:这个项目里的 Preferences,我一开始的用法到底对不对

后来我把这块整个推倒重来了一遍,走到今天这个 singleton 封装的样子。这篇文章就是把这次的完整思路,从"最直觉的写法"到"我们现在的写法"讲清楚。写给刚开始碰 HarmonyOS 本地配置存储的同行看,也留一份给三个月后可能又来改动这块的自己。

工程侧本文对应的代码在 entry/src/main/ets/data/prefs/PreferencesStore.etsentry/src/main/ets/app/AppConfig.etsentry/src/main/ets/app/AppDataMigration.ets,一共 200 出头行 ------ 不多,但踩过的坑不少。


一、最直觉的写法(然后为什么放弃)

一开始我是这么写的,大概是所有 HarmonyOS 初学者查完文档后的第一反应:

typescript 复制代码
import dataPreferences from '@ohos.data.preferences';

// 在需要读配置的地方:
const prefs = await dataPreferences.getPreferences(context, 'breathflow');
const language = (await prefs.get('language', 'zh-Hans')) as string;

看起来没毛病吧。文档上就是这么写的。

但等我这么写了几次之后,问题一个接一个冒出来:

  • getPreferencesasync 的,我需要在每个用到配置的地方 await。SwiftUI 里我熟悉的那种"打开页面直接读 UserDefaults"完全做不到 ------ 每个组件初始化都要变异步。
  • 每次调用都得传 context。有些 UI 组件其实拿不到 context,或者要额外通过 getContext(this) 绕一圈。
  • 每个 get 返回的都是 ValueType,也就是 string | number | boolean | Array | object。你得每次都做类型判断,不然 TypeScript 编译器就跳出来。
  • 想存对象?官方推荐用 JSON 字符串。可你每次序列化反序列化的代码要重复写一遍。
  • 一旦配置项多了(这个项目里有 14 个 key),到处散落着字符串常量,改一个 key 名要全项目 grep。

我用这套写法坚持了大概两周。改的次数多了以后,就开始有一种"这个抽象层没搭好"的直觉。于是有了下面这些决策。


二、五个决策点

决策 1 · 为什么要 singleton

第一个想清楚的问题是:Preferences 实例应该谁持有

有两种思路:

  • 每个模块自己 getPreferences,各持各的
  • 全局一个 singleton,谁要用谁去拿

我选了第二个,理由不是"设计模式书里推荐单例",而是很实际的两点:

第一,getPreferences 内部是有开销的。它要读文件、初始化内存 map,不是白嫖。用 8 处地方各调用一次,就有 8 次 IO。

第二,初始化时机得可控。多语言、上次状态、隐私同意、订阅信息......这些配置都要在 App 启动最早期读出来。我不希望某个模块 lazy 初始化 Preferences 然后在渲染中间 block 一下 UI。

所以最终的形状是这样:

typescript 复制代码
export class PreferencesStore {
  private static instance: PreferencesStore | null = null;
  private prefs?: dataPreferences.Preferences;
  private initializing?: Promise<dataPreferences.Preferences>;

  static shared(): PreferencesStore {
    if (!PreferencesStore.instance) {
      PreferencesStore.instance = new PreferencesStore();
    }
    return PreferencesStore.instance;
  }

  async init(context: Context, name: string = DEFAULT_PREF_NAME): Promise<void> {
    if (this.prefs) {
      return;
    }
    if (!this.initializing) {
      try {
        this.initializing = dataPreferences.getPreferences(context, name);
      } catch (_err) {
        this.initializing = Promise.reject(_err);
      }
    }
    try {
      this.prefs = await this.initializing;
    } catch (_err) {
      this.prefs = undefined;
      this.initializing = undefined;
    }
  }

init 里那个 initializing 状态很重要 ------ 如果多个地方并发调 init,我不希望重复 getPreferences,也不希望第二个 caller 拿不到实例。这是踩过一次坑之后才补的。

init 只调用一次,在 EntryAbility.onCreate 或者 App 启动的 bootstrap 阶段。之后所有的读写都是同步的(这是决策 2 的事)。

决策 2 · getSync / putSync 还是 async

拿到 Preferences 实例之后,官方文档给了两套 API:

  • get(key, fallback) / put(key, value) / flush() ------ 全 Promise
  • getSync(key, fallback) / putSync(key, value) / flushSync() ------ 全同步

一开始我以为同步版本是"用于测试"或者"性能不好",下意识用了 async 版本。后来仔细看了下 SDK 源码和几个 demo,才明白:

Preferences 是内存里的 map,get/put 只操作内存 。真正的 IO 是 flush。同步版本只是省掉了 Promise 的包装开销,本质上并不慢。

对我这个场景来说,同步版本的好处是巨大的:

  • 组件构造函数里能直接读配置
  • UI 渲染时能直接判断"是否引导过"
  • 没有到处 async/await 传染

代价是什么?代价是 flushSync 会阻塞当前调用线程,直到写盘完成。但配置写入是低频操作(用户改个开关、切个语言),不是循环里疯狂写。一次几毫秒完全可以接受。

所以最终我们的封装全部走同步版本:

typescript 复制代码
setString(key: string, value: string): void {
  if (!this.prefs) {
    return;
  }
  try {
    this.prefs.putSync(key, value);
    this.prefs.flushSync();
  } catch (_err) {
    // Ignore persistence failures.
  }
}

一处判断失误 :这里我一开始把 flushSync 抽出来做批量 flush,比如"每 100ms flush 一次",觉得这样能省 IO。跑了两天以后发现有一次崩溃 ------ App 被系统 kill 的一瞬间,未 flush 的写入丢了,用户的语言设置回到了默认。之后就改成了 put 完立刻 flush,宁愿多写几次盘,也不能让用户改完的设置丢。

决策 3 · 每次 get 都 typeof 检查(静默失败哲学)

Preferences 的返回值是 ValueType

typescript 复制代码
type ValueType = number | string | boolean | Array<number> | Array<string> | Array<boolean> | Uint8Array | object;

理论上你 put 什么类型进去,get 就是什么类型出来。但实际上,可能出现两种情况:

  1. schema 升级:老版本存的是 number,新版本改成 string,读旧数据的时候类型不对
  2. 手动改文件:极少数用户会导入别人的配置文件,格式不一致

这两种情况在生产里都遇到过。如果不做类型保护,App 会直接 crash 或者跑出奇怪的状态。

所以我们的封装里,每个 getter 都做严格类型判断:

typescript 复制代码
getString(key: string, fallback: string = ''): string {
  if (!this.prefs) {
    return fallback;
  }
  try {
    const value = this.prefs.getSync(key, fallback);
    return typeof value === 'string' ? value : fallback;
  } catch (_err) {
    return fallback;
  }
}

getNumber(key: string, fallback: number = 0): number {
  if (!this.prefs) {
    return fallback;
  }
  try {
    const value = this.prefs.getSync(key, fallback);
    return typeof value === 'number' ? value : fallback;
  } catch (_err) {
    return fallback;
  }
}

每个 getter 都返回 fallback,永远不抛异常,永远不会返回 undefined。这条原则我叫它"静默失败哲学":配置读取失败绝不能让 App 挂掉,最多是回到默认值。

我知道有人会说"静默失败会掩盖 bug"。我的看法是 ------ 对于 Preferences 这种设施型组件,静默失败换来的稳定性远大于代价。真出问题的时候我们靠日志埋点(这个 App 里其实连日志都极少,主要靠用户反馈定位),不是靠让 App crash 来提醒。

决策 4 · Object 序列化:走 setString / getString

Preferences 的 ValueType 里其实是包含 object 的,理论上可以直接 put 一个复杂对象进去。

但我没这么做。我把复杂对象全部序列化成 JSON 字符串 ,走 setString / getString

typescript 复制代码
getObject<T>(key: string, fallback: T): T {
  const raw = this.getString(key, '');
  if (!raw) {
    return fallback;
  }
  try {
    return JSON.parse(raw) as T;
  } catch (_err) {
    return fallback;
  }
}

setObject<T>(key: string, value: T): void {
  this.setString(key, JSON.stringify(value));
}

为什么这么做?三个原因:

  1. 可预测性。Preferences 内置的 object 存储机制,SDK 之间的兼容行为我不敢完全信 ------ HarmonyOS 5 到 6 之间 API 有过调整。用 JSON string 我完全掌控。
  2. 可迁移。等哪天我要把 App 数据导出成备份文件,JSON 字符串直接就能贴出来看。
  3. iOS 兼容。这个项目未来会做 iOS 版,iOS 的 UserDefaults 也存 JSON 字符串最省事,模型层可以复用。

这个决策不适合所有场景 ------ 如果你要存一个 5MB 的大对象,JSON 每次序列化会有开销。但 App 配置基本都是几百 bytes 到几 KB 的东西,开销可以忽略。

决策 5 · schemaVersion + Migration

这是这个封装里最不"直觉"、但真正救过我的一部分。

App 会迭代,配置项会变。老用户升级到新版本时,Preferences 里可能还残留着旧版的字段。第二版的 breathing 里,lastState 从字符串变成了对象,interruptedSession.timestamp 从秒改成了毫秒,quickRecents 加了长度限制......如果不做迁移,老用户升级后要么看到奇怪的状态,要么直接 crash。

我在 AppConfig.ets 里存了一个 schema 版本:

typescript 复制代码
export const APP_SCHEMA_VERSION = 2;

export const PREF_KEYS: PrefKeys = {
  schemaVersion: 'breathflow_schema_version',
  settings: 'breathflow_settings',
  lastState: 'breathflow_last_state',
  intent: 'breathflow_intent',
  customModes: 'breathflow_custom_modes',
  scenePrefs: 'breathflow_scene_prefs',
  quickRecents: 'breathflow_quick_recents',
  guideSeen: 'breathflow_guide_seen',
  onboardingSeen: 'breathflow_onboarding_seen',
  privacyConsentAccepted: 'breathflow_privacy_consent_accepted',
  interruptedSession: 'breathflow_interrupted_session',
  widgetSnapshot: 'breathflow_widget_snapshot',
  liveSnapshot: 'breathflow_live_snapshot',
  language: 'breathflow_language'
};

然后有一个 AppDataMigration 类专门做迁移:

typescript 复制代码
export class AppDataMigration {
  static run(prefs: PreferencesStore): void {
    const currentVersion = prefs.getNumber(PREF_KEYS.schemaVersion, 0);
    if (currentVersion >= APP_SCHEMA_VERSION) {
      return;
    }
    AppDataMigration.migrateLastState(prefs);
    AppDataMigration.migrateInterruptedSession(prefs);
    AppDataMigration.migrateIntent(prefs);
    AppDataMigration.migrateQuickRecents(prefs);
    AppDataMigration.migrateScenePrefs(prefs);
    prefs.setNumber(PREF_KEYS.schemaVersion, APP_SCHEMA_VERSION);
  }
  // ... 5 个 migrate 方法
}

在 App 启动阶段,bootstrapinit 完 Preferences 之后立刻跑一次 Migration.run。逻辑很简单:读到的版本号如果比当前小,就跑迁移,然后把版本号写成当前

migrateInterruptedSession 举个例子,处理"timestamp 从秒改成毫秒"的问题:

typescript 复制代码
private static migrateInterruptedSession(prefs: PreferencesStore): void {
  const fallback: LegacyInterruptedSession = {};
  const interrupted = prefs.getObject<LegacyInterruptedSession>(PREF_KEYS.interruptedSession, fallback);
  const rawTimestamp = typeof interrupted.timestamp === 'number'
    ? interrupted.timestamp
    : (typeof interrupted.time === 'number' ? interrupted.time : 0);
  if (rawTimestamp <= 0) {
    prefs.remove(PREF_KEYS.interruptedSession);
    return;
  }
  // 小于 10^10 认为是秒级时间戳,×1000 转换成毫秒
  const timestamp = rawTimestamp < 10_000_000_000
    ? Math.floor(rawTimestamp * 1000)
    : Math.floor(rawTimestamp);
  const normalized: LegacyInterruptedSession = {
    timestamp,
    // ... 其他字段
  };
  prefs.setObject(PREF_KEYS.interruptedSession, normalized);
}

这段代码不复杂,但它是老用户升级到新版本时不 crash 也不丢数据的核心保障

我推荐每个非玩具项目从第一天就上 schema version + migration 机制。上线之后再想加就晚了 ------ 你不知道用户 Preferences 里都残留着什么祖传字段。


三、用起来是什么感觉

封装完之后,业务侧的代码变成这样(AppStore.bootstrap 里的真实片段):

typescript 复制代码
async bootstrap(context: Context): Promise<void> {
  this.context = context;
  await this.prefs.init(context);
  AppDataMigration.run(this.prefs);
  // ...
  const storedLanguage = this.prefs.getString(PREF_KEYS.language, 'zh-Hans');
  this.language = this.normalizeLanguage(storedLanguage);
  this.guideSeen = this.prefs.getBoolean(PREF_KEYS.guideSeen, false);
  this.onboardingSeen = this.prefs.getBoolean(PREF_KEYS.onboardingSeen, false);
  this.privacyConsentAccepted = this.prefs.getBoolean(PREF_KEYS.privacyConsentAccepted, false);
  this.settings = this.normalizeSettings(
    this.prefs.getObject<AppSettings>(PREF_KEYS.settings, defaultSettings)
  );
  this.customModes = AppStore.normalizeCustomModes(
    this.prefs.getObject<CustomModePayload[]>(PREF_KEYS.customModes, [])
  );
  // ...
}

一段代码把十几项配置全读出来,全同步、类型安全、没有 await 传染。跟一开始那种"每个组件自己 await getPreferences"的写法比,可读性和维护性完全不是一个量级。

保存也简单:

typescript 复制代码
this.prefs.setString(PREF_KEYS.language, 'en');
this.prefs.setBoolean(PREF_KEYS.guideSeen, true);
this.prefs.setObject(PREF_KEYS.settings, this.settings);

一行搞定,不用 await,不用 flush,不用 try/catch。


四、几个可能被问到的点

为什么不用官方的 dataAbility 或 dataShare?

我们只做单进程内的配置存储,不需要跨进程访问。Preferences 就是最简单最快的选择。dataShare 更适合"多个 App 之间共享数据"或者"给系统组件(比如卡片)读数据"的场景,用错场景等于杀鸡用牛刀。

顺带一提,我们的 Widget(卡片)没有 直接读 Preferences,而是走 widgetSnapshot 这个专门存"给卡片看的数据"的 key ------ 每次 App 状态变化时序列化一份最新快照进去,卡片 refresh 时就是读它。这样一是安全(卡片不需要看到用户的完整设置),二是快(一个 key 一次读完就渲染)。

setObject 之后 App 崩了,数据会丢吗?

不会。我们的 setObject 内部走的是 setStringputSync + flushSync,写盘是同步的。除非你 kill 得极精准正好落在 putSyncflushSync 之间的几微秒,否则数据是安全的。

这套封装能直接搬到 iOS 版吗?

心智可以直接搬,代码要重写。iOS 版对应的是 UserDefaults.standard,同样有 getString/setString/setObject 这些语义,同步 API,静默失败哲学也一致。唯一大的区别是 iOS 有 NSUbiquitousKeyValueStore 可以做跨设备同步,这在 HarmonyOS 里目前没有对应产品(分布式数据是另一码事,交互模型不同)。

每 App 启动就 read 十几个 key,会不会慢?

不会。Preferences 内部就是一个 HashMap,getPreferences 那一步已经把整个文件加载到内存了。之后每个 getSync 都是 O(1) 的内存读取。我们这个 App 启动完整个 bootstrap 加上 Preferences 读取,冷启动大概在 200-400ms 之间,Preferences 那块占的时间可以忽略。


五、总结与建议

如果你是刚接手一个 HarmonyOS 项目,或者从 iOS/Android 迁过来的老鸟,我的三条建议:

  1. 不要在业务代码里直接 dataPreferences.getPreferences 。封装一个 singleton,把所有 IO 挡在 App 启动阶段的 init 里。
  2. 优先用 sync 版本。put/flush 立刻做完,不要 batch。
  3. 从第一天上 schemaVersion + Migration。上线之后再补是补不干净的。

用起来的形状差不多就是这样了:PreferencesStore.shared().getString(...)。写业务的人不需要关心 async、不需要关心异常、不需要关心 context ------ 那些复杂性都被这一层吃掉了。

这套封装在我这个项目里已经跑了半年出头,大概处理了几千次用户的配置读写,没出过一次 crash,也没出过一次数据丢失。它不复杂,但值得每个非玩具项目都做一次。

间就酱!

相关推荐
懿路向前1 小时前
【HarmonyOS学习笔记】2026-07-24 | textProcessing 实体识别与踩坑实录
笔记·学习·边缘计算·harmonyos
贾伟康2 小时前
【笔下生辉|02】HarmonyOS ArkTS 素材库详情实战:组织例句、解释、收藏和练习入口
harmonyos·arkts·详情页·学习进度·收藏功能
独隅2 小时前
DevEco Code 在 Windows/MacOS 双系统上的完整使用指南
ide·人工智能·windows·macos·华为·harmonyos
●VON2 小时前
鸿蒙 PC Markdown 编辑器有界版本历史:沙箱快照、完整性校验与安全恢复
安全·华为·编辑器·harmonyos·鸿蒙
痕忆丶3 小时前
OpenHarmony北向开发基础之 沙箱机制+分布式文件
harmonyos
youtootech13 小时前
HarmonyOS《柚兔学伴》项目实战25-我的页面、Web 嵌入与项目总结
前端·华为·harmonyos
三声三视14 小时前
uni-app 鸿蒙端传参变成 [object Object]?顺着源码追到 ArkTS router 底层才搞明白
人工智能·ai·uni-app·aigc·ai编程·harmonyos
达子66614 小时前
第7章_HarmonyOS 图解 Ability公共事件与通知
华为·harmonyos
爱写代码的阿森14 小时前
鸿蒙三方库 | harmony-utils之KvUtil键值型数据库操作详解
数据库·华为·harmonyos·鸿蒙·huawei