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

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的刷新时间戳、页面是否展开、动画阶段、临时输入框文本不应进入载荷。

interfaceBackupPayloadV1{app:Pick<AppPreferences,'schemaVersion'|'currentDeckId'|'firstLaunchDone'>;decks:Deck[];favorites:Favorite[];questionHistory:string[];}interfaceBackupEnvelopeV1{format:'the-book-of-answers-backup';schemaVersion:1;createdAt:number;checksum:string;payload:BackupPayloadV1;}

format用于拒绝拿错文件;schemaVersion表示文件格式而不是应用 versionName;createdAt方便用户辨认备份时间;checksum只保护载荷的完整性,不替代加密。模型中的字段应显式列出,不能用Record<string, unknown>把未来不该导出的 key 自动带进去。

摘要必须基于稳定序列化结果计算

同一份对象如果序列化键顺序不稳定,摘要会在没有数据变化时不断变化。更稳的办法是先定义稳定的序列化形式,再计算摘要;校验时使用同一规则。

functioncanonicalPayload(payload:BackupPayloadV1):string{returnJSON.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});}asyncfunctionbuildEnvelope(payload:BackupPayloadV1):Promise<BackupEnvelopeV1>{consttext:string=canonicalPayload(payload);return{format:'the-book-of-answers-backup',schemaVersion:1,createdAt:Date.now(),checksum:awaitDigest.sha256(text),payload};}

Digest.sha256是示例依赖,接入时应使用项目确定的摘要实现。关键在于摘要只针对 canonical payload,而非针对包含时间戳的整个 envelope;否则每次导出都会改变摘要,无法比较内容是否真的相同。

恢复先走判定表,不能直接覆盖 Preferences

恢复函数面对的是不可信输入。即使 JSON 能解析,也可能缺字段、版本过高、摘要不符,或currentDeckId指向不存在题库。把这些判断压缩为一个try { saveAll(...) },会把异常留给下一次页面挂载。

typeRestoreCheck=|{kind:'accepted';payload:BackupPayloadV1}|{kind:'migrate';fromVersion:number;raw:unknown}|{kind:'rejected';reason:string};asyncfunctioninspectEnvelope(raw:string):Promise<RestoreCheck>{letvalue:BackupEnvelopeV1;try{value=JSON.parse(raw)asBackupEnvelopeV1;}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};}constactual=awaitDigest.sha256(canonicalPayload(value.payload));if(actual!==value.checksum){return{kind:'rejected',reason:'备份摘要不匹配,文件可能不完整'};}return{kind:'accepted',payload:value.payload};}

这里的拒绝不是失败兜底,而是保护现有用户数据。migrate也不应直接进入保存逻辑;它必须调用针对旧版本的纯转换函数,并将转换结果再次走当前版本的完整性判断。

通过校验后仍要检查领域约束

摘要正确只能说明载荷没有在传输中变化,不能说明内容满足应用规则。比如题库可能没有答案、收藏引用了已不存在的答案、当前题库 id 不存在。恢复服务应把 envelope 校验和领域校验分开,避免将“文件可信”误解为“数据可用”。

functionvalidatePayload(payload:BackupPayloadV1):string|null{if(payload.decks.length===0)return'备份中没有题库';if(payload.decks.some((deck)=>deck.answers.length===0)){return'备份中存在没有答案的题库';}constcurrentExists=payload.decks.some((deck)=>deck.id===payload.app.currentDeckId);if(!currentExists)return'当前题库引用不存在';returnnull;}

如果领域校验失败,正确行为是展示原因并保持现有 store 不变。不要为了“尽量恢复”而写入半份数据;用户至少还保留恢复前的本地内容,之后可以选择重新导出或使用差异导入。

一次恢复应在成功点统一提交

题库、收藏、历史分散在不同 Preferences store,因此恢复可能部分写入成功、部分失败。建议新增的恢复编排器需要先准备候选数据,确认所有约束后再按固定顺序写入;若底层不支持事务,应至少在写入前建立可恢复快照,并在失败时停止继续写入。

asyncfunctionrestoreAcceptedPayload(payload:BackupPayloadV1):Promise<void>{constreason=validatePayload(payload);if(reason)thrownewError(reason);awaitDeckRepository.replaceAll(payload.decks);// 建议新增批量接口awaitFavoriteRepository.saveAll(payload.favorites);awaitQuestionHistoryRepository.saveAll(payload.questionHistory);awaitPreferencesStore.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”变成真正可演进、可拒绝、可恢复的本地数据协议。