第44篇|GalleryMoment 模型:一张“记忆照片”不只是 Uri

第 10 天开始,我们从拍摄链路进入相册数据层。很多相机 Demo 只保存一张图片 Uri,但双镜记忆相机要承载地点、时间、前后摄、智能摘要、备注、隐私和云同步。这个承载结构就是 GalleryMoment

如果模型设计太薄,后面每个功能都要临时拼字段;如果模型过度复杂,页面又会难以维护。训练营项目采用一个中等厚度的记录模型,让照片从“文件”升级成“记忆条目”。

这一篇继续围绕 21 天「智能相机开发实战」训练营展开。内容只使用当前项目里的 ArkTS、服务层代码和真实页面截图来讲,不把封面图放进正文。阅读时可以先看截图理解用户侧效果,再顺着函数名回到工程定位实现。

本篇目标

  • 理解 GalleryMoment 为什么要保存多类字段。
  • 掌握 createRecord 如何统一生成相册记录。
  • 知道单拍、双拍、地图和云同步为什么可以共用同一模型。
  • 为后续本地持久化、分组和详情页打基础。

对应源码位置

  • entry/src/main/ets/services/GalleryRecordService.ets

一、记录模型是相册、地图和云同步的共同语言

相册要展示图片和时间,地图要用经纬度和地点,详情页要显示备注和智能摘要,云同步要知道 dirty 状态和 revision。如果这些字段散落在页面状态里,功能之间很难协作。

GalleryMoment 把一张照片相关的核心信息放到同一条记录中。后续无论从相册进入地图,还是从地图回到相册,大家都围绕同一个 id 和同一组字段工作。

图1 GalleryMoment 把照片、地点、智能内容和同步状态串成一条记录

图1 GalleryMoment 把照片、地点、智能内容和同步状态串成一条记录

二、字段设计覆盖照片、地点、智能内容和同步状态

模型里既有 backPath/frontPath 这样的本地文件路径,也有 backUri/frontUri 这样的展示 Uri;既有 latitude/longitude/place,也有 aiCaption/videoPrompt/userNote。这说明项目不是只服务图片展示,而是在为完整记忆体验做准备。

你可以把这些字段分成四类:文件类、场景类、内容类、同步类。分类后阅读源码会清楚很多。

图2 GalleryMoment 字段覆盖照片、地点、智能内容和同步状态

图2 GalleryMoment 字段覆盖照片、地点、智能内容和同步状态

export interface GalleryMoment {
  id: string;
  createdAt: number;
  updatedAt?: number;
  createdLabel: string;
  pairIndex: number;
  place: string;
  memoryTitle: string;
  latitude: number;
  longitude: number;
  backPath: string;
  frontPath: string;
  backUri: string;
  frontUri: string;
  aiStatus: GalleryMomentStatus;
  visibility: GalleryMomentVisibility;
  aiCaption: string;
  videoPrompt: string;
  watermarkStyle?: GalleryWatermarkStyle;
  watermarkText?: string;
  userNote?: string;
  aiPoem?: string;
  ownerKey?: string;
  syncDirty?: boolean;
  cloudRevision?: number;
  cloudBackAssetDataUrl?: string;
  cloudFrontAssetDataUrl?: string;
}

模型字段要克制,但不能太薄。凡是多个页面都会用到、并且需要持久保存的信息,都适合进入记录模型。

三、createRecord 统一生成可展示、可同步的记录

createRecord 是拍摄链路和相册模型之间的边界。它接收拍摄上下文,把时间、地点、路径、坐标、水印信息转成标准记录,并补齐默认智能字段、可见性和同步状态。

统一创建函数能减少入库差异。单拍、双拍、导入照片如果都能最终走到类似创建逻辑,后续相册页面就不需要为不同来源写大量 if。

图3 createRecord 统一生成可展示、可同步的相册记录

图3 createRecord 统一生成可展示、可同步的相册记录

  static createRecord(options: CreateGalleryMomentOptions): GalleryMoment {
    const record: GalleryMoment = {
      id: options.id,
      createdAt: options.createdAt,
      updatedAt: options.createdAt,
      createdLabel: GalleryRecordService.formatTimestamp(options.createdAt),
      pairIndex: options.pairIndex,
      place: options.place,
      memoryTitle: options.memoryTitle,
      latitude: GalleryRecordService.normalizeCoordinate(options.latitude),
      longitude: GalleryRecordService.normalizeCoordinate(options.longitude),
      backPath: options.backPath,
      frontPath: options.frontPath,
      backUri: GalleryRecordService.toFileUri(options.backPath),
      frontUri: GalleryRecordService.toFileUri(options.frontPath),
      aiStatus: 'pending',
      visibility: 'public',
      aiCaption: GalleryRecordService.DEFAULT_AI_CAPTION,
      videoPrompt: GalleryRecordService.DEFAULT_VIDEO_PROMPT,
      watermarkStyle: GalleryRecordService.normalizeWatermarkStyle(options.watermarkStyle),
      watermarkText: options.watermarkText && options.watermarkText.trim().length > 0
        ? options.watermarkText.trim()
        : '',
      userNote: GalleryRecordService.DEFAULT_USER_NOTE,
      aiPoem: GalleryRecordService.DEFAULT_AI_POEM,
      syncDirty: true,
      cloudRevision: 0
    };
    return record;

这里的 syncDirty: true 很重要。它说明新记录默认需要同步,后续云端流程可以根据这个字段决定是否上传。

四、智能字段从记录继续生长

applyLocalInsight 展示了记录如何从 pending 状态变为 ready 状态。项目会根据单拍或双拍生成不同的摘要和视频提示词,并保留原有的路径、地点、水印、备注和同步字段。

这类函数体现了“不可丢字段”的原则:更新智能内容时,不应该把用户备注、云端 revision 或可见性覆盖掉。

图4 applyLocalInsight 让记录获得本地智能摘要和视频提示词

图4 applyLocalInsight 让记录获得本地智能摘要和视频提示词

  static applyLocalInsight(record: GalleryMoment): GalleryMoment {
    const singleCameraRecord = GalleryRecordService.isSingleCameraRecord(record);
    const updatedRecord: GalleryMoment = {
      id: record.id,
      createdAt: record.createdAt,
      updatedAt: Date.now(),
      createdLabel: record.createdLabel,
      pairIndex: record.pairIndex,
      place: record.place,
      memoryTitle: record.memoryTitle,
      latitude: record.latitude,
      longitude: record.longitude,
      backPath: record.backPath,
      frontPath: record.frontPath,
      backUri: record.backUri,
      frontUri: record.frontUri,
      aiStatus: 'ready',
      visibility: record.visibility === 'private' ? 'private' : 'public',
      aiCaption: singleCameraRecord
        ? `${record.place} 的这张照片保留了地点、时间和现场氛围,适合整理成一段影像记忆。`
        : `${record.place} 的这组双镜照片同时保留了主画面和副视角,适合整理成一段更有现场感的影像记忆。`,
      videoPrompt: singleCameraRecord
        ? `以 ${record.place} 的照片为开场,沿用原图内容和现场光影,生成自然的回忆短片。`
        : `以 ${record.place} 的双镜照片为开场,保留主画面和副视角关系,轻微运镜,突出真实回忆和现场氛围。`
    };
    updatedRecord.watermarkStyle = GalleryRecordService.normalizeWatermarkStyle(record.watermarkStyle);
    updatedRecord.watermarkText = record.watermarkText && record.watermarkText.trim().length > 0
      ? record.watermarkText.trim()
      : '';
    updatedRecord.userNote = record.userNote ?? GalleryRecordService.DEFAULT_USER_NOTE;
    updatedRecord.aiPoem = record.aiPoem ?? GalleryRecordService.DEFAULT_AI_POEM;
    updatedRecord.ownerKey = record.ownerKey;
    updatedRecord.syncDirty = true;
    updatedRecord.cloudRevision = record.cloudRevision ?? 0;
    updatedRecord.cloudBackAssetDataUrl = record.cloudBackAssetDataUrl ?? '';
    updatedRecord.cloudFrontAssetDataUrl = record.cloudFrontAssetDataUrl ?? '';
    return updatedRecord;
  }

后续接入小艺智能体或云端识图时,也应该生成新的记录副本,而不是原地随意改字段。这样页面刷新和持久化更稳定。

五、端云同步前还要再准备一次记录字段

GalleryMoment 进入端云同步时,项目不会直接把页面里的原始对象原封不动丢给云端。GallerySyncService.prepareMoment 会再做一次字段准备:时间戳归一化、经纬度归一化、账号归属写入、备注和诗句兜底、同步标记和云端版本号更新。这样做的意义是把“页面里能用的数据”变成“跨设备也能稳定恢复的数据”。

这一步也能反过来验证模型字段设计是否完整。如果 userNoteaiPoemvisibilitysyncDirty 这类字段没有在模型里固定下来,云同步阶段就只能临时拼对象,后续合并和恢复都会变得脆弱。

图5 prepareMoment 在端云同步前准备 GalleryMoment 字段

图5 prepareMoment 在端云同步前准备 GalleryMoment 字段

  private static prepareMoment(
    identity: GalleryAccountIdentity,
    record: GalleryMoment,
    dirty: boolean
  ): GalleryMoment {
    const updatedAt = GallerySyncService.getMomentUpdatedAt(record);
    const preparedRecord: GalleryMoment = {
      id: record.id,
      createdAt: GallerySyncService.normalizeTimestamp(record.createdAt),
      updatedAt: updatedAt,
      createdLabel: record.createdLabel,
      pairIndex: record.pairIndex,
      place: record.place,
      memoryTitle: record.memoryTitle,
      latitude: GallerySyncService.normalizeNumber(record.latitude),
      longitude: GallerySyncService.normalizeNumber(record.longitude),
      backPath: record.backPath,
      frontPath: record.frontPath,
      backUri: record.backUri,
      frontUri: record.frontUri,
      aiStatus: record.aiStatus,
      visibility: record.visibility,
      aiCaption: record.aiCaption,
      videoPrompt: record.videoPrompt,
      watermarkStyle: record.watermarkStyle,
      watermarkText: record.watermarkText ?? '',
      userNote: record.userNote ?? '',
      aiPoem: record.aiPoem ?? '',
      ownerKey: identity.userKey,
      syncDirty: dirty,
      cloudRevision: updatedAt
    };
    return preparedRecord;
  }

这里最值得注意的是 ownerKeysyncDirtycloudRevisionownerKey 让记录知道属于哪个账号;syncDirty 告诉同步服务这条记录是否有本地改动;cloudRevision 用更新时间充当合并依据。它们都不是 UI 字段,但会决定多设备场景下照片记录能不能合并正确。

也就是说,GalleryMoment 不是只服务相册页的展示模型,而是贯穿拍摄、相册、地图、保险箱、智能生成和端云同步的业务模型。训练营后面继续讲同步、隐私和成片时,仍然会围绕这条记录往外扩展。

工程检查清单

  • 记录模型要覆盖多个页面共同使用的信息。
  • 本地路径和展示 Uri 分开保存,避免每次渲染重复转换。
  • 创建记录时补齐默认值,页面不处理空字段兜底。
  • 更新智能内容时保留用户备注、可见性和同步字段。
  • 新增字段后同步更新 normalizeRecord,兼容旧数据。

今日练习

  1. 把 GalleryMoment 字段按文件、场景、内容、同步四类重新整理成表格。
  2. 思考如果要加入“天气”字段,应该在 createRecord 哪一步写入。
  3. 检查单拍记录中 backPath 和 frontPath 的关系,理解为什么模型仍然能统一表示。

训练营后面的内容会继续按“真实页面效果 → 源码定位 → 状态闭环 → 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现,而不是只停留在概念说明。

Logo

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

更多推荐