写页面写了二十多个排查过最折磨人的一次Bug用户在设置页把深色模式打开退出应用再进来又变回浅色。功能代码翻来覆去看了三遍最后发现是数据压根没存住——我没调用数据持久化页面状态全靠内存变量扛着。这事儿之后我养成了习惯凡是跟“用户偏好”挂钩的UI状态一律交给鸿蒙的 Preferences 用户首选项去管。这一节虽然是《鸿蒙应用开发UI基础》的第二十三节但严格说它不画UI而是讲UI状态如何落地。Preferences 是鸿蒙官方提供的轻量级键值对数据持久化工具专门用来存主题、字号、开关状态、登录账号这类小体量配置数据。适合的人群很明确正在学鸿蒙开发、写过几个页面但还没系统了解数据持久化以及从Android/Web转过来想快速对标的同学。1. Preferences到底是什么先搞清楚它该管哪些事1.1 与数据库、文件存储的边界划分很多新手第一次接触 Preferences 时会困惑我明明有数据库为什么还要一个“键值对”存储这要回到业务需求来看。你在设置页放一个开关用户拨了一下你需要记住的是“开”还是“关”就这么一个布尔值。为这建一张表、写一套增删改查纯属杀鸡用牛刀。鸿蒙至少提供了四类数据持久化方案我平时的选型逻辑是这样的存储方案定位典型场景适合规模上手成本Preferences轻量键值对主题、字号、开关、记住账号小几十到几千条很低RelationalStore关系型数据库聊天记录、任务清单、商品列表中到大中分布式键值库跨设备同步KV手机改平板跟着变中高文件存储大文件/流式图片、日志、导出数据大低Preferences 解决的场景非常聚焦用户可感知的配置项和轻量偏好。你把“上次选中的城市”“引导页是否看过”“导航栏排序方式”这类数据交给它读写都是一行代码不需要建表不需要写SQL。而消息列表、订单数据这类结构化、可查询、体量大的数据就别硬塞给 Preferences。1.2 键值对存储的底层形态Preferences 底层就是一个KV结构Key是字符串Value支持 number、string、boolean、数组等基础类型。数据文件落在应用沙箱的 preferences 目录下你通过context.getPreferencesDir()能拿到实际路径。这里有个非常关键的底层行为Preferences 在首次访问时会把这个文件里的键值对整体读入内存形成一个内存快照。后面你所有的 get、put 操作操作的是这份快照而不是直接读写磁盘文件。这也是它只适合小数据量的原因之一——文件越大加载越慢内存占用也越高。我自己用一个不太严谨但很好懂的类比put 是往草稿纸上写字flush 才是把草稿誊到正式本子上。草稿纸上的内容正式本子上没有一旦纸被揉掉应用进程被杀就什么都没留下。为什么要设计成两段式因为磁盘IO比内存操作慢几个数量级。如果你每次put(key, 1)都立刻写一次盘用户快速切换十几个开关系统就要扛住十几次不必要的全量序列化。Preferences 的策略是让你攒一批修改最后调一次 flush 统一落盘性能友好得多。1.3 数据作用域与共享边界Preferences 的数据作用域是应用沙箱内部不跨应用共享。A应用的 PreferencesB应用读不到。同一应用内不同页面、不同Ability只要拿着同一个Context去获取同名Preferences读写的是同一个文件天然共享。不过有一个容易忽略的边界如果应用配置了多进程比如某个Ability单独跑在独立进程中Preferences 的跨进程一致性是有局限的。跨进程频繁读写同一个Preferences文件可能出现一方读到旧数据甚至写入冲突。遇到多进程需求老老实实引入 DataShare 或分布式数据能力别拿 Preferences 硬扛。2. 核心API一把梭从获取实例到数据落地2.1 获取Preferences实例的两种方式先看入口。以前用 ohos.data.preferences新版开发中我更推荐用 kit.ArkData 的导入方式代码更干净import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; const context getContext(this) as common.UIAbilityContext; const options: preferences.Options { name: userSettings }; try { const pref await preferences.getPreferences(context, options); // 拿到实例后续可以做读写 } catch (err) { let e err as BusinessError; console.error(获取Preferences失败: code${e.code}, message${e.message}); }这里options.name是Preferences文件名标识相当于给你的“存储空间”起名。不同名字是不同的文件互不干扰同一个名字对应同一个文件数据共享。如果你的工程还在用旧版API直接传字符串也是可以的const pref await preferences.getPreferences(context, userSettings);两者效果一致新写法主要是为了支持后续的dataGroupId等扩展参数。2.2 读写改删的完整代码示例拿到实例后核心操作就五个读、写、删、清、落盘。我写一个“保存设置页主题和字号”的完整示例// 写入主题字号 async function saveThemeSetting() { const pref await preferences.getPreferences(context, { name: userSettings }); await pref.put(theme, dark); // 保存字符串 await pref.put(fontSize, 16); // 保存数字 await pref.put(reduceMotion, true); // 保存布尔值 await pref.flush(); // 关键一步必须落盘 } // 读取带默认值 async function loadThemeSetting() { const pref await preferences.getPreferences(context, { name: userSettings }); const theme await pref.get(theme, light) as string; const fontSize await pref.get(fontSize, 14) as number; return { theme, fontSize }; } // 删除单个Key async function removeTheme() { const pref await preferences.getPreferences(context, { name: userSettings }); await pref.delete(theme); await pref.flush(); } // 清空全部 async function clearAll() { const pref await preferences.getPreferences(context, { name: userSettings }); await pref.clear(); await pref.flush(); }注意get的第二个参数是默认值。我强烈建议你永远传默认值特别是从旧版本迁移数据或Key被误删时没默认值就直接返回 undefined 或 null页面容易崩。2.3 为什么必须flush内存与磁盘的双层结构我见过太多人写完 put 就不管了然后跑过来说“Preferences数据丢了”。十有八九就是漏了 flush。flush 的作用是把内存快照中的全部修改序列化回磁盘文件。只put不flush数据只存在于内存中一旦应用被系统回收或用户手动杀死修改就丢了。这是Preferences最核心的使用纪律。flush 的合理姿势是“批量修改后一次性落盘”await pref.put(theme, dark); await pref.put(fontSize, 16); await pref.put(reduceMotion, true); await pref.flush(); // 一次落盘搞定这样既保证数据安全又避免频繁IO磨损性能。我实测下来几百个键值对的一次flush在真机上通常也就是毫秒级用户完全无感。真正需要警惕的是把大字符串或大对象塞进去那flush一次可能就要几十毫秒甚至更久页面就会卡顿。如果你确定存储的数据非常小又嫌 async/await 麻烦新版也提供了同步接口可以用putSync、getSync。但同步接口会阻塞调用线程UI主线程上要谨慎使用。个人建议无脑用异步版本习惯养好以后遇到大数据量场景不容易踩坑。3. 踩坑记录Preferences实战中的八个典型问题3.1 类型陷阱数字、字符串与布尔值的“看起来一样”这是所有踩坑里最阴的一个。Preferences对Value的存储是类型敏感的你在写入时用的是number读出来还是number写入时用的是字符串16读出来就是16字符串。问题出在表单、接口返回的数据经常把数字转成字符串你一把梭塞给put(fontSize, 16)下次读出来是16直接参与计算就出幺蛾子。我在项目里定了一条规矩写入前先统一类型读取时用相同类型的默认值兜底。写个小工具函数function toNumber(value: preferences.ValueType): number { if (typeof value number) return value; if (typeof value string) return Number.parseInt(value, 10); return 0; } async function getFontSize(pref: preferences.Preferences): Promisenumber { const raw await pref.get(fontSize, 0); return toNumber(raw); }3.2 页面关闭时数据还没写完Preferences 的读写是异步的。用户在设置页改了个开关你await put(...)之后就直接返回上一页如果过程中没有await flush极端情况下任务还没落盘页面就销毁了。尤其是onPageHide、onBackPress这类生命周期回调里做保存一不当心就是“保存了个寂寞”。我的做法是在用户交互的每个关键节点直接await put await flush不拖到页面销毁时再批量处理。比如开关切换的onChange回调里就写这样即使用户马上左滑杀掉应用数据也已经稳稳落盘。3.3 大对象直接塞进去序列化翻车Preferences 对对象的支持很有限我不建议直接存对象。正确做法是JSON.stringify后再put读取时JSON.parseconst userProfile { name: 张三, lastLoginAt: Date.now() }; // 写入 await pref.put(userProfile, JSON.stringify(userProfile)); await pref.flush(); // 读取 const raw await pref.get(userProfile, ) as string; const profile raw ? JSON.parse(raw) : null;但这里有个经典坑JSON序列化会改变类型。比如Date对象经过JSON.stringify会变成字符串undefined属性会被整个丢掉NaN会变成null。读回来之后你要自己做类型恢复。我吃过亏的是一次统计lastLoginAt存进去读出来直接参与时间差计算结果变成一个非法日期的NaN排查了半天。3.4 on(change)监听器的正确用法Preferences 支持数据变更监听这是跨页面同步状态的好帮手。但新手很容易把它用歪pref.on(change, (key: string) { console.info(数据变更的Key:, key); // 注意change回调只告诉你“哪个key变了”不会告诉你新值 // 需要在这里手动 get });要注意三点第一回调里只拿得到key拿不到新值你还要主动get一次第二监听器注册后记得在不需要时off掉否则页面销毁了还挂着轻则内存泄漏重则回调了已经销毁的页面逻辑第三千万别在change回调里写同一个key否则很容易形成写-回调-再写的循环。3.5 多个模块共用一个文件key互相覆盖应用里多个模块如果都用默认的同一个Preferences文件又都顺手用了setting这个key那么后写的模块会覆盖先写的模块。这种Bug在开发阶段不容易发现因为各自功能看起来都正常。保险的做法有两种一是不同业务模块用不同的name建独立文件二是同一个文件内的key加上模块前缀比如moduleA_theme、moduleB_theme。我个人更推荐前者独立文件便于后期清数据、排查问题坏处是文件数量会多几个可维护性反而更好。3.6 获取Context的方式不对接口直接抛错getPreferences的第一个参数必须是有效的Context。很多新手在页面里直接传this结果发现报错。正确姿势分场景在UIAbility的生命周期里用this.context在页面组件里用getContext(this) as common.UIAbilityContext在普通工具类里由外部把Context传入不要自己去全局找我见过有人把Context存在一个全局单例里然后到处取用结果页面没销毁时Context已经失效接口报出一堆看不懂的错误。不如老老实实在入口处初始化把Preferences实例创建好其他模块直接用实例就行。3.7 重复获取实例代码到处都是getPreferencesPreferences 实例一旦获取内部会维护本地缓存重复获取同一个name性能损失不大。但代码层面会变得很乱每个页面都写一遍获取实例的逻辑错误处理散落各处改起来头疼。更好的做法是只在一个地方初始化把实例交给一个全局封装管理。这样后续要换存储方案、要加加密逻辑、要统一监控异常都只改一个文件。这个封装模板我放在下一节。3.8 误用clear把不该清的也清了clear 清空的是整个文件里所有键值对。如果你只是想删一个token用了clear那主题、字号、引导页标志等全没了用户又要重新设置一遍体验极差。这个属于“边界意识”问题。删除指定key用delete只有确定要把整个业务模块的配置全部重置时才用clear。另外clear后也要flush不然内存快照里清空了磁盘上还留着旧数据。4. 实用封装一个PreferencesManager模板4.1 为什么要封装Preferences 的API本身不复杂但裸用有几个很现实的问题一是key散落各处想改个key名得全局替换二是获取实例、try-catch、flush的逻辑重复写三是类型处理靠自觉不统一就容易翻车。封装的核心目标是把“底层存储细节”和“业务使用”剥离开业务代码只关心存什么、取什么。4.2 完整代码单例 统一初始化下面是我项目里沉淀下来的一个精简版封装你可以直接抄走改改import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; export class PrefUtil { private static instance: PrefUtil | null null; private pref?: preferences.Preferences; private constructor() {} public static getInstance(): PrefUtil { if (!PrefUtil.instance) { PrefUtil.instance new PrefUtil(); } return PrefUtil.instance; } // 在Ability入口处调用一次 public async init(context: common.UIAbilityContext, name: string app_config): Promisevoid { if (this.pref) { return; } try { const options: preferences.Options { name }; this.pref await preferences.getPreferences(context, options); } catch (err) { let e err as BusinessError; console.error(PrefUtil init failed: code${e.code}, msg${e.message}); } } public async put(key: string, value: preferences.ValueType): Promisevoid { if (!this.pref) { throw new Error(PrefUtil is not initialized); } await this.pref.put(key, value); await this.pref.flush(); } public async get(key: string, defaultValue: preferences.ValueType): Promisepreferences.ValueType { if (!this.pref) { return defaultValue; } return await this.pref.get(key, defaultValue); } public async remove(key: string): Promisevoid { if (!this.pref) { return; } await this.pref.delete(key); await this.pref.flush(); } public async clear(): Promisevoid { if (!this.pref) { return; } await this.pref.clear(); await this.pref.flush(); } }在EntryAbility的onWindowStageCreate里初始化import { AbilityConstant, UIAbility, Want } from kit.AbilityKit; import { window } from kit.ArkUI; export default class EntryAbility extends UIAbility { onWindowStageCreate(windowStage: window.WindowStage): void { PrefUtil.getInstance().init(this.context); // ... windowStage.loadContent(...) } }初始化之后页面里就可以非常清爽地读写const theme await PrefUtil.getInstance().get(theme, light) as string; await PrefUtil.getInstance().put(theme, dark);4.3 与页面状态结合设置页记住主题以最常见的“主题切换”为例展示 Preferences 怎么和UI联动。核心思路是进入页面时从 Preferences 读状态设置时立即写入 Preferences 并更新当前UI状态两者保持同步。Entry Component struct SettingsPage { State theme: string light; async aboutToAppear() { this.theme await PrefUtil.getInstance().get(theme, light) as string; } async onThemeChange(newTheme: string) { this.theme newTheme; await PrefUtil.getInstance().put(theme, newTheme); } build() { Column() { Text(当前主题${this.theme}) Button(切换为深色) .onClick(() this.onThemeChange(dark)) } } }这段代码的核心在心智模型Preferences是唯一数据源页面状态只是它的投影。下次启动时页面从 Preferences 读到上次的值UI自动恢复。这也是“UI基础”这一节真正的重点——不是教你怎么画控件而是教你怎么用持久化让界面状态不丢失。4.4 生命周期保存别把保存拖到最后一刻设置页这种场景我建议在用户每次操作的当下就写入不要等onPageHide再批量保存。但有些场景必须依赖生命周期比如编辑页的草稿内容onPageHide() { // 页面离开时保存草稿 PrefUtil.getInstance().put(draftContent, this.editorText); }这里有一个防抖建议如果用户在输入框里疯狂打字每敲一个字符都调一次 put flush虽然Preferences够快但IO次数还是挺浪费的。可以给写入加个简单的防抖逻辑比如输入停止后300毫秒再保存体验没影响性能更好。5. 进阶探索加密、跨设备同步与性能优化5.1 敏感信息不能裸奔Preferences 的特点是方便不是安全。它存在应用沙箱内普通用户拿不到但对于支付、密码、Token这类高度敏感的数据我不建议明文存进 Preferences。一句话原则能不放就不放非要放也必须先加密再写入。比较好的做法是结合系统加解密能力用密钥把敏感字段加密后再 put读取时先取出来解密。这类能力鸿蒙是提供的做敏感业务时值得专门研究。退一步说就算没有强加密方案也要至少做一层轻量混淆别把密码裸写在文件里。5.2 跨设备同步别拿Preferences硬做Preferences 是本地存储默认没有跨设备同步能力。你不可能指望手机改个主题平板自动也变。如果业务确实需要“一端修改、多端生效”应该走分布式数据管理方案或者基于账号体系的云同步。很多开发者在这个地方会纠结Preferences 和分布式KV库看起来都是键值对能不能互相替换答案是不能。分布式库要多一套设备协同、冲突处理、网络状态管理的逻辑复杂度完全不同。单设备的轻量偏好用Preferences多设备的偏好同步换方案。选型错了后期维护会很痛苦。5.3 数据规模与性能维护建议Preferences 虽然简单也不是无底洞。我总结了几条经验按重要程度排单个Preferences文件的键值对数量控制在几百条以内。超过这个量首次加载和flush都会开始变慢。单个value的体量不要超过几KB。大字段要么拆成多个key要么干脆丢文件存储或数据库。高频写入要做防抖或批量flush不要在循环里反复调用flush。不用的key要定期清理不然文件越滚越大启动变慢。业务模块多的应用按模块拆成多个Preferences文件别全都堆在一个文件里。另外提供一个很实用的调试技巧想确认Preferences里的数据到底是什么可以在开发版页面里打印Preferences文件路径用调试工具去拉取沙箱文件查看。应用沙箱在真机上不是随便能进的但debug版本配合调试工具可以做到。这样能直观看到数据有没有写对、格式有没有混淆排查效率比纯靠日志高不少。Preferences 在整个鸿蒙持久化体系里属于“小而美”的角色。我个人的经验是凡是用户肉眼可感知、需要下次启动恢复的偏好设置一律交给Preferences凡是业务数据就进数据库。这套边界守住了项目后期基本不会在“数据存没存住”这种问题上翻车。最后分享一个小习惯所有Preferences的key我都会在文件顶部用常量统一管理而不是在代码里手写字符串。这招看着土但遇到需要批量改key、排查历史数据时能帮你省下大把时间。