HarmonyOS 应用实战 52:备份文件别没有版本,导出本地数据时带上 schema 和校验摘要

本地题库应用的备份很容易被写成一段 JSON:把题库、收藏、历史序列化后交给系统,恢复时再直接写回 Preferences。它在第一个版本能工作,但一旦字段改名、数据截断、文件传输损坏,恢复端无法判断拿到的是旧格式、半份文件,还是完全不属于本应用的数据。

《答案之书》已经注册了备份扩展能力,但当前 EntryBackupAbilityonBackuponRestore 只记录生命周期日志。本文不把“已注册扩展”夸大成“已经有可迁移备份协议”,而是从现有 Preferences 边界出发,设计一个可验证的备份 envelope。

在这里插入图片描述

系统回调存在,不等于备份格式已经定义

entry/src/main/module.json5 中的 EntryBackupAbility 类型为 backup,并通过 ohos.extension.backup 指向 backup_configEntryBackupAbility 也确实继承 BackupExtensionAbility,拥有 onBackup()onRestore(bundleVersion)

这些事实说明系统可以进入备份生命周期;但它们没有回答“导出哪些 store”“字段怎样演进”“恢复前如何证明内容可信”。目前应用级偏好已有 schemaVersioncurrentDeckIdfirstLaunchDone,题库、收藏和历史又使用独立 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());
}

replaceAllapp_preferences 的具体接口是设计示例,需要按现有 Repository 形态实现。示例的重点是顺序:先校验,后写稳定 store,最后发布轻量刷新信号;不要把完整 payload 放进 AppStorage 作为跨页面数据源。

验证要覆盖格式演进,不只覆盖一份正常文件

建议准备以下五类样本:

  1. 当前版本、摘要正确、各领域数据完整的备份,应恢复成功。
  2. JSON 可解析但 format 错误的文件,应拒绝且不写任何 store。
  3. 删除一个 answers 字段或修改一条答案后的文件,应因摘要不匹配被拒绝。
  4. schemaVersion 文件,应进入迁移分支;迁移失败时保留现有数据。
  5. 摘要正确但 currentDeckId 无对应题库的文件,应被领域校验拒绝。

恢复后还应退出应用并冷启动:EntryAbility 会重新初始化 Preferences 并执行 SeedLoader,此时题库列表、当前题库、收藏和历史必须保持一致。仅在恢复页看到成功提示并不能证明数据已正确进入下次启动路径。

常见误区

做法 会发生什么 更稳的替代
直接 JSON.stringify 全部 store 临时 key 和未来敏感字段自动被导出 明确 BackupPayload 白名单
只带应用版本号 无法区分文件格式迁移与应用升级 单独维护 schemaVersion
校验摘要后直接覆盖 领域引用可能已经无效 继续校验题库、答案与 currentDeckId
恢复后只改当前页面状态 冷启动仍读取旧 store 成功写 store 后再发布刷新信号

小结

备份扩展负责让系统进入备份生命周期,备份 envelope 负责让应用判断文件能否安全恢复。把格式版本、稳定摘要、领域校验和最终提交分成四步,才能把“拿到一段 JSON”变成真正可演进、可拒绝、可恢复的本地数据协议。

Logo

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

更多推荐