HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底

HarmonyOS趣味相机实战第7篇:Preferences相册缓存、旧数据兼容与异常兜底

摘要

本地相册看起来只是一个列表,但真正上线后会遇到很多细节:第一次启动没有数据怎么办,Preferences 里的 JSON 损坏怎么办,老版本缺少新字段怎么办,滤镜强度出现 NaN 怎么办,保存太多记录会不会拖慢启动,页面拿到列表后会不会直接改坏服务内部缓存。

本文继续基于 D:/APP/1quweixiangji HarmonyOS ArkTS 水印相机项目,专门复盘 PhotoAlbumService.ets 的相册缓存设计。第 2 篇讲过水印快照和本地相册闭环,本文更聚焦"数据兼容和异常兜底":initTask 防重复初始化,parsePhotos() 解析失败回空列表,clonePhotos() 给旧数据字段填默认值,safeNumber() 限制异常数值,persistPhoto() 新记录置顶并限制 60 条,deletePhoto() 删除后 flush。

这篇文章适合拿来做 HarmonyOS 小型本地缓存服务的模板:数据不大,用 Preferences;字段会演进,就做 clone 和默认值;读取可能失败,就兜底;页面不要直接碰内部缓存。

工程背景与源码定位

文件 作用
entry/src/main/ets/service/PhotoAlbumService.ets 本文主角:相册缓存、Preferences 读写、照片克隆、旧数据兼容
entry/src/main/ets/model/DecorationModels.ets 定义 CapturedPhoto、WatermarkSnapshot、CaptureSource
entry/src/main/ets/pages/Index.ets 拍照后调用 persistPhoto(),相册页调用 listPhotos() 和 deletePhoto()
entry/src/main/ets/entryability/EntryAbility.ets Ability 创建时初始化 PhotoAlbumService
entry/src/test/LocalUnit.test.ets 已有照片快照和保存态测试
entry/src/main/module.json5 相机权限声明,本地相册元数据不新增敏感权限

环境与版本边界

项目 当前值 说明
当前复盘日期 2026-07-14 按当前工程源码复盘
工程路径 D:/APP/1quweixiangji 本文只引用该项目已有源码
工程类型 HarmonyOS Stage 模型 EntryAbility 初始化本地服务
target SDK 6.0.2(22) 升级 SDK 后要回归 Preferences 行为
本地存储 @kit.ArkData Preferences 适合轻量 JSON 元数据
集合名 watermark_camera_album / captured_photos 相册元数据存储位置
数据上限 60 条照片记录 防止 Preferences JSON 长期膨胀
图片文件 当前不长期保存 PixelMap Preferences 只存元数据,不存图片二进制

本地构建命令:

powershell 复制代码
cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon

一、缓存服务先定义存储边界

PhotoAlbumService.ets 顶部定义了 Preferences 名称和 key:

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

服务内部状态:

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

三者职责不同:

字段 职责
prefs Preferences 实例,负责本地读写
initTask 防止重复初始化,调用方可等待首次读取完成
cachedPhotos 内存缓存,页面操作先更新缓存再 flush

把它们封在服务层里,页面只调用 listPhotos()、persistPhoto()、deletePhoto(),不会直接读写 Preferences。

二、初始化任务防止重复读取

初始化入口:

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

这个设计避免了多个页面或 Ability 生命周期重复调用时反复创建 Preferences 实例。真正读取逻辑在 initInternal():

ts 复制代码
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) {
    hilog.error(DOMAIN, TAG, 'init album failed: %{public}s', JSON.stringify(error));
    PhotoAlbumService.cachedPhotos = [];
  }
}

初始化失败时回退为空数组,不让相册缓存问题拖垮拍照主流程。

三、读取列表时永远返回克隆结果

读取照片:

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

这里没有直接返回 cachedPhotos,而是返回克隆结果。这样页面拿到列表后,即使修改某个对象,也不会绕过服务层污染内部缓存。

等待初始化:

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

这让 listPhotos()、persistPhoto()、deletePhoto() 都能在初始化未完成时安全等待。

四、创建照片:只生成元数据,不保存大图

拍照成功后,页面会调用 PhotoAlbumService.createPhoto() 生成预览态记录:

ts 复制代码
static createPhoto(
  sequence: number,
  _layers: DecorationLayer[],
  _filterName: string,
  _frameName: string,
  _beautySummary: string,
  captureSource: CaptureSource = 'simulated',
  captureSummary: string = '真实相机照片',
  _filterIntensity: number = 0,
  _beautyFeature: string = '标准',
  _beautyIntensity: number = 0,
  resolutionLabel: string = '12MP (4:3)',
  watermark?: WatermarkSnapshot
): CapturedPhoto {
  return {
    id: `photo_${Date.now()}_${sequence}`,
    title: `水印照片 ${sequence}`,
    createdAt: PhotoAlbumService.formatNow(),
    layerCount: 0,
    layerSummary: PhotoAlbumService.watermarkSummary(watermark),
    filterName: '无滤镜',
    frameName: '无相框',
    beautySummary: '标准模式',
    resolutionLabel,
    captureSource,
    captureSummary,
    status: 'preview',
    watermark: PhotoAlbumService.cloneWatermark(watermark)
  };
}

注意:这里创建的是照片元数据,不是图片文件。PixelMap 用于预览,保存或关闭后释放;相册缓存只保存标题、时间、分辨率、来源和水印快照。

五、保存态转换放在服务层

保存照片时不是页面直接改 status,而是调用:

ts 复制代码
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 ? photo.beautyFeature : '标准',
    beautyIntensity: PhotoAlbumService.safeNumber(photo.beautyIntensity, 0),
    resolutionLabel: photo.resolutionLabel ? photo.resolutionLabel : '12MP (4:3)',
    captureSource: photo.captureSource,
    captureSummary: photo.captureSummary,
    status: 'saved',
    watermark: PhotoAlbumService.cloneWatermark(photo.watermark)
  };
}

这一步同时做三件事:

  1. preview 状态变为 saved。
  2. 可选字段补默认值。
  3. 水印对象做克隆,避免引用串改。

六、persistPhoto:新记录置顶并限制 60 条

持久化保存:

ts 复制代码
static async persistPhoto(photo: CapturedPhoto): Promise<CapturedPhoto[]> {
  await PhotoAlbumService.waitForInit();
  const savedPhoto: CapturedPhoto = PhotoAlbumService.savePhoto(photo);
  const nextPhotos: CapturedPhoto[] = [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
  PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
  await PhotoAlbumService.flushPhotos();
  return PhotoAlbumService.clonePhotos(PhotoAlbumService.cachedPhotos);
}

关键策略:

策略 价值
waitForInit() 防止覆盖尚未读完的旧数据
savePhoto() 统一保存态转换
新记录置顶 相册列表默认最新在前
slice(0, 60) 控制 Preferences JSON 大小
flush 后返回 clone 页面拿到的是安全快照

Preferences 不适合无限增长。60 条元数据对轻量相册足够,后续如果要保存真实图片文件,应迁移到沙箱文件或媒体库。

七、删除记录也要 flush 并返回克隆列表

删除逻辑:

ts 复制代码
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);
}

删除后返回最新列表,页面直接替换 @State album。这比页面自己 filter 更稳,因为服务层和 Preferences 始终同步。

八、flushPhotos:写入失败不打断主流程

写入 Preferences:

ts 复制代码
private static async flushPhotos(): Promise<void> {
  if (PhotoAlbumService.prefs === null) {
    return;
  }
  try {
    await PhotoAlbumService.prefs.put(PREF_KEY_PHOTOS, JSON.stringify(PhotoAlbumService.cachedPhotos));
    await PhotoAlbumService.prefs.flush();
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'flush album failed: %{public}s', JSON.stringify(error));
  }
}

这里捕获异常并记录日志,不让页面崩溃。对拍照类 App 来说,保存失败要提示用户,但不应该让相机预览和拍照入口一起不可用。

九、parsePhotos:缓存损坏时回退空列表

解析逻辑:

ts 复制代码
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 [];
  }
}

如果 Preferences 中的 JSON 损坏,服务直接返回空列表。这是一个合理降级:用户可能丢失本地相册元数据,但拍照主功能仍可继续使用。

后续如果要做更强恢复,可以增加备份 key:

text 复制代码
captured_photos
captured_photos_backup

每次 flush 前先写 backup,解析主 key 失败时尝试 backup。不过当前项目作为轻量相册,回退空列表已经能保证主流程稳定。

十、clonePhotos:旧数据字段兼容集中处理

克隆逻辑:

ts 复制代码
static clonePhotos(photos: CapturedPhoto[]): CapturedPhoto[] {
  return photos.map((photo: CapturedPhoto) => {
    const clonedPhoto: CapturedPhoto = {
      id: photo.id,
      title: photo.title,
      createdAt: photo.createdAt,
      layerCount: photo.layerCount,
      layerSummary: photo.layerSummary,
      filterName: photo.filterName ? photo.filterName : '无滤镜',
      filterIntensity: PhotoAlbumService.safeNumber(photo.filterIntensity, 0),
      frameName: photo.frameName ? photo.frameName : '无相框',
      beautySummary: photo.beautySummary ? photo.beautySummary : '标准模式',
      beautyFeature: photo.beautyFeature ? photo.beautyFeature : '标准',
      beautyIntensity: PhotoAlbumService.safeNumber(photo.beautyIntensity, 0),
      resolutionLabel: photo.resolutionLabel ? photo.resolutionLabel : '12MP (4:3)',
      captureSource: photo.captureSource ? photo.captureSource : 'simulated',
      captureSummary: photo.captureSummary ? photo.captureSummary : '真实相机照片',
      status: photo.status,
      watermark: PhotoAlbumService.cloneWatermark(photo.watermark)
    };
    return clonedPhoto;
  });
}

这里是兼容旧数据的核心。比如老版本没有 resolutionLabel,新版本读取时自动补 12MP (4:3);老版本没有 captureSource,新版本按 simulated 兜底;强度字段异常时走 safeNumber()。

十一、safeNumber:限制 NaN 和越界值

数值兜底:

ts 复制代码
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 fallback
NaN fallback
-20 0
180 100
35 35

滤镜强度、美颜强度这类 UI 数值一旦越界,可能导致滑块显示异常或渲染参数失真。服务层读取时裁剪,页面会更稳。

十二、水印快照也要克隆

水印摘要:

ts 复制代码
private static watermarkSummary(watermark: WatermarkSnapshot | undefined): string {
  if (!watermark || !watermark.enabled) {
    return '未添加水印';
  }
  const locationText: string = watermark.locationText.length > 0 ? watermark.locationText : '未填写地点';
  return `${watermark.title} · ${locationText}`;
}

水印克隆:

ts 复制代码
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
  };
}

克隆的意义是保存"拍照当时"的水印信息。用户之后修改地点、备注或模板,旧照片的水印不应该被污染。

十三、建议的数据迁移方案

如果后续相册要从纯元数据升级到真实图片文件,可以增加版本字段:

ts 复制代码
interface CapturedPhotoV2 extends CapturedPhoto {
  schemaVersion: 2;
  imageUri: string;
  thumbnailUri: string;
  fileSize?: number;
}

迁移策略:

旧字段 新字段 处理
无 schemaVersion schemaVersion: 1 读取时补默认版本
无 imageUri 空字符串 继续展示元数据卡片
无 thumbnailUri 空字符串 使用渐变缩略图兜底
watermark 保留 继续作为历史快照
captureSource 缺失 simulated clone 时兜底

不要一次性强制把所有旧照片变成 V2 文件记录。更稳的做法是:旧记录继续可读,新拍照走 V2,列表根据字段决定展示真实缩略图还是元数据卡片。

十四、常见问题排查

现象 可能原因 排查方式
App 重启后相册为空 Preferences 解析失败或 key 不一致 查 PREF_NAME、PREF_KEY_PHOTOS 和 parsePhotos()
保存后列表顺序不对 没有新记录置顶 查 [savedPhoto].concat(cachedPhotos)
相册越用越慢 没有限制记录数量 查 slice(0, 60)
旧照片字段缺失崩溃 读取时没做默认值 查 clonePhotos()
强度数值异常 NaN 或越界 查 safeNumber()
水印被当前输入污染 保存了引用 查 cloneWatermark()
删除后重启又出现 删除后没 flush 查 deletePhoto()
页面改坏缓存 直接返回内部数组 确认 listPhotos() 返回 clone
保存大图导致卡顿 把图片二进制写入 Preferences 只保存元数据,文件另存
初始化重复执行 没有 initTask 查 init()

十五、上线前验收清单

  • 首次安装时相册为空但不报错。
  • init() 多次调用不会重复初始化。
  • Preferences key 稳定,不随版本随意改名。
  • JSON 解析失败时回退空列表。
  • listPhotos() 返回克隆数组。
  • persistPhoto() 会等待初始化完成。
  • 新照片保存后排在第一位。
  • 相册最多保留 60 条记录。
  • 删除照片后能 flush 到 Preferences。
  • 老数据缺少 filterName、resolutionLabel、captureSource 时能正常展示。
  • 数值字段 NaN、负数、超过 100 时能被裁剪。
  • 水印快照保存和读取都使用克隆。
  • Preferences 不保存 PixelMap、Base64 或图片二进制。
  • 后续真实文件化存储要增加 schemaVersion、imageUri、thumbnailUri。
  • 2026-07-14 之后升级 HarmonyOS SDK 时,回归 Preferences 读写、flush 和数据迁移。

总结

PhotoAlbumService 的价值不是"把数组写进 Preferences"这么简单,而是给本地相册建立了一套稳定边界:初始化只做一次,读取先等初始化,返回永远是克隆,保存时统一从 preview 转 saved,新记录置顶并限制 60 条,解析失败回空列表,旧字段有默认值,异常数值被裁剪,水印快照不被引用串改。

对 HarmonyOS 小型工具类 App 来说,这套模式很实用。Preferences 适合保存轻量元数据,但一定要把兼容、兜底和上限写清楚。等未来接真实图片文件、系统图库或云同步时,也能在这个稳定的数据模型上继续演进。

相关推荐
xq952713 小时前
ArkTS Component Generator 插件横空出世
harmonyos
Fate_I_C13 小时前
Capacitor 插件鸿蒙化实战:@capacitor/device 从安装到模拟器验证全记录
华为·harmonyos
2501_9197490318 小时前
华为鸿蒙免费记账与生活统计APP—小羊统计
华为·生活·harmonyos·鸿蒙
Fate_I_C18 小时前
Capacitor 应用鸿蒙化实战:用 hionic 把 React 应用跑在 OpenHarmony 上
react.js·华为·harmonyos
SuperHeroWu719 小时前
华为云码道接入 DevEco CLI 鸿蒙应用开发AICoding
华为·华为云·harmonyos
ChinaDragonDreamer20 小时前
HarmonyOS:User Authentication Kit简介
华为·harmonyos
m0_7381858220 小时前
Flutter 鸿蒙化实战:qrcode_flutter 适配 OpenHarmony,二维码生成与识别
数码相机·flutter·华为·harmonyos·鸿蒙
万物智能信息科技20 小时前
血氧心跳传感器MAX30100芯片驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·驱动开发·华为·开源·harmonyos·鸿蒙
老陈说编程20 小时前
1. 鸿蒙 (HarmonyOS) 2012 至 2026 年的发展历程
分布式·华为·个人开发·harmonyos·鸿蒙·鸿蒙系统·程序员创富