HarmonyOS趣味相机实战第3篇:照片转文档、归档缓存与水印信息继承

摘要

前两篇分别拆了相机权限、真实拍摄、水印快照和本地相册。本文继续沿着 D:/APP/1quweixiangji 这个 HarmonyOS 趣味相机项目,复盘“照片转文档”这一段工程链路:用户拍照后可以直接把预览照片生成文档,也可以从本地相册里选择一张已保存照片再生成文档;文档记录需要继承原照片标题、拍摄时间、拍摄摘要和水印快照,并能在页面中列表展示、预览和删除。

这个功能看起来像一个按钮,但实际要处理四类问题:

  1. 文档记录应该保存哪些字段,不能只保存一个标题。
  2. 文档服务如何初始化、读取缓存、写入 Preferences,并避免外部直接修改内部数组。
  3. 预览照片和相册照片进入文档流时,页面状态要如何切换。
  4. 后续如果从“元数据文档”升级到“真实图片/PDF 文件”,当前结构要怎么留扩展口。

本文代码来自一个 ArkTS Stage 模型项目,重点文件是 PhotoDocumentService.etsPhotoAlbumService.etsDecorationModels.etsIndex.ets

工程背景与源码定位

文件 作用
entry/src/main/ets/entryability/EntryAbility.ets Ability 创建时初始化相册服务和文档服务
entry/src/main/ets/model/DecorationModels.ets 定义 CapturedPhotoCapturedDocumentWatermarkSnapshot 等数据结构
entry/src/main/ets/service/PhotoAlbumService.ets 创建照片、保存照片、维护本地相册缓存
entry/src/main/ets/service/PhotoDocumentService.ets 把照片转换为文档记录,并持久化到 Preferences
entry/src/main/ets/pages/Index.ets 页面状态、拍照预览、相册、文档列表、文档预览和删除交互
entry/src/test/LocalUnit.test.ets 已有水印快照和保存态克隆测试,可继续扩展文档转换测试
entry/src/main/module.json5 声明相机权限,文档归档本身不新增敏感权限
oh-package.json5 使用 Hypium/Hamock 作为测试依赖

项目版本边界如下:

项目 当前值 对本文影响
App 类型 HarmonyOS Stage 模型 服务在 UIAbility 生命周期中初始化
工程路径 D:/APP/1quweixiangji 本文只引用该项目已有源码
ArkTS 模型 声明式页面 + 状态变量 文档列表依赖 @State documents 刷新
SDK 6.0.2(22) 示例以当前工程配置为准
bundleName com.fun.quweixiangji 来自 AppScope/app.json5
versionName 1.0.1 当前复盘对应版本
数据存储 @kit.ArkData Preferences 适合保存小型结构化元数据,不适合长期保存大体积图片
文档类型 imageDocument 当前是单页图片文档记录,不是系统 PDF 文件
权限边界 相机权限已声明 文档元数据归档不需要媒体库写入权限

HarmonyOS 水印相机文档归档预览

本地构建命令可以沿用项目 README 中的方式:

cd D:\APP\1quweixiangji
$env:JAVA_HOME='D:\Program Files\Huawei\DevEco Studio\jbr'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
& 'D:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat' --mode module -p module=entry@default -p product=default assembleHap --no-daemon

正式上架或分发前,需要单独检查签名配置、权限说明、隐私政策、真机相机能力和不同设备的页面适配。本文不展示本地签名密码、证书路径等敏感信息。

版本兼容性、隐私边界与数据口径

文档归档功能会同时涉及照片标题、拍摄时间、地点备注和水印文本。虽然当前没有把真实图片写到系统相册,也没有上传云端,但这些字段仍然属于用户拍摄上下文,设计时要先把数据口径说清楚。

数据 当前保存位置 是否敏感 处理策略
照片标题 Preferences 文档 JSON 用于列表和预览展示
拍摄时间 Preferences 文档 JSON 只保存格式化时间,不做定位推断
地点文本 WatermarkSnapshot 来自用户输入或模板默认值
备注文本 WatermarkSnapshot 只在本地展示,后续分享前要二次确认
PixelMap 内存预览 转文档或关闭预览后释放
PDF 文件 当前未生成 后续生成时走应用沙箱和权限说明

当前文章讨论的是“本地文档元数据归档”,不是“PDF 导出能力已完成”。这个边界必须明确,否则后续用户看到“文档”二字,容易误以为已经落地真实 PDF 文件。

一、文档记录不是照片的复制品

相机项目里已经有照片模型 CapturedPhoto,它描述的是一次拍摄结果:滤镜、相框、分辨率、来源、保存状态、水印快照等。文档归档不能简单复用 CapturedPhoto,因为“照片”和“文档”在产品语义上不同:

对象 关注点 典型字段
照片 拍摄结果和相册状态 titlefilterNamecaptureSourcestatuswatermark
文档 归档结果和来源追溯 photoIdsourcePhotoTitlepageCountdocumentTypesummary

项目在 DecorationModels.ets 中把两者拆成独立接口:

export interface CapturedPhoto {
  id: string;
  title: string;
  createdAt: string;
  layerCount: number;
  layerSummary: string;
  filterName: string;
  captureSource: CaptureSource;
  captureSummary: string;
  status: 'preview' | 'saved';
  watermark?: WatermarkSnapshot;
}

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;
}

这里最关键的是 photoIdsourcePhotoTitlesourceCreatedAt。它们让文档可以追溯来源照片,即使以后相册里删除了照片,文档列表仍能展示“来自哪次拍摄”。watermark 继续作为可选字段保留,方便文档预览显示地点、时间、模板标题和备注。

二、Ability 启动时初始化相册与文档服务

文档服务需要在页面读取前完成初始化。项目没有把 Preferences 读取散落到页面组件里,而是在 Ability 创建时统一初始化:

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    try {
      this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
      PhotoAlbumService.init(this.context);
      PhotoDocumentService.init(this.context);
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
    }
  }
}

这个设计有两个好处:

  1. 页面只关心 listDocuments()convertPhoto()deleteDocument() 等业务方法,不直接持有 Preferences 实例。
  2. 后续如果增加迁移逻辑,可以放在服务初始化阶段完成,不必改动所有页面入口。

需要注意的是,init() 只是触发初始化任务,页面读取时仍要通过 waitForInit() 等待异步任务完成。这样既能避免重复初始化,又能保证首次进入页面时拿到缓存数据。

三、PhotoDocumentService 的缓存与持久化设计

文档服务核心常量如下:

const PREF_NAME: string = 'watermark_camera_documents';
const PREF_KEY_DOCUMENTS: string = 'converted_documents';

PREF_NAME 相当于这个业务模块的存储空间,PREF_KEY_DOCUMENTS 是文档列表字段。服务内部维护三类状态:

private static prefs: preferences.Preferences | null = null;
private static initTask: Promise<void> | null = null;
private static cachedDocuments: CapturedDocument[] = [];
状态 作用
prefs Preferences 实例,负责读写本地结构化数据
initTask 防止重复初始化,也让调用方能等待第一次读取完成
cachedDocuments 内存缓存,页面操作时先改缓存再 flush

初始化逻辑如下:

static init(context: common.UIAbilityContext): Promise<void> {
  if (PhotoDocumentService.initTask !== null) {
    return PhotoDocumentService.initTask;
  }
  PhotoDocumentService.initTask = PhotoDocumentService.initInternal(context);
  return PhotoDocumentService.initTask;
}

private static async initInternal(context: common.UIAbilityContext): Promise<void> {
  try {
    PhotoDocumentService.prefs = await preferences.getPreferences(context, PREF_NAME);
    const raw: preferences.ValueType = PhotoDocumentService.prefs.getSync(PREF_KEY_DOCUMENTS, '[]');
    if (typeof raw === 'string') {
      PhotoDocumentService.cachedDocuments = PhotoDocumentService.parseDocuments(raw);
    }
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'init documents failed: %{public}s', JSON.stringify(error));
    PhotoDocumentService.cachedDocuments = [];
  }
}

这里没有让异常冒泡到页面,而是记录日志并回退为空数组。对本地文档列表来说,这是比较稳的选择:即使缓存损坏,主拍照功能仍然可用。

四、从照片生成文档:字段映射要有业务含义

真正的转换逻辑在 createDocument(photo)

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: photo.captureSummary,
    watermark
  };
}

这段映射有几个值得保留的工程细节:

字段 映射方式 价值
id doc_${Date.now()}_${photo.id} 文档自身 ID 和来源照片 ID 都可追溯
title ${photo.title} 文档 文档列表里能直接看出来源
createdAt 当前时间 表示文档生成时间,不等同于拍摄时间
sourceCreatedAt photo.createdAt 保留原始拍摄时间
summary 照片标题 + 水印地点 卡片摘要更适合快速扫描
captureSummary 直接继承 预览时能展示拍摄模式和来源
watermark 深拷贝 避免后续修改水印设置影响历史记录

如果直接把 photo.watermark 引用塞进文档,后续页面复用水印模板时可能出现历史记录被连带修改的问题。这里通过 cloneWatermark() 做一次对象复制,是小功能里很容易被忽略的防线。

五、写入文档列表:新记录置顶,最多保留 80 条

转换入口 convertPhoto() 负责把文档插入列表并持久化:

static async convertPhoto(photo: CapturedPhoto): Promise<CapturedDocument[]> {
  await PhotoDocumentService.waitForInit();
  const document: CapturedDocument = PhotoDocumentService.createDocument(photo);
  const nextDocuments: CapturedDocument[] = [document].concat(PhotoDocumentService.cachedDocuments);
  PhotoDocumentService.cachedDocuments = nextDocuments.slice(0, 80);
  await PhotoDocumentService.flushDocuments();
  return PhotoDocumentService.cloneDocuments(PhotoDocumentService.cachedDocuments);
}

这里有三个明确策略:

  1. await waitForInit():避免还没读完旧数据就写入新数据。
  2. [document].concat(cachedDocuments):新生成的文档排在列表顶部。
  3. slice(0, 80):限制本地元数据数量,避免 Preferences 长期膨胀。

持久化逻辑也很直接:

private static async flushDocuments(): Promise<void> {
  if (PhotoDocumentService.prefs === null) {
    return;
  }
  try {
    await PhotoDocumentService.prefs.put(PREF_KEY_DOCUMENTS, JSON.stringify(PhotoDocumentService.cachedDocuments));
    await PhotoDocumentService.prefs.flush();
  } catch (error) {
    hilog.error(DOMAIN, TAG, 'flush documents failed: %{public}s', JSON.stringify(error));
  }
}

Preferences 适合保存这种文档元数据列表,但不应该把图片二进制、Base64 大字段或 PDF 内容直接塞进去。当前实现保存的是结构化 JSON,这个边界是合理的。

六、读取与删除:对外永远返回克隆对象

服务层没有直接返回 cachedDocuments,而是统一走克隆:

static async listDocuments(): Promise<CapturedDocument[]> {
  await PhotoDocumentService.waitForInit();
  return PhotoDocumentService.cloneDocuments(PhotoDocumentService.cachedDocuments);
}

static cloneDocuments(documents: CapturedDocument[]): CapturedDocument[] {
  return documents.map((document: CapturedDocument) => {
    const clonedDocument: CapturedDocument = {
      id: document.id,
      photoId: document.photoId,
      title: document.title,
      createdAt: document.createdAt,
      sourcePhotoTitle: document.sourcePhotoTitle,
      sourceCreatedAt: document.sourceCreatedAt,
      pageCount: document.pageCount,
      documentType: 'imageDocument',
      status: 'ready',
      summary: document.summary,
      captureSummary: document.captureSummary,
      watermark: PhotoDocumentService.cloneWatermark(document.watermark)
    };
    return clonedDocument;
  });
}

这样页面拿到的是一份业务快照,不能绕过服务层直接改缓存。删除同样遵循“改缓存、flush、返回克隆列表”的套路:

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

这个结构和 PhotoAlbumService 保持一致,页面层就能用同样的状态刷新方式处理相册和文档列表。

七、页面状态:预览照片和相册照片都能进入文档流

Index.ets 里维护了文档状态:

@State documents: CapturedDocument[] = [];
@State documentPreview: CapturedDocument | null = null;

页面启动时读取历史文档:

this.documents = await PhotoDocumentService.listDocuments();

用户有两个入口生成文档。

第一个入口来自拍照预览:

private async convertPreviewPhotoToDocument(): Promise<void> {
  if (!this.previewPhoto) {
    return;
  }
  this.documents = await PhotoDocumentService.convertPhoto(this.previewPhoto);
  this.previewPhoto = null;
  await this.releasePreviewPixelMap();
  this.activeTab = 2;
  this.captureStatusText = '已生成照片文档';
}

这个方法做了四件事:转换文档、关闭预览照片、释放预览 PixelMap、切换到文档页。也就是说,用户拍完以后点“转文档”,界面会直接进入“照片文档”列表。

第二个入口来自相册:

private async convertAlbumPhotoToDocument(photo: CapturedPhoto): Promise<void> {
  this.documents = await PhotoDocumentService.convertPhoto(photo);
  this.activeTab = 2;
  this.closeAlbumPreview();
  this.captureStatusText = '已生成照片文档';
}

相册照片已经是稳定记录,不需要释放预览 PixelMap,只要转换、切页、关闭相册预览即可。两个入口复用同一个服务方法,避免预览照片和相册照片走出两套文档结构。

八、文档列表与空态:让状态变化可见

文档面板 DocumentPanel() 先展示标题和数量:

Text('照片文档')
Text(`${this.documents.length}`)

当没有文档时,页面展示空态:

if (this.documents.length === 0) {
  Column() {
    Text('还没有生成文档')
    Text('在拍照预览或相册里点击转文档,会生成带水印信息的图片文档记录。')
  }
} else {
  List({ space: 10 }) {
    ForEach(this.documents, (document: CapturedDocument) => {
      ListItem() {
        this.DocumentCard(document);
      }
    }, (document: CapturedDocument) => this.documentKey(document))
  }
}

空态文案不是纯装饰,它告诉用户文档入口在哪里。对这种工具类 App 来说,空态比大段说明更有效,因为它只在用户需要时出现。

文档卡片展示标题、摘要、页数和生成时间:

Text(document.title)
Text(document.summary)
Text(`${document.pageCount} 页图片文档 · ${document.createdAt}`)

卡片右侧提供“查看”和“删除”两个动作:

Button('查看')
  .onClick(() => {
    this.openDocumentPreview(document);
  })
Button('删除')
  .onClick(() => {
    this.deleteDocument(document.id);
  })

删除之后,页面会刷新 documents,如果当前预览窗口正好打开的是被删文档,也会同步关闭:

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 = '已删除文档记录';
}

这就是一个完整的前端状态闭环:服务删除成功后返回新列表,页面替换状态,预览弹窗也不会残留旧对象。

九、文档预览:把水印快照作为历史信息展示

文档预览不是打开真实文件,而是用卡片形式展示当前文档记录。核心内容包括来源照片、拍摄摘要、页数和水印信息:

if (this.documentPreview?.watermark?.enabled) {
  Text(`${this.documentPreview.watermark.title} · ${this.documentPreview.watermark.locationText}`)
  Text(`${this.documentPreview.watermark.timeText} · ${this.documentPreview.watermark.note}`)
}

这里再次体现“快照”的价值。文档生成后,即使用户回到拍摄页切换模板,或者从相册复用另一张照片的水印,文档预览里仍应该显示当时生成文档时继承的水印信息。

可以把文档预览理解成一个轻量归档凭证:

{
  "id": "doc_1783500000000_photo_2",
  "photoId": "photo_2",
  "title": "水印照片 2 文档",
  "sourcePhotoTitle": "水印照片 2",
  "pageCount": 1,
  "documentType": "imageDocument",
  "summary": "水印照片 2 · 杭州",
  "watermark": {
    "enabled": true,
    "template": "work",
    "title": "工作",
    "locationText": "杭州",
    "note": "客户拜访记录",
    "timeText": "07-12 09:30"
  }
}

当前实现没有把图片文件写入媒体库,也没有导出 PDF,所以它更准确的名字是“照片文档记录”。这个边界在代码和文章里都要讲清楚,否则容易让用户误以为已经生成了系统级文档文件。

十、解析失败和兼容:缓存损坏时不要拖垮主流程

parseDocuments() 专门处理 JSON 解析:

private static parseDocuments(raw: string): CapturedDocument[] {
  try {
    const parsed: CapturedDocument[] = JSON.parse(raw) as CapturedDocument[];
    if (!parsed || parsed.length === 0) {
      return [];
    }
    return PhotoDocumentService.cloneDocuments(parsed);
  } catch (error) {
    hilog.warn(DOMAIN, TAG, 'parse documents failed: %{public}s', JSON.stringify(error));
    return [];
  }
}

这里的策略是“文档列表可丢,拍照主流程不能挂”。当 Preferences 中的 JSON 被破坏,服务会返回空列表,不影响相机预览、拍照和水印编辑。

如果后续要升级字段,建议增加 schemaVersion,比如:

{
  "schemaVersion": 2,
  "id": "doc_1783500000000_photo_2",
  "photoId": "photo_2",
  "imageUri": "internal://documents/doc_1783500000000_photo_2.jpg",
  "thumbnailUri": "internal://documents/doc_1783500000000_photo_2_thumb.jpg",
  "documentType": "imageDocument"
}

升级时不要直接覆盖旧字段,可以在 parseDocuments() 或独立迁移函数里做兼容:

旧字段 新字段 迁移策略
documentType: imageDocument 保留 继续作为文档类型标识
schemaVersion schemaVersion: 1 读取时补默认值
imageUri 可选字段 没有文件时继续展示元数据卡片
watermark 保留 继续作为历史快照,不跟随当前设置变动

这样后续从“元数据归档”升级到“真实图片文件/PDF 导出”时,老用户数据不会断。

十一、从 imageDocument 升级到真实 PDF 的兼容迁移策略

当前 documentType 固定为 imageDocument,表示它是一条“图片文档记录”。如果产品后续要支持真实 PDF 文件,不能简单把 documentType 改成 pdf,否则旧记录会在列表、预览、删除和分享流程里出现兼容问题。比较稳妥的升级方式是增加版本字段和文件字段,让旧数据继续可读,新数据逐步启用真实文件能力。

建议把文档模型演进成下面这种结构:

export type DocumentType = 'imageDocument' | 'pdfDocument';

export interface CapturedDocumentV2 {
  schemaVersion: number;
  id: string;
  photoId: string;
  title: string;
  createdAt: string;
  sourcePhotoTitle: string;
  sourceCreatedAt: string;
  pageCount: number;
  documentType: DocumentType;
  status: 'ready' | 'generating' | 'failed';
  summary: string;
  captureSummary: string;
  imageUri?: string;
  pdfUri?: string;
  thumbnailUri?: string;
  fileSize?: number;
  watermark?: WatermarkSnapshot;
}

升级重点不是字段越多越好,而是把“可展示的旧元数据”和“可打开的真实文件”拆开:

字段 对旧 imageDocument 对新 pdfDocument 兼容策略
schemaVersion 读取时补 1 创建时写 2 没有版本号的记录按 V1 处理
documentType 保持 imageDocument 写入 pdfDocument 列表按类型选择打开方式
imageUri 可为空 可作为 PDF 来源图 没有 URI 时展示元数据缩略卡
pdfUri 为空 指向沙箱 PDF 文件 为空时不展示分享/打开 PDF 按钮
thumbnailUri 可为空 指向缩略图 为空时继续用当前 DocumentThumbnail()
status 默认 ready 生成中可为 generating 失败时保留记录并允许重试

迁移函数可以放在 parseDocuments() 之后,先把旧记录规范化成新结构,再返回给页面:

private static normalizeDocument(raw: CapturedDocument): CapturedDocumentV2 {
  const sourceVersion: number = (raw as CapturedDocumentV2).schemaVersion ?? 1;
  if (sourceVersion >= 2) {
    return raw as CapturedDocumentV2;
  }
  return {
    schemaVersion: 1,
    id: raw.id,
    photoId: raw.photoId,
    title: raw.title,
    createdAt: raw.createdAt,
    sourcePhotoTitle: raw.sourcePhotoTitle,
    sourceCreatedAt: raw.sourceCreatedAt,
    pageCount: raw.pageCount,
    documentType: 'imageDocument',
    status: 'ready',
    summary: raw.summary,
    captureSummary: raw.captureSummary,
    watermark: PhotoDocumentService.cloneWatermark(raw.watermark)
  };
}

真实 PDF 生成时建议采用“两阶段提交”:

  1. 先创建一条 status: 'generating' 的文档记录,并写入 Preferences。
  2. 在应用沙箱目录生成图片文件或 PDF 文件。
  3. 文件生成成功后,把 pdfUrithumbnailUrifileSizestatus: 'ready' 写回记录。
  4. 如果生成失败,把 status 改为 failed,保留来源照片、水印和错误状态,允许用户重试。

这样做的原因是,PDF 文件生成属于更容易失败的 I/O 流程:空间不足、文件写入失败、用户中途退出、权限策略变化都有可能发生。如果只在全部完成后才写 Preferences,失败时用户看不到任何反馈;如果先把记录写成 ready,失败时又会出现“列表有文档但打不开”的假成功状态。

删除逻辑也要升级成“元数据 + 文件”双删除:

删除目标 删除时机 失败处理
Preferences 记录 用户确认删除后 记录删除成功即可刷新列表
pdfUri 文件 记录删除后尝试删除 文件删除失败时记录日志,下次清理任务兜底
thumbnailUri 文件 跟随 PDF 文件删除 失败不影响主流程

真实 PDF 版本还要补两个产品边界:

  1. 如果 PDF 只保存在应用沙箱内,卸载 App 后文件会随应用清理。
  2. 如果要写入系统图库、文件管理或对外分享,需要补对应权限、隐私说明和用户授权提示。

最终迁移目标不是一次性把旧记录全部改写成 PDF,而是保证三种记录能同时存在:

记录类型 用户体验
imageDocument 仍然能在文档列表和预览弹窗中查看元数据
imageDocument 继续作为轻量图片文档记录使用
pdfDocument 可以打开、分享或导出真实 PDF 文件

这个策略对存量用户最友好,也能避免为了新增 PDF 能力而破坏已经生成的文档记录。

十二、测试思路:从水印克隆扩展到文档转换

当前项目已有两条本地测试:

it('photo snapshot keeps watermark metadata', 0, () => {
  const photo = PhotoAlbumService.createPhoto(...);
  expect(photo.watermark?.title).assertEqual('工作');
  expect(photo.watermark?.locationText).assertEqual('杭州');
});

it('saved photo keeps cloned watermark state', 0, () => {
  const photo = PhotoAlbumService.createPhoto(...);
  const saved = PhotoAlbumService.savePhoto(photo);
  expect(saved.watermark?.template).assertEqual('check');
  expect(saved.watermark?.note).assertEqual('记录一下');
});

文档功能可以继续补三类测试:

测试点 断言
createDocument(photo) photoId 等于来源照片 ID,sourceCreatedAt 等于照片拍摄时间
水印克隆 修改原照片水印后,文档水印不被影响
删除文档 删除后列表不包含目标 documentId,预览状态清空

一个最小化的转换测试可以这样写:

it('document keeps source photo and watermark snapshot', 0, () => {
  const photo = PhotoAlbumService.createPhoto(
    3,
    [],
    '无滤镜',
    '无相框',
    '标准模式',
    'real',
    '测试拍照',
    0,
    '标准',
    0,
    '12MP (4:3)',
    {
      enabled: true,
      template: 'work',
      title: '工作',
      locationText: '杭州',
      note: '客户拜访',
      timeText: '07-12 09:30'
    }
  );
  const document = PhotoDocumentService.createDocument(photo);

  expect(document.photoId).assertEqual(photo.id);
  expect(document.sourcePhotoTitle).assertEqual(photo.title);
  expect(document.pageCount).assertEqual(1);
  expect(document.watermark?.locationText).assertEqual('杭州');
});

运行验证时可以用 DevEco Studio 的本地测试入口,也可以在命令行执行工程已有测试任务。本文更推荐先补纯函数测试,因为 createDocument() 不依赖真实相机,也不依赖 Preferences 实例,反馈速度最快。

十三、常见问题排查

现象 可能原因 排查方式
进入文档页为空 PhotoDocumentService.init() 未执行或 Preferences 为空 检查 EntryAbility.onCreate() 是否初始化文档服务
生成文档后列表不刷新 页面没有替换 this.documents 确认调用后使用返回列表赋值给 @State documents
删除后预览还在 删除时只改了列表,没有清空 documentPreview 删除后判断当前预览 ID 是否等于被删 ID
文档水印被当前设置影响 直接引用了 photo.watermark 使用 cloneWatermark() 保存快照
缓存 JSON 解析失败 Preferences 中存储内容损坏或字段结构变化 parseDocuments() 捕获异常并回退空列表
文档数量过多导致读取慢 没有限制列表长度 使用 slice(0, 80) 控制元数据规模
用户误以为导出了 PDF 产品文案表达不清 明确写成“图片文档记录”,真实导出另做能力
PDF 升级后旧文档打不开 直接改了 documentType,没有做版本兼容 给旧记录补 schemaVersion: 1,按 imageDocument 继续展示
PDF 记录显示成功但文件不存在 生成文件和写入元数据不是原子流程 使用 generating/ready/failed 状态,失败时允许重试
删除 PDF 后还有残留缩略图 只删除了 Preferences 记录 删除流程补充 pdfUrithumbnailUri 文件清理

十四、上线前验收清单

  • 拍照预览页点击“转文档”后,预览关闭,PixelMap 释放,页面切到文档 Tab。
  • 相册预览页点击“转文档”后,相册弹窗关闭,页面切到文档 Tab。
  • 文档列表第一条是最新生成的文档。
  • 文档卡片显示标题、摘要、页数和生成时间。
  • 文档预览能展示来源照片、拍摄摘要、水印标题、地点、时间和备注。
  • 删除文档后列表立即刷新;如果删除的是当前预览文档,预览弹窗同步关闭。
  • 退出 App 再进入,文档列表能从 Preferences 恢复。
  • Preferences 中只保存元数据 JSON,不保存图片二进制或 Base64 大字段。
  • 旧缓存解析失败时,拍照主流程不崩溃。
  • 后续要导出图片/PDF 时,先补 schemaVersionimageUrithumbnailUri 等兼容字段。
  • PDF 升级版本要能同时打开旧 imageDocument 和新 pdfDocument
  • PDF 生成失败时不能出现假成功记录,应展示失败状态并允许重试。

总结

照片转文档这个功能的关键,不是把按钮接上,而是把“拍摄结果”和“归档结果”拆成两个稳定模型。CapturedPhoto 负责描述照片,CapturedDocument 负责描述文档,PhotoDocumentService 负责转换、缓存、持久化和克隆,Index.ets 负责把预览、相册、文档列表和弹窗串成可见的状态闭环。

当前实现是一个轻量的图片文档记录方案,适合先把本地归档体验跑通。等后续要支持真实文件、PDF、系统分享或媒体库写入时,再在现有模型上增加文件 URI、缩略图 URI、版本号和权限说明,整体结构也不会推倒重来。

Logo

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

更多推荐