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

摘要

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

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

工程环境与数据存储

项目照片侧文档侧
服务PhotoAlbumServicePhotoDocumentService
Preferenceswatermark_camera_albumwatermark_camera_documents
Keycaptured_photosconverted_documents
最大条数6080
主键CapturedPhoto.idCapturedDocument.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、测试、元服务和应用上架分发等。

更多推荐