引言

移动应用中最令人沮丧的体验之一是什么?花了 20 分钟写的笔记,因为一个意外返回手势或来电中断,全部消失了。用户对"数据丢失"的容忍度为零——这不是功能缺失,这是信任崩塌。

数据持久化,即将应用数据保存到设备存储中以便下次启动时恢复,是每个移动开发者迟早要面对的问题。HarmonyOS 为开发者提供了多层级的数据持久化方案:轻量级键值存储(Preferences)、关系型数据库(RelationalStore)、以及文件存储。其中,Preferences 是最轻量、最易用的方案——适合保存用户设置、草稿内容、阅读进度等小型结构化数据。

本文将通过构建一个完整的"草稿箱"应用,深入讲解 @ohos.data.preferences API 的实战使用。草稿箱支持多草稿管理、自动保存(2 秒防抖)、应用重启后恢复、以及草稿的创建、切换和删除。全程代码驱动,不堆砌理论。

读完本文你将能够:

  • 使用 getPreferencesSync 创建和获取 Preferences 实例
  • 掌握 putSync / getSync / flushSync 等同步 API 的使用
  • 实现"输入即保存"的自动持久化模式
  • 理解 JSON 序列化在 ArkTS 严格类型系统下的处理方式
  • 构建完整的草稿 CRUD(创建、读取、切换、删除)

Preferences API 全景

什么是 Preferences?

Preferences 是一个轻量级的键值对(Key-Value)存储系统。你可以把它理解为一个"持久化的 Map"——你可以用字符串作为 key 存储数据(字符串、数字、布尔值),通过 key 读取数据,数据保存在设备的 XML 文件中,应用重启后依然存在。

Preferences 的设计目标不是替代数据库,而是覆盖那些"我不想为此建表"的轻量场景:

  • 用户设置(字体大小、主题颜色、通知开关)
  • 草稿内容(笔记、评论、表单的半成品状态)
  • 应用状态(首次启动标记、上次使用时间)
  • 阅读进度(当前章节、滚动位置)

核心 API 速览

API类型用途
getPreferencesSync(context, options)实例创建同步获取 Preferences 实例
putSync(key, value)写入同步写入一个键值对
getSync(key, defaultValue)读取同步读取,键不存在时返回默认值
hasSync(key)检查判断键是否存在
deleteSync(key)删除删除单个键值对
flushSync()持久化将内存中的数据刷写到磁盘
clearSync()清空删除所有键值对
on('change', callback)监听订阅数据变更事件

每个方法都有三态变体:Sync(同步,直接返回值)、Promise(返回 Promise<T>)、Callback(通过回调参数返回)。本文使用 Sync 系列——代码更简洁,适合 aboutToAppear 等初始化阶段。

导入与实例创建

import preferences from '@ohos.data.preferences';

// 在 aboutToAppear 或初始化方法中
let context = getContext(this);
this.prefs = preferences.getPreferencesSync(context, { name: 'draft_notes_store' });

Options 中的 name 参数指定了存储文件的名称。系统会在应用沙箱中创建对应的 XML 文件。多个 Preferences 实例可以使用不同的 name 来隔离数据——例如 'user_settings' 和 'draft_store' 分别管理设置和草稿。

数据类型限制

Preferences 支持的值类型(ValueType)包括:number | string | boolean | Array<number> | Array<string> | Array<boolean> | Uint8Array | object | bigint。

但对于复杂对象(如 DraftItem 数组),不能直接存储——需要先序列化为 JSON 字符串。读取时再反序列化。这是本文实现的核心约束之一。

键长度与值大小限制

  • 键最大长度:80 字符
  • 值最大大小:8192 字符

对于草稿箱场景,8192 字符足以覆盖绝大多数笔记内容。如果需要存储更大的内容(如长文),应考虑使用文件存储或数据库。

草稿箱设计概览

功能需求

  1. 多草稿管理:用户可以创建多个草稿,在草稿间切换
  2. 自动保存:编辑时自动保存,2 秒防抖避免频繁写入
  3. 跨会话持久化:应用退出后草稿不丢失,重新打开恢复到最后编辑的草稿
  4. 标题与正文:每个草稿有独立的标题和正文内容
  5. 删除管理:可以删除不需要的草稿,至少保留一个

数据模型

class DraftItem {
  id: number;         // 唯一标识
  title: string;      // 草稿标题
  content: string;    // 正文内容
  updatedAt: number;  // 最后修改时间戳

  constructor(id: number, title: string, content: string, updatedAt: number) {
    this.id = id;
    this.title = title;
    this.content = content;
    this.updatedAt = updatedAt;
  }
}

存储策略

将所有草稿序列化为一个 JSON 数组,存储在键 'drafts_data' 下。同时存储当前活跃草稿 ID 在键 'active_id' 下:

Preferences Store
├── "drafts_data" → '[{"id":1,"title":"会议纪要","content":"...","updatedAt":1720502400000}, ...]'
└── "active_id"   → 1

这种"一 JSON 全存储"策略比每个草稿一个键(draft_1, draft_2…)更简洁——不需要维护草稿 ID 列表,添加和删除草稿只需修改一个 JSON 数组。
在这里插入图片描述
在这里插入图片描述

核心实现一:初始化与加载

aboutToAppear 生命周期

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

initPrefs(): void {
  try {
    let context = getContext(this);
    this.prefs = preferences.getPreferencesSync(context, { name: 'draft_notes_store' });
    this.loadDrafts();
  } catch (e) {
    // preferences 不可用时降级为空草稿
  }
}

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件即将显示时调用。这是执行初始化的最佳时机——此时 Context 已经可用,但 UI 尚未渲染。

try-catch 包裹整个初始化逻辑是因为 getPreferencesSync 在某些异常场景下可能抛出错误(如存储空间不足)。降级策略是:如果 Preferences 不可用,后续的编辑不会自动保存,但不影响用户在界面上的操作。

数据加载与恢复

loadDrafts(): void {
  if (this.prefs === null) {
    // Preferences 不可用,创建空白草稿
    let now = Date.now();
    this.drafts = [new DraftItem(1, '新建草稿', '', now)];
    this.activeId = 1;
    this.nextId = 2;
    return;
  }

  let dataStr = this.prefs.getSync('drafts_data', '') as string;
  let activeId = this.prefs.getSync('active_id', 0) as number;

  if (dataStr.length > 0) {
    // 解析 JSON 并还原草稿列表
    let raw: Record<string, number | string>[] = 
      JSON.parse(dataStr) as Record<string, number | string>[];
    let loaded: DraftItem[] = [];
    for (let i = 0; i < raw.length; i++) {
      let item = raw[i];
      loaded.push(new DraftItem(
        item['id'] as number,
        item['title'] as string,
        item['content'] as string,
        item['updatedAt'] as number
      ));
    }
    this.drafts = loaded;

    // 恢复上次活跃的草稿
    let found = false;
    for (let i = 0; i < loaded.length; i++) {
      if (loaded[i].id === activeId) {
        this.activeId = activeId;
        this.editTitle = loaded[i].title;
        this.editContent = loaded[i].content;
        found = true;
        break;
      }
    }
    if (!found && loaded.length > 0) {
      // 上次活跃草稿被删除,回退到第一个
      this.activeId = loaded[0].id;
      this.editTitle = loaded[0].title;
      this.editContent = loaded[0].content;
    }
  } else {
    // 首次启动
    let now = Date.now();
    this.drafts = [new DraftItem(1, '新建草稿', '', now)];
    this.activeId = 1;
    this.editTitle = '新建草稿';
    this.editContent = '';
    this.saveDrafts(); // 立即保存初始状态
  }
}

这段代码体现了"防御性数据恢复"的思路:每一步都有失败处理和默认值。当 JSON 解析失败、活跃 ID 对应的草稿不存在、或数据为空时,都不会导致崩溃——而是回退到一个干净的初始状态。

ArkTS 严格类型与 JSON 解析

这里有一个值得展开的技术细节。在标准 TypeScript 中,JSON.parse() 返回 any 类型,你可以直接访问 raw[i].id 而不报类型错误。但在 ArkTS 的严格模式下,any 被禁止使用(arkts-no-any-unknown 规则)。

解决方案是显式类型标注:

// 错误(ArkTS 严格模式不允许)
let raw = JSON.parse(dataStr); // raw 类型为 any

// 正确
let raw: Record<string, number | string>[] = 
  JSON.parse(dataStr) as Record<string, number | string>[];

Record<string, number | string> 表示"键为字符串、值为数字或字符串的对象"。因为 DraftItem 的字段恰好都是 number 和 string 类型,这个类型可以覆盖。访问时需要通过 item['fieldName'] 配合 as 断言来取得具体的值类型:

let id = item['id'] as number;
let title = item['title'] as string;

这种写法比标准 TypeScript 繁琐,但 ArkTS 的类型严格性确保了编译期的类型安全——将运行时错误(“属性不存在”、“类型不匹配”)前置到编译期发现。

核心实现二:自动保存机制

防抖调度

自动保存的关键不是"每次都写",而是"停止输入后等待一小段时间再写"。这样可以避免用户每敲一个字符就触发一次磁盘写入。

private saveTimer: number = -1;

scheduleSave(): void {
  if (this.saveTimer >= 0) {
    clearTimeout(this.saveTimer); // 取消之前的定时器
  }
  this.saveStatus = '未保存';
  this.saveTimer = setTimeout(() => {
    this.saveDrafts();
  }, 2000); // 2 秒后执行保存
}

每次用户输入(onChange 事件触发)时调用 scheduleSave()。如果 2 秒内再次输入,之前的定时器被取消,重新计时。只有用户在 2 秒内停止输入,保存才真正执行。

这是一个经典的"防抖(debounce)"模式——在前端开发中无处不在(搜索建议、窗口 resize、表单验证),在移动端同样重要(减少 I/O 操作、节省电量)。

编辑触发

标题和正文的 onChange 都会触发自动保存调度:

TextInput({ placeholder: '草稿标题', text: this.editTitle })
  .onChange((val: string) => {
    this.editTitle = val;
    this.scheduleSave();
  })

TextArea({ placeholder: '开始写点什么...', text: this.editContent })
  .onChange((val: string) => {
    this.editContent = val;
    this.scheduleSave();
  })

不需要额外的"保存"按钮——但本文 Demo 也提供了"立即保存"按钮,用于用户想手动确保持久化的场景(如在退出应用前):

Button('立即保存')
  .onClick(() => { this.saveDrafts(); })

保存执行

saveDrafts(): void {
  // 1. 更新内存中的草稿列表
  let updated: DraftItem[] = [];
  let now = Date.now();
  let found = false;
  for (let i = 0; i < this.drafts.length; i++) {
    if (this.drafts[i].id === this.activeId) {
      updated.push(new DraftItem(this.activeId, this.editTitle, this.editContent, now));
      found = true;
    } else {
      updated.push(this.drafts[i]);
    }
  }
  if (!found) {
    updated.push(new DraftItem(this.activeId, this.editTitle, this.editContent, now));
  }
  this.drafts = updated;

  // 2. 序列化并写入 Preferences
  if (this.prefs !== null) {
    try {
      let plain: object[] = [];
      for (let i = 0; i < updated.length; i++) {
        let d = updated[i];
        let obj: Record<string, Object> = {};
        obj['id'] = d.id;
        obj['title'] = d.title;
        obj['content'] = d.content;
        obj['updatedAt'] = d.updatedAt;
        plain.push(obj);
      }
      this.prefs.putSync('drafts_data', JSON.stringify(plain));
      this.prefs.putSync('active_id', this.activeId);
      this.prefs.flushSync(); // 强制刷写到磁盘

      // 3. 更新保存状态显示
      let date = new Date(now);
      let h = date.getHours().toString().padStart(2, '0');
      let m = date.getMinutes().toString().padStart(2, '0');
      let s = date.getSeconds().toString().padStart(2, '0');
      this.saveStatus = '已保存 ' + h + ':' + m + ':' + s;
    } catch (e) {
      this.saveStatus = '保存失败';
    }
  }
}

保存过程的三个步骤:

  1. 内存更新:在当前草稿列表中更新活跃草稿(或追加新草稿),遵循不可变模式创建新数组
  2. 持久化写入:将草稿列表序列化为 JSON,通过 putSync 写入,再通过 flushSync 强制写到磁盘
  3. UI 反馈:更新时间戳,让用户知道保存已成功执行

flushSync() 是这里的关键调用。putSync 将数据写入内存缓存,但不会立即写到磁盘——系统会在合适的时机批量刷写。flushSync() 强制立即刷写,确保在应用被杀死等异常场景下数据不丢失。对于草稿这种"用户花了时间写了内容"的数据,立即刷写的开销完全可以接受。

核心实现三:草稿切换与新建

草稿切换

用户点击草稿列表中的某一项时,先保存当前草稿,再加载目标草稿:

switchDraft(id: number): void {
  // 先保存当前草稿(如果正在编辑)
  this.saveDrafts();
  // 查找并加载目标草稿
  for (let i = 0; i < this.drafts.length; i++) {
    if (this.drafts[i].id === id) {
      this.activeId = id;
      this.editTitle = this.drafts[i].title;
      this.editContent = this.drafts[i].content;
      this.saveStatus = '已加载';
      break;
    }
  }
}

先保存再切换是一个重要的设计决策。如果先切换再保存,用户刚才在旧草稿上输入的内容会丢失(因为 saveDrafts 会基于新的 activeId 覆盖错误的数据)。这个"保存-切换"的顺序确保了数据完整性。

新建草稿

newDraft(): void {
  this.saveDrafts(); // 保存当前草稿
  let now = Date.now();
  let newItem = new DraftItem(this.nextId, '新建草稿', '', now);
  let updated = this.drafts.slice();
  updated.push(newItem);
  this.drafts = updated;
  this.activeId = this.nextId;
  this.editTitle = '新建草稿';
  this.editContent = '';
  this.saveStatus = '新建草稿';
  this.nextId = this.nextId + 1;
}

同样先保存当前草稿,然后创建新的 DraftItem。nextId 自增计数器在整个会话期间递增——它从已加载草稿的最大 ID + 1 开始,确保新草稿的 ID 永远不会与已有草稿冲突。

删除草稿

deleteDraft(id: number): void {
  let updated: DraftItem[] = [];
  for (let i = 0; i < this.drafts.length; i++) {
    if (this.drafts[i].id !== id) {
      updated.push(this.drafts[i]);
    }
  }
  // 至少保留一个草稿
  if (updated.length === 0) {
    let now = Date.now();
    updated.push(new DraftItem(this.nextId, '新建草稿', '', now));
    this.nextId = this.nextId + 1;
  }
  this.drafts = updated;

  // 如果删除的是活跃草稿,切换到第一个
  if (id === this.activeId) {
    this.activeId = updated[0].id;
    this.editTitle = updated[0].title;
    this.editContent = updated[0].content;
  }

  // 持久化
  if (this.prefs !== null) {
    try {
      let plain: object[] = [];
      for (let i = 0; i < updated.length; i++) {
        let d = updated[i];
        let obj: Record<string, Object> = {};
        obj['id'] = d.id;
        obj['title'] = d.title;
        obj['content'] = d.content;
        obj['updatedAt'] = d.updatedAt;
        plain.push(obj);
      }
      this.prefs.putSync('drafts_data', JSON.stringify(plain));
      this.prefs.putSync('active_id', this.activeId);
      this.prefs.flushSync();
    } catch (e) {
      // 静默处理
    }
  }
  this.saveStatus = '已删除';
}

删除逻辑中的关键保护措施:不允许删除最后一个草稿。如果用户试图删除最后一个,系统会静默地创建一个新的空白草稿来替代。这避免了"零草稿"状态给 UI 带来的空状态处理复杂度。

核心实现四:UI 状态反馈

保存状态指示器

标题栏右侧有一个状态徽章,根据 saveStatus 的值显示不同背景色:

.backgroundColor(
  this.saveStatus.startsWith('已保存') ? '#52C41A80' : // 绿色
  this.saveStatus.startsWith('保存失败') ? '#FF4D4F80' : // 红色
  '#FFFFFF20' // 默认半透明白
)

三种状态的视觉编码:

  • 已保存(绿色):用户输入已持久化到磁盘,数据安全
  • 保存失败(红色):写入出现问题,提醒用户注意
  • 未保存(半透明白):用户正在输入,尚未触发自动保存

这种"红绿灯"式的状态指示让用户对数据安全状态一目了然——无需阅读文字,扫一眼颜色即可判断。

草稿列表高亮

当前活跃的草稿在列表中通过蓝色竖线标识和浅蓝背景区分:

Row()
  .width(3)
  .height(36)
  .borderRadius(2)
  .backgroundColor(draft.id === this.activeId ? '#1677FF' : '#E8E8E8')

// 背景色
.backgroundColor(draft.id === this.activeId ? '#F8FAFF' : '#FFFFFF')

蓝色竖线是本系列 Demo 中反复出现的视觉模式——从阅读器的进度条到看板的优先级标签,再到草稿箱的选中指示器。这种视觉一致性帮助用户建立"蓝色 = 当前/活跃"的心理模型。

完整页面结构

Column
├── Header(标题 + 草稿总数 + 保存状态徽章)
├── Editor Area
│   ├── TextInput(标题输入 · onChange → scheduleSave)
│   ├── TextArea(正文编辑 · onChange → scheduleSave)
│   └── Action Bar(当前位置 + 立即保存按钮)
├── Section Header(所有草稿 + 新建按钮)
└── Scroll
    └── Draft List
        ├── Draft Card 1(蓝色竖线 + 标题 + 预览 + 时间 · 可点击切换)
        ├── Draft Card 2(灰色竖线 · 可点击切换)
        └── ...

常见问题与解决方案

问题一:JSON.parse 报 “arkts-no-any-unknown”

原因:ArkTS 严格模式禁止 any 类型,JSON.parse 返回 any。

解决:使用显式类型断言 as Record<string, number | string>[],并通过 item['key'] as Type 访问字段。

问题二:应用重启后草稿丢失

原因:putSync 后没有调用 flushSync,数据只写入内存缓存,应用被杀死前未刷写到磁盘。

解决:每次 putSync 后调用 flushSync() 强制持久化。

问题三:切换草稿后之前编辑的内容丢失

原因:switchDraft 中先设置了新的 editTitle 和 editContent,然后 saveDrafts 把新草稿的空白内容保存到了旧草稿上。

解决:在切换草稿前先调用 saveDrafts() 保存当前草稿状态。

问题四:频繁自动保存导致性能问题

原因:每次按键都触发 saveDrafts,大量磁盘 I/O。

解决:使用防抖(debounce)模式,2 秒无输入后才执行保存。这既保证了数据安全,又避免了不必要的写入。

进阶扩展方向

1. 云端同步

在 saveDrafts 中增加云同步逻辑——保存到 Preferences 后,同时将草稿数据发送到云端(通过 HTTP 请求或分布式数据服务)。在下一次 loadDrafts 时,先从云端拉取最新数据,与本地合并。

2. 自动保存历史版本

不光保存当前版本,每次 saveDrafts 时将旧版本追加到一个"历史"键下。用户可以通过"版本历史"功能回溯到之前的编辑状态。

3. 草稿加密

对于敏感内容(如日记、密码备忘),在 JSON.stringify 后执行加密,在 JSON.parse 前执行解密。可以使用 @ohos.security.cryptoFramework 提供的 AES 加密能力。

4. 富文本草稿

将正文从纯文本扩展为 Markdown 或富文本。内容以 HTML/Markdown 字符串形式存储,在编辑器中提供格式化工具栏,在列表中渲染为纯文本预览。

Preferences vs 数据库:何时用哪个?

这是新手最常问的问题。简单来说:

场景推荐方案
用户设置(开关、选项)Preferences
草稿/临时数据Preferences
少量结构化数据(< 50 条)Preferences
大量结构化数据(50-5000 条)RelationalStore
文件/二进制数据文件存储
跨设备同步分布式数据服务

Preferences 的优势在于简单——不需要建表、不需要写 SQL、不需要管理数据库版本迁移。劣势在于不支持复杂查询、不适合大量数据。对于草稿箱这个场景(通常 5-20 条草稿),Preferences 是完美匹配。

总结

本文通过构建一个完整的"草稿箱"应用,深入讲解了 HarmonyOS @ohos.data.preferences API 的实战应用。核心要点如下:

  1. Preferences 实例创建:getPreferencesSync(context, { name }) 同步获取,name 用于数据隔离
  2. CRUD 操作:putSync / getSync / hasSync / deleteSync / flushSync 覆盖读写删全流程
  3. JSON 序列化策略:复杂数据序列化为 JSON 字符串存储,ArkTS 严格模式下需要显式类型断言
  4. 防抖自动保存:2 秒 debounce 模式,clearTimeout + setTimeout 实现,兼顾体验和性能
  5. 数据恢复:aboutToAppear 中读取并反序列化,多层防御性检查确保不崩溃
  6. 草稿生命周期:创建→编辑→自动保存→切换/删除,状态管理保证不丢数据
  7. 类型安全:Record<string, number | string> 类型和 as 断言解决 ArkTS 严格模式约束

数据持久化是移动开发中"不那么酷炫但绝对不能出错"的基础能力。一个草稿不会自动保存的笔记应用,即使 UI 再精美,也无法赢得用户的信任。Preferences API 以最简洁的方式解决了这个问题——几行代码,就能给用户"我的内容不会丢"的安全感。


Logo

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

更多推荐