做鸿蒙应用开发也算踩了不少坑,最近在整理一个偏好设置模块时发现,很多刚接触 HarmonyOS App 开发的朋友对用户首选项(Preferences)的理解还停留在“会用接口”的层面。实际上这个 API 虽然看起来简单,但用得好不好,直接决定了一个应用在“记住用户设置”这件事上是从容还是翻车。尤其你做的是像网约车 App、工具类应用、内容类应用这类需要保存登录态、主题、筛选条件的项目,用户首选项几乎是绕不开的第一站数据存储方案。
这篇东西我不会讲大而全的鸿蒙理论,就围绕“用户首选项应用 App 开发”这个场景,把我在实际项目里怎么选型、怎么写、怎么踩坑、怎么封装的完整过程摊开来说。适合那些已经在用 DevEco Studio 写过 Hello World、想快速把本地数据持久化做扎实的开发者。刚入门的也能看,复杂概念我会拿生活里的例子打比方。
1. 用户首选项到底解决什么问题
1.1 先给用户首选项一个准确画像
用户首选项(Preferences)是 HarmonyOS 提供的一种轻量级键值对存储能力,官方定位是“用于保存应用的配置信息和用户偏好”。你可以把它理解成一张只有两个列的表格:一个 key,一个 value。存进去的时候按 key 写入,取出来的时候按 key 读取,没有复杂的查询语句、没有表结构设计,就是这么直接。
在 Android 开发里有一个非常像的东西叫 SharedPreferences,如果你以前用 Android Studio 开发过安卓 App 项目,上手鸿蒙的 Preferences 会感觉极其亲切。数据默认保存在应用沙箱内的文件中,应用卸载即清除,不需要你手动管理文件路径,也不需要申请存储权限,更不需要考虑多线程并发冲突——框架层面已经帮你把多数脏活累活干完了。
那它和“用户首选项应用 App 开发”这个题目有什么关系?其实绝大多数 App 里所谓的“记住我”功能,拆开来看就是几个键值对:用户ID、昵称、是否深色模式、上次选中的城市、消息推送开关。这些东西单独拿出来每一个都很小,但合在一起就是完整的用户体验。Preferences 就是为这种场景量身定做的。
1.2 为什么偏偏选它来做轻量持久化
我在项目里见过不少新人在需要持久化时,第一反应直接上关系型数据库,比如 RDB。说实话,如果只是存几个开关状态,这有点杀鸡用牛刀。我拿实际对比给你看:
| 维度 | Preferences 用户首选项 | 关系型数据库(RDB) | 文件读写 |
|---|---|---|---|
| 数据模型 | 键值对 | 表、行、列,支持 SQL | 任意格式 |
| 使用难度 | 极低,几行代码 | 高,要建表写 SQL | 中,要处理序列化 |
| 适合数据量 | KB 级,单个键值建议小 | MB 级以上 | 视格式而定 |
| 读取速度 | 快,适合频繁读 | 中,有解析开销 | 慢,全量读入 |
| 典型场景 | 设置项、状态位 | 业务数据、列表 | 日志、导出文件 |
做用户首选项应用开发,核心诉求就两个:快、简单。Preferences 读取是同步的,拿过来直接就能用,不需要 await 数据库查询,也不会有 Cursor 没关闭这种低级错误。而且它内部有缓存机制,多次读同一个 key 不会每次都走磁盘,这在设置页里频繁读取开关状态时体感特别明显。
我当时选它还有一个原因:不用引入额外的依赖和初始化代码。在 HarmonyOS 工程里,Preferences 属于系统能力 SDK 的一部分,直接在代码里 import 就能用,不折腾 Gradle 依赖,不折腾版本冲突。这对小项目来说太重要了。
1.3 哪些场景适合、哪些场景千万别用
虽然 Preferences 很好用,但它不是万能的。我见过有人试图用它存整个列表数据,结果数据量大以后读取越来越慢,这就是典型的用错场景。
适合用 Preferences 的场景:
- 用户登录态:token、userId、过期时间
- 界面偏好:深色模式开关、字体大小、语言选择
- 筛选条件:上次选择的城市、排序方式
- 启动状态:是否首次启动、是否已经引导过
- 音量、播放进度等轻量状态
千万别用 Preferences 的场景:
- 大数据列表:比如消息记录、订单历史,这种应该用关系型数据库或分布式数据管理
- 图片、文件资源:应该存文件,Preferences 只存路径
- 高频修改的超大 JSON:如果单条数据超过 KB 级,哪怕只有一个 key,也不建议塞这里,读写和序列化开销会让你后悔
- 需要跨设备实时同步的核心业务数据:这种情况请优先考虑分布式数据库或云服务
你想想,网约车 App 里的订单列表会存 Preferences 吗?肯定不会,那必须是数据库加服务端接口。但网约车 App 里的“记住上次选的常用地址”“深色模式”“免密支付开关”这种,用 Preferences 就非常合适。搞清楚边界,比学会 API 更重要。
2. 环境准备与工程搭建的细节
2.1 开发工具与 SDK 版本怎么定
写 HarmonyOS App 开发,官方 IDE 是 DevEco Studio。如果你之前用惯了 Android Studio,会发现两者很多地方类似,但鸿蒙这边从工程创建到构建链路都是独立的一套。
版本选择上我的建议是:别用太老的版本。Preferences API 从 API 9 开始就很稳定了,但如果你的目标设备是较新版本系统,直接用 API 12 甚至更新的 SDK 编译,能享受到更完善的类型提示和 ArkTS 语法支持。我目前常用的组合是 DevEco Studio 的稳定版加配套 SDK,编译 SDK 选 API 12 以上,兼容性没什么大问题。
创建工程时,模板选Empty Ability就够了,Preferences 不需要额外的工程配置。这里要注意一点:华为账号登录、签名配置这些可以后面再做,本地开发调试用 auto signing 即可,不影响你写业务逻辑。
2.2 创建一个最小工程骨架
工程建好后,你会看到典型的鸿蒙工程结构:
entry模块:应用主入口src/main/ets:ArkTS 源码目录entryability/EntryAbility.ets:UIAbility 入口pages/Index.ets:默认页面
很多第一次从 Android 转过来的朋友会到处找 XML 布局文件,其实鸿蒙用的是 ArkUI 声明式语法,直接在.ets 文件里用build()方法写 UI 结构,类似 Flutter 和 SwiftUI 的写法。我刚开始也不太习惯,写多了发现这种声明式的好处是:数据和 UI 绑定特别自然,状态变量一改,界面自动刷新。
如果你的目标是开发一个鸿蒙 App 小项目,比如做本地工具类应用,这个骨架完全够了。App 可以用什么框架开发?HarmonyOS 这边不需要纠结跨端框架,直接用官方 ArkTS/ArkUI 就是最省事、性能最稳的方案。
2.3 工程结构里那些容易被忽略的配置
有两个配置点,新人不注意会浪费不少时间:
第一,module.json5 里的权限声明。Preferences 不需要任何权限,所以你不需要往requestPermissions里加东西。如果你看到网上有些老文章让你申请存储权限,那是早期版本 API 的误导,别照抄。
第二,混淆和构建配置。Preferences 用的是字符串 key,不涉及代码混淆适配,所以不用像某些 SDK 那样配置 keep 规则。但你要注意:key 的命名一旦线上发布,后续尽量不要改动,因为用户本地已经存了旧 key 的数据,改名等于旧数据全部失效。这一点在设计阶段就要想清楚。
启动流程上,我的习惯是在EntryAbility的onCreate里初始化 Preferences 工具类,传入 UIAbility 的上下文,这样后续所有页面都能直接用同一个实例。后面讲到封装的时候我再详细说明。
3. 核心 API 逐行拆解与读写实战
3.1 获取 Preferences 实例的正确姿势
Preferences 的所有操作都基于一个 Preferences 实例。获取实例的代码如下:
import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; let context = getContext(this) as common.UIAbilityContext; let pref = preferences.getPreferencesSync(context, 'user_settings');第一行的@kit.ArkData是新版本推荐的数据管理 Kit 导入方式,如果你查老资料看到的是@ohos.data.preferences,那是旧包名,功能一致,但新项目建议直接按新方式写。
getPreferencesSync接收两个参数:第一个是上下文,用来定位应用沙箱目录;第二个是存储文件名,叫user_settings也好、app_config也好,本质上是把你偏好数据归到不同的“房间”里。我的建议是:一个 App 只用一个 Preferences 文件,不要按页面拆成多个。拆太碎会让初始化代码变多,而且多个实例同时写文件还可能引入你完全不想排查的时序问题。
同步接口拿实例,后续的 get 和 put 也有同步版,直接读就直接用,不需要写一堆异步回调,这对写设置页特别友好。
3.2 数据写入:put、flush 与异步落盘的秘密
写入操作很简单:
pref.putSync('dark_mode', true); pref.putSync('nick_name', '小张'); pref.putSync('login_token', 'token_string_here');这里有个关键点:putSync只修改了内存中的缓存,并没有马上写入磁盘。要真正持久化,必须调用:
await pref.flush();flush()是异步的,作用是把内存里的数据一次性落盘。你可能会问:为什么不 put 一下就马上写盘?因为频繁写磁盘很伤性能,合并成一次 flush 效率最高。但副作用就是,如果你 put 完直接杀进程,没等 flush 完成,数据就可能丢了。
实际开发里,我的做法是:连续多个 put 之后统一 flush 一次,比如保存整个设置页时,凑齐所有字段再调 flush。如果单个操作非常重要,比如登录 token,那必须立即 flush,不能省。这是我吃过亏换来的教训,后面排查问题部分会再讲。
3.3 数据读取:默认值决定了你的代码能活多久
读取接口长这样:
let darkMode = pref.getSync('dark_mode', false) as boolean; let nickName = pref.getSync('nick_name', '') as string;第二个参数是默认值,也就是 key 不存在的时候返回什么。这个参数极其重要,因为你不能保证每个 key 都存在:用户第一次安装还没写过设置、旧版本升级过来没有新字段、数据被手动清空过,都有可能。
我见过有人图省事不传默认值,直接getSync('dark_mode'),返回 null 后一处理不好就崩了,或者界面出现各种诡异状态。写默认值不只是为了兜底,更是给你的类型转换兜底。ArkTS 对类型要求比较严格,as转换之前,你必须保证默认值的类型和实际存储类型一致,否则拿到 undefined 后一切皆有可能。
3.4 删除、监听与跨页面联动
删除单个 key:
pref.deleteSync('login_token'); await pref.flush();清空所有配置:
pref.clearSync(); await pref.flush();clearSync慎用,最好是配合“恢复默认设置”这种明确的用户操作。以前我做一个设置页,用户点“恢复默认”时直接 clear 整个文件,结果把登录态也清了,用户体验非常差。后来改成只删那些真正属于“用户设置”的 key,登录态单独放另一个文件,或者从清空列表里排除。这个设计问题一定要提前想。
Preferences 还支持监听数据变化:
pref.on('change', (key: string) => { console.info(`偏好数据变更: ${key}`); // 在这里刷新页面状态 });这个监听器在 App 内部所有页面更新同一个 Preferences 文件时都会触发,非常适合做跨页面联动。比如深色模式开关在设置页改了,首页、列表页都要跟着换主题;靠这个回调广播一下,各页面各自处理自己的刷新逻辑,比手动用 EventHub 转一圈简单不少。
注意:监听器用完后要off注销,特别是在页面销毁时要清理,否则会内存泄漏。在aboutToDisappear里注销是标准操作。
3.5 数据导出与备份能力
Preferences 还提供了把数据导出成文件、再导入恢复的能力:
// 导入 preferences.importPreferences(getContext(this), 'backup_file_path'); // 导出 preferences.exportPreferences(getContext(this), 'export_file_path');这个能力我一开始完全没注意,直到做一个“设置备份”功能时才翻到。如果你做的是工具类 App,给用户提供“备份设置到本地/云盘,恢复设置”的功能,这俩接口能省下不少序列化代码。但要注意,这两个接口操作的是整个 Preferences 文件,不是单个 key,所以模块拆分设计还是要提前规划好。
4. 完整实现:做一个可用的偏好设置页
4.1 页面 UI 与交互设计
下面我用一个最简单的设置页串一遍完整流程。页面包含两块:昵称输入框和深色模式开关,另外加一个“保存设置”按钮,保存时统一写入并落盘。UI 用 ArkUI 声明式写法:
@Entry @Component struct SettingPage { @State nickName: string = ''; @State darkMode: boolean = false; build() { Column({ space: 16 }) { Text('用户偏好设置') .fontSize(20) .fontWeight(FontWeight.Bold) TextInput({ placeholder: '请输入昵称', text: this.nickName }) .onChange((value: string) => { this.nickName = value; }) Row() { Text('深色模式') Toggle({ type: ToggleType.Switch, isOn: this.darkMode }) .onChange((isOn: boolean) => { this.darkMode = isOn; }) } Button('保存设置') .width('100%') .onClick(() => { saveUserSettings(); }) } .padding(20) .width('100%') } }这里的 @State 变量就是页面级状态,输入和开关变化时自动更新。注意按钮的设计:我故意把保存操作集中到按钮上,而不是每次 onChange 都写盘。这样一是减少磁盘写入频率,二是逻辑清晰——用户改完所有项,点一下保存,全部生效。但如果你是做自动保存的交互,比如开关一拨就生效,那就在对应 onChange 里单独调用保存函数并立即 flush,看产品需求。
4.2 读取并回显用户偏好
页面显示出来的时候,需要从 Preferences 里把上次保存的值读出来,回显到输入框和开关上。我在aboutToAppear里做这件事:
aboutToAppear() { const pref = getUserPreferences(); this.nickName = pref.getSync('nick_name', '') as string; this.darkMode = pref.getSync('dark_mode', false) as boolean; }getUserPreferences()是封装好的获取实例函数,内部会判断上下文和存储文件名。读取是同步操作,在这里不会卡 UI,因为 Preferences 有缓存,首次启动也就一次磁盘读,后续都在内存。实测这个页面启动速度没有明显感知差异。
这里我想强调一个 ArkUI 的习惯:不要直接在 build 里读取 Preferences。build 会因为各种状态变化被频繁调用,你在 build 里做 IO 操作,等于每次刷新都读一遍文件缓存,性能白白浪费。要读就在生命周期函数里读,比如aboutToAppear、onPageShow。
4.3 保存与全局状态联动
保存函数这样写:
import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; function getUserPreferences(): preferences.Preferences { let context = getContext(this) as common.UIAbilityContext; return preferences.getPreferencesSync(context, 'user_settings'); } function saveUserSettings() { let pref = getUserPreferences(); pref.putSync('nick_name', this.nickName); pref.putSync('dark_mode', this.darkMode); pref.flush().then(() => { console.info('用户设置保存成功'); }).catch((err: Error) => { console.error(`保存失败: ${err.message}`); }); }如果你做了很深色模式,保存后还得通知全局状态变化。我推荐用AppStorage来存放全局的深色模式标记,保存时同步更新:
AppStorage.setOrCreate('darkMode', this.darkMode);其他页面通过@StorageProp('darkMode')或者@StorageLink('darkMode')绑定这个值,UI 会自动响应。这样 Preferences 负责持久化,AppStorage 负责运行时的 UI 状态分发,两边各司其职,是鸿蒙开发里很标准的配合姿势。
4.4 多页面下数据变更的同步策略
如果你的 App 有多个页面都会读写相同 key,只靠 AppStorage 可能还是不够,因为 AppStorage 生命周期和应用进程相关,进程杀掉重启后就没了,最终还是得从 Preferences 读。这种场景我建议在封装的工具类里加一层“版本号”机制,或者在每个页面aboutToAppear时重新读取相关 key。
你也可以利用 3.4 节提到的on('change')监听,在非当前页面接收变更事件,手动刷新。比如设置页修改主题后,首页在后台收到了 change 回调,就重新读取 dark_mode 并更新自己的状态。这个方案适合页面少、逻辑简单的小项目;页面多了之后,还是建议在统一的状态管理里做,避免回调满天飞。
5. 问题排查实录与性能调优
5.1 数据丢失:十有八九是没 flush
我遇到最多的问题就是:明明 put 了,重启 App 之后数据没了。排查下来大多数是同一个原因:没调用 flush,或者 flush 还没完成进程就被杀了。
这里提醒一下,putSync 和数据落盘之间是异步的,你 put 完后紧接着this.finish()退出应用,进程直接被回收,内存里的数据根本没机会写进磁盘。正确的做法是,在退出前、切后台前、或者做完关键写入后,显式调await flush()。如果是在 Ability 的onBackground里做,就得等 flush 完成再走,别图省事丢一个 pending promise 就不管了。
有一个例外:HarmonyOS 在系统正常退出时会尽量回收未完成的 flush,但这不是官方保证的行为,千万别赌。
5.2 类型错乱:一个 key 只能有一种性格
Preferences 支持 string、number、boolean 以及它们的数组类型。同一个 key,你用字符串写入,再用布尔读取,虽然不会直接报错,但返回结果会不可预期。举个例子:
pref.putSync('dark_mode', 'false'); // 字符串 "false" let value = pref.getSync('dark_mode', true) as boolean; // 实际是字符串 "false"value并不是 boolean 的 false,而是字符串 “false”,然后你拿去做三元判断,会发现永远走的是 truthy 分支。这种 bug 极难排查,因为编译不报错、运行不崩溃,就是逻辑不对。
我的解决方案很简单粗暴:key 命名带类型前缀。比如settings_dark_mode_boolean、settings_nick_name_string,或者在单独的常量类里定义所有 key,写注释标明类型。这样读代码的时候一眼就知道该用什么类型读写,避免别人接手时无意间改错。这属于很低成本但收益很高的工程习惯。
5.3 界面卡顿:别把 Preferences 当数据库
有朋友说设置页打开有点卡,我让他把相关的读取代码发给我,结果他在aboutToAppear里循环读了 100 多个 key,而且每个 key 都存了很大的数组。Preferences 单次读写虽然快,但扛不住高频大量操作。
虽然 get 是同步且内存缓存的,但首次读取要加载整个文件到内存。文件被撑得越大,第一次读取就越慢。为此,我给的优化建议是:
- 单文件的键值对数量控制在几十个以内
- 单个 value 尽量控制在小 KB 以内
- 高频读写的状态可以和低频配置拆分到两个 Preferences 文件
- 批量写入时先连续 putSync,再统一 flush,避免每写一个 key 就 flush 一次
如果你发现自己确实需要存更大的数据,那说明该引入关系型数据库 RDB 或者分布式数据了,Preferences 的定位就是轻量,非要拿它扛大件只会两边都难受。
5.4 多模块共享:统一入口才是正解
项目一大,多个模块都会读写 Preferences。如果每个模块都自己调getPreferencesSync,容易出现两个问题:文件名不统一,各写各的文件,数据互相看不到;或者同一个文件被多处实例同时操作,出现写覆盖。
我的做法是做一个全局单例的 PreferencesUtil,在入口统一初始化,所有模块走同一个工具类:
// PreferencesUtil.ets import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; const PREFERENCES_NAME = 'app_user_preferences'; class PreferencesUtil { private pref?: preferences.Preferences; init(context: common.UIAbilityContext) { this.pref = preferences.getPreferencesSync(context, PREFERENCES_NAME); } put(key: string, value: preferences.ValueType) { this.pref?.putSync(key, value); this.pref?.flush(); } get(key: string, defaultValue: preferences.ValueType): preferences.ValueType { return this.pref?.getSync(key, defaultValue) ?? defaultValue; } delete(key: string) { this.pref?.deleteSync(key); this.pref?.flush(); } } export default new PreferencesUtil();然后在 EntryAbility 的onCreate里初始化一次:
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { PreferencesUtil.init(this.context); }之后所有页面import PreferencesUtil from './PreferencesUtil'就能直接用了。我在第 6 节还会展开讲这个封装的更多扩展点。
5.5 问题排查速查表
| 现象 | 大概率原因 | 处理方案 |
|---|---|---|
| 重启后数据丢失 | 未调用 flush 或进程过早被杀 | 写入后立即 flush,关键数据等待完成回调 |
| 读取结果类型不对 | 同一 key 被不同类型读写 | key 命名带类型标识,统一管理 |
| 页面启动卡顿 | Preferences 文件过大 | 拆文件、降单条数据大小、减少同步读取量 |
| 设置文件被莫名清空 | 误调 clearSync | 慎用 clear,改为逐个 delete 指定 key |
| 多模块数据对不上 | 多实例、文件名不统一 | 全局单例工具类统一入口 |
6. 工程化封装与项目扩展心得
6.1 把 Preferences 包成一个更顺手的工具类
上面的单例工具类只是最基础的一层。我实际项目里还会加几个能力:
第一个是key 常量管理。所有 key 单独放一个文件,甚至用枚举类:
export enum SettingKey { NickName = 'settings_nick_name_string', DarkMode = 'settings_dark_mode_boolean', LoginToken = 'settings_login_token_string', }这样写代码时不怕手滑拼错字符串,DevEco 的代码提示也能帮你。第二个是类型安全的 getter:
getString(key: string, defaultValue: string): string { return this.get(key, defaultValue) as string; } getBoolean(key: string, defaultValue: boolean): boolean { return this.get(key, defaultValue) as boolean; } getNumber(key: string, defaultValue: number): number { return this.get(key, defaultValue) as number; }把as转换收敛到工具类内部,业务代码里就不需要到处做类型断言了,看起来干净,也更好维护。第三个是统一异常兜底。Preferences 操作虽然简单,但沙箱异常、磁盘空间不足时也会抛错。我在工具类里捕获一下,打日志,但不会让它崩到上层。特别是flush失败时,最好能留一条清晰日志,方便线上排查。
6.2 接上状态管理,让数据驱动 UI
把 Preferences 和 AppStorage 联动起来,是鸿蒙开发中很舒服的一种写法。每个 key 在 App 启动时读取一次,注册进 AppStorage;后续 UI 只依赖 AppStorage 的状态变量,设置项每次变更时同步写 Preferences 和 AppStorage。可能有人担心这样双重维护会不会冗余,我的经验是:完全不会,因为定位不同——Preferences 管“跨启动持久化”,AppStorage 管“运行时 UI 刷新”。一个小项目中,用好这两个能力,基本不需要引入额外的状态管理框架。
如果你用到了@StorageLink,有一点要留意:AppStorage 状态的初始值不一定来自 Preferences,首次启动时 Preferences 里可能还没有数据。你需要在初始化时先从 Preferences 读取默认值写入 AppStorage,再让页面绑定这个值。顺序反了,页面上就会出现一瞬间的默认值闪烁。
6.3 从个人项目到上架:时间与成本估算
很多朋友做鸿蒙 App 小项目时,会关心“开发一个 app 并上架大概要多少钱”。我个人的经验是,像用户首选项这种基础能力的开发,本身不产生额外直接成本——SDK、IDE 都是官方免费提供的。成本主要在你的时间上:一个设置页从零到跑通,熟练的话半天到一天;如果涉及主题联动、多页面同步、数据迁移,再加两三天。至于上架,需要准备应用签名、审核材料等,个人开发者建议提前规划应用名称、图标、隐私说明这类基础素材,真正走流程时能省不少来回沟通的时间。小步快跑,先把核心功能做扎实,比一开始就堆功能上线更有价值。
6.4 一点额外的小技巧
最后分享一个我自己比较受用的小习惯:凡是进入 Preferences 的 key,我都会在常量文件里统一管理,并且命名带上模块和类型。例如settings_dark_mode_boolean而不是dark,settings_login_token_string而不是token。这个习惯让我少踩了很多类型错乱的坑,也让别人接手代码时能快速判断这个 key 能存什么。
另外,测试时别忘了用 DevEco Studio 自带的设备文件浏览器直接查看 Preferences 的落盘文件,确认数据真的写进去了。我在排查时经常用它验证“到底是我代码没写对,还是 UI 刷新问题”,十次里有八次能立刻定位问题。开发阶段多花一分钟看文件,上线后少花一小时猜 bug,这笔账怎么算都划算。