HarmonyOS趣味相机实战第3篇:照片转文档、归档缓存与水印信息继承
HarmonyOS趣味相机实战第3篇:照片转文档、归档缓存与水印信息继承
摘要
前两篇分别拆了相机权限、真实拍摄、水印快照和本地相册。本文继续沿着 D:/APP/1quweixiangji 这个 HarmonyOS 趣味相机项目,复盘“照片转文档”这一段工程链路:用户拍照后可以直接把预览照片生成文档,也可以从本地相册里选择一张已保存照片再生成文档;文档记录需要继承原照片标题、拍摄时间、拍摄摘要和水印快照,并能在页面中列表展示、预览和删除。
这个功能看起来像一个按钮,但实际要处理四类问题:
- 文档记录应该保存哪些字段,不能只保存一个标题。
- 文档服务如何初始化、读取缓存、写入 Preferences,并避免外部直接修改内部数组。
- 预览照片和相册照片进入文档流时,页面状态要如何切换。
- 后续如果从“元数据文档”升级到“真实图片/PDF 文件”,当前结构要怎么留扩展口。
本文代码来自一个 ArkTS Stage 模型项目,重点文件是 PhotoDocumentService.ets、PhotoAlbumService.ets、DecorationModels.ets 和 Index.ets。
工程背景与源码定位
| 文件 | 作用 |
|---|---|
entry/src/main/ets/entryability/EntryAbility.ets |
Ability 创建时初始化相册服务和文档服务 |
entry/src/main/ets/model/DecorationModels.ets |
定义 CapturedPhoto、CapturedDocument、WatermarkSnapshot 等数据结构 |
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 文件 |
| 权限边界 | 相机权限已声明 | 文档元数据归档不需要媒体库写入权限 |

本地构建命令可以沿用项目 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,因为“照片”和“文档”在产品语义上不同:
| 对象 | 关注点 | 典型字段 |
|---|---|---|
| 照片 | 拍摄结果和相册状态 | title、filterName、captureSource、status、watermark |
| 文档 | 归档结果和来源追溯 | photoId、sourcePhotoTitle、pageCount、documentType、summary |
项目在 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;
}
这里最关键的是 photoId、sourcePhotoTitle 和 sourceCreatedAt。它们让文档可以追溯来源照片,即使以后相册里删除了照片,文档列表仍能展示“来自哪次拍摄”。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));
}
}
}
这个设计有两个好处:
- 页面只关心
listDocuments()、convertPhoto()、deleteDocument()等业务方法,不直接持有 Preferences 实例。 - 后续如果增加迁移逻辑,可以放在服务初始化阶段完成,不必改动所有页面入口。
需要注意的是,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);
}
这里有三个明确策略:
await waitForInit():避免还没读完旧数据就写入新数据。[document].concat(cachedDocuments):新生成的文档排在列表顶部。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 生成时建议采用“两阶段提交”:
- 先创建一条
status: 'generating'的文档记录,并写入 Preferences。 - 在应用沙箱目录生成图片文件或 PDF 文件。
- 文件生成成功后,把
pdfUri、thumbnailUri、fileSize和status: 'ready'写回记录。 - 如果生成失败,把
status改为failed,保留来源照片、水印和错误状态,允许用户重试。
这样做的原因是,PDF 文件生成属于更容易失败的 I/O 流程:空间不足、文件写入失败、用户中途退出、权限策略变化都有可能发生。如果只在全部完成后才写 Preferences,失败时用户看不到任何反馈;如果先把记录写成 ready,失败时又会出现“列表有文档但打不开”的假成功状态。
删除逻辑也要升级成“元数据 + 文件”双删除:
| 删除目标 | 删除时机 | 失败处理 |
|---|---|---|
| Preferences 记录 | 用户确认删除后 | 记录删除成功即可刷新列表 |
pdfUri 文件 |
记录删除后尝试删除 | 文件删除失败时记录日志,下次清理任务兜底 |
thumbnailUri 文件 |
跟随 PDF 文件删除 | 失败不影响主流程 |
真实 PDF 版本还要补两个产品边界:
- 如果 PDF 只保存在应用沙箱内,卸载 App 后文件会随应用清理。
- 如果要写入系统图库、文件管理或对外分享,需要补对应权限、隐私说明和用户授权提示。
最终迁移目标不是一次性把旧记录全部改写成 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 记录 | 删除流程补充 pdfUri、thumbnailUri 文件清理 |
十四、上线前验收清单
- 拍照预览页点击“转文档”后,预览关闭,PixelMap 释放,页面切到文档 Tab。
- 相册预览页点击“转文档”后,相册弹窗关闭,页面切到文档 Tab。
- 文档列表第一条是最新生成的文档。
- 文档卡片显示标题、摘要、页数和生成时间。
- 文档预览能展示来源照片、拍摄摘要、水印标题、地点、时间和备注。
- 删除文档后列表立即刷新;如果删除的是当前预览文档,预览弹窗同步关闭。
- 退出 App 再进入,文档列表能从 Preferences 恢复。
- Preferences 中只保存元数据 JSON,不保存图片二进制或 Base64 大字段。
- 旧缓存解析失败时,拍照主流程不崩溃。
- 后续要导出图片/PDF 时,先补
schemaVersion、imageUri、thumbnailUri等兼容字段。 - PDF 升级版本要能同时打开旧
imageDocument和新pdfDocument。 - PDF 生成失败时不能出现假成功记录,应展示失败状态并允许重试。
总结
照片转文档这个功能的关键,不是把按钮接上,而是把“拍摄结果”和“归档结果”拆成两个稳定模型。CapturedPhoto 负责描述照片,CapturedDocument 负责描述文档,PhotoDocumentService 负责转换、缓存、持久化和克隆,Index.ets 负责把预览、相册、文档列表和弹窗串成可见的状态闭环。
当前实现是一个轻量的图片文档记录方案,适合先把本地归档体验跑通。等后续要支持真实文件、PDF、系统分享或媒体库写入时,再在现有模型上增加文件 URI、缩略图 URI、版本号和权限说明,整体结构也不会推倒重来。
更多推荐


所有评论(0)