第44篇|GalleryMoment 模型:一张“记忆照片”不只是 Uri
第44篇|GalleryMoment 模型:一张“记忆照片”不只是 Uri
第 10 天开始,我们从拍摄链路进入相册数据层。很多相机 Demo 只保存一张图片 Uri,但双镜记忆相机要承载地点、时间、前后摄、智能摘要、备注、隐私和云同步。这个承载结构就是 GalleryMoment。
如果模型设计太薄,后面每个功能都要临时拼字段;如果模型过度复杂,页面又会难以维护。训练营项目采用一个中等厚度的记录模型,让照片从“文件”升级成“记忆条目”。
这一篇继续围绕 21 天「智能相机开发实战」训练营展开。内容只使用当前项目里的 ArkTS、服务层代码和真实页面截图来讲,不把封面图放进正文。阅读时可以先看截图理解用户侧效果,再顺着函数名回到工程定位实现。
本篇目标
- 理解 GalleryMoment 为什么要保存多类字段。
- 掌握 createRecord 如何统一生成相册记录。
- 知道单拍、双拍、地图和云同步为什么可以共用同一模型。
- 为后续本地持久化、分组和详情页打基础。
对应源码位置
entry/src/main/ets/services/GalleryRecordService.ets
一、记录模型是相册、地图和云同步的共同语言
相册要展示图片和时间,地图要用经纬度和地点,详情页要显示备注和智能摘要,云同步要知道 dirty 状态和 revision。如果这些字段散落在页面状态里,功能之间很难协作。
GalleryMoment 把一张照片相关的核心信息放到同一条记录中。后续无论从相册进入地图,还是从地图回到相册,大家都围绕同一个 id 和同一组字段工作。

图1 GalleryMoment 把照片、地点、智能内容和同步状态串成一条记录
二、字段设计覆盖照片、地点、智能内容和同步状态
模型里既有 backPath/frontPath 这样的本地文件路径,也有 backUri/frontUri 这样的展示 Uri;既有 latitude/longitude/place,也有 aiCaption/videoPrompt/userNote。这说明项目不是只服务图片展示,而是在为完整记忆体验做准备。
你可以把这些字段分成四类:文件类、场景类、内容类、同步类。分类后阅读源码会清楚很多。

图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 统一生成可展示、可同步的相册记录
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 让记录获得本地智能摘要和视频提示词
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 会再做一次字段准备:时间戳归一化、经纬度归一化、账号归属写入、备注和诗句兜底、同步标记和云端版本号更新。这样做的意义是把“页面里能用的数据”变成“跨设备也能稳定恢复的数据”。
这一步也能反过来验证模型字段设计是否完整。如果 userNote、aiPoem、visibility、syncDirty 这类字段没有在模型里固定下来,云同步阶段就只能临时拼对象,后续合并和恢复都会变得脆弱。

图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;
}
这里最值得注意的是 ownerKey、syncDirty 和 cloudRevision。ownerKey 让记录知道属于哪个账号;syncDirty 告诉同步服务这条记录是否有本地改动;cloudRevision 用更新时间充当合并依据。它们都不是 UI 字段,但会决定多设备场景下照片记录能不能合并正确。
也就是说,GalleryMoment 不是只服务相册页的展示模型,而是贯穿拍摄、相册、地图、保险箱、智能生成和端云同步的业务模型。训练营后面继续讲同步、隐私和成片时,仍然会围绕这条记录往外扩展。
工程检查清单
- 记录模型要覆盖多个页面共同使用的信息。
- 本地路径和展示 Uri 分开保存,避免每次渲染重复转换。
- 创建记录时补齐默认值,页面不处理空字段兜底。
- 更新智能内容时保留用户备注、可见性和同步字段。
- 新增字段后同步更新 normalizeRecord,兼容旧数据。
今日练习
- 把 GalleryMoment 字段按文件、场景、内容、同步四类重新整理成表格。
- 思考如果要加入“天气”字段,应该在 createRecord 哪一步写入。
- 检查单拍记录中 backPath 和 frontPath 的关系,理解为什么模型仍然能统一表示。
训练营后面的内容会继续按“真实页面效果 → 源码定位 → 状态闭环 → 可验证结果”的节奏推进。每一篇都尽量让你能拿着代码直接回到项目里复现,而不是只停留在概念说明。
更多推荐



所有评论(0)