【Jack实战】如何让《时光旅记》适配华为数据克隆迁移

大家好,我是鸿蒙 Jack。本期以我的《时光旅记》APP 为例,聊一次真实问题驱动的适配:为什么用户用华为"数据克隆"APP 从旧机迁移到新机时,《时光旅记》的数据会迁移失败,以及我是怎么把这个问题修掉的。

事情的起点很简单。有用户反馈,旧手机里《时光旅记》的时光本、瞬间、旅行计划这些数据都在,但通过华为"数据克隆"迁移到新手机后,新机上的数据没有正常恢复。对一个记录类 APP 来说,这类问题优先级很高,因为用户记录的是自己的照片、旅行、情绪和时间线,一旦迁移失败,体验会非常差。

我最开始的判断是:这不是普通页面逻辑问题,也不是某个按钮没有保存成功,而是应用没有正确接入系统级的数据备份恢复能力。查阅华为官方文档后确认,应用如果希望在克隆、迁移这类系统流程里迁移自己的应用数据,需要适配 BackupExtensionAbility

参考文档:

https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/app-file-backup-extension

API 参考:

https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-application-backupextensionability

问题定位

《时光旅记》的数据不是只存在一个简单 JSON 里。它有几类核心数据:

数据类型 存储位置 说明
时光本、瞬间、旅行计划、倒数日等结构化数据 data/storage/el2/database/ RDB 本地数据库
主题、登录态、偏好设置、回收站等状态 data/storage/el2/base/preferences/ Preferences
时光照片、视频缩略图、封面、音频、生成图片等文件 data/storage/el2/base/files/ 应用沙箱文件

用户通过"数据克隆"迁移时,系统并不会自动理解一个 APP 的业务数据结构。应用需要告诉系统:哪些目录允许备份恢复,哪些缓存目录不应该迁移,以及恢复完成后应用是否还要做额外加工。

官方文档里有一句话很关键:应用接入数据备份恢复需要通过 BackupExtensionAbility 实现。也就是说,适配数据克隆不是在业务页面里写一个"导出"按钮,而是要把应用接入 HarmonyOS 的备份恢复框架。

这也是我这次改造的方向。

适配目标

我给《时光旅记》定了三个目标。

第一,系统克隆迁移时能把核心数据带到新机。数据库、Preferences、用户保存在沙箱里的媒体文件都要进入备份范围。

第二,临时缓存不能跟着迁移。分享缓存、导出缓存、WebDAV 缓存、导入暂存目录这些内容不属于用户核心数据,迁移过去只会增加体积,还可能污染新机状态。

第三,恢复后数据要能继续显示。这个点最容易被忽略。很多记录类 APP 会把图片和封面的 fileUri 或沙箱路径存在数据库里,但旧机路径迁移到新机后不一定还能直接访问。所以恢复完成后,需要把这些本地文件引用重新归一化到新设备的沙箱路径。

技术方案

这次真正用到的技术栈主要是下面这些:

BackupExtensionAbility:系统级备份恢复扩展能力。数据克隆、迁移等系统任务会拉起它,并回调 onBackuponRestore

module.json5:声明 backup 类型扩展,让系统知道这个应用实现了备份恢复扩展。

backup_config.json:声明允许备份恢复、包含目录、排除目录,以及恢复落盘方式。

@kit.CoreFileKit:提供 BackupExtensionAbilityBundleVersionfileIofileUri。其中 fileUri 用于恢复后把当前设备上的文件路径重新转成可用 URI。

@kit.ArkData:项目里使用 relationalStorepreferences 保存核心数据。备份前需要刷盘并关闭数据库,避免系统拿到不完整状态。

@kit.PerformanceAnalysisKit:使用 hilog 打印备份恢复生命周期日志,方便验证系统有没有拉起扩展。

架构图

#mermaid-svg-I3rcobtzdgvo00ww{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-I3rcobtzdgvo00ww .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I3rcobtzdgvo00ww .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I3rcobtzdgvo00ww .error-icon{fill:#552222;}#mermaid-svg-I3rcobtzdgvo00ww .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I3rcobtzdgvo00ww .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I3rcobtzdgvo00ww .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I3rcobtzdgvo00ww .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I3rcobtzdgvo00ww .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I3rcobtzdgvo00ww .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I3rcobtzdgvo00ww .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I3rcobtzdgvo00ww .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I3rcobtzdgvo00ww .marker.cross{stroke:#333333;}#mermaid-svg-I3rcobtzdgvo00ww svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I3rcobtzdgvo00ww p{margin:0;}#mermaid-svg-I3rcobtzdgvo00ww .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-I3rcobtzdgvo00ww .cluster-label text{fill:#333;}#mermaid-svg-I3rcobtzdgvo00ww .cluster-label span{color:#333;}#mermaid-svg-I3rcobtzdgvo00ww .cluster-label span p{background-color:transparent;}#mermaid-svg-I3rcobtzdgvo00ww .label text,#mermaid-svg-I3rcobtzdgvo00ww span{fill:#333;color:#333;}#mermaid-svg-I3rcobtzdgvo00ww .node rect,#mermaid-svg-I3rcobtzdgvo00ww .node circle,#mermaid-svg-I3rcobtzdgvo00ww .node ellipse,#mermaid-svg-I3rcobtzdgvo00ww .node polygon,#mermaid-svg-I3rcobtzdgvo00ww .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I3rcobtzdgvo00ww .rough-node .label text,#mermaid-svg-I3rcobtzdgvo00ww .node .label text,#mermaid-svg-I3rcobtzdgvo00ww .image-shape .label,#mermaid-svg-I3rcobtzdgvo00ww .icon-shape .label{text-anchor:middle;}#mermaid-svg-I3rcobtzdgvo00ww .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-I3rcobtzdgvo00ww .rough-node .label,#mermaid-svg-I3rcobtzdgvo00ww .node .label,#mermaid-svg-I3rcobtzdgvo00ww .image-shape .label,#mermaid-svg-I3rcobtzdgvo00ww .icon-shape .label{text-align:center;}#mermaid-svg-I3rcobtzdgvo00ww .node.clickable{cursor:pointer;}#mermaid-svg-I3rcobtzdgvo00ww .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-I3rcobtzdgvo00ww .arrowheadPath{fill:#333333;}#mermaid-svg-I3rcobtzdgvo00ww .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-I3rcobtzdgvo00ww .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-I3rcobtzdgvo00ww .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I3rcobtzdgvo00ww .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-I3rcobtzdgvo00ww .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I3rcobtzdgvo00ww .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-I3rcobtzdgvo00ww .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-I3rcobtzdgvo00ww .cluster text{fill:#333;}#mermaid-svg-I3rcobtzdgvo00ww .cluster span{color:#333;}#mermaid-svg-I3rcobtzdgvo00ww div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-I3rcobtzdgvo00ww .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-I3rcobtzdgvo00ww rect.text{fill:none;stroke-width:0;}#mermaid-svg-I3rcobtzdgvo00ww .icon-shape,#mermaid-svg-I3rcobtzdgvo00ww .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I3rcobtzdgvo00ww .icon-shape p,#mermaid-svg-I3rcobtzdgvo00ww .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-I3rcobtzdgvo00ww .icon-shape .label rect,#mermaid-svg-I3rcobtzdgvo00ww .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I3rcobtzdgvo00ww .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-I3rcobtzdgvo00ww .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-I3rcobtzdgvo00ww :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户使用华为数据克隆
系统开始迁移应用数据
读取 module.json5
发现 EntryBackupAbility
读取 backup_config.json
按 includes/excludes 迁移沙箱数据
旧机: onBackup 备份前处理
新机: onRestore 恢复后处理
刷写 Preferences 并关闭 RDB
重新初始化持久化层
重建时光本/瞬间/旅行计划的本地文件 URI
新机打开 APP 后数据正常显示

这里的核心不是"手动打包数据"。真正搬数据的是系统备份恢复服务。APP 侧要做的是接入生命周期,并在合适的时机把数据状态处理干净。

时序图

RDB/Preferences/Files TimeImprintPersistence EntryBackupAbility 系统备份恢复框架 华为数据克隆 用户 RDB/Preferences/Files TimeImprintPersistence EntryBackupAbility 系统备份恢复框架 华为数据克隆 用户 #mermaid-svg-Q6RP8RQo3rYY0Qc9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .error-icon{fill:#552222;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .marker.cross{stroke:#333333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 p{margin:0;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .sequenceNumber{fill:white;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 #sequencenumber{fill:#333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .messageText{fill:#333;stroke:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .labelText,#mermaid-svg-Q6RP8RQo3rYY0Qc9 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .loopText,#mermaid-svg-Q6RP8RQo3rYY0Qc9 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .noteText,#mermaid-svg-Q6RP8RQo3rYY0Qc9 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actorPopupMenu{position:absolute;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 .actor-man circle,#mermaid-svg-Q6RP8RQo3rYY0Qc9 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Q6RP8RQo3rYY0Qc9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发起旧机到新机迁移 请求迁移《时光旅记》数据 旧机回调 onBackup() prepareTimeImprintPersistenceForBackup() flush Preferences close RDB 按 backup_config 采集数据 在新机恢复数据 新机回调 onRestore(bundleVersion) normalizeTimeImprintPersistenceAfterRestore() 读取恢复后的快照 重建当前沙箱 fileUri 写回修正后的数据

第一步:注册 BackupExtensionAbility

先在 entry/src/main/module.json5 里注册备份恢复扩展。

完整代码如下:

json5 复制代码
{
  "name": "EntryBackupAbility",
  "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
  "type": "backup",
  "exported": false,
  "metadata": [
    {
      "name": "ohos.extension.backup",
      "resource": "$profile:backup_config"
    }
  ]
}

这里有三个字段必须对应上。

type 必须是 backupmetadata.name 必须是 ohos.extension.backupresource 指向 resources/base/profile/backup_config.json,所以这里写 $profile:backup_config

这个扩展不是给其他三方应用调用的,所以 exported 设为 false

第二步:声明数据克隆要迁移哪些目录

接着新增或修改 entry/src/main/resources/base/profile/backup_config.json

《时光旅记》当前完整配置如下:

json 复制代码
{
  "allowToBackupRestore": true,
  "includes": [
    "data/storage/el2/database/",
    "data/storage/el2/base/preferences/",
    "data/storage/el2/base/files/",
    "data/storage/el2/base/haps/*/preferences/",
    "data/storage/el2/base/haps/*/files/"
  ],
  "excludes": [
    "data/storage/el2/base/files/time-imprint/temp/",
    "data/storage/el2/base/files/time-imprint/share-cache/",
    "data/storage/el2/base/files/time-imprint/geo/",
    "data/storage/el2/base/files/time-imprint/altitude-export-cache/",
    "data/storage/el2/base/files/time-imprint/watermark-camera/",
    "data/storage/el2/base/files/time-imprint-webdav-cache/",
    "data/storage/el2/base/files/time-imprint-import-staging/",
    "data/storage/el2/base/files/time-imprint-import-backup/",
    "data/storage/el2/base/haps/*/files/time-imprint/temp/",
    "data/storage/el2/base/haps/*/files/time-imprint/share-cache/",
    "data/storage/el2/base/haps/*/files/time-imprint/geo/",
    "data/storage/el2/base/haps/*/files/time-imprint/altitude-export-cache/",
    "data/storage/el2/base/haps/*/files/time-imprint/watermark-camera/",
    "data/storage/el2/base/haps/*/files/time-imprint-webdav-cache/",
    "data/storage/el2/base/haps/*/files/time-imprint-import-staging/",
    "data/storage/el2/base/haps/*/files/time-imprint-import-backup/"
  ],
  "fullBackupOnly": false
}

allowToBackupRestore 打开后,系统才允许这个应用参与备份恢复。

includes 我选择了 EL2 下的 database、preferences 和 files。原因很直接:这些地方覆盖了《时光旅记》的核心数据。如果只备份数据库,图片封面会丢;如果只备份 files,业务记录和设置又恢复不了。

excludes 排除了临时目录和缓存目录。比如 share-cache 是分享图缓存,altitude-export-cache 是海拔水印导出缓存,time-imprint-webdav-cache 是 WebDAV 缓存,这些都可以重新生成,不应该跟着克隆迁移。

fullBackupOnly 设置为 false,表示恢复时系统直接把数据恢复到目标沙箱路径。我的业务只需要在恢复完成后修正 URI,不需要自己从临时目录搬运文件。如果设置成 true,数据会先恢复到 backupDir 下,应用需要自己实现最终恢复逻辑,适合更复杂的合并场景。

第三步:实现旧机备份前处理和新机恢复后处理

entry/src/main/ets/entrybackupability/EntryBackupAbility.ets 的完整代码如下:

ts 复制代码
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
import {
  normalizeTimeImprintPersistenceAfterRestore,
  prepareTimeImprintPersistenceForBackup,
  registerTimeImprintPersistenceContext,
  resetTimeImprintPersistenceState
} from '../utils/TimeImprintPersistence';

const DOMAIN = 0x0000;

export default class EntryBackupAbility extends BackupExtensionAbility {
  async onBackup() {
    registerTimeImprintPersistenceContext(this.context);
    await prepareTimeImprintPersistenceForBackup();
    hilog.info(DOMAIN, 'testTag', 'onBackup ok');
  }

  async onRestore(bundleVersion: BundleVersion) {
    resetTimeImprintPersistenceState();
    registerTimeImprintPersistenceContext(this.context);
    await normalizeTimeImprintPersistenceAfterRestore();
    hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
  }
}

onBackup 运行在备份准备阶段。这里不要误解成"手动把数据库复制一遍"。系统后面会根据 backup_config.json 去采集数据。我的处理重点是让持久化层进入稳定状态:注册当前 BackupExtensionContext,刷写 Preferences,关闭 RDB,清掉内存缓存。

onRestore 运行在恢复完成后。新机上的文件已经落盘,但数据库里的本地文件引用可能仍然是旧设备的路径,所以这里要重置持久化缓存,重新注册上下文,然后做一次恢复后归一化。

如果需要区分更复杂的迁移场景,可以改用 onBackupExonRestoreEx。当前这个问题是数据克隆适配,onBackuponRestore 就够用。

第四步:备份前把数据刷干净

TimeImprintPersistence 里备份前处理是这样的:

ts 复制代码
export function prepareTimeImprintPersistenceForBackup(): Promise<void> {
  return enqueueOperation(async (): Promise<void> => {
    await ensureInitialized();
    flushPreferencesSilently();
    await closeDatabaseSilently();
    invalidatePersistenceCache();
  });
}

这段代码做了三件事。

先确保持久化层初始化完成。然后把 Preferences 静默刷盘。最后关闭数据库并清理缓存。

为什么要关数据库?因为数据克隆是系统在迁移沙箱数据。如果应用侧还有未落盘的写入,或者数据库连接仍然保持着某些中间状态,就可能出现新机恢复到的不是最新数据。记录类 APP 尤其要避免这种情况。

第五步:恢复后重建沙箱 URI

恢复后的关键处理在这里:

ts 复制代码
export function resetTimeImprintPersistenceState(): void {
  invalidatePersistenceCache();
}

export function normalizeTimeImprintPersistenceAfterRestore(): Promise<void> {
  return enqueueOperation(async (): Promise<void> => {
    await ensureInitialized();
    const context: Context = requireContext();
    const restoredSnapshot: TimeImprintStore = await readStoreSnapshot();
    rebaseSandboxUris(restoredSnapshot, context);
    await writeStoreSnapshot(restoredSnapshot);
  });
}

normalizeTimeImprintPersistenceAfterRestore 是我这次适配里最重要的业务补丁。它不是简单读写数据,而是把恢复后的业务快照拿出来,重新处理所有沙箱文件引用。

下面是核心逻辑:

ts 复制代码
function rebaseSandboxUris(store: TimeImprintStore, context: Context): boolean {
  let changed: boolean = false;
  for (let i: number = 0; i < store.notebooks.length; i++) {
    changed = rebaseNotebookUris(store.notebooks[i], context) || changed;
  }
  for (let i: number = 0; i < store.deletedNotebooks.length; i++) {
    changed = rebaseNotebookUris(store.deletedNotebooks[i].notebook, context) || changed;
  }
  for (let i: number = 0; i < store.deletedMoments.length; i++) {
    changed = rebaseMomentUris(store.deletedMoments[i].moment, context, store.deletedMoments[i].originalNotebookId) ||
      changed;
  }
  for (let i: number = 0; i < store.travelPlans.length; i++) {
    changed = rebaseTravelPlanUris(store.travelPlans[i], context) || changed;
  }
  return changed;
}

这一步覆盖了正常时光本、回收站里的时光本、回收站里的瞬间、旅行计划。也就是说,就算用户之前删除过内容,只要还在回收站恢复范围内,也要处理它们的媒体引用。

时光本和瞬间的处理如下:

ts 复制代码
function rebaseNotebookUris(notebook: NotebookRecord, context: Context): boolean {
  let changed: boolean = false;
  const nextCustomCoverUri: string = rebaseEntityUri(context, notebook.id, notebook.customCoverUri);
  if (nextCustomCoverUri !== notebook.customCoverUri) {
    changed = true;
  }
  notebook.customCoverUri = nextCustomCoverUri;
  for (let i: number = 0; i < notebook.moments.length; i++) {
    changed = rebaseMomentUris(notebook.moments[i], context, notebook.id) || changed;
  }
  const previousCoverUri: string = notebook.coverUri;
  notebook.coverUri = notebook.customCoverUri.length > 0 ? notebook.customCoverUri : resolveNotebookCoverUri(notebook);
  return changed || previousCoverUri !== notebook.coverUri;
}

function rebaseMomentUris(moment: MomentRecord, context: Context, notebookId: string): boolean {
  let changed: boolean = false;
  const entityId: string = notebookId.trim().length > 0 ? notebookId : moment.notebookId;
  const nextCustomBackgroundUri: string = rebaseEntityUri(context, entityId, moment.customBackgroundUri);
  if (nextCustomBackgroundUri !== moment.customBackgroundUri) {
    changed = true;
  }
  moment.customBackgroundUri = nextCustomBackgroundUri;
  for (let i: number = 0; i < moment.mediaItems.length; i++) {
    const media: LocalMediaRecord = moment.mediaItems[i];
    if (media.fileName.trim().length === 0) {
      media.fileName = extractFileName(media.localUri);
    }
    if (media.fileName.trim().length === 0 || entityId.trim().length === 0) {
      continue;
    }
    const nextPath: string = resolveRestoredMediaFilePath(context, entityId, media);
    const nextUri: string = fileUri.getUriFromPath(nextPath);
    if (nextPath !== media.localPath || nextUri !== media.localUri || nextUri !== media.sourceUri) {
      changed = true;
    }
    media.localPath = nextPath;
    media.localUri = nextUri;
    media.sourceUri = nextUri;

    const nextOriginalPath: string = resolveRestoredOriginalMediaFilePath(context, entityId, media, nextPath);
    const nextOriginalUri: string = fileUri.getUriFromPath(nextOriginalPath);
    if (nextOriginalPath !== media.originalLocalPath || nextOriginalUri !== media.originalLocalUri) {
      changed = true;
    }
    media.originalLocalPath = nextOriginalPath;
    media.originalLocalUri = nextOriginalUri;

    const nextThumbnailPath: string = resolveRestoredThumbnailFilePath(context, entityId, media);
    const nextThumbnailUri: string = nextThumbnailPath.length > 0 ? fileUri.getUriFromPath(nextThumbnailPath) : '';
    if (nextThumbnailPath !== media.thumbnailPath || nextThumbnailUri !== media.thumbnailUri) {
      changed = true;
    }
    media.thumbnailPath = nextThumbnailPath;
    media.thumbnailUri = nextThumbnailUri;
  }
  const previousCoverUri: string = moment.coverUri;
  moment.coverUri = resolveMomentCoverUri(moment.mediaItems);
  return changed || previousCoverUri !== moment.coverUri;
}

旅行计划封面也要处理:

ts 复制代码
function rebaseTravelPlanUris(plan: TravelPlan, context: Context): boolean {
  const previousCoverUri: string = plan.coverUri;
  const previousShareCoverUri: string = plan.shareCoverUri;
  const previousCloudSyncCoverCacheUri: string = plan.cloudSyncCoverCacheUri;

  plan.cloudSyncCoverCacheUri = rebaseLocalEntityUri(context, plan.id, plan.cloudSyncCoverCacheUri);
  plan.coverUri = resolveRestoredTravelPlanCoverUri(context, plan, previousCoverUri);
  plan.shareCoverUri = isRemoteReference(plan.shareCoverUri)
    ? plan.shareCoverUri
    : rebaseLocalEntityUri(context, plan.id, plan.shareCoverUri);

  return previousCoverUri !== plan.coverUri ||
    previousShareCoverUri !== plan.shareCoverUri ||
    previousCloudSyncCoverCacheUri !== plan.cloudSyncCoverCacheUri;
}

function resolveRestoredTravelPlanCoverUri(context: Context, plan: TravelPlan, originalCoverUri: string): string {
  const rebasedCoverUri: string = isRemoteReference(originalCoverUri)
    ? originalCoverUri
    : rebaseLocalEntityUri(context, plan.id, originalCoverUri);
  if (hasReadableReference(rebasedCoverUri) || isRemoteReference(rebasedCoverUri)) {
    return rebasedCoverUri;
  }
  if (hasReadableReference(plan.cloudSyncCoverCacheUri)) {
    return plan.cloudSyncCoverCacheUri;
  }
  return '';
}

这里我保留了远程地址。如果某个封面本来就是网络图,不需要转成本地 URI。只有本地沙箱文件才需要重建。

文件路径查找策略

恢复后不能只相信旧路径,所以我做了多候选路径查找:

ts 复制代码
function resolveRestoredEntityFilePath(
  context: Context,
  entityId: string,
  fileName: string,
  existingReferences: Array<string>,
  rootDirectory: string = SANDBOX_ROOT_DIRECTORY
): string {
  const foundPath: string = findRestoredEntityFilePath(context, entityId, fileName, existingReferences, rootDirectory);
  if (foundPath.length > 0) {
    return foundPath;
  }
  return buildEntityFilePath(context, entityId, fileName);
}

function findRestoredEntityFilePath(
  context: Context,
  entityId: string,
  fileName: string,
  existingReferences: Array<string>,
  rootDirectory: string
): string {
  for (let i: number = 0; i < existingReferences.length; i++) {
    const existingPath: string = resolveReferenceToPath(existingReferences[i]);
    if (existingPath.length > 0 && fileIo.accessSync(existingPath)) {
      return existingPath;
    }
  }

  const roots: Array<string> = buildSandboxRootCandidates(context, rootDirectory);
  for (let i: number = 0; i < roots.length; i++) {
    const candidatePath: string = roots[i] + '/' + entityId + '/' + fileName;
    if (fileIo.accessSync(candidatePath)) {
      return candidatePath;
    }
  }
  return '';
}

function buildSandboxRootCandidates(context: Context, rootDirectory: string): Array<string> {
  const roots: Array<string> = [];
  const filesDir: string = context.filesDir;
  pushUniqueString(roots, filesDir + '/' + rootDirectory);

  const hapsIndex: number = filesDir.indexOf('/haps/');
  if (hapsIndex > 0) {
    pushUniqueString(roots, filesDir.substring(0, hapsIndex) + '/files/' + rootDirectory);
  }

  const baseFilesSuffix: string = '/files';
  if (filesDir.endsWith(baseFilesSuffix)) {
    const basePath: string = filesDir.substring(0, filesDir.length - baseFilesSuffix.length);
    pushUniqueString(roots, basePath + '/haps/entry/files/' + rootDirectory);
  }
  return roots;
}

这样做是为了兼容不同设备、不同沙箱目录形态。恢复后只要文件还在当前应用沙箱内,并且业务实体 ID 和文件名能对应上,就能找回来。

验证重点

我验证这次适配时,重点看四件事。

第一,看日志里有没有 onBackup okonRestore ok。如果没有,说明系统没有正确发现或拉起 EntryBackupAbility

第二,看新机数据库和 Preferences 是否恢复。打开 APP 后,时光本、瞬间、旅行计划、倒数日、主题设置这些内容应该还在。

第三,看媒体是否能显示。首页封面、时光本封面、瞬间照片、视频缩略图、旅行计划封面都要打开检查。只恢复文字不算真正修好。

第四,看缓存是否没有被迁移。分享缓存、水印导出缓存这类目录不应该出现在新机恢复结果里。

【效果图占位:恢复后时光本详情页,图片和封面正常显示】

这次踩到的关键点

数据克隆失败,不一定是系统没有迁移任何文件,也可能是应用没有声明自己的备份恢复范围。backup_config.json 是系统理解应用沙箱数据的入口。

onBackup 不负责手动打包,它更适合做刷盘、关闭数据库、生成备份前状态这些事情。

onRestore 的价值在恢复后加工。对于《时光旅记》这种有大量本地媒体引用的 APP,恢复后重建 URI 是必要步骤。

缓存目录一定要排除。迁移用户数据,不等于迁移所有文件。能重新生成的内容就不要进数据克隆。

总结

这次适配的本质,是把《时光旅记》接入 HarmonyOS 系统级备份恢复框架,让华为"数据克隆"在迁移应用时知道应该迁移哪些数据,也让 APP 在新机恢复后能把旧沙箱引用修正成当前设备可用的引用。

最终方案并不复杂:module.json5 注册 backup 扩展,backup_config.json 声明数据范围,EntryBackupAbility 承接系统回调,持久化层在备份前刷盘、恢复后重建 URI。

对记录类 APP 来说,数据迁移不是锦上添花。用户换机后还能继续看到过去的照片、旅行和时间线,这才是这类应用最基础的可靠性。