HarmonyOS趣味相机实战第25篇:Preferences相册Schema归一化与水印快照隔离

摘要

本地相册看似只是把数组 JSON.stringify 后写进 Preferences,但真正上线后会遇到旧版本缺字段、异常 JSON、并发初始化、对象引用被页面修改、数字越界、缓存无限增长等问题。若读取层直接把历史数据交给 ArkUI,升级一次字段就可能造成列表空白或水印内容被意外联动修改。

本文基于 D:/APP/1quweixiangjiPhotoAlbumService.ets,完整复盘初始化任务复用、缓存副本、Schema 归一化、WatermarkSnapshot 深拷贝、摘要脱敏、容量上限和写入顺序。重点是让本地数据层成为稳定边界:页面拿到的数据可用,持久化失败可定位,旧数据可以安全降级。

环境与数据边界

项目 当前实现
开发语言 ArkTS
数据组件 @kit.ArkData Preferences
Preferences 名称 watermark_camera_album
数据键 captured_photos
内存缓存 CapturedPhoto[]
最大记录数 60
水印字段 WatermarkSnapshot
异常日志 @kit.PerformanceAnalysisKit hilog

HarmonyOS趣味相机本地数据链路

一、相册服务需要明确责任边界

PhotoAlbumService 负责的不是页面展示,而是五件事:

  1. 建立 Preferences 连接。
  2. 把持久化字符串解析为领域对象。
  3. 修复缺失或越界字段。
  4. 保存、删除并限制缓存容量。
  5. 向调用方返回隔离后的副本。

页面只调用稳定接口:

await PhotoAlbumService.init(context);
const photos: CapturedPhoto[] = await PhotoAlbumService.listPhotos();
const next: CapturedPhoto[] = await PhotoAlbumService.persistPhoto(photo);

这样 Preferences 的名字、键和序列化格式不会散落在多个 ArkUI 组件里。

二、用initTask合并并发初始化

Ability 启动、页面出现和测试代码可能同时触发初始化。若每次都调用 getPreferences 并读取数据,会产生重复 I/O 和状态覆盖。

项目用一个 Promise 复用正在进行的任务:

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

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

这是一种单次初始化门闩。调用者共享同一个结果,不会出现后发初始化先覆盖缓存的竞态。

需要注意:若产品希望初始化失败后允许重试,应在失败路径把 initTask 设回 null,同时保留错误状态;否则当前进程内后续调用会继续复用已经完成但失败的 Promise。

三、所有公开操作都等待初始化

读取接口不能假设页面一定先调用过 init()

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

static async listPhotos(): Promise<CapturedPhoto[]> {
  await PhotoAlbumService.waitForInit();
  return PhotoAlbumService.clonePhotos(
    PhotoAlbumService.cachedPhotos
  );
}

更严格的版本可以在 initTask === null 时抛出领域错误,避免静默返回空列表:

if (PhotoAlbumService.initTask === null) {
  throw new Error('PhotoAlbumService is not initialized');
}

选择抛错还是空数据取决于产品降级策略,但行为必须明确并可测试。

四、CapturedPhoto是持久化契约

照片模型包含标识、展示和来源信息:

export interface CapturedPhoto {
  id: string;
  title: string;
  createdAt: string;
  layerCount: number;
  layerSummary: string;
  filterName: string;
  filterIntensity?: number;
  frameName: string;
  beautySummary: string;
  beautyFeature?: string;
  beautyIntensity?: number;
  resolutionLabel?: string;
  captureSource: 'real' | 'simulated';
  captureSummary: string;
  status: 'preview' | 'saved';
  watermark?: WatermarkSnapshot;
}

接口中的可选字段就是升级兼容信号。读取旧版本记录时,不能直接断言这些字段存在,必须提供默认值。

五、创建对象与保存对象是两个阶段

拍照结束先创建预览记录:

return {
  id: `photo_${Date.now()}_${sequence}`,
  title: `水印照片 ${sequence}`,
  createdAt: PhotoAlbumService.formatNow(),
  layerCount: 0,
  layerSummary: PhotoAlbumService.watermarkSummary(watermark),
  filterName: '无滤镜',
  frameName: '无相框',
  beautySummary: '标准模式',
  resolutionLabel,
  captureSource,
  captureSummary: PhotoAlbumService.safeCaptureSummary(captureSummary),
  status: 'preview',
  watermark: PhotoAlbumService.cloneWatermark(watermark)
};

用户点击“保存到相册”后,服务再生成 status: 'saved' 的规范对象。区分两个阶段可以让结果预览、取消拍摄和真正持久化保持一致,而不是一拍照就产生无法撤销的记录。

六、savePhoto同时承担Schema归一化

保存时不要原样扩展 ...photo。显式列出字段能阻止页面临时状态或未知属性进入持久化:

static savePhoto(photo: CapturedPhoto): CapturedPhoto {
  return {
    id: photo.id,
    title: photo.title,
    createdAt: photo.createdAt,
    layerCount: photo.layerCount,
    layerSummary: photo.layerSummary,
    filterName: photo.filterName || '无滤镜',
    filterIntensity: PhotoAlbumService.safeNumber(
      photo.filterIntensity, 0),
    frameName: photo.frameName || '无相框',
    beautySummary: photo.beautySummary || '标准模式',
    beautyFeature: photo.beautyFeature || '标准',
    beautyIntensity: PhotoAlbumService.safeNumber(
      photo.beautyIntensity, 0),
    resolutionLabel: photo.resolutionLabel || '12MP (4:3)',
    captureSource: photo.captureSource,
    captureSummary: PhotoAlbumService.safeCaptureSummary(
      photo.captureSummary),
    status: 'saved',
    watermark: PhotoAlbumService.cloneWatermark(photo.watermark)
  };
}

显式映射也让代码评审可以直接看到落盘字段,不必追踪对象上可能存在的所有属性。

七、safeNumber同时处理缺省和越界

强度类字段应限制在领域范围:

private static safeNumber(
  value: number | undefined,
  fallback: number
): number {
  if (value === undefined || Number.isNaN(value)) {
    return fallback;
  }
  return Math.max(0, Math.min(100, value));
}

典型输入与结果:

输入 结果 原因
undefined 0 旧数据缺字段
NaN 0 非法计算结果
-20 0 下界收敛
45 45 合法值保留
130 100 上界收敛

如果数据来自不可信导入,还应检查 Number.isFinite,防止 Infinity 进入页面计算。

八、嵌套水印对象必须深拷贝

浅拷贝数组并不能隔离嵌套对象。若页面修改 photo.watermark.note,缓存中的同一对象也可能被修改,下一次 flush 就会把临时编辑写回。

项目逐字段克隆:

private static cloneWatermark(
  watermark?: WatermarkSnapshot
): WatermarkSnapshot | undefined {
  if (!watermark) {
    return undefined;
  }
  return {
    enabled: watermark.enabled,
    template: watermark.template,
    title: watermark.title,
    locationText: watermark.locationText,
    note: watermark.note,
    timeText: watermark.timeText
  };
}

listPhotos()savePhoto()createPhoto() 都通过这条路径,形成双向隔离:输入对象不会被服务保存引用,输出对象也不会暴露内部缓存引用。

九、水印快照保存的是拍摄时事实

页面当前模板会变化,但历史照片不应跟着变化。拍照瞬间构造快照:

private watermarkSnapshot(): WatermarkSnapshot {
  const template: WatermarkTemplate = this.selectedTemplate;
  return {
    enabled: this.watermarkEnabled,
    template,
    title: this.templateTitle(template),
    locationText: this.customPlace.length > 0 ?
      this.customPlace : this.templateLocation(template),
    note: this.customNote.length > 0 ?
      this.customNote : this.templateNote(template),
    timeText: this.currentTimeText
  };
}

这里保存渲染后的标题、地点和备注,而不只是模板 ID。即使后续版本修改模板默认文案,历史照片仍能还原拍摄时内容。

十、用户摘要与内部诊断信息分离

相机服务返回的消息可能包含“真实照片”“目标对齐”等实现细节,不适合长期显示在相册卡片。项目统一转换:

private static safeCaptureSummary(
  summary: string | undefined
): string {
  if (!summary || summary.length === 0) {
    return DEFAULT_CAPTURE_SUMMARY;
  }
  if (summary.indexOf('真实照片已捕获') >= 0 ||
      summary.indexOf('预览目标对齐') >= 0) {
    return summary
      .replace('真实照片已捕获,正在使用预览目标对齐',
        DEFAULT_CAPTURE_SUMMARY)
      .replace('个人物目标对齐', '个取景目标');
  }
  return summary;
}

更可扩展的方案是从源头分开字段:

interface CaptureResult {
  userMessage: string;
  diagnosticCode: string;
  targetCount: number;
}

页面显示 userMessage,hilog 记录 diagnosticCode。这样无需依赖文案替换,也不会误改正常用户文本。

十一、读取旧数据时统一经过clonePhotos

解析成功不代表字段完整:

private static parsePhotos(raw: string): CapturedPhoto[] {
  try {
    const parsed: CapturedPhoto[] =
      JSON.parse(raw) as CapturedPhoto[];
    if (!parsed || parsed.length === 0) {
      return [];
    }
    return PhotoAlbumService.clonePhotos(parsed);
  } catch (error) {
    hilog.warn(DOMAIN, TAG,
      'parse album failed: %{public}s', JSON.stringify(error));
    return [];
  }
}

clonePhotos 在这里不仅是复制,也是兼容层。旧版本缺失的 filterNamebeautyFeatureresolutionLabel 会获得默认值。

需要进一步加固时,可先判断 Array.isArray(parsed),并逐条验证 idtitlestatus 的类型,过滤无法恢复的记录。

十二、损坏JSON要降级但不能悄悄覆盖

当前实现解析失败后返回空数组,保证应用可启动。这是合理的可用性兜底,但若随后立刻 flush,原损坏数据会被空数组覆盖,排查证据消失。

可以引入恢复状态:

interface AlbumLoadResult {
  photos: CapturedPhoto[];
  recovered: boolean;
  reason?: string;
}

处理策略:

  1. 读取失败时保留原始字符串的哈希和错误码。
  2. UI 显示“本地相册数据需要恢复”,不要暴露技术栈。
  3. 在用户产生新保存动作前,不主动覆盖损坏值。
  4. 若业务重要,保留一份受控备份键并设置迁移期限。

日志不得记录完整水印地点、备注或完整 JSON。

十三、容量上限必须在写入前生效

项目把新照片放在最前,再截取 60 条:

const savedPhoto: CapturedPhoto =
  PhotoAlbumService.savePhoto(photo);
const nextPhotos: CapturedPhoto[] =
  [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
await PhotoAlbumService.flushPhotos();

这个顺序保证最新照片不会因上限被丢弃。还需明确:Preferences 中保存的是元数据,不宜保存图片 Base64;真实媒体应放在适合的文件或媒体资产存储中,Preferences 只记录引用和轻量展示信息。

十四、内存先更新还是落盘先更新

当前流程先更新缓存,再执行 flush。优点是页面响应快,缺点是落盘失败后内存与磁盘不一致。可以返回结构化结果:

interface PersistPhotoResult {
  photos: CapturedPhoto[];
  persisted: boolean;
  errorCode?: string;
}

若产品承诺“保存成功”,应只在 flush 完成后显示成功状态;失败时恢复旧缓存或把记录标记为待重试。不要捕获错误后仍然让页面显示“照片已保存”。

十五、删除操作也需要一致性语义

当前删除逻辑:

static async deletePhoto(photoId: string): Promise<CapturedPhoto[]> {
  await PhotoAlbumService.waitForInit();
  PhotoAlbumService.cachedPhotos =
    PhotoAlbumService.cachedPhotos.filter(
      (photo: CapturedPhoto) => photo.id !== photoId);
  await PhotoAlbumService.flushPhotos();
  return PhotoAlbumService.clonePhotos(
    PhotoAlbumService.cachedPhotos);
}

至少需要验证三种情况:存在的 ID、重复删除、空字符串 ID。若照片还有对应文档或媒体文件,需要由更高层用事务式流程协调,而不是让两个服务互相隐式调用。

十六、建议增加Schema版本

字段继续增长后,仅靠默认值难以表达复杂迁移。可以把存储结构升级为:

interface AlbumStoreV2 {
  schemaVersion: 2;
  updatedAt: number;
  photos: CapturedPhoto[];
}

读取流程:

识别根结构
-> 读取 schemaVersion
-> v1 转 v2
-> 逐条校验与归一化
-> 写回新结构
-> 更新内存缓存

迁移函数应保持纯函数,输入旧数据、输出新数据,便于用固定样本做回归测试。

十七、测试矩阵

用例 输入 期望结果
首次启动 键不存在 返回空数组
正常恢复 完整 JSON 字段完整、顺序不变
旧版数据 缺可选字段 使用默认值
损坏数据 非法 JSON 安全降级并记录错误码
数值越界 -10 / 160 / NaN 收敛到 0…100
对象隔离 修改 listPhotos 返回值 内部缓存不变化
容量边界 连续保存 61 条 保留最新 60 条
重复初始化 并发调用 init 只执行一次真实初始化
写入失败 flush 抛错 页面不误报成功
删除不存在项 未知 photoId 列表不变且不崩溃

深拷贝测试示例:

it('returns isolated watermark snapshots', 0, async () => {
  const first: CapturedPhoto[] =
    await PhotoAlbumService.listPhotos();
  first[0].watermark!.note = 'changed by page';

  const second: CapturedPhoto[] =
    await PhotoAlbumService.listPhotos();
  expect(second[0].watermark!.note)
    .not().assertEqual('changed by page');
});

十八、常见问题排查

现象 原因 修复方向
改一张照片水印,其他位置同步变化 嵌套对象共享引用 深拷贝 WatermarkSnapshot
升级后列表空白 旧数据缺字段或根结构变化 归一化与版本迁移
重启后刚保存的照片消失 flush 失败但 UI 误报成功 返回持久化状态
相册越来越慢 缓存和字符串无限增长 限制元数据条数
异常日志泄露地点备注 打印完整 JSON 只记录错误码和条数
并发启动数据闪回 多次初始化覆盖缓存 复用 initTask

十九、发布前验收清单

  • Preferences 名称和键只在服务层定义。
  • 所有公开操作都等待初始化完成。
  • 旧字段通过统一归一化函数补默认值。
  • 数值字段处理 undefined、NaN 与越界。
  • 水印快照在输入和输出两侧都深拷贝。
  • 用户摘要与内部诊断字段分开。
  • JSON 损坏时应用可启动且保留排查线索。
  • 元数据有明确容量上限,不保存图片 Base64。
  • flush 失败不会向用户误报保存成功。
  • Schema 迁移有固定样本自动测试。

总结

Preferences 适合保存趣味相机的轻量元数据,但不能把它当成“任意对象数组仓库”。稳定实现需要用服务层封装初始化和写入,用显式字段映射完成 Schema 归一化,用深拷贝隔离水印快照,用容量上限控制增长,并把损坏数据、写入失败和版本迁移纳入正常流程。

PhotoAlbumService 对外只返回经过验证的领域对象,ArkUI 页面就不必到处判断缺字段;当用户文案与诊断信息分离,数据层也能兼顾可读性和隐私。这样的本地相册才能承受真实升级、异常退出和长期使用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐