欢迎光临
我们一直在努力

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

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

摘要

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

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

环境与数据边界

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

HarmonyOS趣味相机本地数据链路

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

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

  • 建立 Preferences 连接。
  • 把持久化字符串解析为领域对象。
  • 修复缺失或越界字段。
  • 保存、删除并限制缓存容量。
  • 向调用方返回隔离后的副本。
  • 页面只调用稳定接口:

    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 在这里不仅是复制,也是兼容层。旧版本缺失的 filterName、beautyFeature 和 resolutionLabel 会获得默认值。

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

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

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

    可以引入恢复状态:

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

    处理策略:

  • 读取失败时保留原始字符串的哈希和错误码。
  • UI 显示“本地相册数据需要恢复”,不要暴露技术栈。
  • 在用户产生新保存动作前,不主动覆盖损坏值。
  • 若业务重要,保留一份受控备份键并设置迁移期限。
  • 日志不得记录完整水印地点、备注或完整 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 页面就不必到处判断缺字段;当用户文案与诊断信息分离,数据层也能兼顾可读性和隐私。这样的本地相册才能承受真实升级、异常退出和长期使用。

    赞(0)
    未经允许不得转载:171主机测评 » HarmonyOS趣味相机实战第25篇:Preferences相册Schema归一化与水印快照隔离
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址