HarmonyOS应用实战-启示散页-52-备份文件别没有版本:导出本地数据时带上 schema 和校验摘要
HarmonyOS 应用实战 52:备份文件别没有版本,导出本地数据时带上 schema 和校验摘要
本地题库应用的备份很容易被写成一段 JSON:把题库、收藏、历史序列化后交给系统,恢复时再直接写回 Preferences。它在第一个版本能工作,但一旦字段改名、数据截断、文件传输损坏,恢复端无法判断拿到的是旧格式、半份文件,还是完全不属于本应用的数据。
《答案之书》已经注册了备份扩展能力,但当前 EntryBackupAbility 的 onBackup 与 onRestore 只记录生命周期日志。本文不把“已注册扩展”夸大成“已经有可迁移备份协议”,而是从现有 Preferences 边界出发,设计一个可验证的备份 envelope。

系统回调存在,不等于备份格式已经定义
entry/src/main/module.json5 中的 EntryBackupAbility 类型为 backup,并通过 ohos.extension.backup 指向 backup_config。EntryBackupAbility 也确实继承 BackupExtensionAbility,拥有 onBackup() 与 onRestore(bundleVersion)。
这些事实说明系统可以进入备份生命周期;但它们没有回答“导出哪些 store”“字段怎样演进”“恢复前如何证明内容可信”。目前应用级偏好已有 schemaVersion、currentDeckId、firstLaunchDone,题库、收藏和历史又使用独立 Preferences key。备份协议的责任正是在这些稳定边界之外补一层版本与完整性判断。
| 层 | 已有能力 | 本文建议新增的责任 |
|---|---|---|
| Backup Extension | 系统调用备份/恢复生命周期 | 调用编解码与恢复编排 |
| Preferences Repository | 读取题库、收藏、历史、App 偏好 | 只提供稳定数据,不解析外来文件 |
| Backup Envelope | 当前不存在 | 声明格式版本、时间、摘要与载荷 |
| Restore Service | 当前不存在 | 先校验,再决定迁移或拒绝 |


备份内容要有边界,不能把全部 Preferences 一起搬走
应导出的业务数据和不应导出的运行状态不同。题库、收藏、提问历史以及恢复它们必需的 App 偏好可以进入载荷;AppStorage 的刷新时间戳、页面是否展开、动画阶段、临时输入框文本不应进入载荷。
interface BackupPayloadV1 {
app: Pick<AppPreferences, 'schemaVersion' | 'currentDeckId' | 'firstLaunchDone'>;
decks: Deck[];
favorites: Favorite[];
questionHistory: string[];
}
interface BackupEnvelopeV1 {
format: 'the-book-of-answers-backup';
schemaVersion: 1;
createdAt: number;
checksum: string;
payload: BackupPayloadV1;
}
format 用于拒绝拿错文件;schemaVersion 表示文件格式而不是应用 versionName;createdAt 方便用户辨认备份时间;checksum 只保护载荷的完整性,不替代加密。模型中的字段应显式列出,不能用 Record<string, unknown> 把未来不该导出的 key 自动带进去。
摘要必须基于稳定序列化结果计算
同一份对象如果序列化键顺序不稳定,摘要会在没有数据变化时不断变化。更稳的办法是先定义稳定的序列化形式,再计算摘要;校验时使用同一规则。
function canonicalPayload(payload: BackupPayloadV1): string {
return JSON.stringify({
app: payload.app,
decks: [...payload.decks].sort((a, b) => a.id.localeCompare(b.id)),
favorites: [...payload.favorites].sort((a, b) => a.id.localeCompare(b.id)),
questionHistory: payload.questionHistory
});
}
async function buildEnvelope(payload: BackupPayloadV1): Promise<BackupEnvelopeV1> {
const text: string = canonicalPayload(payload);
return {
format: 'the-book-of-answers-backup',
schemaVersion: 1,
createdAt: Date.now(),
checksum: await Digest.sha256(text),
payload
};
}
Digest.sha256 是示例依赖,接入时应使用项目确定的摘要实现。关键在于摘要只针对 canonical payload,而非针对包含时间戳的整个 envelope;否则每次导出都会改变摘要,无法比较内容是否真的相同。
恢复先走判定表,不能直接覆盖 Preferences
恢复函数面对的是不可信输入。即使 JSON 能解析,也可能缺字段、版本过高、摘要不符,或 currentDeckId 指向不存在题库。把这些判断压缩为一个 try { saveAll(...) },会把异常留给下一次页面挂载。
type RestoreCheck =
| { kind: 'accepted'; payload: BackupPayloadV1 }
| { kind: 'migrate'; fromVersion: number; raw: unknown }
| { kind: 'rejected'; reason: string };
async function inspectEnvelope(raw: string): Promise<RestoreCheck> {
let value: BackupEnvelopeV1;
try {
value = JSON.parse(raw) as BackupEnvelopeV1;
} catch (_) {
return { kind: 'rejected', reason: '备份文件不是有效 JSON' };
}
if (value.format !== 'the-book-of-answers-backup') {
return { kind: 'rejected', reason: '文件不属于答案之书备份' };
}
if (value.schemaVersion > 1) {
return { kind: 'rejected', reason: '备份格式比当前应用更新' };
}
if (value.schemaVersion < 1) {
return { kind: 'migrate', fromVersion: value.schemaVersion, raw: value };
}
const actual = await Digest.sha256(canonicalPayload(value.payload));
if (actual !== value.checksum) {
return { kind: 'rejected', reason: '备份摘要不匹配,文件可能不完整' };
}
return { kind: 'accepted', payload: value.payload };
}
这里的拒绝不是失败兜底,而是保护现有用户数据。migrate 也不应直接进入保存逻辑;它必须调用针对旧版本的纯转换函数,并将转换结果再次走当前版本的完整性判断。
通过校验后仍要检查领域约束
摘要正确只能说明载荷没有在传输中变化,不能说明内容满足应用规则。比如题库可能没有答案、收藏引用了已不存在的答案、当前题库 id 不存在。恢复服务应把 envelope 校验和领域校验分开,避免将“文件可信”误解为“数据可用”。
function validatePayload(payload: BackupPayloadV1): string | null {
if (payload.decks.length === 0) return '备份中没有题库';
if (payload.decks.some((deck) => deck.answers.length === 0)) {
return '备份中存在没有答案的题库';
}
const currentExists = payload.decks.some((deck) => deck.id === payload.app.currentDeckId);
if (!currentExists) return '当前题库引用不存在';
return null;
}
如果领域校验失败,正确行为是展示原因并保持现有 store 不变。不要为了“尽量恢复”而写入半份数据;用户至少还保留恢复前的本地内容,之后可以选择重新导出或使用差异导入。
一次恢复应在成功点统一提交
题库、收藏、历史分散在不同 Preferences store,因此恢复可能部分写入成功、部分失败。建议新增的恢复编排器需要先准备候选数据,确认所有约束后再按固定顺序写入;若底层不支持事务,应至少在写入前建立可恢复快照,并在失败时停止继续写入。
async function restoreAcceptedPayload(payload: BackupPayloadV1): Promise<void> {
const reason = validatePayload(payload);
if (reason) throw new Error(reason);
await DeckRepository.replaceAll(payload.decks); // 建议新增批量接口
await FavoriteRepository.saveAll(payload.favorites);
await QuestionHistoryRepository.saveAll(payload.questionHistory);
await PreferencesStore.setJson(PrefStoreName.App, 'app_preferences', payload.app);
AppStorage.setOrCreate(AppStorageKey.CurrentDeckId, payload.app.currentDeckId);
AppStorage.setOrCreate(AppStorageKey.LastDeckUpdateAt, Date.now());
AppStorage.setOrCreate(AppStorageKey.LastFavoriteUpdateAt, Date.now());
AppStorage.setOrCreate(AppStorageKey.LastQuestionHistoryUpdateAt, Date.now());
}
replaceAll 与 app_preferences 的具体接口是设计示例,需要按现有 Repository 形态实现。示例的重点是顺序:先校验,后写稳定 store,最后发布轻量刷新信号;不要把完整 payload 放进 AppStorage 作为跨页面数据源。
验证要覆盖格式演进,不只覆盖一份正常文件
建议准备以下五类样本:
- 当前版本、摘要正确、各领域数据完整的备份,应恢复成功。
- JSON 可解析但
format错误的文件,应拒绝且不写任何 store。 - 删除一个 answers 字段或修改一条答案后的文件,应因摘要不匹配被拒绝。
- 旧
schemaVersion文件,应进入迁移分支;迁移失败时保留现有数据。 - 摘要正确但
currentDeckId无对应题库的文件,应被领域校验拒绝。
恢复后还应退出应用并冷启动:EntryAbility 会重新初始化 Preferences 并执行 SeedLoader,此时题库列表、当前题库、收藏和历史必须保持一致。仅在恢复页看到成功提示并不能证明数据已正确进入下次启动路径。
常见误区
| 做法 | 会发生什么 | 更稳的替代 |
|---|---|---|
直接 JSON.stringify 全部 store |
临时 key 和未来敏感字段自动被导出 | 明确 BackupPayload 白名单 |
| 只带应用版本号 | 无法区分文件格式迁移与应用升级 | 单独维护 schemaVersion |
| 校验摘要后直接覆盖 | 领域引用可能已经无效 | 继续校验题库、答案与 currentDeckId |
| 恢复后只改当前页面状态 | 冷启动仍读取旧 store | 成功写 store 后再发布刷新信号 |
小结
备份扩展负责让系统进入备份生命周期,备份 envelope 负责让应用判断文件能否安全恢复。把格式版本、稳定摘要、领域校验和最终提交分成四步,才能把“拿到一段 JSON”变成真正可演进、可拒绝、可恢复的本地数据协议。
更多推荐


所有评论(0)