【时光清单|08】HarmonyOS ArkTS 备份服务实战:定义导出、恢复和版本兼容边界

发布时间:2026/9/2 15:30:36
【时光清单|08】HarmonyOS ArkTS 备份服务实战:定义导出、恢复和版本兼容边界 【时光清单08】HarmonyOS ArkTS 备份服务实战定义导出、恢复和版本兼容边界本地应用一旦允许用户积累纪念日、收藏语录、情侣空间和主题偏好备份就不再是“把对象JSON.stringify()一下”这么简单。真正困难的是恢复文件来自哪个版本、字段是否完整、数据能否迁移、旧数据与当前数据如何合并、写入一半失败怎么办、恢复后页面和桌面卡片何时刷新。若这些边界没有定义清楚一个能成功生成 JSON 的按钮反而可能给用户制造“数据已经安全”的错觉。时光清单的真实源码提供了一个轻量BackupService它在应用私有filesDir中写入带时间戳的 JSON 文件能够读取、列出和删除备份BackupData包含version、timestamp、纪念日、语录、情侣空间和主题 ID。与此同时当前importBackup()只解析文件并做最低格式判断并没有把数据写回DataStore或各 Repository项目声明的EntryBackupAbility也只记录系统备份与恢复回调没有执行数据搬运。因此本文不会把“读取成功”描述成“恢复完成”而是以现有能力为起点说明一个可审核、可测试、可演进的本地备份方案应该怎样划分导出、校验、迁移、预览、提交和回滚边界。本文将完成以下真实复核解释BackupData的版本、时间戳与数据域。还原私有目录导出、UTF-8 读取、列表和删除流程。区分“导入文件”“解析备份”和“恢复业务数据”。分析当前最低校验无法覆盖的类型、大小与路径风险。设计版本迁移、原子恢复、冲突策略和 UI 状态。区分应用内手动备份与系统BackupExtensionAbility。本文唯一标记CSDN-SERIES:ALL-163207773证据边界哪些是当前事实哪些只是建议本文的当前事实来自BackupService.ets、DataStore.ets、相关模型、ProfileView.ets、EntryBackupAbility.ets、module.json5与backup_config.json的定向复核。源码可以证明接口、字段和静态调用边界却不能单独证明构建已经通过、真机已经运行、系统迁移已经成功或用户数据已经恢复。本文没有运行构建、安装或真机恢复也不会把这些结果补写成既成事实。项目错误记录中没有找到备份服务的直接实现历史定向 Git 历史查询也没有返回可引用的变更。因此“历史证据”只能写成没有发现直接记录不能反推某个功能何时上线。下文凡是使用“建议”“应当”“可以设计”的段落均属于未来实现方案不代表当前工程已有这些能力。一、先把“备份”拆成六个动作一个完整备份流程至少有六个阶段采集业务快照 - 序列化并写入文件 - 让用户识别或导出文件 - 读取并限制输入 - 校验、迁移与预览 - 原子写回并刷新应用状态当前BackupService已覆盖“构造快照、写文件、读文件、列文件、删文件”但没有接入文件选择、外部分享、迁移、仓库写回或事务回滚。工程分析必须尊重这个边界。二、真实 BackupData 契约源码把备份结构定义为export interface BackupData { version: number; timestamp: number; anniversaries: Anniversary[]; quotes: Quote[]; coupleSpace: CoupleSpace | null; themeId: string; }字段语义如下字段用途恢复时的关注点version备份格式版本决定迁移路径timestamp备份生成时间展示、排序、审计anniversaries纪念日集合ID、日期、枚举与重复项quotes语录集合收藏状态与内容完整性coupleSpace情侣空间可空、嵌套任务和留言themeId主题身份合法 ID 与降级version应表示备份 schema 版本不是应用版本号。应用版本可能从1.0.0升到1.2.0备份结构却没有变化反过来一次字段迁移也可能需要提升备份版本即使应用营销版本只是补丁更新。三、createBackupData先构造明确快照createBackupData()把多个数据域组合成版本 1createBackupData( anniversaries: Anniversary[], quotes: Quote[], coupleSpace: CoupleSpace | null, themeId: string ): BackupData { const bd: BackupData { version: 1, timestamp: Date.now(), anniversaries, quotes, coupleSpace, themeId }; return bd; }这个方法的价值在于统一备份包形状但它并不负责从 Repository 采集数据。调用方必须保证这些集合属于同一逻辑时刻否则可能出现纪念日已经更新、情侣空间仍是旧快照的情况。对于当前本地小数据量可以依次读取后立即构造若未来迁移到 RDB 或多个异步数据源应增加快照协调层。备份开始后还要禁用重复点击避免同时生成多个内容几乎相同的文件。四、导出文件实际位于应用私有目录构造函数使用context.filesDirexport class BackupService { private baseDir: string; constructor(context: Context) { this.baseDir context.filesDir; } }导出文件名由当前时间戳组成const fileName backup_${Date.now()}.json; const filePath ${this.baseDir}/${fileName};随后序列化并写入const json JSON.stringify(data, null, 2); const file fileIo.openSync( filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY ); fileIo.writeSync(file.fd, json); fileIo.closeSync(file); return filePath;这里的“导出”准确说是“在应用私有文件目录创建备份”。返回路径不等于用户已经把文件保存到下载目录也不等于其他应用可以直接读取。若产品要支持用户手动保存或跨设备传输还需要系统文档选择器、分享能力或平台支持的文件 URI并遵循相应授权规则。五、写文件需要补上的资源关闭保证当前代码在正常路径调用closeSync()。若writeSync()抛出异常文件可能没有进入关闭语句。更稳妥的结构是把关闭放入finallylet file: fileIo.File | null null; try { file fileIo.openSync( filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY ); fileIo.writeSync(file.fd, json); return filePath; } finally { if (file ! null) { fileIo.closeSync(file); } }具体文件类型和 API 签名应以项目当前 SDK 为准。原则是任何打开成功的句柄都必须在成功和失败路径关闭。另一个问题是覆盖模式。文件名精确到毫秒正常操作很难冲突但自动化或并发调用仍可能产生同名。可以在创建前检查或增加随机后缀。更重要的是写入原子性先写.tmp完成后再重命名避免列表里出现半写文件。六、导入当前只完成读取与解析importBackup()打开指定路径、查询大小、分配缓冲区、读取并用 UTF-8 解码const file fileIo.openSync( filePath, fileIo.OpenMode.READ_ONLY ); const stat fileIo.statSync(filePath); const buf new ArrayBuffer(stat.size); fileIo.readSync(file.fd, buf); fileIo.closeSync(file); const decoder util.TextDecoder.create(utf-8); const json decoder.decodeWithStream( new Uint8Array(buf) ); const data JSON.parse(json) as BackupData;最后只检查两个条件if (!data.version || !data.anniversaries) { throw new Error(Invalid backup format); }成功后直接返回BackupData。它没有调用AnniversaryRepository.save()DataStore.putJson()语录或情侣空间服务AppStorage数据版本通知页面重载桌面卡片更新。所以当前方法更准确的命名是readBackup()或parseBackup()。如果保留importBackup()调用方也必须理解它只返回候选数据不代表恢复提交成功。七、类型断言不是运行时校验这行代码const data JSON.parse(json) as BackupData;只告诉 ArkTS 编译器“把它当作 BackupData”不会验证运行时字段。攻击者、旧版本或损坏文件都可能提供错误结构{ version: 1, anniversaries: not-an-array, quotes: null, coupleSpace: [], themeId: 123 }当前!data.anniversaries对非空字符串会通过。恢复层若随后调用数组方法就会异常。最小校验应覆盖function isBackupData(value: Object): boolean { const data value as BackupData; return Number.isInteger(data.version) data.version 0 Number.isFinite(data.timestamp) Array.isArray(data.anniversaries) Array.isArray(data.quotes) typeof data.themeId string; }随后还要逐项检查纪念日 ID、类型、日期、重复规则和布尔字段。不能因为顶层是数组就相信数组中的每一项。八、导入前必须限制文件大小当前实现根据stat.size一次性分配ArrayBuffer。应用私有备份通常很小但若未来允许用户选择任意文件超大文件可能造成内存压力甚至无响应。应在分配前设置上限const MAX_BACKUP_BYTES 5 * 1024 * 1024; const stat fileIo.statSync(filePath); if (stat.size 0 || stat.size MAX_BACKUP_BYTES) { throw new Error( Backup file size is invalid ); }上限要根据真实数据规模设定并在 UI 中给出可理解提示。还应检查扩展名和内容但扩展名只能作为初筛真正可信的是解码、JSON 解析和 schema 校验。九、路径边界与删除安全deleteBackup()直接拼接fileIo.unlinkSync( ${this.baseDir}/${fileName} );若fileName只来自listBackups()的结果风险较低若未来来自文本参数或路由参数就必须拒绝../、绝对路径和路径分隔符。可以只允许固定命名const BACKUP_NAME /^backup_\d\.json$/; if (!BACKUP_NAME.test(fileName)) { throw new Error( Invalid backup file name ); }导入外部路径时也不能把任意路径永久保存为应用配置。应通过官方文件选择能力获取临时可访问 URI把内容复制到受控临时区后校验。十、listBackups文件名排序为什么可行列表方法筛选固定前后缀并倒序return entries .filter((fname: string) fname.startsWith(backup_) fname.endsWith(.json) ) .sort() .reverse();因为中间部分是同长度毫秒时间戳字符串排序与时间排序一致。该结论依赖命名规则不变。若以后加入手动标签、不同前缀或 schema 版本应该读取文件元数据或解析文件名而不是继续依赖裸字符串。列表 UI 最好展示信息来源生成时间文件名或timestamp格式版本version纪念日数量anniversaries.length文件大小stat.size校验状态预解析结果只展示路径会把内部实现暴露给用户也不利于识别目标备份。十一、版本迁移必须先于恢复写入当前version固定为 1却没有分支处理。一个可扩展迁移器可以采用逐版本升级interface BackupV2 extends BackupData { version: 2; colorModePreference: string; } function migrateBackup( raw: BackupData ): BackupV2 { if (raw.version 1) { return { ...raw, version: 2, colorModePreference: system }; } if (raw.version 2) { return raw as BackupV2; } throw new Error( Unsupported backup version ); }迁移只生成新的内存对象不立即写入当前数据。所有版本转换、字段默认值和非法值修复完成后再进入预览和提交阶段。如果备份版本高于当前应用支持版本应明确提示“请升级应用后恢复”不能盲目忽略未知字段后继续写入因为新版本可能改变了关键语义。十二、恢复不是逐条 save最直接的恢复方式是循环for (const item of backup.anniversaries) { await anniversaryRepo.save(item); }这种做法有三个问题写到一半失败会留下部分恢复数据。每条都刷新持久化性能差。相同 ID 的冲突策略被隐式交给save()。更可靠的方案是在 Repository 或恢复协调层提供批量替换interface RestorePlan { anniversaries: Anniversary[]; quotes: Quote[]; coupleSpace: CoupleSpace | null; themeId: string; } interface RestoreResult { success: boolean; restoredCounts: number[]; warnings: string[]; }提交前先保存当前数据快照按固定顺序写入所有数据域任一步失败时尝试回滚。若底层改为 RDB应使用事务。Preferences 跨多个键没有天然跨键事务需要设计临时键、提交标记或双缓冲方案。十三、覆盖、合并还是跳过恢复必须先定义冲突策略。覆盖清空当前数据完整替换为备份。结果容易理解但风险最大必须二次确认并展示将被替换的数量。合并保留当前数据按 ID 合并备份。需要定义同 ID 时使用较新的updatedAt、优先当前值还是让用户选择。仅新增只导入当前不存在的 ID。最安全但无法恢复被错误修改的记录。建议在恢复预览中明确显示备份版本1 生成时间2026-07-25 18:20 纪念日18 条 语录42 条 情侣空间有 冲突3 条 模式覆盖 / 合并 / 仅新增不可逆覆盖必须由用户明确确认自动化流程也应停在最终确认之前。十四、恢复后如何让页面同步即使所有数据已写回现有 Repository 可能仍缓存旧数组。恢复提交必须协调写入底层存储。让 Repository 重新水合或替换内存集合。更新当前主题。递增StateKeys.DATA_VERSION。刷新桌面 Form 数据。返回成功结果。如果只直接调用DataStore.putJson()AnniversaryRepository.initialized仍为true后续getAll()会继续返回旧缓存。因此恢复 API 应由 Repository 暴露而不是绕过它修改 Preferences。可设计async replaceAll( items: Anniversary[] ): Promisevoid { const normalized items.map(normalizeAnniversary); await this.store.putJson( DataKeys.ANNIVERSARIES, normalized ); this.items normalized; }持久化成功后才替换内存或者准备旧值用于失败回滚。恢复协调层完成所有域后只发送一次全局数据版本通知。十五、当前 BackupData 并未覆盖全部应用数据DataKeys还包含static readonly WISHES ds_wishes; static readonly DIARY ds_diary; static readonly HABITS ds_habits; static readonly HABIT_WALL ds_habit_wall; static readonly COUPLE ds_couple; static readonly ALBUM ds_album; static readonly MOOD_BACKGROUND ds_mood_background; static readonly WIDGET_ANNIVERSARY_ID ds_widget_anniversary_id;而BackupData只包含纪念日、语录、情侣空间和主题 ID。愿望、日记、习惯、相册元数据、心情背景和桌面卡片选择并未进入当前备份结构。因此产品文案不能写“完整备份所有数据”。更准确的是“备份纪念日、语录、情侣空间和主题信息”或者先扩充 schema 再声明完整备份。相册还可能只保存媒体 URI而不是图片二进制。跨设备恢复后旧 URI 未必仍可访问。备份媒体需要单独的文件复制、容量预算和授权策略不能只复制字符串。十六、手动备份与系统备份是两条链路模块声明了{ name: EntryBackupAbility, srcEntry: ./ets/entrybackupability/EntryBackupAbility.ets, type: backup, exported: false }对应 Ability 当前只记录回调export default class EntryBackupAbility extends BackupExtensionAbility { async onBackup() { hilog.info( DOMAIN, testTag, onBackup ok ); await Promise.resolve(); } async onRestore( bundleVersion: BundleVersion ) { hilog.info( DOMAIN, testTag, onRestore ok %{public}s, JSON.stringify(bundleVersion) ); await Promise.resolve(); } }这证明系统备份扩展已注册但不能证明实际数据已被系统备份或恢复。应用内BackupService创建 JSON 文件系统BackupExtensionAbility响应平台生命周期两者目的和触发方式不同。若要接通系统备份应依据当前 HarmonyOS 官方 Core File Kit 文档确认备份目录、配置、版本与恢复时机并测试卸载重装或设备迁移。不能简单在回调里调用私有 JSON 导出就宣称跨设备恢复完成。十七、敏感数据与隐私边界备份可能包含纪念日标题、关系开始日期、情侣留言和个人收藏。这些信息未必属于系统定义的敏感权限数据但对用户具有明显隐私价值。本地私有目录的优势是默认受应用沙箱保护。若允许导出到公共位置或分享明确提示文件包含哪些内容不把绝对私有路径显示为“云备份成功”不在日志打印完整 JSON不自动上传不把备份附加到反馈或分析请求删除操作要说明只删除备份不删除当前数据恢复覆盖前明确说明影响。如果加入密码保护应使用平台安全能力和经过验证的加密方案不能自行设计简单异或或把密钥写进代码。十八、UI 状态必须完整备份页面至少需要type BackupUiState idle | collecting | writing | reading | validating | preview | restoring | success | error;交互规则状态行为writing禁用重复导出reading显示文件读取进度或等待preview展示版本、数量、冲突restoring禁止离开或重复提交error保留当前数据提供可理解原因success显示恢复数量并刷新页面删除备份和覆盖当前数据是两种不同风险。删除单个备份应确认目标文件覆盖恢复则应显示当前数据将如何变化不能复用一个模糊的“确定”弹窗。十九、测试矩阵导出空数据能生成合法备份。中文、emoji、长备注经过 UTF-8 往返不损坏。写入失败会关闭句柄并显示错误。连续导出不会覆盖旧文件。列表按最新时间优先。解析合法 v1 文件通过。空文件、非 JSON、超大文件被拒绝。anniversaries不是数组时被拒绝。非法枚举、日期、ID 被报告。未知高版本提示升级应用。恢复覆盖、合并、仅新增结果符合预览。中途失败不留下半恢复状态。Repository 内存与 Preferences 一致。页面统计在一次版本通知后刷新。当前主题和桌面卡片同步更新。重启后数据与恢复完成时一致。多设备与发布当前模块只声明phone不能虚构平板或 2in1 已支持。若未来扩大设备类型要验证文件选择器、确认弹窗、长列表和安全区适配。发布前还要确认隐私说明与实际备份范围一致。二十、渐进实现路线基于现有源码建议依次完成将importBackup()明确为读取与解析阶段。增加文件大小、文件名和完整 schema 校验。为 v1 建立规范化函数为未来版本建立迁移器。新增恢复预览与冲突策略。给 Repository 增加批量替换或合并 API。设计失败回滚再接入页面确认。恢复成功后统一刷新 AppStorage 和 Form。扩展 BackupData 覆盖真实需要的数据域。单独评估相册文件与 URI 的可迁移性。按官方文档实现并验证系统备份扩展。这条路线保留了当前BackupService的文件能力又避免直接把解析结果写入业务仓库。二十一、把“可解析”推进到“可恢复”的建议契约这一节全部是建议实现用于把前面的风险落到可检查的接口上不是当前源码能力。第一步不是立刻写回数据而是把导入结果分成明确状态。解析成功只能得到候选包只有候选包通过版本、结构和业务规则检查才允许进入预览。type InspectResult | { status: valid; data: BackupData; warnings: string[] } | { status: unsupported_version; version: number } | { status: invalid; errors: string[] };这个联合类型能阻止调用方用一个布尔值掩盖差异。未知高版本意味着当前应用看不懂不等于文件损坏字段缺失属于结构问题日期范围或重复 ID 则是业务规则问题。三者对应的用户提示、重试方式和日志级别都不同。第二步是建立明确的版本支持表。当前源码创建的是 v1但导入逻辑只检查version是否为真值没有拒绝未知版本。建议只接受列入支持表的版本并且让迁移按相邻版本逐级执行避免一个巨大的条件分支同时理解所有历史结构。const CURRENT_BACKUP_VERSION: number 2; function migrateToCurrent(raw: object, version: number): BackupData { if (version 1) { return migrateV1ToV2(raw); } if (version CURRENT_BACKUP_VERSION) { return normalizeV2(raw); } throw new Error(unsupported backup version); }这里的migrateV1ToV2和normalizeV2是示意名称。真正实现时必须根据真实 schema 变化编写不能为了让示例“看起来完整”而虚构字段。迁移后还要再次校验因为迁移器本身也可能产生非法值。第三步是限制输入资源。当前实现读取stat.size后直接申请同等大小的ArrayBuffer并且没有检查一次readSync的返回字节数。建议先设定与业务数据规模相匹配的上限再循环读取或核对已读字节。上限必须由真实数据量和测试决定文章不编造一个“通用安全值”。function assertReadableSize(size: number, maxBytes: number): void { if (!Number.isInteger(size) || size 0) { throw new Error(empty or invalid backup file); } if (size maxBytes) { throw new Error(backup file exceeds configured limit); } }第四步是把提交设计成一个可回滚边界。建议在用户确认之前只做读取、校验、迁移和预览确认之后先保存当前数据快照再通过服务层或仓库批量写入。任何数据域失败都应停止后续写入并恢复旧快照。恢复完成后还要重新读取一次持久层并比较关键数量、ID 集合和版本不能只相信写入 API 没有抛异常。async function restoreCandidate(candidate: BackupData): Promisevoid { const before await snapshotCurrentData(); try { await commitAllDomains(candidate); await verifyCommittedData(candidate); } catch (error) { await restoreSnapshot(before); throw error; } }这段代码表达的是控制边界不代表当前工程已有snapshotCurrentData、commitAllDomains或restoreSnapshot。如果 Preferences 无法提供原生事务就要在应用服务层设计临时键、提交标记或双份快照如果未来改用关系型存储则应优先使用数据库事务。选择必须服从实际存储机制。第五步是把路径和文件名当作输入验证的一部分。当前删除逻辑把传入文件名直接拼到filesDir建议仅允许服务自身生成的命名格式并在规范化后确认目标仍处于基准目录。列表接口也应区分“目录为空”和“读取失败”否则 UI 会把权限或 I/O 错误误报成“暂无备份”。function isGeneratedBackupName(name: string): boolean { return /^backup_[0-9]\.json$/.test(name); }文件名校验不是完整防线真实实现还需要使用平台提供的规范化路径能力并验证父目录。删除前可以展示文件生成时间和包含的数据数量但这些摘要必须来自已经校验过的备份不能直接信任外部文件中的任意文本。二十二、验收标准必须对应真实动作建议把验收拆成四层。静态层检查 schema、支持版本表、资源关闭和路径约束单元层覆盖合法、损坏、超大、未知版本和嵌套字段错误集成层验证多数据域写入失败后的回滚设备层才验证文件选择、应用重启、系统备份回调和真实恢复。某一层通过不能代替下一层。对于当前文章能确认的是源文件中存在手动 JSON 服务、系统备份扩展声明和空实现式回调不能确认的是构建、设备、跨版本、卸载重装或系统迁移结果。后续实现若完成也应记录实际命令、设备环境、备份版本、输入样本和失败注入点再据此更新结论。二十三、总结时光清单当前备份能力可以准确概括为BackupData 定义 v1 快照 - exportBackup 写入 filesDir - listBackups 管理本地备份文件 - importBackup 读取、解码和最低校验 - 返回候选 BackupData它已经具备本地 JSON 备份文件的基础设施但尚未形成完整恢复闭环。真正可靠的恢复还需要运行时 schema 校验、大小和路径限制、版本迁移、冲突预览、Repository 批量提交、失败回滚、页面失效通知以及桌面卡片同步。对本地数据应用而言备份功能最重要的承诺不是“生成了一个文件”而是能说清备份了什么、能在支持的版本中完整验证、恢复失败不破坏现有数据恢复成功后所有数据层和界面保持一致。本文基于时光清单项目的BackupService.ets、EntryBackupAbility.ets、DataStore.ets和相关模型真实源码复核整理。文中明确区分了当前文件读写能力与尚未实现的完整恢复流程。AI 辅助声明本文在真实源码核验、结构梳理和文字编辑过程中使用了 AI 辅助关键接口、调用边界和工程结论均以项目源码为依据进行人工复核。