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 定义 CapturedPhotoWatermarkSnapshotCaptureSource
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 只存元数据,不存图片二进制

HarmonyOS Preferences 相册缓存兼容设计

本地构建命令:

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:

const PREF_NAME: string = 'watermark_camera_album';
const PREF_KEY_PHOTOS: string = 'captured_photos';

服务内部状态:

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。

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

初始化入口:

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

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 = [];
  }
}

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

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

读取照片:

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

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

等待初始化:

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

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

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

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

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,而是调用:

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 条

持久化保存:

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 并返回克隆列表

删除逻辑:

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:

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:缓存损坏时回退空列表

解析逻辑:

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:

captured_photos
captured_photos_backup

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

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

克隆逻辑:

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 和越界值

数值兜底:

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 数值一旦越界,可能导致滑块显示异常或渲染参数失真。服务层读取时裁剪,页面会更稳。

十二、水印快照也要克隆

水印摘要:

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}`;
}

水印克隆:

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

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

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

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

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_NAMEPREF_KEY_PHOTOSparsePhotos()
保存后列表顺序不对 没有新记录置顶 [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。
  • 老数据缺少 filterNameresolutionLabelcaptureSource 时能正常展示。
  • 数值字段 NaN、负数、超过 100 时能被裁剪。
  • 水印快照保存和读取都使用克隆。
  • Preferences 不保存 PixelMap、Base64 或图片二进制。
  • 后续真实文件化存储要增加 schemaVersionimageUrithumbnailUri
  • 2026-07-14 之后升级 HarmonyOS SDK 时,回归 Preferences 读写、flush 和数据迁移。

总结

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

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

Logo

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

更多推荐