引言

移动应用中最令人沮丧的体验之一是什么?花了 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 的字段恰好都是 numberstring 类型,这个类型可以覆盖。访问时需要通过 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;
}

同样先保存当前草稿,然后创建新的 DraftItemnextId 自增计数器在整个会话期间递增——它从已加载草稿的最大 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 中先设置了新的 editTitleeditContent,然后 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐