资讯动态

鸿蒙UI开发:Preferences用户首选项存储与持久化实践

发布时间:2026/10/13 4:27:21 来源:尧图企业网站定制
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): Promisevoid { 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): Promisevoid { await this.requirePref().put(key, value); } async putNumber(key: string, value: number): Promisevoid { await this.requirePref().put(key, value); }但实际UI场景里除了基本类型我们还经常要存对象和数组。比如最近搜索列表它是一个数组用户自定义的主题配置它是一个对象。Preferences原生并不支持直接存储对象所以需要序列化import { JSON } from kit.ArkTS; getObjectT(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): Promisevoid { 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): Promisevoid { 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)监听机制在数据变化时更新AppStoragepref.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层的数据记忆问题思路会清晰很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑