鸿蒙 HarmonyOS 6 | 逻辑核心 (05):数据持久化 Preferences 的封装最佳实践
今天,我们就来聊聊如何优雅地封装 Preferences,以及在实际开发中那些容易被忽视的性能陷阱。
前言
在开发任何一个成熟的 App 时,我们都会遇到一类非常琐碎但又至关重要的数据存储需求。比如,用户第一次打开应用时显示的引导页,第二次打开就不应该再出现了;用户在设置里关闭了“消息推送声音”,杀掉进程重启后,这个开关依然得是关闭状态。
这些数据既不需要像用户信息那样存进关系型数据库里进行复杂的 SQL 查询,也不像图片视频那样需要庞大的文件系统来承载。它们只是几个简单的 Key-Value 键值对,轻量、高频且需要持久化。
在鸿蒙 HarmonyOS 6 (API 20) 中,系统为我们提供了一个专门处理这类需求的模块 用户首选项 Preferences。很多初学者在刚接触时,喜欢在每一个需要存数据的地方都写一遍 getPreferences,然后硬编码一堆 "is_first_launch" 这样的字符串 Key。这种做法在项目初期可能跑得通,但随着业务迭代,散落在各处的魔法字符串和未经管理的 I/O 操作,会让代码变得极其脆弱且难以维护。
今天,我们就来聊聊如何优雅地封装 Preferences,以及在实际开发中那些容易被忽视的性能陷阱。

一、 轻量级存储的选择哲学
我们首先要搞清楚 Preferences 到底适合存什么。它的底层实现其实是基于内存缓存加上 XML 文件的持久化。这意味着当你调用 API 读取数据时,速度是非常快的,因为它直接从内存里拿;但当你写入数据时,虽然内存是即时更新的,但最终写入磁盘是一个 I/O 操作。
因此,Preferences 的最佳应用场景是 “配置型” 数据。比如字体大小设置、夜间模式开关、是否已展示隐私协议弹窗等。这些数据通常是 String、Number 或者 Boolean 类型,且数据量极小。千万不要试图用 Preferences 去存一张图片的 Base64 编码,或者一个包含了成百上千条记录的 JSON 字符串。
虽然 API 20 取消了早期版本中严格的 8KB 限制,但如果你存入过大的数据,每次应用启动加载 Preferences 实例时,都会引发显著的内存抖动和启动耗时。
对于结构化的大数据,请出门左转找 RDB (关系型数据库);对于文件流,请使用 FileFs。
二、 封装的核心:告别魔法字符串与 I/O 焦虑
在原生 API 中,使用 Preferences 的流程通常是:获取 Context -> 获取 Preferences 实例 -> 调用 put 方法 -> 调用 flush 方法。这一连串动作里,最容易出问题的就是最后一步 flush。
很多开发者在写代码时,习惯性地写完 put 就以为万事大吉了。实际上,put 操作仅仅是修改了内存中的对象,如果没有调用 flush,数据是不会写入磁盘文件的。如果此时应用崩溃或者被系统强杀,你刚才存的数据就丢了。但是,如果我们每次 put 之后都立即 await flush(),在高频操作下又会造成大量的磁盘 I/O,导致界面卡顿。
所以,我们需要构建一个 PreferenceManager 单例类。这个类需要解决两个核心问题:第一,统一管理所有的 Key,严禁在业务代码里裸写字符串;第二,封装 put 和 flush 的逻辑。我们可以利用 TypeScript 的泛型和枚举能力,让键值对的读写变成类型安全的操作。比如,我们可以定义一个枚举 StorageKeys,在调用 save 方法时,入参必须是这个枚举的成员,这样 IDE 就能帮我们自动补全,彻底杜绝了因为手抖把 "user_age" 写成 "user_agr" 而导致的 Bug。
此外,在 API 20 中,Preferences 的实例获取需要依赖 Context。为了避免在每个页面都去重复获取 Context,我们可以在 EntryAbility 的 onCreate 阶段就初始化这个单例,持有全局的 Context,这样后续在任何 UI 组件或逻辑类中,都可以直接调用 PreferenceManager.getInstance().getValue(...),极其方便。
三、 配合 AppStorage 实现响应式配置
单独使用 Preferences 只能解决“存”和“取”的问题,但在 UI 开发中,我们更关心的是“变”。当用户在设置页修改了字号,首页的列表应该立即感知并刷新。虽然 Preferences 提供了 on('change') 监听器,但让每个页面都去注册监听器显然太繁琐了。
最佳的实践是将 Preferences 作为 AppStorage 的持久化后端。当应用启动时,我们从 Preferences 读取所有配置项,一次性写入 AppStorage。之后的业务中,UI 组件只通过 @StorageLink 与 AppStorage 交互。当用户修改配置时,我们同时更新 AppStorage(驱动 UI 刷新)和 Preferences(负责存盘)。
这样,我们既享受了 AppStorage 的响应式便利,又拥有了 Preferences 的持久化能力。虽然系统提供的 PersistentStorage 也能做类似的事,但手动封装 Preferences 能让我们对数据的迁移(比如 App 升级后旧配置的兼容处理)拥有 100% 的控制权,这在商业级项目中是非常必要的。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
// -------------------------------------------------------------
// 1. 定义 Key 的枚举与常量
// -------------------------------------------------------------
export enum AppStorageKeys {
IS_FIRST_LAUNCH = 'is_first_launch',
USER_FONT_SIZE = 'user_font_size',
ENABLE_NIGHT_MODE = 'enable_night_mode',
USER_NAME = 'user_name'
}
// -------------------------------------------------------------
// 2. Preferences 管理单例封装
// -------------------------------------------------------------
class PreferenceManager {
private static instance: PreferenceManager;
private pref: preferences.Preferences | null = null;
private readonly PREF_NAME = 'my_app_prefs';
private constructor() {}
public static getInstance(): PreferenceManager {
if (!PreferenceManager.instance) {
PreferenceManager.instance = new PreferenceManager();
}
return PreferenceManager.instance;
}
/**
* 初始化方法,通常在 Ability 的 onCreate 中调用
* @param context UIAbilityContext
*/
public async init(context: common.UIAbilityContext): Promise<void> {
try {
// 获取 Preferences 实例
// 注意:getPreferences 是异步方法
this.pref = await preferences.getPreferences(context, this.PREF_NAME);
console.info('[PreferenceManager] Initialized success');
} catch (err) {
console.error('[PreferenceManager] Failed to get preferences', JSON.stringify(err));
}
}
/**
* 保存数据 (自动处理 flush)
* @param key 存储键
* @param value 存储值 (支持 string | number | boolean | Array<string>)
*/
public async setValue(key: AppStorageKeys, value: preferences.ValueType): Promise<void> {
if (!this.pref) {
console.error('[PreferenceManager] Not initialized, call init() first');
return;
}
try {
await this.pref.put(key, value);
// 关键步骤:每次 put 后必须 flush 才能写入磁盘
// 在高频写入场景(如 Slider 拖动),建议在页面销毁或特定时机统一 flush,而非每次都 flush
await this.pref.flush();
console.info(`[PreferenceManager] Saved: ${key} = ${value}`);
} catch (err) {
console.error(`[PreferenceManager] Failed to put value for ${key}`, JSON.stringify(err));
}
}
/**
* 读取数据
* @param key 存储键
* @param defaultValue 默认值
*/
public async getValue<T extends preferences.ValueType>(key: AppStorageKeys, defaultValue: T): Promise<T> {
if (!this.pref) {
console.error('[PreferenceManager] Not initialized');
return defaultValue;
}
try {
const value = await this.pref.get(key, defaultValue);
return value as T;
} catch (err) {
console.error(`[PreferenceManager] Failed to get value for ${key}`, JSON.stringify(err));
return defaultValue;
}
}
/**
* 删除指定 Key
*/
public async deleteValue(key: AppStorageKeys): Promise<void> {
if (!this.pref) return;
try {
await this.pref.delete(key);
await this.pref.flush();
} catch (err) {
console.error(`[PreferenceManager] Failed to delete ${key}`, JSON.stringify(err));
}
}
}
// 导出单例供页面使用
export const prefManager = PreferenceManager.getInstance();
// -------------------------------------------------------------
// 3. 实战页面:用户偏好设置
// -------------------------------------------------------------
@Entry
@Component
struct PreferencesDemoPage {
// 使用 @State 驱动 UI,数据加载后会同步更新这里
@State isNightMode: boolean = false;
@State fontSize: number = 16;
@State userName: string = '';
@State isLoading: boolean = true;
// 模拟初始化加载
async aboutToAppear() {
// 【关键】获取 UIAbilityContext
// 在真实项目中,建议在 EntryAbility.ts 的 onCreate 中初始化 prefManager
// 这里为了演示方便,在页面加载时进行初始化
const context = getContext(this) as common.UIAbilityContext;
await prefManager.init(context);
// 读取持久化的数据
// 注意:await 会等待异步读取完成,此时页面可能尚未渲染完毕
this.isNightMode = await prefManager.getValue(AppStorageKeys.ENABLE_NIGHT_MODE, false);
this.fontSize = await prefManager.getValue(AppStorageKeys.USER_FONT_SIZE, 16);
this.userName = await prefManager.getValue(AppStorageKeys.USER_NAME, '');
this.isLoading = false;
}
build() {
Column() {
// 顶部标题
Text('偏好设置持久化')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ top: 40, bottom: 20 })
.fontColor(this.isNightMode ? Color.White : Color.Black)
.animation({ duration: 300 })
if (this.isLoading) {
LoadingProgress()
.width(50)
.height(50)
.color(Color.Blue)
} else {
Column({ space: 20 }) {
// ------------------------------------------------
// 1. 夜间模式开关
// ------------------------------------------------
Row() {
Column() {
Text('夜间模式')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(this.isNightMode ? Color.White : Color.Black)
Text('重启 App 后依然生效')
.fontSize(12)
.fontColor('#999')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.isNightMode })
.selectedColor('#0A59F7') // 鸿蒙蓝
.onChange(async (isOn: boolean) => {
this.isNightMode = isOn;
// 持久化保存
await prefManager.setValue(AppStorageKeys.ENABLE_NIGHT_MODE, isOn);
promptAction.showToast({ message: isOn ? '已开启夜间模式' : '已关闭夜间模式' });
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(16)
.backgroundColor(this.isNightMode ? '#333' : '#FFF')
.borderRadius(16)
.shadow({ radius: 8, color: '#1A000000' })
.animation({ duration: 300 })
// ------------------------------------------------
// 2. 字体大小调节
// ------------------------------------------------
Column() {
Text(`字体大小: ${this.fontSize.toFixed(0)}`)
.fontSize(16)
.fontColor(this.isNightMode ? Color.White : Color.Black)
.width('100%')
.margin({ bottom: 10 })
Slider({
value: this.fontSize,
min: 12,
max: 30,
step: 2,
style: SliderStyle.OutSet
})
.blockColor('#0A59F7')
.trackColor('#E0E0E0')
.selectedColor('#0A59F7')
.onChange(async (value: number, mode: SliderChangeMode) => {
this.fontSize = value;
// 优化:仅在拖动结束 (End) 或 点击 (Click) 时写入磁盘,避免高频 IO
if (mode === SliderChangeMode.End || mode === SliderChangeMode.Click) {
await prefManager.setValue(AppStorageKeys.USER_FONT_SIZE, value);
}
})
}
.padding(16)
.backgroundColor(this.isNightMode ? '#333' : '#FFF')
.borderRadius(16)
.shadow({ radius: 8, color: '#1A000000' })
.animation({ duration: 300 })
// ------------------------------------------------
// 3. 用户名输入 (自动保存)
// ------------------------------------------------
Column() {
Text('用户名 (输入即保存)')
.fontSize(16)
.fontColor(this.isNightMode ? Color.White : Color.Black)
.width('100%')
.margin({ bottom: 10 })
TextInput({ text: this.userName, placeholder: '请输入名字' })
.backgroundColor(this.isNightMode ? '#444' : '#F5F5F5')
.fontColor(this.isNightMode ? Color.White : Color.Black)
.placeholderColor(this.isNightMode ? '#888' : '#CCC')
.padding({ left: 12 })
.height(48)
.borderRadius(8)
.onChange(async (value: string) => {
this.userName = value;
// 实际开发中建议做防抖 (Debounce)
await prefManager.setValue(AppStorageKeys.USER_NAME, value);
})
}
.padding(16)
.backgroundColor(this.isNightMode ? '#333' : '#FFF')
.borderRadius(16)
.shadow({ radius: 8, color: '#1A000000' })
.animation({ duration: 300 })
// ------------------------------------------------
// 4. 预览效果
// ------------------------------------------------
Text('这是一段预览文字,用于展示字体大小和夜间模式的效果。用户首选项不仅能保存简单的开关,也能保存用户的个性化配置。')
.fontSize(this.fontSize) // 动态应用字号
.fontColor(this.isNightMode ? '#EEE' : '#333')
.margin({ top: 20 })
.lineHeight(this.fontSize * 1.5)
.animation({ duration: 300 })
}
.width('90%')
}
}
.width('100%')
.height('100%')
.backgroundColor(this.isNightMode ? '#1a1a1a' : '#F1F3F5')
.animation({ duration: 300 }) // 全局背景色切换动画
}
}

四、 总结与实战
Preferences 是鸿蒙应用中最不起眼但使用率最高的组件之一。
用好它,关键在于 克制 和 规范。克制是指不要滥用它存大数据,规范是指通过封装来隔离底层 API 的复杂性。
一个优秀的数据持久化层,应该像空气一样,平时感觉不到它的存在,但在你每次打开 App 时,它都能精准地还原你上次离开时的样子。
更多推荐





所有评论(0)