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的刷新时间戳、页面是否展开、动画阶段、临时输入框文本不应进入载荷。interfaceBackupPayloadV1{app:PickAppPreferences,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表示文件格式而不是应用 versionNamecreatedAt方便用户辨认备份时间checksum只保护载荷的完整性不替代加密。模型中的字段应显式列出不能用Recordstring, 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):PromiseBackupEnvelopeV1{consttext:stringcanonicalPayload(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):PromiseRestoreCheck{letvalue:BackupEnvelopeV1;try{valueJSON.parse(raw)asBackupEnvelopeV1;}catch(_){return{kind:rejected,reason:备份文件不是有效 JSON};}if(value.format!the-book-of-answers-backup){return{kind:rejected,reason:文件不属于答案之书备份};}if(value.schemaVersion1){return{kind:rejected,reason:备份格式比当前应用更新};}if(value.schemaVersion1){return{kind:migrate,fromVersion:value.schemaVersion,raw:value};}constactualawaitDigest.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.length0)return备份中没有题库;if(payload.decks.some((deck)deck.answers.length0)){return备份中存在没有答案的题库;}constcurrentExistspayload.decks.some((deck)deck.idpayload.app.currentDeckId);if(!currentExists)return当前题库引用不存在;returnnull;}如果领域校验失败正确行为是展示原因并保持现有 store 不变。不要为了“尽量恢复”而写入半份数据用户至少还保留恢复前的本地内容之后可以选择重新导出或使用差异导入。一次恢复应在成功点统一提交题库、收藏、历史分散在不同 Preferences store因此恢复可能部分写入成功、部分失败。建议新增的恢复编排器需要先准备候选数据确认所有约束后再按固定顺序写入若底层不支持事务应至少在写入前建立可恢复快照并在失败时停止继续写入。asyncfunctionrestoreAcceptedPayload(payload:BackupPayloadV1):Promisevoid{constreasonvalidatePayload(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());}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”变成真正可演进、可拒绝、可恢复的本地数据协议。