HarmonyOS趣味相机实战第17篇:BackupExtensionAbility备份恢复与Preferences版本迁移
摘要
本地相机应用通常同时保存两类数据:真实照片文件,以及用于页面展示的标题、时间、水印和文档索引。仅恢复其中一类会产生"列表存在但图片打不开"或"文件还在但应用相册为空"的半恢复状态。
本文基于 D:/APP/1quweixiangji 趣味相机工程,复盘 EntryBackupAbility.ets、backup_config.json、PhotoAlbumService.ets 与 PhotoDocumentService.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 |
CapturedPhoto、CapturedDocument |
恢复后的模型兼容 |
entry/src/main/ets/entryability/EntryAbility.ets |
启动期初始化两个服务 | 恢复完成后的重载 |
环境与数据边界
| 项目 | 当前值 | 说明 |
|---|---|---|
| 应用版本 | 1.0.4 |
versionCode 为 1000004 |
| 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 被调用,而是用户重新打开应用后,照片、文档、水印信息和可操作状态一致,并且多次冷启动不会再次改变数据。把恢复流程做成可验证的数据迁移,才能让系统能力真正保护用户内容。