HarmonyOS 本地持久化实战:Preferences、schema version 与空状态初始化

这个项目是一个桌面卡片工具,用户的卡片、收藏、回收站、提醒、主题、样式、备份信息和桌面 Form 选择都需要保存到本地。项目没有引入复杂数据库,而是使用 HarmonyOS preferences 做轻量持久化。
这篇讲 AppDataService.ets 里的持久化设计,重点是 schema version、空状态初始化、快照备份和页面刷新。
状态边界:所有业务状态收进一个 snapshot
共享模型中定义了完整状态:
export interface AppStateSnapshotModel {
profile: UserProfileModel;
cards: CardRecordModel[];
recycleBin: CardRecordModel[];
desktopCardId: string;
desktopFormIds: string[];
reminder: ReminderSettingsModel;
selectedThemeId: string;
selectedStyleId: string;
backupMeta: BackupMetaModel;
activityLog: ActivityRecordModel[];
}
这不是唯一选择,但对一个轻量工具应用很合适。原因是:
- 状态量不大。
- 大部分写操作都需要刷新多个摘要。
- 备份和恢复可以直接处理整份 JSON。
- 桌面 Form 只需要读取其中一部分。
Preferences key 设计
AppDataService.ets 中集中定义 key:
const PREFS_NAME = 'project028_card_tool';
const STATE_KEY = 'app_state_v1';
const STATE_SCHEMA_VERSION_KEY = 'app_state_schema_version';
const STATE_SCHEMA_VERSION = '2';
const LAST_BACKUP_KEY = 'last_backup_snapshot_v1';
const SYSTEM_BACKUP_FILE = 'project028-backup.json';
这里有一个细节:STATE_KEY 仍然叫 app_state_v1,但真正控制迁移的是 STATE_SCHEMA_VERSION。这样做可以避免 key 名频繁变化,同时保留清晰的版本边界。
初始化时先判断版本
项目的发布态策略是“无版本即空状态”。如果本地没有 schema version,或者版本不匹配,就不继续沿用旧 demo 数据,而是清空为正式发布态的空状态。
伪代码可以理解为:
this.preferences = preferences.getPreferencesSync(context, { name: PREFS_NAME });
const storedVersion = this.preferences.getSync(STATE_SCHEMA_VERSION_KEY, '') as string;
if (storedVersion !== STATE_SCHEMA_VERSION) {
this.state = this.createDefaultState();
this.persistState();
return;
}
const rawState = this.preferences.getSync(STATE_KEY, '') as string;
this.state = this.normalizeState(JSON.parse(rawState));
这个策略适合上架前从 demo 数据切到真实用户状态。否则用户第一次打开应用,可能会看到开发阶段预置的样例卡片。
createDefaultState 不等于塞满示例数据
项目早期为了展示页面效果,会构造一些默认卡片。后面发布态改成:真实用户数据默认空,首页和分类页通过内置模板目录撑起首屏。
这意味着:
cards可以为空。recycleBin可以为空。backupMeta是空备份信息。- 首页推荐和分类概览不依赖真实用户数据。
这样既能保证首屏完整,又不会把 demo 数据当成用户数据持久化。
normalizeState:让旧数据不直接污染页面
持久化数据来自本地文件,必须防御字段缺失、类型变化和旧版本状态。项目用 normalizeState()、normalizeCard()、normalizeReminder() 等方法兜底。
例如草稿和卡片保存时,页面只提交当前字段,服务层负责补齐:
private normalizeDraft(draft: CardDraftModel): CardDraftModel {
return {
id: draft.id,
templateId: draft.templateId,
title: draft.title.trim(),
subtitle: draft.subtitle.trim(),
detail: draft.detail.trim(),
value: draft.value.trim(),
footer: draft.footer.trim(),
badge: draft.badge.trim(),
tone: this.normalizeTone(draft.tone, 'brand'),
categoryId: this.normalizeCategory(draft.categoryId, 'tool'),
favorite: draft.favorite
};
}
这类 normalize 不只是为了“代码好看”,而是为了避免页面直接拿到非法状态。
每次写操作都记录 activity
项目有统计页,所以创建、编辑、归档、恢复、备份、主题切换都会写活动日志:
private recordActivity(type: ActivityType, title: string, weight: number, date: Date): void {
const record: ActivityRecordModel = {
id: this.generateId('activity'),
type: type,
title: title,
createdAt: formatDateTime(date),
dayKey: createDateKey(date),
weight: Math.max(1, weight)
};
this.state.activityLog.unshift(record);
if (this.state.activityLog.length > 60) {
this.state.activityLog = this.state.activityLog.slice(0, 60);
}
}
这里限制最多 60 条,是为了让轻量 Preferences 不无限增长。统计页需要的是趋势,不是完整审计日志。
持久化写入:putSync + flushSync
服务层的写入方式是同步写入:
this.preferences.putSync(STATE_SCHEMA_VERSION_KEY, STATE_SCHEMA_VERSION);
this.preferences.putSync(STATE_KEY, JSON.stringify(this.state));
this.preferences.flushSync();
对这个项目来说,同步写更直接:写操作来自按钮点击,数据量小,页面需要立刻刷新摘要。如果是大量数据或高频写入,就需要改成异步和节流。
备份快照复用同一份状态
本地“立即备份”不是另起一套数据结构,而是导出当前 snapshot:
private exportSnapshot(): string {
return JSON.stringify(this.state);
}
恢复时再走 importSnapshot() 和 normalizeState()。这样备份恢复和版本兼容可以共享一套逻辑。
页面刷新时机
各页面常见写法是:
aboutToAppear(): void {
this.refreshData();
}
onPageShow(): void {
this.refreshData();
}
这是因为卡片可能从详情页、编辑页、管理页、备份页等不同入口被修改。页面重新显示时从服务层取最新视图模型,比跨页传一堆状态更稳。
常见坑
- 不要把 demo 数据直接当默认用户状态。
- 不要只在
STATE_KEY上做版本判断,最好单独存 schema version。 - 不要让页面直接修改全局 state,写操作集中到服务层。
- 恢复备份后要重新计算摘要、统计和桌面卡片数据。
- 旧数据字段缺失时必须 normalize,不能直接断言类型正确。
小结
这个项目的本地持久化思路可以概括为一句话:Preferences 保存整份轻量状态,schema version 控制迁移边界,服务层统一 normalize 和写入。
对卡片工具这类应用来说,这种方案足够简单,也足够稳定。真正要注意的不是 API 调用,而是“默认状态、旧数据、页面刷新、备份恢复”这四件事是否在同一套规则里。

被折叠的 条评论
为什么被折叠?



