在这里插入图片描述
在这里插入图片描述

一、引言

在移动应用开发中,数据持久化是一个绕不开的话题。无论是用户偏好设置、登录状态、主题模式,还是简单的应用配置,都需要一种轻量、高效、可靠的存储方案。HarmonyOS NEXT 为我们提供了多种数据持久化方案,其中 Preferences 是最轻量、最易用的一种。

本文将以一个完整的实战案例为主线,深入讲解 HarmonyOS 中 Preferences 键值存储的使用方法、底层原理、最佳实践以及常见陷阱。全文以代码为主,配合逐行说明,帮助读者彻底掌握这一技术方向。

二、为什么需要数据持久化

2.1 内存数据与持久化数据的区别

在讲解 Preferences 之前,我们先明确一个基本概念:应用运行时的数据默认是保存在内存中的。内存中的数据具有以下特点:

  1. 易失性:应用进程被杀死、系统重启、内存回收时,数据会丢失。
  2. 快速访问:内存读写速度极快,适合频繁访问的临时数据。
  3. 容量有限:内存资源宝贵,不能无限存储。

而持久化数据则存储在磁盘上,具有以下特点:

  1. 持久性:应用重启后数据依然存在。
  2. 访问较慢:磁盘 I/O 比内存慢,需要合理设计读写策略。
  3. 容量较大:磁盘空间远大于内存。

2.2 典型应用场景

在 HarmonyOS 应用中,以下场景非常适合使用 Preferences:

  • 用户偏好设置:主题颜色、字体大小、语言选择等。
  • 登录状态:记住用户名、登录令牌、会话信息。
  • 应用配置:首次启动标记、引导页是否展示过、缓存策略配置。
  • 简单的业务数据:游戏最高分、阅读进度、草稿内容等。

三、Preferences 核心概念

3.1 什么是 Preferences

Preferences 是 HarmonyOS 提供的一种轻量级键值(Key-Value)存储组件,专门用于存储少量数据。它的设计哲学是"小而快",适合存储配置类数据,但不适合存储大量结构化数据(那种场景应该使用关系型数据库 RelationalStore)。

Preferences 的主要特点:

  1. 键值对存储:数据以 Key-Value 形式组织,Key 是字符串,Value 支持 string、number、boolean 等基本类型。
  2. 异步 API:所有操作都是异步的,避免阻塞 UI 线程。
  3. 实例隔离:不同实例之间数据相互隔离,互不影响。
  4. 落盘机制:支持手动 flush 和自动落盘。

3.2 数据模型

Preferences 的数据模型非常简单,可以理解为一张只有两列的表格:

Key Value
user_name 张三
theme_mode dark
login_count 5

每个 Preferences 实例对应一个独立的存储文件,文件名的后缀固定为 .preferences

四、实战代码:完整的数据持久化页面

下面我们来实现一个完整的数据持久化演示页面。这个页面采用极简黑白风格,包含数据展示表格、写入表单和操作按钮。

4.1 导入依赖

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

代码说明:

  • @kit.ArkData:数据管理能力套件,提供了 Preferences、关系型数据库、分布式数据库等数据管理 API。
  • @kit.AbilityKit:能力套件,这里用来获取 UIAbilityContext,因为获取 Preferences 实例需要传入上下文。
  • @kit.PerformanceAnalysisKit:性能分析套件,提供了 hilog 日志打印能力,用于调试和错误追踪。

4.2 定义数据结构

interface KeyValueRow {
  key: string;
  value: string;
}

代码说明:

我们定义了一个 KeyValueRow 接口,用于描述表格中的一行数据。它包含两个字段:

  • key:键名,用于唯一标识一条数据。
  • value:键值,存储实际的数据内容。

使用接口而非直接使用对象字面量,是为了保证类型安全,符合 ArkTS 严格类型检查的要求。

4.3 组件状态与成员变量

@Entry
@Component
struct DataPersistencePage {
  @State rows: KeyValueRow[] = [
    { key: 'user_name', value: '未设置' },
    { key: 'theme_mode', value: '未设置' },
    { key: 'login_count', value: '未设置' }
  ];
  @State inputKey: string = '';
  @State inputValue: string = '';
  private prefs?: preferences.Preferences;

代码说明:

  • @Entry:标记该组件为页面入口组件,可以被路由跳转。
  • @Component:标记该结构体为自定义组件。
  • @State rows:使用 @State 装饰器声明响应式数据,当 rows 变化时,UI 会自动刷新。这里初始化了三条默认数据,对应三个键。
  • @State inputKey / @State inputValue:分别绑定输入框的键名和键值。
  • private prefs:保存 Preferences 实例的引用。使用 preferences.Preferences 类型,并用可选类型 ? 标记,因为实例在异步获取之前是未定义的。

4.4 生命周期方法:加载数据

aboutToAppear(): void {
  this.loadPrefs();
}

async loadPrefs(): Promise<void> {
  try {
    const ctx = getContext(this) as common.UIAbilityContext;
    this.prefs = await preferences.getPreferences(ctx, 'myStore');
    const name = await this.prefs.get('user_name', '未设置');
    const theme = await this.prefs.get('theme_mode', '未设置');
    const count = await this.prefs.get('login_count', 0);
    this.rows = [
      { key: 'user_name', value: `${name}` },
      { key: 'theme_mode', value: `${theme}` },
      { key: 'login_count', value: `${count}` }
    ];
  } catch (e) {
    hilog.error(0x0000, 'Prefs', 'load failed %{public}s', JSON.stringify(e));
  }
}

代码说明:

aboutToAppear 是组件的生命周期方法,在组件即将显示时调用。在这里我们调用 loadPrefs 方法加载已存储的数据。

loadPrefs 方法的核心逻辑:

  1. 获取上下文getContext(this) as common.UIAbilityContext 获取当前组件的上下文对象。getContext 是全局方法,返回组件的上下文,我们将其断言为 UIAbilityContext 类型,因为 getPreferences 需要这个类型的参数。

  2. 获取 Preferences 实例preferences.getPreferences(ctx, 'myStore') 是异步方法,返回一个 Promise。第一个参数是上下文,第二个参数是存储文件名(不需要带后缀)。如果文件不存在,会自动创建。这里我们使用 await 等待实例获取完成。

  3. 读取数据this.prefs.get('user_name', '未设置') 从存储中读取键为 user_name 的值,如果不存在则返回默认值 '未设置'get 方法同样返回 Promise,需要 await

  4. 更新 UI:将读取到的数据重新组装为 rows 数组,由于 rows@State 数据,赋值后 UI 会自动刷新。

  5. 异常处理:使用 try...catch 捕获可能出现的异常,并通过 hilog.error 打印错误日志。%{public}s 是日志格式化占位符,表示公开字符串(不涉及隐私)。

4.5 保存数据

async saveRow(key: string, value: string): Promise<void> {
  if (!this.prefs || !key.trim()) {
    return;
  }
  try {
    await this.prefs.put(key.trim(), value.trim());
    await this.prefs.flush();
    await this.loadPrefs();
  } catch (e) {
    hilog.error(0x0000, 'Prefs', 'save failed %{public}s', JSON.stringify(e));
  }
}

代码说明:

saveRow 方法用于将新的键值对写入存储:

  1. 参数校验:如果 Preferences 实例不存在(!this.prefs)或者键名为空字符串(!key.trim()),则直接返回,避免无效操作。

  2. 写入数据this.prefs.put(key, value) 将键值对写入内存中的 Preferences 实例。注意,此时数据还没有真正写入磁盘,只是写入了内存缓存。

  3. 强制落盘this.prefs.flush() 将内存中的数据强制写入磁盘。这是关键一步,如果不调用 flush,数据可能不会立即持久化。

  4. 刷新界面:调用 loadPrefs() 重新读取数据并刷新 UI,让用户看到最新状态。

4.6 清空数据

async clearAll(): Promise<void> {
  if (!this.prefs) {
    return;
  }
  try {
    await this.prefs.clear();
    await this.prefs.flush();
    await this.loadPrefs();
  } catch (e) {
    hilog.error(0x0000, 'Prefs', 'clear failed %{public}s', JSON.stringify(e));
  }
}

代码说明:

clearAll 方法清空当前 Preferences 实例中的所有数据:

  1. this.prefs.clear():清空内存中的所有键值对。
  2. this.prefs.flush():将清空操作同步到磁盘。
  3. this.loadPrefs():刷新 UI,此时所有数据应显示为默认值。

4.7 构建 UI

@Builder
SectionTitle(title: string) {
  Text(title)
    .fontSize(14)
    .fontWeight(FontWeight.Bold)
    .fontColor('#1B1B1B')
    .letterSpacing(2)
    .margin({ top: 8, bottom: 8 })
}

代码说明:

@Builder 装饰器用于声明一个 UI 构建函数,可以复用一段 UI 结构。这里的 SectionTitle 接收一个标题字符串参数,返回一个带样式的标题文本。使用 @Builder 可以避免重复编写相同的标题样式代码。

build() {
  Scroll() {
    Column() {
      // 顶部黑区
      Column() {
        Text('DATA')
          .fontSize(12)
          .fontColor('#888888')
          .letterSpacing(6)
        Text('数据持久化')
          .fontSize(26)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ top: 6 })
        Text('Preferences 轻量级键值存储')
          .fontSize(12)
          .fontColor('#AAAAAA')
          .margin({ top: 6 })
      }
      .width('100%')
      .padding({ top: 48, bottom: 32 })
      .backgroundColor('#111111')

代码说明:

build 方法定义组件的 UI 结构。整体布局采用 Scroll 包裹 Column,支持页面滚动。

顶部是一个黑色背景的标题区域:

  • Scroll:可滚动容器,当内容超出屏幕时支持滚动查看。
  • Column:垂直布局容器,子组件按从上到下的顺序排列。
  • 标题区域使用 padding({ top: 48, bottom: 32 }) 设置内边距,backgroundColor('#111111') 设置黑色背景,形成强烈的黑白对比风格。
      Column() {
        this.SectionTitle('当前存储数据')

        // 极简两列表格:左键右值,中间竖线分隔
        Column() {
          ForEach(this.rows, (row: KeyValueRow) => {
            Row() {
              Text(row.key)
                .fontSize(13)
                .fontColor('#666666')
                .fontFamily('monospace')
                .layoutWeight(1)
              Text('|')
                .fontColor('#DDDDDD')
              Text(row.value)
                .fontSize(13)
                .fontWeight(FontWeight.Medium)
                .fontColor('#111111')
                .layoutWeight(1)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .padding({ top: 14, bottom: 14, left: 4, right: 4 })
            .border({ width: { bottom: 1 }, color: '#EEEEEE' })
          })
        }
        .width('100%')
        .border({ width: 1, color: '#E5E5E5' })
        .padding({ left: 16, right: 16 })

代码说明:

这里是数据展示表格的核心实现:

  • ForEach:循环渲染 rows 数组中的每一行数据。第一个参数是数据源,第二个参数是渲染函数,第三个参数是键生成器(用于列表复用优化)。
  • Row:水平布局容器,将键名、分隔符、键值放在一行。
  • .layoutWeight(1):设置布局权重,让键名和键值各占一半宽度,实现两端对齐。
  • .fontFamily('monospace'):使用等宽字体显示键名,增强代码风格。
  • .border({ width: { bottom: 1 }, color: '#EEEEEE' }):为每行添加底部边框,形成表格分隔线效果。
          this.SectionTitle('写入新键值')

          // 输入区
          Row({ space: 10 }) {
            TextInput({ placeholder: '键 (key)', text: this.inputKey })
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#F5F5F5')
              .placeholderColor('#AAAAAA')
              .onChange((v: string) => { this.inputKey = v; })
            TextInput({ placeholder: '值 (value)', text: this.inputValue })
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#F5F5F5')
              .placeholderColor('#AAAAAA')
              .onChange((v: string) => { this.inputValue = v; })
          }
          .width('100%')

代码说明:

输入区包含两个 TextInput 输入框:

  • TextInput:文本输入组件。placeholder 参数设置占位提示文字,text 参数绑定当前输入值。
  • .onChange:输入内容变化时的回调,将输入值同步到 @State 变量,实现数据绑定。
  • 两个输入框通过 Row({ space: 10 }) 水平排列,各占一半宽度。
          // 黑白按钮组:描边 + 实心
          Row({ space: 12 }) {
            Button('写入存储')
              .height(44)
              .layoutWeight(1)
              .fontSize(14)
              .fontColor(Color.White)
              .backgroundColor('#111111')
              .borderRadius(0)
              .onClick(() => {
                this.saveRow(this.inputKey, this.inputValue);
              })
            Button('清空全部')
              .height(44)
              .layoutWeight(1)
              .fontSize(14)
              .fontColor('#111111')
              .backgroundColor(Color.White)
              .borderRadius(0)
              .border({ width: 1, color: '#111111' })
              .onClick(() => {
                this.clearAll();
              })
          }
          .width('100%')
          .margin({ top: 16 })

代码说明:

按钮组采用黑白对比风格:

  • "写入存储"按钮:黑色实心背景、白色文字,.borderRadius(0) 设置为直角,体现极简风格。
  • "清空全部"按钮:白色背景、黑色文字,带 1 像素黑色描边,形成描边按钮效果。
  • 两个按钮通过 .layoutWeight(1) 各占一半宽度。
  • .onClick 回调分别调用 saveRowclearAll 方法。
          this.SectionTitle('技术要点')
          // 要点列表(另一种"表格":编号圆点)
          ForEach(['getPreferences 获取实例', 'put / get / flush 同步落盘', 'clear 清空全部数据', '支持 string / number / boolean'], (tip: string, i: number) => {
            Row({ space: 10 }) {
              Text(`${i + 1}`)
                .fontSize(11)
                .fontColor(Color.White)
                .width(20)
                .height(20)
                .textAlign(TextAlign.Center)
                .borderRadius(10)
                .backgroundColor('#111111')
              Text(tip)
                .fontSize(13)
                .fontColor('#333333')
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          })
        }
        .padding(20)
        .width('100%')
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }
}

代码说明:

技术要点部分使用编号圆点列表展示核心知识点:

  • ForEach 遍历一个字符串数组,每个元素是一个技术要点。
  • 每个要点左侧是一个黑色圆形编号(20x20 的圆,borderRadius(10) 使其成为正圆),右侧是文字说明。
  • 这种设计既是一种信息展示表格,又区别于传统的行列表格,体现了页面风格的多样性。

五、Preferences 完整 API 详解

5.1 获取实例

// 方式一:异步获取
const prefs = await preferences.getPreferences(context, 'myStore');

// 方式二:带选项获取
const options: preferences.Options = {
  name: 'myStore',
  dataGroupId: 'group1'
};
const prefs2 = await preferences.getPreferences(context, options);

代码说明:

  • getPreferences(context, name):根据名称获取实例,name 是文件名(不含后缀)。
  • 第二个参数也可以传入 Options 对象,支持指定 dataGroupId(数据组 ID),用于跨应用数据共享场景。
  • 该方法返回 Promise,需要使用 await.then() 处理。

5.2 写入数据

// 写入字符串
await prefs.put('user_name', '张三');

// 写入数字
await prefs.put('login_count', 5);

// 写入布尔值
await prefs.put('is_vip', true);

// 强制落盘
await prefs.flush();

代码说明:

  • put(key, value):写入数据,value 支持 string、number、boolean 类型。
  • 多个 put 操作可以合并后统一 flush,减少磁盘 I/O 次数,提升性能。

5.3 读取数据

// 读取字符串,带默认值
const name = await prefs.get('user_name', '默认值');

// 读取数字,带默认值
const count = await prefs.get('login_count', 0);

// 读取布尔值,带默认值
const vip = await prefs.get('is_vip', false);

// 获取所有键值对
const all = await prefs.getAll();

代码说明:

  • get(key, defaultValue):读取指定键的值,如果键不存在则返回默认值。默认值参数是必须的。
  • getAll():获取实例中所有键值对,返回一个对象。
  • 注意:get 返回值的类型是 ValueType,即 string | number | boolean 的联合类型。如果需要特定类型,需要进行类型转换。

5.4 删除与清空

// 删除单个键
await prefs.delete('user_name');

// 清空所有数据
await prefs.clear();

// 删除整个实例(从磁盘删除文件)
await preferences.deletePreferences(context, 'myStore');

代码说明:

  • delete(key):删除指定键的数据。
  • clear():清空实例中的所有数据,但实例本身仍存在。
  • deletePreferences(context, name):从磁盘上彻底删除存储文件,此操作不可逆,使用前需谨慎。

5.5 数据变更监听

// 注册监听
const observer = (key: string) => {
  console.info(`${key} 的数据发生了变化`);
};
prefs.on('change', observer);

// 取消监听
prefs.off('change', observer);

代码说明:

Preferences 支持数据变更监听机制:

  • on('change', callback):当数据发生变化时触发回调,回调参数是发生变化的键名。
  • off('change', callback):取消监听。
  • 监听机制非常适合多页面之间数据同步的场景,例如设置页修改了主题,首页可以监听变化并自动刷新。

六、底层原理剖析

6.1 存储文件结构

Preferences 的底层存储文件是一个 JSON 格式的文件,后缀为 .preferences。文件内容大致如下:

{
  "user_name": "张三",
  "theme_mode": "dark",
  "login_count": 5
}

6.2 内存与磁盘的双层结构

Preferences 采用"内存为主、磁盘为辅"的设计:

  1. 内存缓存层:所有 put 操作首先写入内存中的 Map 结构,读取操作也优先从内存读取,保证读写速度。
  2. 磁盘持久层:调用 flush() 时,将内存中的数据序列化为 JSON 写入磁盘文件。
  3. 启动加载:应用启动获取实例时,会自动从磁盘加载数据到内存。

这种设计的优势是读多写少场景下性能极佳,但代价是如果应用在 flush 之前崩溃,内存中未落盘的数据会丢失。

6.3 为什么需要 flush

很多初学者会困惑:为什么 put 之后还要调用 flush?原因在于:

  1. 性能考虑:频繁的磁盘写入会严重影响性能,所以 Preferences 将写入操作缓存到内存。
  2. 批量写入:通过 flush 可以将多次 put 操作合并为一次磁盘写入,大幅减少 I/O。
  3. 时机控制:开发者可以自主控制落盘时机,例如在应用进入后台时统一 flush。

七、最佳实践与性能优化

7.1 合理控制数据量

Preferences 适合存储少量数据(建议不超过 1KB),如果数据量较大,应该考虑使用关系型数据库(RelationalStore)或文件存储。

7.2 批量操作

// 不推荐:多次 flush
await prefs.put('a', 1);
await prefs.flush();
await prefs.put('b', 2);
await prefs.flush();

// 推荐:一次 flush
await prefs.put('a', 1);
await prefs.put('b', 2);
await prefs.flush();

7.3 键名规范

使用有意义且统一的键名,推荐使用"模块_语义"的命名方式:

await prefs.put('user_name', '张三');
await prefs.put('user_age', 18);
await prefs.put('setting_theme', 'dark');

7.4 异步处理

所有 Preferences API 都是异步的,务必使用 await.then() 处理,避免出现未捕获的 Promise 异常。

八、常见问题与解决方案

8.1 数据写入后重启丢失

原因:调用 put 后没有调用 flush,数据只保存在内存中。

解决方案

await prefs.put('key', 'value');
await prefs.flush(); // 必须调用

8.2 类型不匹配

原因get 返回的是联合类型 ValueType,直接赋值给特定类型变量会报错。

解决方案

const raw = await prefs.get('login_count', 0);
const count: number = raw as number; // 类型断言

8.3 实例获取失败

原因:传入的上下文类型不正确,或者文件名非法。

解决方案

const ctx = getContext(this) as common.UIAbilityContext;
const prefs = await preferences.getPreferences(ctx, 'myStore');

九、总结

本文从实战角度完整讲解了 HarmonyOS Preferences 键值存储的使用方法。我们实现了一个功能完整的极简黑白风格页面,包含数据展示、写入、清空等完整功能,并深入剖析了 Preferences 的底层原理、完整 API、最佳实践和常见问题。

核心要点回顾:

  1. Preferences 是轻量级键值存储,适合存储配置类少量数据。
  2. 核心 API:getPreferencesputgetflushclear
  3. 写入后必须 flush 才能持久化。
  4. 所有 API 均为异步,需用 await 处理。
  5. 支持数据变更监听,便于多页面同步。

掌握 Preferences 是 HarmonyOS 开发的基础技能,它为后续学习关系型数据库、分布式数据库等更复杂的数据管理方案打下了坚实基础。希望本文对读者有所帮助,下一篇我们将深入讲解 HarmonyOS 网络请求的实战技巧。

Logo

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

更多推荐