前言

在开发任何一个成熟的 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,严禁在业务代码里裸写字符串;第二,封装 putflush 的逻辑。我们可以利用 TypeScript 的泛型和枚举能力,让键值对的读写变成类型安全的操作。比如,我们可以定义一个枚举 StorageKeys,在调用 save 方法时,入参必须是这个枚举的成员,这样 IDE 就能帮我们自动补全,彻底杜绝了因为手抖把 "user_age" 写成 "user_agr" 而导致的 Bug。

此外,在 API 20 中,Preferences 的实例获取需要依赖 Context。为了避免在每个页面都去重复获取 Context,我们可以在 EntryAbilityonCreate 阶段就初始化这个单例,持有全局的 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 时,它都能精准地还原你上次离开时的样子。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐