HarmonyOS趣味相机实战第28篇:照片转文档的引用一致性、级联删除与孤儿修复

摘要

趣味相机允许把拍照结果转换为图片文档。照片和文档分别存进两个 Preferences,文档通过 photoId 指向来源照片,同时复制标题、时间、水印和摘要。这样的双存储设计简单,但删除照片时会立刻遇到一个产品问题:对应文档应该一起删除、继续保留,还是标记来源已失效?如果没有明确约束,长期使用后会积累找不到来源的孤儿文档,页面计数与用户预期也会分叉。

本文基于 D:/APP/1quweixiangjiPhotoAlbumService.etsPhotoDocumentService.etsIndex.ets,先还原当前数据关系,再给出三种删除语义、应用层协调器、失败补偿、启动自检和自动化测试方案。重点不是照搬数据库外键,而是在 Preferences 这种无事务存储上建立可解释、可恢复的一致性边界。

工程环境与数据存储

项目 照片侧 文档侧
服务 PhotoAlbumService PhotoDocumentService
Preferences watermark_camera_album watermark_camera_documents
Key captured_photos converted_documents
最大条数 60 80
主键 CapturedPhoto.id CapturedDocument.id
关联字段 - photoId
嵌套数据 WatermarkSnapshot 水印快照副本

HarmonyOS趣味相机照片文档数据链路

一、先还原领域关系

照片模型是来源实体:

export interface CapturedPhoto {
  id: string;
  title: string;
  createdAt: string;
  captureSummary: string;
  status: 'preview' | 'saved';
  watermark?: WatermarkSnapshot;
}

文档模型保存来源 ID 与一份展示快照:

export interface CapturedDocument {
  id: string;
  photoId: string;
  title: string;
  createdAt: string;
  sourcePhotoTitle: string;
  sourceCreatedAt: string;
  pageCount: number;
  documentType: 'imageDocument';
  status: 'ready';
  summary: string;
  captureSummary: string;
  watermark?: WatermarkSnapshot;
}

从模型看,关系是“一张照片可生成零到多个文档”。photoId 提供可追踪性,sourcePhotoTitle 等字段又让文档具备一定独立展示能力。

二、转换操作创建的是不可变快照

项目转换逻辑:

static createDocument(photo: CapturedPhoto): CapturedDocument {
  const watermark: WatermarkSnapshot | undefined =
    PhotoDocumentService.cloneWatermark(photo.watermark);
  const locationText: string =
    watermark && watermark.enabled ?
      watermark.locationText : '未添加水印地点';
  const summary: string = `${photo.title} · ${locationText}`;

  return {
    id: `doc_${Date.now()}_${photo.id}`,
    photoId: photo.id,
    title: `${photo.title} 文档`,
    createdAt: PhotoDocumentService.formatNow(),
    sourcePhotoTitle: photo.title,
    sourceCreatedAt: photo.createdAt,
    pageCount: 1,
    documentType: 'imageDocument',
    status: 'ready',
    summary,
    captureSummary: PhotoDocumentService.safeCaptureSummary(
      photo.captureSummary),
    watermark
  };
}

这里不是简单保存外键。文档复制了来源标题、拍摄时间、水印和摘要,因此即使来源照片元数据消失,文档列表仍能显示基本信息。这为“保留文档”策略提供了基础。

三、当前删除照片不会同步处理文档

页面删除照片:

private async deleteAlbumPhoto(photoId: string): Promise<void> {
  this.album = await PhotoAlbumService.deletePhoto(photoId);
  if (this.albumPreviewPhoto &&
      this.albumPreviewPhoto.id === photoId) {
    this.albumPreviewPhoto = null;
  }
  this.captureStatusText = '已从本地相册删除照片';
}

文档删除是另一条独立路径:

private async deleteDocument(documentId: string): Promise<void> {
  this.documents =
    await PhotoDocumentService.deleteDocument(documentId);
  if (this.documentPreview &&
      this.documentPreview.id === documentId) {
    this.documentPreview = null;
  }
  this.captureStatusText = '已删除文档记录';
}

因此删除来源照片后,对应文档会继续存在,photoId 变成悬空引用。这不一定是错误,但必须成为明确产品语义,而不能是服务彼此独立带来的偶然结果。

四、先选择删除语义

常见方案有三种:

策略 删除照片后的文档 适用场景 风险
级联删除 一并删除 文档只是照片视图 用户可能误删重要文档
限制删除 有文档时禁止 强关联业务 操作步骤增加
快照保留 文档独立保留 文档是可交付结果 需处理来源失效状态

趣味相机当前已经复制文档展示字段,更适合“快照保留”或“删除前让用户选择”。如果真实图片资产只存在照片侧,文档保留元数据却无法打开内容,则应级联删除或在转换时生成独立文档资产。

五、不要让底层服务互相直接依赖

可以让 PhotoAlbumService.deletePhoto() 内部调用文档服务,但这会形成数据服务之间的隐式耦合,后续测试和迁移更难。推荐引入应用层协调器:

export type PhotoDeletePolicy =
  'keepDocuments' | 'cascadeDocuments' | 'rejectIfReferenced';

export interface PhotoDeleteResult {
  photos: CapturedPhoto[];
  documents: CapturedDocument[];
  removedDocumentCount: number;
  sourcePhotoId: string;
}

export class CameraLibraryCoordinator {
  static async deletePhoto(
    photoId: string,
    policy: PhotoDeletePolicy
  ): Promise<PhotoDeleteResult> {
    // coordinate two repositories
  }
}

页面只表达用户选择,协调器负责跨存储规则,两个底层服务仍只管理自己的集合。

六、先查询引用再决定动作

文档服务增加按来源查询:

static async listByPhotoId(
  photoId: string
): Promise<CapturedDocument[]> {
  await PhotoDocumentService.waitForInit();
  return PhotoDocumentService.cloneDocuments(
    PhotoDocumentService.cachedDocuments.filter(
      (document: CapturedDocument) =>
        document.photoId === photoId)
  );
}

页面删除前可给出准确提示:

这张照片已生成 2 份图片文档。
删除照片后,文档将保留拍摄摘要和水印信息,但不能返回来源照片。

不要只弹出“确定删除吗”。提示应说明关联数量和实际后果。

七、级联删除需要批量接口

逐条调用 deleteDocument(id) 会反复序列化与 flush。文档服务应提供按来源批量删除:

static async deleteByPhotoId(
  photoId: string
): Promise<CapturedDocument[]> {
  await PhotoDocumentService.waitForInit();
  PhotoDocumentService.cachedDocuments =
    PhotoDocumentService.cachedDocuments.filter(
      (document: CapturedDocument) =>
        document.photoId !== photoId
    );
  await PhotoDocumentService.flushDocuments();
  return PhotoDocumentService.cloneDocuments(
    PhotoDocumentService.cachedDocuments
  );
}

一次过滤、一次 flush,既减少 I/O,也让“移除该照片全部文档”成为原子级服务操作。

八、Preferences没有跨文件事务

级联删除涉及两个 Preferences:先删文档再删照片,任一步都可能失败。

删除文档成功
-> 删除照片失败
-> 照片仍在,但文档已经消失

反过来:

删除照片成功
-> 删除文档失败
-> 产生孤儿文档

Preferences 没有跨实例事务时,不能假装两次 flush 是原子的。需要选择失败后更容易恢复的顺序,并准备补偿操作。

九、用意图日志实现可恢复提交

可以在单独 Preferences 中记录待执行操作:

interface LibraryMutationIntent {
  id: string;
  type: 'deletePhotoCascade';
  photoId: string;
  phase: 'prepared' | 'documentsDeleted' | 'completed';
  createdAt: number;
}

流程:

写入 prepared 意图并 flush
-> 删除关联文档并更新 phase
-> 删除照片并更新 completed
-> 清理已完成意图

应用下次启动发现未完成意图,就根据 phase 重试剩余步骤。删除操作本身应具备幂等性:重复删除不存在的记录视为成功,这样崩溃恢复不会产生新错误。

十、快照保留需要显式来源状态

若产品选择保留文档,不能让 UI 假装来源仍存在。模型可增加:

export interface CapturedDocument {
  // existing fields
  sourceState: 'available' | 'deleted' | 'unknown';
}

删除照片成功后批量标记:

static async markSourceDeleted(
  photoId: string
): Promise<CapturedDocument[]> {
  // map documents and set sourceState='deleted'
}

文档详情显示“来源照片已删除”,并隐藏“查看来源照片”操作。文档的水印和摘要仍来自转换时快照,不会因来源消失而空白。

十一、限制删除策略要避免竞态

简单流程“先查询是否有文档,再删除照片”存在检查与执行之间的竞态:查询后可能又生成一个文档。单进程应用中可以用操作队列串行化:

private static mutationQueue: Promise<void> = Promise.resolve();

private static enqueue(task: () => Promise<void>): Promise<void> {
  CameraLibraryCoordinator.mutationQueue =
    CameraLibraryCoordinator.mutationQueue.then(task, task);
  return CameraLibraryCoordinator.mutationQueue;
}

所有照片删除与文档转换都进入同一队列,使“检查引用 + 删除”在应用进程内保持连续。多进程或跨设备同步场景还需要版本号或真正数据库事务。

十二、转换操作也要验证来源

从相册卡片点击“转文档”时,传入的 photo 可能是页面旧副本。转换前应确认来源仍存在:

const source: CapturedPhoto | undefined =
  await PhotoAlbumService.findById(photo.id);
if (!source) {
  throw new Error('SOURCE_PHOTO_NOT_FOUND');
}
return PhotoDocumentService.convertPhoto(source);

从拍照结果直接转文档则不同:预览照片尚未存入相册,但仍是有效来源。可以把来源类型写入模型:

type DocumentSourceMode = 'previewSnapshot' | 'albumReference';

这能解释为什么某些文档的 photoId 从一开始就不在本地相册中,而不是把它们误判为损坏数据。

十三、孤儿检测不能只判断photoId

如果支持“预览直接转文档”,文档来源照片本来就可能未持久化。因此孤儿规则要结合来源模式:

function isOrphan(
  document: CapturedDocument,
  photoIds: Set<string>
): boolean {
  return document.sourceMode === 'albumReference' &&
    document.sourceState === 'available' &&
    !photoIds.has(document.photoId);
}

没有来源模式的旧数据可以标记为 unknown,由迁移逻辑根据业务字段推断或保守保留,不能启动时一律删除。

十四、启动时执行只读一致性扫描

初始化完成后,可以生成报告:

interface LibraryIntegrityReport {
  photoCount: number;
  documentCount: number;
  orphanDocumentIds: string[];
  duplicatedPhotoIds: string[];
  duplicatedDocumentIds: string[];
  invalidReferenceCount: number;
}

扫描步骤:

  1. 建立照片 ID 集合。
  2. 检查照片和文档 ID 是否重复。
  3. 按 sourceMode 验证文档引用。
  4. 统计异常,不输出用户内容。
  5. 默认只报告,修复操作走显式策略。

首次发现异常不要立即删除数据。自动修复必须可重复、有版本记录,并保留最少必要的诊断信息。

十五、容量上限也会制造悬空引用

照片最多保留 60 条,文档最多 80 条。保存第 61 张照片时,最旧照片会被 slice(0, 60) 淘汰,但其文档仍可能存在。

这意味着“照片过期淘汰”也必须套用删除语义:

  • 级联策略:淘汰照片时同步删除关联文档。
  • 快照策略:把文档来源标记为过期,但保留内容。
  • 限制策略:有文档的照片不参与自动淘汰,改淘汰其他记录。

容量控制不是单纯数组截断,它也是一次领域删除。

十六、真实媒体文件需要第三层一致性

当前模型主要保存元数据。如果以后照片和文档拥有独立文件 URI,关系将变成:

CapturedPhoto metadata -> photo media asset
CapturedDocument metadata -> document asset
CapturedDocument.photoId -> CapturedPhoto metadata

删除需要同时考虑元数据和文件。推荐先把文件移到应用内部回收区,再提交元数据变更,最后异步清理回收区。直接永久删除文件后再写 Preferences,一旦写入失败就无法补偿。

每个资产可保存校验字段:

interface LocalAssetRef {
  uri: string;
  byteSize: number;
  checksum?: string;
  state: 'ready' | 'pendingDelete';
}

不要在日志中打印完整私有文件 URI。

十七、UI需要展示关联后果

删除确认弹窗可以根据引用数生成内容:

private deletePhotoMessage(referenceCount: number): string {
  if (referenceCount === 0) {
    return '删除后无法在本地相册中恢复。';
  }
  return `这张照片关联 ${referenceCount} 份图片文档,` +
    '文档将保留,但来源会标记为已删除。';
}

若采用级联删除,主按钮应明确写“删除照片和文档”,而不是模糊的“确定”。高影响操作可以提供取消与清晰的结果提示。

十八、页面状态也要跟随协调结果

协调器返回新照片和文档数组后一次更新:

private async deleteAlbumPhoto(photoId: string): Promise<void> {
  if (this.libraryMutationRunning) {
    return;
  }
  this.libraryMutationRunning = true;
  try {
    const result: PhotoDeleteResult =
      await CameraLibraryCoordinator.deletePhoto(
        photoId, 'keepDocuments');
    this.album = result.photos;
    this.documents = result.documents;
    this.albumPreviewPhoto = null;
    this.captureStatusText = '照片已删除,关联文档已保留';
  } catch (error) {
    this.captureStatusText = '删除失败,请重试';
  } finally {
    this.libraryMutationRunning = false;
  }
}

不要在两个 await 之间分别更新页面数组,否则 UI 可能短暂显示一半完成的关系。

十九、测试故障而不只测试成功

跨存储逻辑最需要故障注入:

场景 注入点 期望
无引用删除 只删除照片
单文档级联 照片文档都消失
多文档级联 一次删除全部关联
文档flush失败 第一步 照片保持,允许重试
照片flush失败 第二步 意图日志可恢复
操作后进程退出 任意phase 重启继续或补偿
重复执行同一意图 completed前后 结果不重复、不报错
预览直接转文档 来源未入相册 不误判孤儿
容量淘汰来源 第61张保存 执行选定删除策略
旧文档无sourceMode 迁移读取 标记unknown并保留

测试要断言最终两个集合和意图日志,而不只是断言某个方法被调用。

二十、幂等删除是恢复基础

批量删除函数应允许目标已经不存在:

const next = documents.filter(
  (document: CapturedDocument) =>
    document.photoId !== photoId
);

重复运行得到同一结果,这就是幂等性。恢复任务可能因崩溃无法知道上次 flush 是否真正完成,只有幂等操作才能安全重试。

创建文档则需要请求 ID 或去重键,避免重试生成两份:

interface ConvertPhotoRequest {
  requestId: string;
  photoId: string;
}

服务先按 requestId 查询,存在则返回原结果。

二十一、诊断日志遵守最小信息原则

推荐记录:

hilog.info(DOMAIN, TAG,
  'library mutation type=%{public}s phase=%{public}s refs=%{public}d',
  intent.type,
  intent.phase,
  referenceCount);

不记录水印地点、备注、图片内容和完整文件路径。为了关联问题,可以记录经过截断或哈希处理的操作 ID,而不是直接输出全部业务对象。

二十二、发布前验收清单

  • 明确照片与文档是一对多还是一对一。
  • 产品明确选择级联、限制或快照保留策略。
  • 删除前能统计关联文档数量。
  • 跨服务规则由协调器管理,底层服务保持单一职责。
  • 批量删除只执行一次过滤和一次 flush。
  • 跨 Preferences 操作有意图日志或补偿方案。
  • 删除与恢复操作满足幂等性。
  • 预览直接转文档有明确 sourceMode。
  • 容量淘汰沿用同一删除策略。
  • 启动扫描默认只报告,不静默清除用户数据。
  • 页面一次性更新照片与文档状态。
  • 故障注入覆盖每一个 flush 失败点。

总结

photoId 只是建立了引用,并没有自动带来一致性。照片和图片文档分别存储时,必须先定义删除语义,再由应用层协调器执行跨服务操作。选择快照保留,就要标记来源状态;选择级联删除,就要批量处理并准备故障补偿;选择限制删除,就要串行化检查与执行。

Preferences 缺少跨存储事务并不可怕,真正危险的是没有承认这个边界。通过意图日志、幂等操作、启动一致性扫描、容量淘汰规则和故障注入测试,照片转文档链路可以在异常退出、重复操作和版本迁移后仍保持可解释、可恢复。

Logo

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

更多推荐