技巧精选

ArkTS 用 Preferences 存配置:从踩坑到 singleton 封装

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

精选理由

作者用真实踩坑经历教你用 Singleton 封装 ArkTS 的 Preferences,避免到处传 context 和异步问题,代码仅 200 行。

AI 摘要

作者分享在 HarmonyOS 的 ArkTS 中使用 Preferences 存储 14 个配置项的经验。最初直接调用 getPreferences 导致每个组件需 await 并传 context,产生 8 次 IO 开销。通过 Singleton 封装 PreferencesStore,将初始化控制在 EntryAbility.onCreate,并提供 getSync/putSync 同步接口,将代码精简至 200 余行。最终解决了异步传染、类型判断和 key 散落等问题。

原文 · 掘金本周最热

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

上周三晚上,我在 breathing 这个项目里补一个"多语言持久化"的功能。原本以为半小时能搞定的活,前后弄了差不多两个多小时。不是因为多语言复杂,是因为我第一次开始怀疑: 这个项目里的 Preferences,我一开始的用法到底对不对 。 后来我把这块整个推倒重来了一遍,走到今天这个 singleton 封装的样子。这篇文章就是把这次的完整思路,从"最直觉的写法"到"我们现在的写法"讲清楚。写给刚开始碰 HarmonyOS 本地配置存储的同行看,也留一份给三个月后可能又来改动这块的自己。 工程侧本文对应的代码在 entry/src/main/ets/data/prefs/PreferencesStore.ets 、 entry/src/main/ets/app/AppConfig.ets 和 entry/src/main/ets/app/AppDataMigration.ets ,一共 200 出头行 —— 不多,但踩过的坑不少。 一、最直觉的写法(然后为什么放弃) 一开始我是这么写的,大概是所有 HarmonyOS 初学者查完文档后的第一反应: import dataPreferences from '@ohos.data.preferences' ; // 在需要读配置的地方: const prefs = await dataPreferences. getPreferences (context, 'breathflow' ); const language = ( await prefs. get ( 'language' , 'zh-Hans' )) as string ; 看起来没毛病吧。文档上就是这么写的。 但等我这么写了几次之后,问题一个接一个冒出来: getPreferences 是 async 的,我需要在每个用到配置的地方 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。 所以最终的形状是这样: 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 会阻塞当前调用线程,直到写盘完成。但配置写入是低频操作(用户改个开关、切个语言),不是循环里疯狂写。一次几毫秒完全可以接受。 所以最终我们的封装全部走同步版本: 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 : type ValueType = number | string | boolean | Array < number > | Array < string > | Array < boolean > | Uint8Array | object ; 理论上你 put 什么类型进去,get 就是什么类型出来。但实际上,可能出现两种情况: schema 升级 :老版本存的是 number,新版本改成 string,读旧数据的时候类型不对 手动改文件 :极少数用户会导入别人的配置文件,格式不一致 这两种情况在生产里都遇到过。如果不做类型保护,App 会直接 crash 或者跑出奇怪的状态。 所以我们的封装里,每个 getter 都做严格类型判断: 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 : 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)); } 为什么这么做?三个原因: 可预测性 。Preferences 内置的 object 存储机制,SDK 之间的兼容行为我不敢完全信 —— HarmonyOS 5 到 6 之间 API 有过调整。用 JSON string 我完全掌控。 可迁移 。等哪天我要把 App 数据导出成备份文件,JSON 字符串直接就能贴出来看。 iOS 兼容 。这个项目未来会做 iOS 版,iOS 的 UserDefaults 也存 JSON 字符串最省事,模型层可以复用。 这个决策不适合所有场景 —— 如果你要存一个 5MB 的大对象,JSON 每次序列化会有开销。但 App 配置基本都是几百 bytes 到几 KB 的东西,开销可以忽略。 决策 5 · schemaVersion + Migration 这是这个封装里最不"直觉"、但真正救过我的一部分。 App 会迭代,配置项会变。老用户升级到新版本时,Preferences 里可能还残留着旧版的字段。第二版的 breathing 里, lastState 从字符串变成了对象, interruptedSession.timestamp 从秒改成了毫秒, quickRecents 加了长度限制……如果不做迁移,老用户升级后要么看到奇怪的状态,要么直接 crash。 我在 AppConfig.ets 里存了一个 schema 版本: 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 类专门做迁移: 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 启动阶段, bootstrap 里 init 完 Preferences 之后立刻跑一次 Migration.run 。逻辑很简单: 读到的版本号如果比当前小,就跑迁移,然后把版本号写成当前 。 拿 migrateInterruptedSession 举个例子,处理"timestamp 从秒改成毫秒"的问题: 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 里的真实片段): 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"的写法比,可读性和维护性完全不是一个量级。 保存也简单: 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 内部走的是 setString → putSync + flushSync ,写盘是同步的。除非你 kill 得极精准正好落在 putSync 和 flushSync 之间的几微秒,否则数据是安全的。 这套封装能直接搬到 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 迁过来的老鸟,我的三条建议: 不要在业务代码里直接 dataPreferences.getPreferences 。封装一个 singleton,把所有 IO 挡在 App 启动阶段的 init 里。 优先用 sync 版本 。put/flush 立刻做完,不要 batch。 从第一天上 schemaVersion + Migration 。上线之后再补是补不干净的。 用起来的形状差不多就是这样了: PreferencesStore.shared().getString(...) 。写业务的人不需要关心 async、不需要关心异常、不需要关心 context —— 那些复杂性都被这一层吃掉了。 这套封装在我这个项目里已经跑了半年出头,大概处理了几千次用户的配置读写,没出过一次 crash,也没出过一次数据丢失。它不复杂,但值得每个非玩具项目都做一次。 间就酱!