HarmonyOS趣味相机实战第17篇:BackupExtensionAbility备份恢复与Preferences版本迁移

HarmonyOS趣味相机实战第17篇:BackupExtensionAbility备份恢复与Preferences版本迁移

摘要

本地相机应用通常同时保存两类数据:真实照片文件,以及用于页面展示的标题、时间、水印和文档索引。仅恢复其中一类会产生"列表存在但图片打不开"或"文件还在但应用相册为空"的半恢复状态。

本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 EntryBackupAbility.etsbackup_config.jsonPhotoAlbumService.etsPhotoDocumentService.ets。当前工程已允许系统备份恢复,但回调只记录日志,因此本文重点补齐数据清单、版本迁移、恢复后校验、幂等初始化和故障演练。文章不假设系统一定会自动包含全部媒体文件,而是以可验证的恢复结果作为完成标准。

工程背景与源码定位

文件 当前责任 备份恢复关注点
entry/src/main/ets/entrybackupability/EntryBackupAbility.ets 接收备份与恢复回调 扩展能力入口和版本信息
entry/src/main/resources/base/profile/backup_config.json 允许备份恢复 总开关,不等于完整策略
entry/src/main/module.json5 注册 backup extensionAbility metadata 资源绑定
entry/src/main/ets/service/PhotoAlbumService.ets 照片元数据 Preferences watermark_camera_album
entry/src/main/ets/service/PhotoDocumentService.ets 文档索引 Preferences watermark_camera_documents
entry/src/main/ets/model/DecorationModels.ets CapturedPhotoCapturedDocument 恢复后的模型兼容
entry/src/main/ets/entryability/EntryAbility.ets 启动期初始化两个服务 恢复完成后的重载

环境与数据边界

项目 当前值 说明
应用版本 1.0.4 versionCode1000004
target SDK 6.0.2(22) 当前工程配置
应用模型 Stage 模型 UIAbility + BackupExtensionAbility
相册 Preferences watermark_camera_album key 为 captured_photos
文档 Preferences watermark_camera_documents key 为 converted_documents
相册上限 60 条 slice(0, 60)
文档上限 80 条 slice(0, 80)
当前媒体模型 元数据为主 必须与真实文件策略分开验证

一、先看当前扩展能力配置

模块中注册:

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

配置资源:

json 复制代码
{
  "allowToBackupRestore": true
}

扩展能力:

ts 复制代码
import { hilog } from '@kit.PerformanceAnalysisKit';
import {
  BackupExtensionAbility,
  BundleVersion
} from '@kit.CoreFileKit';

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();
  }
}

这证明系统可以调度扩展能力,但不能证明照片、Preferences 和文档索引已经被正确恢复。完成标准必须下沉到数据验证。

二、列出需要保护的数据清单

趣味相机至少有四类数据:

数据 例子 丢失影响 推荐策略
用户照片文件 拍照结果、导出图 核心内容丢失 明确文件或媒体库归属
照片元数据 标题、水印、分辨率、来源 相册列表不完整 Preferences 备份与迁移
文档索引 photoId、摘要、页数 文档工作台丢失 Preferences 备份与重建
临时预览数据 PixelMap、倒计时、当前 Tab 无长期价值 不备份

必须排除:

  • 相机 Surface ID。
  • PixelMap 实例。
  • CameraInput、Session 和 Output。
  • 当前识别目标坐标。
  • Timer 和 Busy 状态。
  • 页面弹窗是否打开。

这些对象与当前进程和设备资源绑定,恢复后重新创建才正确。

三、照片元数据的当前存储模型

相册服务使用 ArkData Preferences:

ts 复制代码
const PREF_NAME: string = 'watermark_camera_album';
const PREF_KEY_PHOTOS: string = 'captured_photos';

private static async initInternal(
  context: common.UIAbilityContext
): Promise<void> {
  try {
    PhotoAlbumService.prefs =
      await preferences.getPreferences(context, PREF_NAME);
    const raw: preferences.ValueType =
      PhotoAlbumService.prefs.getSync(PREF_KEY_PHOTOS, '[]');
    if (typeof raw === 'string') {
      PhotoAlbumService.cachedPhotos =
        PhotoAlbumService.parsePhotos(raw);
    }
  } catch (error) {
    PhotoAlbumService.cachedPhotos = [];
  }
}

写入流程:

ts 复制代码
await prefs.put(
  PREF_KEY_PHOTOS,
  JSON.stringify(cachedPhotos)
);
await prefs.flush();

Preferences 中保存 JSON 字符串,恢复后必须执行 JSON 解析、默认值补齐和条数限制,而不是直接断言为当前 CapturedPhoto[]

四、文档索引必须与照片建立可检查关系

文档服务单独使用:

ts 复制代码
const PREF_NAME: string = 'watermark_camera_documents';
const PREF_KEY_DOCUMENTS: string = 'converted_documents';

文档模型包含 photoId

ts 复制代码
export interface CapturedDocument {
  id: string;
  photoId: string;
  title: string;
  createdAt: string;
  sourcePhotoTitle: string;
  sourceCreatedAt: string;
  pageCount: number;
  documentType: 'imageDocument';
  status: 'ready';
  summary: string;
  captureSummary: string;
  watermark?: WatermarkSnapshot;
}

恢复后要检查引用:

ts 复制代码
function findBrokenDocuments(
  photos: CapturedPhoto[],
  documents: CapturedDocument[]
): CapturedDocument[] {
  const photoIds = new Set(
    photos.map((photo: CapturedPhoto) => photo.id)
  );
  return documents.filter((document: CapturedDocument) => {
    return !photoIds.has(document.photoId);
  });
}

当前 CapturedDocument 还保存来源标题和水印快照,因此即使源照片记录缺失,也可以显示部分信息。但页面应标记"源照片不可用",不能继续提供打开原图操作。

五、给持久化数据加入schemaVersion

当前 Preferences 直接保存数组。更可维护的格式:

ts 复制代码
interface AlbumStoreV2 {
  schemaVersion: 2;
  updatedAt: string;
  photos: CapturedPhoto[];
}

interface DocumentStoreV2 {
  schemaVersion: 2;
  updatedAt: string;
  documents: CapturedDocument[];
}

读取时兼容旧数组:

ts 复制代码
function decodeAlbum(raw: string): AlbumStoreV2 {
  const parsed: Object = JSON.parse(raw) as Object;
  if (Array.isArray(parsed)) {
    return {
      schemaVersion: 2,
      updatedAt: new Date().toISOString(),
      photos: migratePhotosV1(parsed as CapturedPhoto[])
    };
  }
  const store = parsed as AlbumStoreV2;
  return migrateAlbumStore(store);
}

schemaVersion 表示数据结构版本,不等同于应用 versionCode。一个应用版本可能不改存储结构,也可能在开发期多次升级结构。

六、恢复回调中的BundleVersion用于选择迁移路径

onRestore(bundleVersion) 提供来源版本信息。可把它转换为迁移上下文:

ts 复制代码
interface RestoreContext {
  sourceVersionCode: number;
  sourceVersionName: string;
  targetSchemaVersion: number;
}

迁移策略示例:

text 复制代码
来源 versionCode < 1000002
  -> captureSource 缺失时设为 simulated
来源 versionCode < 1000003
  -> resolutionLabel 缺失时设为 12MP (4:3)
来源 versionCode < 1000004
  -> watermark 缺失时保持 undefined
所有来源版本
  -> 数值范围夹取
  -> 状态值归一化
  -> 删除非法空 ID

不要用版本号代替字段检查。用户可能跨多个版本恢复,也可能有部分写入数据。迁移函数应同时检查来源版本和实际字段。

七、模型迁移必须是幂等的

迁移可能在系统恢复、应用升级和下一次冷启动中重复执行。执行两次的结果必须与一次相同。

照片规范化:

ts 复制代码
function normalizePhoto(photo: CapturedPhoto): CapturedPhoto {
  return {
    id: photo.id,
    title: photo.title || '未命名照片',
    createdAt: photo.createdAt || '未知时间',
    layerCount: safeNumber(photo.layerCount, 0),
    layerSummary: photo.layerSummary || '未添加水印',
    filterName: photo.filterName || '无滤镜',
    filterIntensity: safePercent(photo.filterIntensity),
    frameName: photo.frameName || '无相框',
    beautySummary: photo.beautySummary || '标准模式',
    beautyFeature: photo.beautyFeature || '标准',
    beautyIntensity: safePercent(photo.beautyIntensity),
    resolutionLabel: photo.resolutionLabel || '12MP (4:3)',
    captureSource: photo.captureSource || 'simulated',
    captureSummary: safeSummary(photo.captureSummary),
    status: photo.status === 'saved' ? 'saved' : 'preview',
    watermark: cloneWatermark(photo.watermark)
  };
}

规范化函数不能生成新的随机 ID 或当前时间,否则每次运行都会改变结果。

八、恢复后的缓存必须重新加载

两个服务都缓存数据:

ts 复制代码
private static prefs: preferences.Preferences | null = null;
private static initTask: Promise<void> | null = null;
private static cachedPhotos: CapturedPhoto[] = [];

如果进程在恢复期间已存在,磁盘 Preferences 被替换后,内存缓存可能仍是旧值。可以提供显式重载:

ts 复制代码
static async reload(context: common.UIAbilityContext): Promise<void> {
  PhotoAlbumService.prefs = null;
  PhotoAlbumService.initTask = null;
  PhotoAlbumService.cachedPhotos = [];
  await PhotoAlbumService.init(context);
}

更安全的方式是把初始化状态封装成实例,并在恢复完成后发布一个内部事件,页面收到后重新执行 listPhotos()listDocuments()

九、初始化任务要避免竞态

当前 initTask 使多个调用者共享同一个初始化 Promise:

ts 复制代码
static init(context: common.UIAbilityContext): Promise<void> {
  if (PhotoAlbumService.initTask !== null) {
    return PhotoAlbumService.initTask;
  }
  PhotoAlbumService.initTask =
    PhotoAlbumService.initInternal(context);
  return PhotoAlbumService.initTask;
}

页面的 listPhotos() 会等待:

ts 复制代码
private static async waitForInit(): Promise<void> {
  if (PhotoAlbumService.initTask !== null) {
    await PhotoAlbumService.initTask;
  }
}

这里还有一个边界:如果调用者在 init() 之前直接执行 listPhotos()initTask 为 null,函数会立即返回空缓存。当前工程在 EntryAbility.onCreate() 先调用初始化,因此正常路径成立;测试和未来新 Ability 仍应显式保证顺序,或让服务保存 Context 后延迟初始化。

十、恢复前先定义冲突策略

设备上可能已有新数据,同时云端或迁移包带来旧数据。常见策略:

策略 适用场景 风险
完全覆盖 新设备首次恢复 覆盖本地新增内容
按 ID 合并 同一账号多次迁移 ID 冲突需定义胜负
按时间取新 有可靠更新时间 旧格式可能无时间
保留双方并重命名 用户内容优先 可能产生重复项

本地相机更适合按稳定 ID 合并:

ts 复制代码
function mergePhotos(
  local: CapturedPhoto[],
  restored: CapturedPhoto[]
): CapturedPhoto[] {
  const map = new Map<string, CapturedPhoto>();
  restored.forEach(photo => map.set(photo.id, normalizePhoto(photo)));
  local.forEach(photo => map.set(photo.id, normalizePhoto(photo)));
  return Array.from(map.values()).slice(0, 60);
}

这里本地数据后写,表示冲突时保留当前设备版本。实际产品也可以比较业务更新时间。

十一、元数据恢复不能替代真实照片验证

CapturedPhoto 当前没有媒体 URI 字段,更多承担业务快照角色。若应用未来保存真实文件,必须增加:

ts 复制代码
interface MediaReference {
  uri: string;
  mediaId?: string;
  contentHash?: string;
  byteSize?: number;
}

恢复后逐项验证:

text 复制代码
URI 是否可访问
  -> 文件大小是否合理
  -> 可否创建 ImageSource
  -> 图片宽高是否有效
  -> 哈希是否匹配(如果保存)

不要把设备绝对路径当作跨设备稳定标识。媒体库 URI、应用沙箱文件与系统备份策略的边界必须按目标 SDK 和发布环境真机验证。

十二、失败恢复要保留原始数据

迁移失败时不要立即用空数组覆盖 Preferences。推荐两阶段写入:

text 复制代码
读取原始 JSON
  -> 解析到临时对象
  -> 执行迁移
  -> 校验结果
  -> 写入临时 key
  -> flush
  -> 切换正式 key
  -> 保留一次旧值备份

可以使用:

ts 复制代码
const CURRENT_KEY = 'captured_photos_v2';
const BACKUP_KEY = 'captured_photos_v1_backup';

只有在新结构校验通过后才替换正式数据。下一版本稳定后再清理备份 key,避免 Preferences 无限增长。

十三、隐私与安全边界

相机数据可能包含人脸、家庭环境、地点和水印备注。备份策略至少满足:

  • 不把 PixelMap 转为日志或 Base64 文本。
  • 不在日志中输出完整 Preferences JSON。
  • 水印地点和备注不进入公开错误日志。
  • 只备份业务需要的字段。
  • 删除照片时同步处理关联文档与媒体文件。
  • 用户清除数据后,不通过应用自建旁路自动恢复。
  • 发布前核对隐私说明中的备份和本地存储描述。

当前代码使用 %{public}s 打印 bundleVersion 可以接受,因为它是应用版本信息;不要用同样方式打印照片元数据。

十四、恢复结果报告

内部可生成不含隐私的报告:

ts 复制代码
interface RestoreReport {
  sourceVersionCode: number;
  restoredPhotoCount: number;
  restoredDocumentCount: number;
  migratedPhotoCount: number;
  brokenDocumentCount: number;
  missingMediaCount: number;
  invalidRecordCount: number;
  durationMs: number;
}

报告用于测试日志或聚合统计,不包含标题、地点、备注、URI 和图片内容。

十五、自动化测试设计

准备固定夹具:

text 复制代码
album_v1_array.json
album_v2_store.json
album_corrupted.json
documents_v1_array.json
documents_with_missing_photo.json

测试断言:

  • 旧数组能迁移到 V2 包装结构。
  • 缺失 captureSource 时补默认值。
  • 百分比字段夹取到 0 至 100。
  • 非法 JSON 返回错误,不覆盖原存储。
  • 同一迁移执行两次结果相同。
  • 合并后照片不超过 60 条。
  • 合并后文档不超过 80 条。
  • photoId 能被识别并标记。
  • Preferences 写入后已执行 flush()

十六、真机备份恢复验收流程

text 复制代码
安装旧版本
  -> 拍摄并保存 3 张不同水印照片
  -> 转换 2 个文档
  -> 记录非隐私计数和 ID
  -> 触发系统支持的备份流程
  -> 升级或换测试设备
  -> 触发恢复
  -> 启动 1.0.4
  -> 检查相册、文档、关联和媒体可访问性
  -> 再次冷启动确认结果稳定

还要执行故障演练:

  • 备份中途终止。
  • Preferences JSON 截断。
  • 只恢复相册,不恢复文档。
  • 文档引用不存在照片。
  • 设备已有一张新照片时恢复旧数据。
  • 来源版本跨越多个 schema。
  • 恢复后立即进入相册 Tab。

十七、常见问题排查

现象 可能原因 排查方式
onRestore 有日志但列表为空 只实现回调,未验证数据 读取 Preferences 并输出计数报告
列表存在但图片打不开 只恢复元数据 检查媒体 URI 与文件策略
文档显示但原照片缺失 photoId 引用断裂 执行关联完整性检查
恢复后仍显示旧缓存 服务未 reload 清理 initTask 并重新加载
首次进入页面偶发空列表 init 与 list 并发 所有读取等待初始化 Promise
迁移失败后数据全部消失 直接覆盖正式 key 使用临时 key 与两阶段提交
多次恢复产生重复照片 没有稳定 ID 合并 按 ID 幂等合并
新字段恢复后为 undefined 缺少 schema 迁移 集中执行 normalize 函数

十八、上线前验收清单

  • module.json5 正确注册 backup extensionAbility。
  • backup_config.json 的总开关符合产品策略。
  • 已列出照片文件、照片元数据和文档索引的数据边界。
  • 临时 Surface、PixelMap 和相机会话不参与备份。
  • 持久化结构包含独立 schemaVersion
  • BundleVersion 只用于选择迁移路径,不替代字段校验。
  • 迁移函数可重复执行且结果幂等。
  • 恢复后会刷新服务缓存和页面状态。
  • 相册和文档按稳定 ID 合并。
  • 文档的 photoId 引用经过完整性检查。
  • 真实媒体 URI 或文件可访问性经过真机验证。
  • 失败迁移不会覆盖原始数据。
  • 日志和报告不包含图像、地点、备注或 URI。
  • 已完成跨版本、坏数据和部分恢复演练。

总结

allowToBackupRestore: true 和两个回调只是备份恢复的入口,不是完成证明。真正的工程闭环包括数据清单、Preferences 结构版本、幂等迁移、缓存重载、照片与文档引用校验、真实媒体可访问性和失败回滚。

对趣味相机而言,恢复成功的标准不是 onRestore 被调用,而是用户重新打开应用后,照片、文档、水印信息和可操作状态一致,并且多次冷启动不会再次改变数据。把恢复流程做成可验证的数据迁移,才能让系统能力真正保护用户内容。

相关推荐
想你依然心痛8 小时前
【共创季稿事节】HarmonyOS 7.0 ArkTS 声明式UI高级实战:自定义动画与手势系统
arkts·关键帧动画·harmonyos 7.0·物理弹簧动画·手势冲突仲裁·共享元素转场·手势组合
千逐688 小时前
HarmonyOS 实战 | 数据存本地——Preferences 轻量存储入门
华为·harmonyos·鸿蒙
youtootech9 小时前
HarmonyOS《柚兔学伴》项目实战08-关系型数据库入门——待办事项持久化
jvm·数据库·华为·harmonyos
解局易否结局9 小时前
ArkTS 设计模式与架构进阶:构建可维护的 HarmonyOS 应用
华为·设计模式·架构·harmonyos
解局易否结局9 小时前
鸿蒙原生开发实战|Native JSON 编解码与高性能数据序列化
华为·json·harmonyos
Helen_cai9 小时前
HarmonyOS ArkTS 实战:实现一个校园门禁与访客预约应用
深度学习·华为·harmonyos
不羁的木木9 小时前
HarmonyOS技术精讲-Connectivity Kit:蓝牙进阶——GATT服务与音频通道
华为·音视频·harmonyos
Helen_cai9 小时前
HarmonyOS ArkTS 实战:实现一个校园快递代取与跑腿应用
华为·harmonyos
2301_7681034910 小时前
HarmonyOS趣味相机实战第13篇:CameraPicker与CameraKit选型、系统拍照回退及架构边界
harmonyos·arkts·架构设计·camerapicker·camerakit