HarmonyOS 5 数据持久化:首选项 (Preferences)

大家好,我是不想掉发的鸿蒙开发工程师 城中的雾,上一系列了解了应用的 Context ,App 得有常见了还有数据存储这一期我们来聊一聊数据的持久化。想象一下:用户费劲登录了你的 App,结果杀掉进程再打开,又要登录?或者刚把主题设为深色,重启后数据是否要保留,我们来学习数据的常见持久化方式:用户首选项 (Preferences)

1. 它是轻量级存储最常用

在鸿蒙中,存储数据的方式有很多,但如果你只是想存一些简单的 Key-Value(键值对) 数据,比如:

  • 用户是否开启了推送?(Boolean)
  • 用户的字号大小是 14 还是 16?(Number)
  • 用户的登录 Token 是多少?(String)

那么,首选项 (Preferences) 是你的不二之选。

为什么不用数据库?

为了存个“字体大小”去建一张表、写 SQL 语句,既麻烦又浪费性能。首选项底层基于 XML 或轻量级文件,读写速度极快,加载内存占用极小。

为什么不用 AppStorage?

AppStorage 确实方便,但它默认存在内存里。App 一关,数据就没了(除非配合 PersistentStorage,这个我们下期讲)。而首选项是实打实写进磁盘文件的。

避坑预警:

首选项不适合存大量数据!官方建议每条记录不超过 8KB,总文件大小如果不必要不要太大。别试图往里面塞高清大图的 Base64,否则会导致 App 启动卡顿。

2. 核心 API

在 API 10+ 中,官方提供了同步接口(Sync),这使得首选项的读写变得非常简单,不再需要满屏的 await

使用首选项通常分三步走:拿实例 -> 读/写 -> 刷盘

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

// 1. 同步获取首选项实例 (需要 Context)
// 'my_store' 是文件名,会生成 my_store.xml
// 使用 getPreferencesSync 会阻塞当前线程直到读取完成,适合轻量级数据
let pref = preferences.getPreferencesSync(context, { name: 'my_store' });

// 2. 同步写入数据 (PutSync)
pref.putSync('is_night_mode', true);

// 3. 极其重要!持久化刷盘 (Flush)
// 注意:API 暂无 flushSync,flush 依然是异步的
// 但 putSync 已经更新了内存,后续 getSync 能立即读到,不影响业务
pref.flushSync();

// 4. 同步读取数据 (GetSync)
// 第二个参数是默认值,如果读不到就返回它
let value = pref.getSync('is_night_mode', false); 

3. 封装一个 PreferenceUtil

利用同步接口,我们可以封装一个无需 await 的全局工具类,彻底解决异步初始化带来的“竞态问题”。

第一步:编写工具类 (PreferenceUtil.ets)

调试小技巧:我们在构造函数里加了日志。如果您在 Log 面板看到 "PreferenceUtil: 创建实例" 出现了两次,说明您遇到了多实例问题,请检查 import 路径。

// utils/PreferenceUtil.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class PreferenceUtil {
  // 单例模式
  private static instance: PreferenceUtil;
  // 持有首选项实例
  private pref: preferences.Preferences | null = null;
  // 定义文件名
  private readonly PREF_NAME = 'my_app_store';

  private constructor() {
    // 【调试日志】如果这个日志打印了多次,说明单例失效(可能是多包引用导致)
    console.info('PreferenceUtil: 创建实例 (Instance Created)');
  }

  public static get(): PreferenceUtil {
    if (!PreferenceUtil.instance) {
      PreferenceUtil.instance = new PreferenceUtil();
    }
    return PreferenceUtil.instance;
  }

  /**
   * 初始化首选项 (同步方法)
   * 建议在 EntryAbility 的 onCreate 中调用
   * @param context ApplicationContext
   */
  init(context: common.Context) {
    // 增加重复初始化检查,避免覆盖
    if (this.pref) {
      console.info('PreferenceUtil: 已经初始化过,跳过');
      return;
    }
    try {
      // 使用同步方法,阻塞等待文件加载,保证后续 getSync 能立即取到数据
      this.pref = preferences.getPreferencesSync(context, { name: this.PREF_NAME });
      console.info('PreferenceUtil: 初始化成功');
    } catch (err) {
      console.error('PreferenceUtil: 初始化失败', JSON.stringify(err));
    }
  }

  /**
   * 保存数据 (同步更新内存,异步刷新磁盘)
   * @param key 键
   * @param value 值
   */
  put(key: string, value: preferences.ValueType) {
    if (!this.pref) {
      console.error(`PreferenceUtil: [PUT FAILED] 实例未初始化! 请检查 import 路径或初始化流程。Key: ${key}`);
      return;
    }
    try {
      // 1. 同步写入内存 (内存操作极其迅速)
      this.pref.putSync(key, value);
      
      // 2. 异步刷盘 (持久化)
      // 注意:鸿蒙暂无 flushSync 接口。
      // 虽然这里是异步的,但 putSync 已保证内存一致性,后续的 getSync 可立即读到最新值。
      // 我们添加 catch 防止潜在的 Promise 异常抛出。
      this.pref.flushSync().catch((err: BusinessError) => {
          console.error(`PreferenceUtil: 持久化刷盘失败 (Key: ${key})`, JSON.stringify(err));
      });
      
    } catch (err) {
      console.error(`PreferenceUtil: 保存 ${key} 失败`, JSON.stringify(err));
    }
  }

  /**
   * 获取数据 (同步)
   * @param key 键
   * @param defaultValue 默认值
   */
  get(key: string, defaultValue: preferences.ValueType): preferences.ValueType {
    if (!this.pref) {
      console.warn(`PreferenceUtil: [GET FAILED] 实例未初始化,返回默认值。Key: ${key}`);
      return defaultValue;
    }
    try {
      return this.pref.getSync(key, defaultValue);
    } catch (err) {
      console.error(`PreferenceUtil: 读取 ${key} 失败`, JSON.stringify(err));
      return defaultValue;
    }
  }
  
  /**
   * 删除数据 (同步)
   */
  delete(key: string) {
    if (!this.pref) return;
    try {
      this.pref.deleteSync(key);
      // 同样异步刷盘并处理错误
      this.pref.flushSync().catch((err: BusinessError) => {
          console.error(`PreferenceUtil: 删除刷盘失败 (Key: ${key})`, JSON.stringify(err));
      });
    } catch (err) {
      console.error(`PreferenceUtil: 删除 ${key} 失败`, JSON.stringify(err));
    }
  }
}

第二步:在 EntryAbility 初始化

重点注意:根据实践反馈,建议在 loadContent 成功加载内容后进行初始化,确保环境准备就绪。

// EntryAbility.ets
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { PreferenceUtil } from '../utils/PreferenceUtil';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
    // ...
  }

  onWindowStageCreate(windowStage: window.WindowStage) {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        console.error('Failed to load the content.');
        return;
      }
      // 在内容加载成功后初始化首选项
      PreferenceUtil.get().init(this.context);
      console.info('Succeeded in loading the content.');
    });
  }
}

4. 场景演练:数据管理控制台

为了更直观地展示增、删、查操作,我们编写一个简单的“控制台”页面。

在这里插入图片描述

UI 代码 (DataManagePage.ets)

import { PreferenceUtil } from '../utils/PreferenceUtil';
import { promptAction } from '@kit.ArkUI'; 
import { common } from '@kit.AbilityKit'; // 导入 common 以使用 Context 类型

@Entry
@Component
struct DataManagePage {
  @State inputText: string = ''; // 输入框内容
  @State resultText: string = '等待读取...'; // 读取结果展示
  private readonly KEY_TEST = 'my_test_key'; // 测试用的 Key

  // 页面加载时执行
  aboutToAppear() {
    // 自动读取一次,验证初始化是否成功
    this.refreshData();
  }

  // 封装读取逻辑
  refreshData() {
    const value = PreferenceUtil.get().get(this.KEY_TEST, '默认值(空)');
    this.resultText = `当前存储值: ${value}`;
  }

  build() {
    Column({ space: 20 }) {
      Text('数据持久化控制台')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 20 })

      // 1. 输入区域
      TextInput({ placeholder: '请输入要保存的内容', text: this.inputText })
        .onChange((val) => {
          this.inputText = val;
        })
        .width('90%')
        .padding(10)
        .backgroundColor('#F5F5F5')
        .borderRadius(8)

      // 2. 操作按钮区域
      Row({ space: 15 }) {
        // 保存按钮
        Button('保存数据')
          .onClick(() => {
            if (this.inputText === '') {
              promptAction.showToast({ message: '内容不能为空' });
              return;
            }
            // 同步保存,无需 await
            PreferenceUtil.get().put(this.KEY_TEST, this.inputText);
            promptAction.showToast({ message: '保存成功!' });
            this.refreshData(); // 保存后立即刷新显示
          })
          .backgroundColor('#007DFF')

        // 读取按钮
        Button('读取数据')
          .onClick(() => {
            this.refreshData();
            promptAction.showToast({ message: '读取完成' });
          })
          .backgroundColor('#00C15B')

        // 删除按钮
        Button('删除数据')
          .onClick(() => {
            // 同步删除
            PreferenceUtil.get().delete(this.KEY_TEST);
            this.inputText = ''; // 清空输入框
            this.refreshData(); // 删除后刷新
            promptAction.showToast({ message: '删除成功' });
          })
          .backgroundColor('#FF4040')
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)

      // 3. 结果展示区域
      Text(this.resultText)
        .fontSize(16)
        .fontColor('#666666')
        .padding(20)
        .border({ width: 1, color: '#E0E0E0', radius: 8 })
        .width('90%')
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .justifyContent(FlexAlign.Start)
  }
}

通过这个控制台 Demo,体验了数据的生命周期:

  1. 输入与保存:点击保存时,数据立即写入内存(同步)并触发后台刷盘(异步),界面无卡顿。
  2. 持久化验证:你可以尝试关闭应用(甚至强杀进程),再次打开时,aboutToAppear 中的 refreshData 依然能读到上次保存的内容。
  3. 代码体验:得益于 PreferenceUtil 的封装,我们在 UI 层的代码中看不到任何 awaitPromise,逻辑清晰流畅,开发效率极高。

5. 总结与避坑指南

首选项虽然好用,但也有几个“雷区”千万别踩:

  1. 持久化是异步的:这是一个重要的知识点。虽然我们用 putSync 实现了同步写内存。
  2. Key 命名冲突:首选项是 Key-Value 结构,建议使用命名空间前缀,比如 user_namesetting_font_size,避免不同模块把 Key 搞重复了。
  3. 不要存大文件:虽然 getPreferencesSync 很爽,但它是阻塞主线程的。如果你往首选项里存了 1MB 的字符串,App 启动时就会黑屏卡顿!大文件请用 FileIO。

下一期预告

首选项需要我们手动调用 getput,有没有一种更高级的办法,像操作普通变量一样操作持久化数据?比如 AppStorage.set('token', '123') 就自动存盘?

当然有!

下一篇,我们将探讨 状态持久化 (PersistentStorage),看看它是如何将 UI 状态与磁盘绑定的,让你的变量持久化存储。

📚 充电时间

如果有想加入鸿蒙生态的大佬们,快来加入鸿蒙认证吧!初高级证书还没获取的,点这里:

🔗 HarmonyOS第一课:官方认证培训

如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,欢迎在评论、私信中提出,非常感谢您的支持。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐