☰
鸿蒙UI开发:Preferences用户首选项存储与持久化实践
2026/10/13 4:27:18 网站建设 项目流程

1. 为什么UI开发中需要Preferences:从“配置忘了”说起

做鸿蒙UI开发的人,大概率都遇到过这种场景:用户把字号调大了,退出应用再进来,设置又回到默认状态;列表页的排序方式刚选好,重启一次应用全部被打回原形;登录状态明明记住了,结果清除缓存的时候连账号信息一起没了。这些痛点表面看是状态管理没做好,实际上问题出在“状态只活在内存里”。这一节要讲的Preferences用户首选项数据存储,就是鸿蒙应用开发里解决这类“小数据持久化”最顺手的手段。

Preferences本质上是一个键值对(Key-Value)存储,位置在应用自己的沙箱目录里,适合保存主题模式、字体大小、搜索历史、登录态、上次浏览位置这类碎片化配置。它和数据库、文件存储最大的区别是使用成本极低:没有建表、没有SQL、不需要定义复杂的数据结构,只要一个Key、一个Value,写进去再读出来就完事。在UI基础系列里安排这一节,是因为很多界面交互都需要这种“记忆能力”,没有它,页面做得再漂亮,用户用起来也觉得像失忆。

这里直接给结论:如果你要保存的数据是扁平结构、单项体积不大、总量通常不超过几十上百个Key,那么Preferences几乎是最省事的方案。它读起来快、写起来简单、接口稳定,属于那种“第一眼不起眼,但越用越顺手”的基础存储组件。这篇内容会把Preferences从API原理到封装思路、从页面接入到坑点排查完整过一遍,适合正在学鸿蒙应用开发、准备在自己项目里保存用户配置的同学参考。

1.1 UI状态和本地存储之间差的“最后一公里”

很多人刚开始写UI时有个惯性思维:界面显示什么,用变量记下来就行。比如页面里有个开关,用户点击之后按钮状态变化,看起来功能已经完成。但变量是放在内存里的,应用一旦被系统杀掉、用户主动清理后台、或者手机重启,变量就没了。

Preferences要解决的,正是“内存状态”到“持久化状态”这最后一公里。以音量设置为例:

  • 内存态:页面显示 80,拖动滑块时变量跟着变。
  • 持久态:用户下次启动应用,需要从沙箱读回 80,而不是回到默认的 50。

把持久态和内存态打通,就是UI开发里接入Preferences的核心操作。通常做法是:页面加载时先从Preferences读数据,赋值给界面状态变量;用户操作时先把数据写进Preferences,再同步更新界面变量。这样既保证了交互实时反馈,又保证了数据不会因为进程退出而丢失。这个过程不复杂,但很多初学者会在“先更新界面还是先写存储”这个顺序上纠结,后面我会用一个完整的案例演示。

1.2 为什么这个场景不用数据库,也不用普通文件

每种存储方案都有自己的生态位。Preferences适合做配置存储,但并不适合做大数据量、强查询的业务数据存储。下面这个对比可以帮你快速建立判断:

存储方案数据特点读写方式适合场景使用成本
Preferences扁平键值对、体量小按Key直接读写应用设置、用户偏好、小型状态极低
关系型数据库结构化、关联性强SQL条件查询、事务历史记录列表、业务对象管理较高
文件存储任意结构、体积可大整体写入、整体读取导出日志、保存图片缓存、大数据中

用数据库管一个开关状态,就像用货车搬一袋米,不是不行,但成本明显过高;用文件存一个主题模式,又像为了放一张纸条专门买了个仓库。Preferences的定义就是轻量,它把“保存用户首选配置”这件事做到了极致:不给数据建模,不要求你设计表结构,Key存在就读取,不存在就返回默认值,心智负担非常小。

当然,轻量也意味着边界。Preferences不会帮你加密,不适合直接存密码、支付令牌这类敏感信息;它也不擅长处理有复杂关系的业务数据,比如订单明细、聊天记录,这些还是交给数据库更合适。这一节开头先把边界划清楚,后面封装工具类时,思路才不会跑偏。

2. 先把Preferences的API逻辑吃透:实例获取、读写、落盘与监听

使用Preferences的第一步,不是直接调用存取方法,而是先拿到一个Preferences实例。很多人在这个环节卡住,是因为没搞明白实例从哪里来、和Context什么关系。鸿蒙的应用模型分FA模型和Stage模型,当前主流是Stage模型,在这种模型下,任何能力组件(Ability)都可以通过自己持有的Context去访问Preferences。

在较新版本的鸿蒙SDK中,一般通过@kit.ArkData导入相关模块,写法如下:

import { preferences } from '@kit.ArkData';

如果你看早期教程,可能见到@ohos.data.preferences,那是老一批的导入路径。功能本质是一回事,新项目建议直接使用新的Kit导入方式。

2.1 获取Preferences实例:同步接口和异步接口怎么选

获取实例有两种方式:getPreferencesSync(同步)和getPreferences(异步)。同步接口代码简单,适合在页面加载这种不追求极端性能的场景使用:

import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; let context = getContext(this) as common.UIAbilityContext; let pref = preferences.getPreferencesSync(context, { name: 'my_preferences' });

异步接口则是用Promise,适合放在初始化逻辑里统一管理:

async function initPreferences() { let context = getContext(this) as common.UIAbilityContext; let pref = await preferences.getPreferences(context, { name: 'my_preferences' }); return pref; }

这里有个容易忽略的点:name参数决定了存储文件的名字。你可以创建一个叫settings的存储,也可以创建一个叫user_data的存储。同一个应用下可以存在多个Preferences实例,对应不同的存储文件,这样做的好处是可以按照业务域隔离数据,比如“全局设置”和“用户个性化数据”分开保存,避免所有Key堆在一个命名空间里互相污染。

2.2 写入与读取:默认参数不是随便写的

拿到实例后,写入接口是put,读取接口是getSync。以保存一个音量值为例:

// 写入 pref.putSync('volume', 80); pref.flush(); // 读取 let volume = pref.getSync('volume', 50);

读接口的第二个参数是默认值。这个参数在设计UI时非常重要,因为UI状态往往需要一个“安全兜底”。比如第一次打开应用,Preferences里根本没有volume这个Key,如果读取时不给默认值,得到的可能是空值或undefined,页面渲染就会出问题。

很多人会把默认值当成一个可有可无的参数,实际上它应该是你定义数据模型的一部分。Key不存在时返回默认值,等于让你少写大量的if null判断。

另外要注意,put之后数据不会立刻落盘,需要调用flush才会真正写入持久化文件。flush是异步的,在需要确保数据不丢失的场景(比如用户退出登录)一定要等它完成。

2.3 删除、清空和数据变更监听:忘掉它们会吃亏

除了读写,Preferences还提供删除单个Key、清空全部数据、监听数据变化等接口:

// 删除单个Key pref.deleteSync('volume'); pref.flush(); // 清空所有数据 pref.clearSync(); pref.flush();

监听接口是on('change', callback),当数据发生变化时会触发回调。这个能力对UI开发很有价值,比如你在一个页面里修改了主题色,其他页面可以同通过监听感知到变化,从而自动刷新UI。

pref.on('change', (key: string) => { console.info(`Preferences key changed: ${key}`); });

需要注意的是,监听回调拿到的只是变化的Key,而不是完整的新值。所以回调里通常还要配合getSync再读一次。这个组合拳在做跨页面同步时非常实用,后面接入AppStorage时还会用到。

3. 构造一个可复用的Preferences工具类:从接口设计到底层封装

Preferences的原生API其实已经够用了,但直接散落在各个页面里用,很快会出现几个问题:到处写getPreferencesSync获取实例、Key名称分散不统一、每次存取都要处理异常。这一节要做的,就是把这些重复劳动收敛到一个工具类里,让它变成项目里可以被多个页面、多个模块复用的基础组件。

3.1 工具类要解决的核心难题:三大痛点

封装之前先想清楚要解决什么。以我实际做项目的经验,直接裸用Preferences会遇到三个痛点:

  • 第一个是上下文获取。工具类的方法需要Context才能获取Preferences实例,如果不能在一个统一入口初始化,每个页面都要传一遍。
  • 第二个是Key管理。如果Key散落在页面里,写错了系统不会报错,只会静默返回默认值,排查成本极高。
  • 第三个是类型安全。Preferences底层存储Value类型被限定为string、number、boolean等几种,但业务侧往往希望保存对象、数组,需要统一的序列化方案。

工具类要做的,就是把这几个痛点全部封装掉,让页面调用方只关心“存进去”和“拿出来”,不关心底层实现。

3.2 存储模块结构设计与Key收敛

我习惯在工程里单独建一个common/PreferencesUtil.ets文件,专门负责存储逻辑。先定义Key常量,把散落的Key统一管起来:

// 统一管理Key名称 export class PreferencesKeys { static readonly THEME_MODE: string = 'theme_mode'; static readonly FONT_SCALE: string = 'font_scale'; static readonly LAST_LOGIN_TIME: string = 'last_login_time'; static readonly RECENT_SEARCH: string = 'recent_search_list'; } // 统一管理默认值 export class PreferencesDefaults { static readonly THEME_MODE: string = 'system'; static readonly FONT_SCALE: number = 1; static readonly LAST_LOGIN_TIME: number = 0; }

接着定义一个单例化的工具类,确保Preferences实例只初始化一次:

import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; const STORE_NAME = 'app_config'; export class PreferencesUtil { private static instance: PreferencesUtil; private pref?: preferences.Preferences; private constructor() {} static getInstance(): PreferencesUtil { if (!PreferencesUtil.instance) { PreferencesUtil.instance = new PreferencesUtil(); } return PreferencesUtil.instance; } async init(context: common.UIAbilityContext): Promise<void> { this.pref = await preferences.getPreferences(context, { name: STORE_NAME }); } private requirePref(): preferences.Preferences { if (!this.pref) { throw new Error('Preferences has not been initialized. Call init() first.'); } return this.pref; } }

这里的关键设计是requirePref方法。很多工具类喜欢在未初始化时自动获取实例,但我更倾向直接抛错。原因是:一个需要上下文的组件,应该显式初始化,而不是偷偷依赖全局环境,否则后期维护时根本不知道数据是从哪个Context里存进去的。

3.3 类型安全读写与对象序列化方案

Preferences原生接口支持的值类型包括string、number、boolean等。对普通类型,直接封装一层就够了:

getString(key: string, defaultValue: string): string { return this.requirePref().getSync(key, defaultValue) as string; } getNumber(key: string, defaultValue: number): number { return this.requirePref().getSync(key, defaultValue) as number; } getBoolean(key: string, defaultValue: boolean): boolean { return this.requirePref().getSync(key, defaultValue) as boolean; } async putString(key: string, value: string): Promise<void> { await this.requirePref().put(key, value); } async putNumber(key: string, value: number): Promise<void> { await this.requirePref().put(key, value); }

但实际UI场景里,除了基本类型,我们还经常要存对象和数组。比如最近搜索列表,它是一个数组;用户自定义的主题配置,它是一个对象。Preferences原生并不支持直接存储对象,所以需要序列化:

import { JSON } from '@kit.ArkTS'; getObject<T>(key: string, defaultValue: T): T { const jsonStr = this.getString(key, ''); if (!jsonStr) { return defaultValue; } try { return JSON.parse(jsonStr) as T; } catch (e) { console.error(`Failed to parse preferences key: ${key}`, e); return defaultValue; } } async putObject(key: string, value: Object): Promise<void> { const jsonStr = JSON.stringify(value); await this.putString(key, jsonStr); }

JSON.parse可能抛异常,所以读取对象时必须包一层try-catch,否则数据一旦损坏,整个页面可能直接崩溃。

有的项目会比较激进,把getString、getNumber直接收拢成一个泛型方法。但ArkTS对泛型的支持不像传统JavaScript那么宽松,而且泛型方法在运行时做类型转换容易藏问题。我个人更推荐用明确的方法名暴露给业务层,虽然代码多了几行,但调用方一眼就知道拿到的是什么类型。

3.4 把“能用”的封装做成“可开源”的样子

一个能跑的封装和“开着源的”封装,差别往往不在功能,而在以下几方面:

  • 接口是否稳定:方法命名是否自解释,参数顺序是否符合直觉。
  • 错误处理是否完整:初始化失败、序列化失败、写入失败有没有日志。
  • 是否有文档:README里写清楚初始化方式、支持的数据类型、使用限制。
  • 是否对调用方友好:默认值、空值、异常路径都考虑到位。

举个例子,我见过一些项目里的封装会直接暴露Preferences实例,让页面自己调put。这个思路不是不行,但封装的边界就被打破了:页面一旦绕开工具类,Key管理和默认值约束就形同虚设。开源工具的价值,恰恰是提供一个“想绕也绕不开”的稳定入口。工具类内部可以保持精简,但对外提供的接口一定要像系统API一样可预测。

4. 在UI页面里接入Preferences:数据加载、保存与状态刷新实战

工具类构造完之后,就要真正去UI层使用。很多初学者在接入时会有一个误区:数据写了,页面却没刷新;页面刷新了,数据又没写。这是因为没有理顺“本地状态、持久化数据、界面渲染”三者之间的关系。这一节用一个具体的页面案例,把完整链路走一遍。

4.1 页面加载时读取首屏数据

假设有一个设置页,包含字体大小滑块和主题模式选项。页面显示什么,理论上应该由Preferences里保存的数据决定。这时候在页面的aboutToAppear生命周期里读取首屏数据:

@Entry @Component struct SettingsPage { @State fontScale: number = 1; @State themeMode: string = 'system'; aboutToAppear(): void { const util = PreferencesUtil.getInstance(); this.fontScale = util.getNumber(PreferencesKeys.FONT_SCALE, 1); this.themeMode = util.getString(PreferencesKeys.THEME_MODE, 'system'); } build() { Column() { Text(`当前字号:${this.fontScale.toFixed(1)}`) Slider({ value: this.fontScale, min: 0.8, max: 1.5, step: 0.1 }) .onChange((value: number) => { this.fontScale = value; this.saveFontScale(value); }) Row() { Text('跟随系统') .onClick(() => { this.themeMode = 'system'; this.saveThemeMode('system'); }) Text('浅色模式') .onClick(() => { this.themeMode = 'light'; this.saveThemeMode('light'); }) } } } }

这里要强调一个细节:首屏加载时,读取操作是同步完成的。Preferences本身就是轻量存储,同步读一个Key几乎不会造成可感知的卡顿,所以放在aboutToAppear里完全没有问题。不需要为了“追求异步”而把初始化搞得很复杂。

4.2 用户操作后写入并立即刷新

用户拖动滑块到新值时,我们要做两件事:更新内存中的状态变量,把新值写入Preferences。代码里我会先改this.fontScale,再调用保存方法,这个顺序是关键。

原因很简单:this.fontScale = value这一行立刻触发了UI重新渲染,用户看到滑块马上停在新的位置,交互是流畅的;Preferences的写入反而可以“慢半拍”,因为它只负责持久化,不负责界面反馈。如果把保存方法放在状态更新之前,又用await等待,用户拖完滑块可能会看到界面卡顿一瞬间,体验非常差。

保存方法的具体实现:

async saveFontScale(value: number): Promise<void> { const util = PreferencesUtil.getInstance(); await util.putNumber(PreferencesKeys.FONT_SCALE, value); await util.flush(); }

这里我把flush也封装进了工具类里。没有flush的话,数据只停留在内存缓存里,应用被杀掉就会丢失。调用flush之后,值才会真正落到沙箱文件中。但在UI交互里,我们一般不期望用户等待flush完成,所以这个方法是异步的,调用方不需要阻塞界面。

4.3 用AppStorage实现Preferences变化跨页面同步

单页面场景比较简单,难的是多页面需要共享同一份Preferences数据。比如你在设置页改了主题模式,个人中心页、首页、导航栏都要感知到变化。这种情况下,每个页面都去aboutToAppear里读一次是可以的,但页面已经处于后台又回到前台的场景,就不够优雅了。

鸿蒙UI框架提供了AppStorage,它是应用级别的状态存储,可以在不同组件之间共享状态。一个常见的做法是:把Preferences读取出来的数据“灌”进AppStorage,页面通过@StorageLink或@StorageProp消费。这样Preferences一旦变化,只要同步更新AppStorage,所有绑定该属性的组件都会自动刷新。

// 全局初始化 AppStorage.setOrCreate('themeMode', PreferencesUtil.getInstance().getString( PreferencesKeys.THEME_MODE, 'system' )); // 页面里消费 @Entry @Component struct HomePage { @StorageLink('themeMode') @Watch('onThemeModeChanged') themeMode: string = 'system'; onThemeModeChanged(): void { // 主题变化后重新渲染相关UI console.info(`theme mode changed to ${this.themeMode}`); } build() { // 使用 this.themeMode 控制页面样式 } }

同时,可以利用Preferences的on('change')监听机制,在数据变化时更新AppStorage:

pref.on('change', (key: string) => { if (key === PreferencesKeys.THEME_MODE) { AppStorage.setOrCreate( 'themeMode', PreferencesUtil.getInstance().getString(PreferencesKeys.THEME_MODE, 'system') ); } });

这个组合的好处是,页面之间不需要互相通知,也不需要暴露一堆事件回调。Preferences作为数据源,AppStorage作为UI层的状态镜像,数据变更后自动驱动UI刷新,整体架构非常干净。

5. 实际项目里才会遇到的边界问题与坑

工具类和页面示例都跑通了,接下来要聊的是真实项目里才会被“毒打”的边界问题。这些东西不是官方文档里没有,而是很多开发者踩坑之后才会意识到。写在这里,希望能帮你少走弯路。

5.1 多Ability多Hap场景下的实例隔离

一个鸿蒙应用可能包含多个Ability,甚至多个HAP包。Preferences实例的获取是基于传入Context的,不同Context可能对应不同的沙箱路径。这就导致一个问题:如果你在Ability A里初始化了Preferences,文件落在了A的沙箱目录;在Ability B里又用同样的name初始化,最后可能各存各的,数据根本不同步。

解决这个问题,核心是统一初始化入口。最简单的方式是在应用启动阶段,也就是最先启动的Ability里完成Preferences初始化,后续所有页面共用同一个工具类实例。工具类的单例设计在这里就发挥作用了:它保证整个进程内只创建一份Preferences实例,避免重复获取造成数据路径不一致。

还有一种更严格的方案,是确定由哪个Context负责存储,其他地方统一把那个Context传进来。但实际开发中,我更推荐保持单例+统一入口,大多数应用的需求用这个方案就够了。

5.2 并发写入、flush时机与性能问题

Preferences的flush并不便宜。如果用户在短时间内高频修改多个设置项,每改一次都调一次flush,磁盘IO会持续占用,页面也会有明显的卡顿感。我见过有的项目把滑块拖动事件和flush绑在一起,结果滑块拖一下,文件就写一次,体验很糟糕。

合理的做法是“改动频率高的数据延迟落盘”,或者做一个简单的防抖。比如滑块拖动时只更新内存值和状态变量,在用户松手或者离开页面的时机再统一调用flush。还可以使用批量写入的方式,把多个Key的改动合并到一起,最后一次性提交。

另外,多个异步写入如果并发执行,顺序无法保证。比如先写font_scale = 1.2,再写font_scale = 1.0,两个异步操作同时执行,最后落盘的值不一定是1.0。所以工具类里的写入方法,必要时可以用一个写入队列串行化,避免竞态问题。

5.3 类型变更、Key冲突与数据迁移

Preferences不校验类型。同一个Key,第一次存了一个number,第二次代码改成了存string,读取的时候返回什么全看底层实现。这种问题最容易在版本迭代里出现:早期没有定义Key规范,后来把theme这个Key从string改成了对象,结果线上老版本的数据直接解析失败。

规避方法主要有三个:

  • 给Key加前缀,比如settings_theme、user_volume,减少业务模块之间误用的概率。
  • 定义统一的数据版本字段。比如存一个config_version,版本升级时做一次迁移,把旧结构的数据转换成新结构。
  • 读取时使用typeof检查,遇到类型不符的情况,直接返回默认值而不是强行转换。

下面这张表总结了我遇到的几类典型问题:

现象根因建议处理
设置不生效,重启后又变回默认忘了调用flush,数据未落盘统一封装flush,离开页面时调用
多个Ability数据各改各的使用不同Context初始化Preferences统一初始化入口,单例持有
页面读取到undefined读取时没给默认值所有get接口必须传默认值
解析对象数据崩溃存储的JSON字符串损坏try-catch兜底,失败返回默认值
同名字段类型冲突版本迭代改变了存储结构引入数据版本号,做迁移逻辑

6. 把它做成真正的“开源工具”还需要哪些设计

前面已经实现了一个能正常工作的Preferences工具类,但要把这个工具放出去给其他人用,或者在自己项目里长期维护,还需要再做几层设计。这一节讲的是“从能用走向好维护”的进阶思路,也是很多教程不会细讲的部分。

6.1 API命名要像系统API一样可预测

工具类的调用方是业务开发,他们不会去读你的源码。因此方法命名必须足够直观:存就是put或者save,读就是get,删除就是remove,清空就是clear。不要自己发明一套奇怪命名,增加理解成本。

入参设计也要符合直觉。我曾经见过一个工具类,读取方法要求传入两个参数:默认值和Key,顺序是反的。调用方只要写错一次,后面就会一直错下去。一个可维护的工具类,应该让调用方在使用时几乎不需要看文档。如果某个方法的参数顺序连你自己都记不住,那就是设计需要调整的信号。

6.2 可测试性:单元测试与模拟验证

存储工具是基础组件,一旦出问题,影响面是全局的。所以最好在DevEco Studio里给工具类配上单元测试,至少覆盖以下场景:

  • 首次读取不存在Key时,返回默认值。
  • 写入后立即读取,能读到新值。
  • 调用clear后,所有Key都被清空。
  • 对象序列化写入后,读取结果和原对象一致。
  • 模拟损坏的JSON字符串,读取方法仍返回默认值且不抛异常。

测试时不需要真实设备,可以在测试环境里准备一个测试Context。单测的价值是当你后续修改工具类实现时,能第一时间发现回归问题。Preferences工具类虽然简单,但它是很多页面共同的地基,地基不稳,上层功能再花哨也没用。

6.3 扩展边界:加密存储、多用户与云同步值得做吗

随着项目变大,有人会给工具类加上加密、多用户切换、云同步能力。这些功能听起来很强大,但要冷静评估一下:Preferences的定位是轻量存储,它不是加密保险箱,也不是云端数据库。如果业务真的需要加密存储,应该使用鸿蒙提供的安全组件;如果需要云同步,应当走云开发服务的数据库通道。把这些重能力塞进Preferences工具类,很容易让它变成一个四不像。

我的建议是:工具类保持“本地轻量键值对存储”这个本质定位,只做薄封装。它要做的是让调用更安全、更统一、更好维护,而不是成为一个什么都干的超级容器。如果未来确实需要加密,可以设计一个加密接口作为扩展,但默认实现仍然保持透明、轻量。

最后再分享一个我个人的使用习惯:只要数据结构是扁平的、字段数量在几十个以内的小配置,我都优先用Preferences;一旦数据量明显膨胀、或者出现列表查询、条件过滤这类需求,就果断切换数据库。工具类封装得再顺手,也改变不了底层存储的边界。把这条边界想清楚,再回头看UI层的数据记忆问题,思路会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询