【时光清单|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()把多个数据域组合成版本 1:
createBackupData( 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.filesDir:
export 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()抛出异常,文件可能没有进入关闭语句。更稳妥的结构是把关闭放入finally:
let 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[] ): Promise<void> { 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): Promise<void> { 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 辅助;关键接口、调用边界和工程结论均以项目源码为依据进行人工复核。