HarmonyOS趣味相机实战第28篇:照片转文档的引用一致性、级联删除与孤儿修复
HarmonyOS趣味相机实战第28篇:照片转文档的引用一致性、级联删除与孤儿修复
摘要
趣味相机允许把拍照结果转换为图片文档。照片和文档分别存进两个 Preferences,文档通过 photoId 指向来源照片,同时复制标题、时间、水印和摘要。这样的双存储设计简单,但删除照片时会立刻遇到一个产品问题:对应文档应该一起删除、继续保留,还是标记来源已失效?如果没有明确约束,长期使用后会积累找不到来源的孤儿文档,页面计数与用户预期也会分叉。
本文基于 D:/APP/1quweixiangji 的 PhotoAlbumService.ets、PhotoDocumentService.ets 和 Index.ets,先还原当前数据关系,再给出三种删除语义、应用层协调器、失败补偿、启动自检和自动化测试方案。重点不是照搬数据库外键,而是在 Preferences 这种无事务存储上建立可解释、可恢复的一致性边界。
工程环境与数据存储
| 项目 | 照片侧 | 文档侧 |
|---|---|---|
| 服务 | PhotoAlbumService |
PhotoDocumentService |
| Preferences | watermark_camera_album |
watermark_camera_documents |
| Key | captured_photos |
converted_documents |
| 最大条数 | 60 | 80 |
| 主键 | CapturedPhoto.id |
CapturedDocument.id |
| 关联字段 | - | photoId |
| 嵌套数据 | WatermarkSnapshot |
水印快照副本 |

一、先还原领域关系
照片模型是来源实体:
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;
}
扫描步骤:
- 建立照片 ID 集合。
- 检查照片和文档 ID 是否重复。
- 按 sourceMode 验证文档引用。
- 统计异常,不输出用户内容。
- 默认只报告,修复操作走显式策略。
首次发现异常不要立即删除数据。自动修复必须可重复、有版本记录,并保留最少必要的诊断信息。
十五、容量上限也会制造悬空引用
照片最多保留 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 缺少跨存储事务并不可怕,真正危险的是没有承认这个边界。通过意图日志、幂等操作、启动一致性扫描、容量淘汰规则和故障注入测试,照片转文档链路可以在异常退出、重复操作和版本迁移后仍保持可解释、可恢复。
更多推荐



所有评论(0)