HarmonyOS趣味相机实战第31篇:图片文档List、详情弹层与删除状态闭环
摘要
照片转成文档后,用户需要在独立页面查看数量、浏览摘要、打开详情并删除记录。这个页面看起来只是 List + Dialog,实际包含多个容易分叉的状态:列表数组、当前详情对象、空状态、稳定 Key、来源照片快照和异步删除结果。若删除列表项后没有同步关闭详情,用户仍能操作已经不存在的文档;若 Key 使用数组下标,删除中间项后卡片又可能复用错误内容。
本文基于 D:/APP/1quweixiangji 的 Index.ets 与 PhotoDocumentService.ets,复盘 DocumentPanel、DocumentCard、DocumentThumbnail 和 DocumentPreviewDialog 的实现。重点是建立"列表是事实来源、详情是派生选择、删除一次性收敛两者"的状态闭环,并覆盖空状态、长文本、大字体和异步失败。
源码定位
| 文件 | 作用 |
|---|---|
pages/Index.ets |
文档列表、卡片、详情与交互 |
service/PhotoDocumentService.ets |
创建、读取和删除文档记录 |
model/DecorationModels.ets |
CapturedDocument与WatermarkSnapshot |
service/PhotoAlbumService.ets |
来源照片元数据 |
entryability/EntryAbility.ets |
文档服务初始化 |
环境与当前UI
| 项目 | 当前实现 |
|---|---|
| UI框架 | ArkUI |
| 列表 | List + ListItem |
| 间距 | 10 vp |
| 缩略图 | 72 × 92 vp |
| 操作 | 查看、删除 |
| 详情状态 | `CapturedDocument |
| 数据上限 | 80份 |

一、文档页只维护两类核心状态
ts
@State private documents: CapturedDocument[] = [];
@State private documentPreview: CapturedDocument | null = null;
documents 是页面事实来源,documentPreview 表示当前选择。弹层是否出现可直接由 documentPreview !== null 决定,不需要额外的 showDocumentDialog。
避免以下非法组合:
text
showDialog=true 但 documentPreview=null
showDialog=false 但仍持有旧对象
文档已从列表删除但详情仍打开
用可空对象同时表达选择和可见性,状态数量更少。
二、进入页面前从服务刷新
ts
private async refreshDocuments(): Promise<void> {
this.documents =
await PhotoDocumentService.listDocuments();
}
文档服务返回克隆数组,页面不会直接修改内部缓存。页面出现、切换到文档 Tab 或完成转换后,都应走同一刷新或使用服务返回的新数组,避免一部分流程读取旧列表。
初始化失败时要区分"确实为空"和"读取失败"。生产实现可返回:
ts
interface DocumentLoadState {
status: 'loading' | 'ready' | 'empty' | 'error';
documents: CapturedDocument[];
}
空状态不能掩盖数据读取异常。
三、顶部计数来自同一数组
ts
Row() {
Text('照片文档')
Blank()
Text(`${this.documents.length} 份`)
}
计数与 List 都消费 documents,删除后数组更新,数字会同步变化。不要另维护 documentCount,否则转换、恢复和删除都要同时更新两个状态。
文案使用"份"而不是"页",因为一份文档内部还有 pageCount。
四、空状态说明入口而不是描述功能
ts
if (this.documents.length === 0) {
Column({ space: 8 }) {
Text('还没有生成文档')
Text('在拍照预览或相册里点击转文档,' +
'会生成带水印信息的图片文档记录。')
.textAlign(TextAlign.Center)
.maxLines(3)
}
}
空状态解释"从哪里产生数据",能帮助用户继续操作。更完整的闭环应增加"去拍照"或"去相册"主操作,直接切换 Tab,而不是让用户自己寻找入口。
空状态与错误状态应有不同文案:
- 空状态:引导产生第一份文档。
- 错误状态:提供重试。
- 恢复中:显示稳定加载占位。
五、List适合纵向文档卡片
ts
List({ space: 10 }) {
ForEach(this.documents,
(document: CapturedDocument) => {
ListItem() {
this.DocumentCard(document)
}
},
(document: CapturedDocument) =>
this.documentKey(document)
)
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
文档包含标题、摘要、页数、时间和操作,横向信息较多,单列 List 比双列 Grid 更易扫描。layoutWeight(1) 让列表填充标题栏下剩余空间,滚动只发生在 List 内。
六、Key必须由业务标识生成
当前键:
ts
private documentKey(document: CapturedDocument): string {
return `${document.id}-${document.createdAt}`;
}
若 createdAt 创建后不变,这个 Key 稳定。更简单的实现是只用唯一 document.id:
ts
return document.id;
不要使用数组下标。删除第二项后,第三项会移动到索引1,ArkUI可能复用旧节点,若卡片内部有加载或选择状态就会串项。
只有希望字段变化时强制重建节点,才把版本字段加入 Key。
七、卡片布局分成缩略图、信息和操作
ts
Row({ space: 12 }) {
this.DocumentThumbnail(document, 72, 92)
Column({ space: 6 }) {
Text(document.title)
Text(document.summary)
Text(`${document.pageCount} 页图片文档 · ${document.createdAt}`)
}
.layoutWeight(1)
Column({ space: 8 }) {
Button('查看')
Button('删除')
}
.width(64)
}
中间列使用 layoutWeight(1) 吸收剩余宽度,右侧操作列固定 64 vp,避免标题变长后挤压按钮。缩略图也使用稳定尺寸,列表滚动时不会因内容加载发生高度跳动。
八、长文案必须逐层约束
ts
Text(document.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(document.summary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
标题一行、摘要两行、元信息一行,卡片高度可预测。仅给父容器固定高度而不设置 Text 溢出,长地点或备注可能覆盖操作列。
大字体测试时,如果三行内容仍放不下,应允许卡片适度增高,而不是把字号压到不可读。
九、缩略图是文档语义而非真实照片
当前缩略图用 ArkUI 组合:
ts
Column() {
Text('文档')
.height(26)
.backgroundColor('#0B8168')
Blank()
Text(document.watermark?.title ?? '照片')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(document.sourceCreatedAt)
Blank()
}
.width(widthValue)
.height(heightValue)
.border({ width: 1, color: '#D8E5DF' })
.clip(true)
这种缩略图不依赖图片资源,进程重启后仍可展示。它清楚表达"这是一份文档记录",不会误导为可查看原图。
若未来生成真实文档页缩略图,也要保留加载占位与错误状态,保持72×92比例稳定。
十、卡片与查看按钮指向同一动作
中间信息区和"查看"按钮都调用:
ts
private openDocumentPreview(
document: CapturedDocument
): void {
this.documentPreview = document;
}
多入口共享方法,避免一个入口打开旧弹层、另一个入口设置不同状态。缩略图点击也可调用同一方法,扩大可发现区域。
但不要让整个卡片都可点击后又在内部放删除按钮而不处理事件冲突;明确哪些区域打开详情,哪些区域执行危险操作。
十一、详情弹层读取快照字段
ts
Text(this.documentPreview?.sourcePhotoTitle ?? '来源照片')
Text(this.documentPreview?.captureSummary ?? '')
Text(this.documentPreview?.watermark?.locationText ?? '未记录地点')
Text(this.documentPreview?.watermark?.note ?? '未记录备注')
文档转换时复制了来源快照,所以详情不必再次查询照片服务。即使来源照片被删除,文档仍能展示当时标题、时间和水印。
可选链与默认值保证旧版本缺字段时不崩溃,但默认文案要区分"未记录"和"读取失败"。
十二、弹层存在条件保持唯一
页面根 Stack:
ts
if (this.documentPreview) {
this.DocumentPreviewDialog();
}
关闭只做:
ts
private closeDocumentPreview(): void {
this.documentPreview = null;
}
这是清晰的两状态模型:null为关闭,对象为打开。弹层内部标题、页数和内容都从同一对象读取,不要同时绑定列表索引。
十三、删除必须同步列表与详情
ts
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 = '已删除文档记录';
}
服务成功返回新列表后,再判断详情是否指向被删除记录。这样不会出现列表已无该项、弹层仍展示并可再次删除的幽灵状态。
十四、删除过程需要防重入
快速点击两次会发起两个异步删除。加入目标级状态:
ts
@State private deletingDocumentId: string = '';
private async deleteDocument(id: string): Promise<void> {
if (this.deletingDocumentId.length > 0) return;
this.deletingDocumentId = id;
try {
const next = await PhotoDocumentService.deleteDocument(id);
this.documents = next;
if (this.documentPreview?.id === id) {
this.documentPreview = null;
}
} catch (error) {
this.captureStatusText = '删除失败,请重试';
} finally {
this.deletingDocumentId = '';
}
}
对应按钮 .enabled(this.deletingDocumentId.length === 0),避免重复提交。
十五、失败时不要提前关闭详情
如果在 await 之前把 documentPreview=null,Preferences flush失败后文档仍存在,但用户已被弹层踢出且看到成功提示。正确顺序是:
text
锁定操作
-> 服务删除并flush
-> 返回新数组
-> 更新列表
-> 关闭对应详情
-> 显示成功
失败则保留详情和列表,显示可重试提示。
当前服务内部捕获 flush 错误但不向上抛出时,页面无法判断是否真正落盘。服务可以返回 persisted 状态或抛出可识别的领域错误,由页面根据实际结果展示对应反馈。
十六、详情对象可能成为旧副本
当前详情直接持有 CapturedDocument 克隆。若未来支持重命名或更新状态,列表更新后详情仍是旧对象。可只保存 ID:
ts
@State private documentPreviewId: string = '';
private previewDocument(): CapturedDocument | undefined {
return this.documents.find(
(item: CapturedDocument) =>
item.id === this.documentPreviewId
);
}
纯只读快照可以持有对象;可编辑详情推荐 ID + 派生查询,确保只有一份事实来源。
十七、删除确认要说明后果
危险操作不应只靠粉色按钮区分。确认信息可以包含文档标题和来源状态,但不展示过长或敏感水印内容:
text
删除"水印照片 3 文档"?
该操作只删除本地文档记录,不影响来源照片。
如果后续实现级联关系,提示必须同步变化。按钮文字可用"删除文档",比泛化"确定"更清楚。
十八、列表数据上限与稳定排序
文档服务把新记录放在首部,再 slice(0,80):
ts
const nextDocuments =
[document].concat(PhotoDocumentService.cachedDocuments);
PhotoDocumentService.cachedDocuments =
nextDocuments.slice(0, 80);
页面无需构建时 sort。若要按真实时间排序,应保存数值时间戳,而不是只用 MM-DD HH:mm 显示字符串。
自动淘汰第81份文档也属于删除,若未来有文件资产,需要同步清理文件。
十九、大字体与窄屏适配
重点验证:
- 标题放大后不挤掉右侧按钮。
- 摘要两行后卡片高度稳定。
- 72×92缩略图内文字不越界。
- 详情弹层正文可滚动,不被底部按钮遮挡。
- "查看""删除"按钮触控尺寸足够。
- 关闭按钮与标题之间有弹性空白。
窄屏可将右侧操作改为菜单或让卡片点击打开详情、详情内执行删除,减少横向拥挤。
二十、测试矩阵
| 场景 | 列表 | 详情 | 计数 |
|---|---|---|---|
| 无文档 | 空状态 | 关闭 | 0份 |
| 创建第一份 | 一张卡片 | 可打开 | 1份 |
| 删除非当前项 | 少一项 | 保持 | n-1 |
| 删除当前详情 | 少一项 | 自动关闭 | n-1 |
| 删除失败 | 不变 | 保持可重试 | 不变 |
| 重复点击删除 | 只提交一次 | 不闪烁 | 正确 |
| 旧数据缺水印 | 正常占位 | 默认文案 | 正确 |
| 删除中间项 | Key不串卡 | 正确对象 | n-1 |
| 连续创建81份 | 保留最新80 | 无旧详情 | 80份 |
二十一、常见问题排查
| 现象 | 原因 | 修复方向 |
|---|---|---|
| 删除后详情仍显示 | 只更新数组 | 同步清空匹配详情 |
| 删除中间项后卡片错位 | Key使用索引 | 使用document.id |
| 空状态掩盖读取错误 | 只看数组长度 | 增加load status |
| 长摘要覆盖按钮 | 无maxLines/layoutWeight | 约束信息列 |
| 删除失败仍提示成功 | 服务吞掉flush错误 | 返回持久化结果 |
| 编辑后详情是旧值 | 详情持有对象副本 | 保存ID并派生查询 |
二十二、发布前验收清单
- 列表数组是计数和渲染的单一事实来源。
- 空状态提供可执行入口,错误状态可重试。
- List使用稳定业务ID作为Key。
- 缩略图、信息列和操作列尺寸稳定。
- 标题摘要和元信息都有溢出策略。
- 打开详情的多个入口调用同一方法。
- 删除当前项后同步关闭详情。
- 异步删除有防重入和失败保留。
- 服务落盘失败不会误报成功。
- 旧数据缺字段时仍可展示。
- 大字体与窄屏下无重叠。
二十三、SDK兼容性与组件边界
本文工程以 HarmonyOS 6.0.2(22) 为目标版本。List、ListItem、ForEach、TextOverflow 和声明式 @Builder 都属于 ArkUI 页面能力,但不同 SDK 的组件属性、事件签名和严格类型检查可能变化。迁移工程时需要核对:
| 边界 | 检查内容 |
|---|---|
| ArkUI组件 | List滚动、layoutWeight和圆角属性是否一致 |
| 严格类型 | 可空文档对象是否经过条件收窄 |
| Preferences | ValueType读取与flush返回行为 |
| 生命周期 | 页面出现时的异步刷新是否仍受支持 |
| 测试框架 | Hypium断言API与工程依赖版本 |
领域服务不应依赖 ArkUI 组件对象。PhotoDocumentService 只接收和返回 CapturedDocument[],使页面组件升级时持久化代码不需要同步改写。
二十四、PhotoDocumentService落盘闭环
转换成功后,服务把新文档放到首位并限制80条:
ts
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);
}
删除同样先按 ID 过滤,再统一写回:
ts
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);
}
页面使用服务返回值替换整个数组,而不是直接 splice 当前状态。这样内存缓存、Preferences和ArkUI列表在成功路径上使用同一份排序结果。
二十五、可执行Hypium回归用例
服务层可以对创建和删除纯逻辑做固定样本测试:
ts
describe('PhotoDocumentService', () => {
it('creates an isolated watermark snapshot', 0, () => {
const source: CapturedPhoto = createPhotoFixture();
const document: CapturedDocument =
PhotoDocumentService.createDocument(source);
source.watermark!.note = 'changed later';
expect(document.watermark!.note)
.not().assertEqual('changed later');
expect(document.photoId).assertEqual(source.id);
expect(document.status).assertEqual('ready');
});
});
页面回归重点验证状态收敛,可以把删除服务替换为 fake:
ts
it('closes preview after deleting selected document', 0,
async () => {
page.documents = [docA, docB];
page.documentPreview = docA;
await page.deleteDocumentForTest(docA.id);
expect(page.documents.length).assertEqual(1);
expect(page.documents[0].id).assertEqual(docB.id);
expect(page.documentPreview === null).assertTrue();
});
建议在 DevEco Studio 中分别运行本地单元测试与真机 UI 测试,并保存以下断言结果:
text
新建文档 -> 列表首项ID与服务返回一致
打开详情 -> 标题、水印和来源时间来自同一快照
删除当前项 -> 列表减少、计数更新、弹层关闭
删除失败 -> 列表和弹层保持、提示可重试
进程重启 -> Preferences恢复顺序与字段默认值正确
二十六、端到端验收路径
一次完整回归不应只从文档页开始:
text
进入拍摄页并获得真实照片
-> 在结果页选择"转文档"
-> activeTab切到文档页
-> 列表数量增加且新文档位于首项
-> 打开详情核对来源标题、水印和时间
-> 关闭详情后重新打开同一项
-> 删除并确认列表、计数、详情同步收敛
-> 重启应用确认删除结果已持久化
这条链路同时覆盖 CameraKit结果、领域快照、Preferences写入和ArkUI状态更新。只有重启后结果仍一致,才能证明页面成功提示与真实落盘形成闭环。
总结
图片文档页的稳定性来自状态关系,而不是组件数量。documents 负责列表事实,当前详情只表达选择;计数、List与空状态读取同一数组;稳定 Key 保证删除后节点不串项;删除成功后一次性更新列表并关闭匹配详情,失败则保留上下文供用户重试。
在此基础上,用固定比例缩略图、长文本约束、明确确认文案和大字体检查完善交互,文档页就能从简单记录列表变成可持续扩展的本地归档入口。