HarmonyOS ArkTS 本地持久化实战:用 Preferences 管理完整 JSON 状态
为什么没有一开始就上数据库
我在开发 HarmonyOS 心情日记“心晴手记”时,首先确定了几个前提:
- 没有账号系统;
- 没有服务端同步;
- 一天最多一条心情记录;
- 每天只有少量习惯打卡;
- 用户可以随时导出全部数据;
- 产品早期更需要结构清晰和迁移简单。
在这个量级下,直接引入关系型数据库并非错误,但会增加表结构、查询层和迁移脚本的维护成本。我最终选择 @kit.ArkData 中的 Preferences,把完整状态序列化成一个 JSON 字符串。
这不是“Preferences 可以代替所有数据库”,而是根据当前数据规模做出的工程取舍。
如果应用包含大量长文本、图片、复杂筛选、分页查询或跨表聚合,RDB 会更合适;如果只是轻量配置和规模可控的本地记录,Preferences 可以让第一版保持简单。
一、先定义一个完整、可版本化的状态
项目中的状态不是散落在多个 Key 中,而是聚合成一个明确的根对象:
export interface PersistedState {
exportVersion: number;
settings: AppSettings;
moodEntries: MoodEntry[];
habits: Habit[];
habitCompletions: HabitCompletion[];
}
这样做有三个好处:
- 内存状态与磁盘快照结构一致;
- 导出 JSON 时可以直接复用数据模型;
- 升级时只需要围绕一个根对象做兼容处理。
对应的存储名称和 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 体积明显增长,启动解析出现可感知延迟;
- 开始需要按多个字段组合筛选和排序;
- 记录量需要分页加载;
- 数据之间出现复杂关联与一致性要求;
- 每次小改动都要重写完整状态,写放大不可接受;
- 需要更强的事务能力。
一旦达到这些条件,正确做法不是直接换一个空数据库,而是:
- 保留旧 Preferences 读取能力;
- 检测旧数据是否存在;
- 在本地完成一次性迁移;
- 校验记录数量和关键字段;
- 迁移成功后再标记完成;
- 失败时允许重试,不能静默清空。
最后总结
Preferences 方案真正需要解决的,不是 put() 和 get() 怎么调用,而是围绕数据生命周期建立约束:
- 用根状态对象明确持久化边界;
- 为状态和导出格式保留版本号;
- 读取旧数据时补齐默认值;
- 保存前创建确定快照;
- 连续异步写入保持顺序;
- 提前定义迁移到数据库的触发条件。
对轻量、本地优先的 HarmonyOS App 来说,这套方法足够简单,也保留了继续演进的空间。
本文示例来自“心晴手记 HarmonyOS 版:一款无账号、数据默认仅保存在本机的心情日记与习惯追踪工具。
更多推荐



所有评论(0)