为什么没有一开始就上数据库

我在开发 HarmonyOS 心情日记“心晴手记”时,首先确定了几个前提:

  • 没有账号系统;
  • 没有服务端同步;
  • 一天最多一条心情记录;
  • 每天只有少量习惯打卡;
  • 用户可以随时导出全部数据;
  • 产品早期更需要结构清晰和迁移简单。

在这个量级下,直接引入关系型数据库并非错误,但会增加表结构、查询层和迁移脚本的维护成本。我最终选择 @kit.ArkData 中的 Preferences,把完整状态序列化成一个 JSON 字符串。

这不是“Preferences 可以代替所有数据库”,而是根据当前数据规模做出的工程取舍。

如果应用包含大量长文本、图片、复杂筛选、分页查询或跨表聚合,RDB 会更合适;如果只是轻量配置和规模可控的本地记录,Preferences 可以让第一版保持简单。

一、先定义一个完整、可版本化的状态

项目中的状态不是散落在多个 Key 中,而是聚合成一个明确的根对象:

export interface PersistedState {
  exportVersion: number;
  settings: AppSettings;
  moodEntries: MoodEntry[];
  habits: Habit[];
  habitCompletions: HabitCompletion[];
}

这样做有三个好处:

  1. 内存状态与磁盘快照结构一致;
  2. 导出 JSON 时可以直接复用数据模型;
  3. 升级时只需要围绕一个根对象做兼容处理。

对应的存储名称和 Key 保持稳定:

const STORE_NAME: string = 'mood_memoir_local_store';
const STATE_KEY: string = 'state_json_v1';

state_json_v1 中的版本后缀并不意味着以后每次更新都必须换 Key。它更像是持久化协议的代号。小字段变化可以继续在读取时补默认值;发生破坏性结构调整时,再设计明确的迁移路径。

二、初始化和读取不要假设数据永远正确

Repository 在页面加载时先拿到 Preferences 实例:

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

export class AppRepository {
  private store?: preferences.Preferences;

  async initialize(context: common.UIAbilityContext): Promise<void> {
    this.store = await preferences.getPreferences(context, STORE_NAME);
  }
}

读取时需要处理至少三种情况:

  • Repository 尚未初始化;
  • 第一次启动,还没有保存内容;
  • JSON 损坏或来自不兼容版本。
async load(): Promise<PersistedState> {
  if (!this.store) {
    return createDefaultState();
  }

  const encoded: string = await this.store.get(STATE_KEY, '') as string;
  if (encoded.length === 0) {
    return createDefaultState();
  }

  try {
    const parsed = JSON.parse(encoded) as PersistedState;
    // 字段兼容处理
    return parsed;
  } catch (_) {
    return createDefaultState();
  }
}

这里选择在解析失败时回退默认状态,是为了避免应用直接崩溃。但正式产品还应结合场景考虑:是否保留损坏快照、是否提供恢复提示,以及是否记录不包含用户正文的本地错误信息。

三、新版本字段必须有默认值

假设 1.0 版本只有 onboardingCompleted,1.1 版本增加了应用锁和语言设置。旧用户磁盘中的 JSON 不会自动出现这些字段。

因此读取后要逐项补齐:

const defaults: PersistedState = createDefaultState();
const persistedSettings: AppSettings =
  parsed.settings ?? defaults.settings;

parsed.exportVersion = parsed.exportVersion ?? 1;
parsed.settings = {
  privacyAcceptedVersion:
    persistedSettings.privacyAcceptedVersion ?? 0,
  onboardingCompleted:
    persistedSettings.onboardingCompleted ?? false,
  reminderEnabled:
    persistedSettings.reminderEnabled ?? false,
  reminderHour:
    persistedSettings.reminderHour ?? 20,
  reminderMinute:
    persistedSettings.reminderMinute ?? 30,
  reminderId:
    persistedSettings.reminderId ?? -1,
  appLockEnabled:
    persistedSettings.appLockEnabled ?? false,
  seededStarterHabits:
    persistedSettings.seededStarterHabits ?? false,
  languagePreference:
    persistedSettings.languagePreference ?? LanguagePreference.SYSTEM
};

数组也要兜底并恢复稳定顺序:

parsed.moodEntries = (parsed.moodEntries ?? []).sort(
  (left, right) => right.timestampMillis - left.timestampMillis
);

parsed.habits = (parsed.habits ?? []).sort(
  (left, right) => left.createdAtMillis - right.createdAtMillis
);

这段代码承担的其实是一个轻量迁移器角色。它保证旧数据进入新版内存模型之后,至少具备完整字段和可预期顺序。

四、保存前创建快照,不直接持有页面数组

保存函数接收当前状态,但不会把页面正在修改的数组引用直接交给异步流程,而是先创建快照:

const snapshot: PersistedState = {
  exportVersion: 1,
  settings: copySettings(state.settings),
  moodEntries: state.moodEntries.slice(),
  habits: state.habits.slice(),
  habitCompletions: state.habitCompletions.slice()
};

const encoded: string = JSON.stringify(snapshot);

slice() 是浅拷贝,适合当前对象采用“修改时创建新对象”的写法。如果项目会直接改变数组中对象的字段,就需要更严格的不可变约束或深拷贝策略。

这里的重点不是机械地复制所有数据,而是让本次异步写入对应一个确定时刻的状态。

五、连续写入要防止旧状态覆盖新状态

用户可能在几百毫秒内完成这些操作:选择心情、增加标签、打卡、修改一句话日记。每一步都可能触发保存。

如果异步写入完成顺序与调用顺序不一致,较早发起的旧快照反而可能最后落盘。

项目通过 Promise 链串行化写入:

private saveQueue: Promise<void> = Promise.resolve();

save(state: PersistedState): Promise<void> {
  if (!this.store) {
    return Promise.resolve();
  }

  const encoded: string = JSON.stringify(/* 当前快照 */);
  const store: preferences.Preferences = this.store;

  const operation: Promise<void> = this.saveQueue.then(async () => {
    await store.put(STATE_KEY, encoded);
    await store.flush();
  });

  this.saveQueue = operation.catch(() => {});
  return operation;
}

后一次操作必须等待前一次结束,因此落盘顺序与调用顺序一致。

catch 只用于确保队列不会因为一次失败永久断掉,调用方拿到的 operation 仍然可以感知本次保存是否失败。生产环境还应把失败状态反馈给 UI,而不是一律显示“已保存”。

六、什么时候应该从 Preferences 迁移到 RDB

可以设置几个明确的迁移信号:

  • JSON 体积明显增长,启动解析出现可感知延迟;
  • 开始需要按多个字段组合筛选和排序;
  • 记录量需要分页加载;
  • 数据之间出现复杂关联与一致性要求;
  • 每次小改动都要重写完整状态,写放大不可接受;
  • 需要更强的事务能力。

一旦达到这些条件,正确做法不是直接换一个空数据库,而是:

  1. 保留旧 Preferences 读取能力;
  2. 检测旧数据是否存在;
  3. 在本地完成一次性迁移;
  4. 校验记录数量和关键字段;
  5. 迁移成功后再标记完成;
  6. 失败时允许重试,不能静默清空。

最后总结

Preferences 方案真正需要解决的,不是 put()get() 怎么调用,而是围绕数据生命周期建立约束:

  • 用根状态对象明确持久化边界;
  • 为状态和导出格式保留版本号;
  • 读取旧数据时补齐默认值;
  • 保存前创建确定快照;
  • 连续异步写入保持顺序;
  • 提前定义迁移到数据库的触发条件。

对轻量、本地优先的 HarmonyOS App 来说,这套方法足够简单,也保留了继续演进的空间。

本文示例来自“心晴手记 HarmonyOS 版:一款无账号、数据默认仅保存在本机的心情日记与习惯追踪工具。

体验心晴手记鸿蒙版


Logo

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

更多推荐