HarmonyOS趣味相机实战第31篇:图片文档List、详情弹层与删除状态闭环

📅 2026/7/22 16:14:13
HarmonyOS趣味相机实战第31篇:图片文档List、详情弹层与删除状态闭环
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.etsCapturedDocument与WatermarkSnapshotservice/PhotoAlbumService.ets来源照片元数据entryability/EntryAbility.ets文档服务初始化环境与当前UI项目当前实现UI框架ArkUI列表List ListItem间距10 vp缩略图72 × 92 vp操作查看、删除详情状态CapturedDocument数据上限80份一、文档页只维护两类核心状态Stateprivatedocuments:CapturedDocument[][];StateprivatedocumentPreview:CapturedDocument|nullnull;documents是页面事实来源documentPreview表示当前选择。弹层是否出现可直接由documentPreview ! null决定不需要额外的showDocumentDialog。避免以下非法组合showDialogtrue 但 documentPreviewnull showDialogfalse 但仍持有旧对象 文档已从列表删除但详情仍打开用可空对象同时表达选择和可见性状态数量更少。二、进入页面前从服务刷新privateasyncrefreshDocuments():Promisevoid{this.documentsawaitPhotoDocumentService.listDocuments();}文档服务返回克隆数组页面不会直接修改内部缓存。页面出现、切换到文档 Tab 或完成转换后都应走同一刷新或使用服务返回的新数组避免一部分流程读取旧列表。初始化失败时要区分“确实为空”和“读取失败”。生产实现可返回interfaceDocumentLoadState{status:loading|ready|empty|error;documents:CapturedDocument[];}空状态不能掩盖数据读取异常。三、顶部计数来自同一数组Row(){Text(照片文档)Blank()Text(${this.documents.length}份)}计数与 List 都消费documents删除后数组更新数字会同步变化。不要另维护documentCount否则转换、恢复和删除都要同时更新两个状态。文案使用“份”而不是“页”因为一份文档内部还有pageCount。四、空状态说明入口而不是描述功能if(this.documents.length0){Column({space:8}){Text(还没有生成文档)Text(在拍照预览或相册里点击转文档会生成带水印信息的图片文档记录。).textAlign(TextAlign.Center).maxLines(3)}}空状态解释“从哪里产生数据”能帮助用户继续操作。更完整的闭环应增加“去拍照”或“去相册”主操作直接切换 Tab而不是让用户自己寻找入口。空状态与错误状态应有不同文案空状态引导产生第一份文档。错误状态提供重试。恢复中显示稳定加载占位。五、List适合纵向文档卡片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必须由业务标识生成当前键privatedocumentKey(document:CapturedDocument):string{return${document.id}-${document.createdAt};}若createdAt创建后不变这个 Key 稳定。更简单的实现是只用唯一document.idreturndocument.id;不要使用数组下标。删除第二项后第三项会移动到索引1ArkUI可能复用旧节点若卡片内部有加载或选择状态就会串项。只有希望字段变化时强制重建节点才把版本字段加入 Key。七、卡片布局分成缩略图、信息和操作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避免标题变长后挤压按钮。缩略图也使用稳定尺寸列表滚动时不会因内容加载发生高度跳动。八、长文案必须逐层约束Text(document.title).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(document.summary).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis})标题一行、摘要两行、元信息一行卡片高度可预测。仅给父容器固定高度而不设置 Text 溢出长地点或备注可能覆盖操作列。大字体测试时如果三行内容仍放不下应允许卡片适度增高而不是把字号压到不可读。九、缩略图是文档语义而非真实照片当前缩略图用 ArkUI 组合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比例稳定。十、卡片与查看按钮指向同一动作中间信息区和“查看”按钮都调用privateopenDocumentPreview(document:CapturedDocument):void{this.documentPreviewdocument;}多入口共享方法避免一个入口打开旧弹层、另一个入口设置不同状态。缩略图点击也可调用同一方法扩大可发现区域。但不要让整个卡片都可点击后又在内部放删除按钮而不处理事件冲突明确哪些区域打开详情哪些区域执行危险操作。十一、详情弹层读取快照字段Text(this.documentPreview?.sourcePhotoTitle??来源照片)Text(this.documentPreview?.captureSummary??)Text(this.documentPreview?.watermark?.locationText??未记录地点)Text(this.documentPreview?.watermark?.note??未记录备注)文档转换时复制了来源快照所以详情不必再次查询照片服务。即使来源照片被删除文档仍能展示当时标题、时间和水印。可选链与默认值保证旧版本缺字段时不崩溃但默认文案要区分“未记录”和“读取失败”。十二、弹层存在条件保持唯一页面根 Stackif(this.documentPreview){this.DocumentPreviewDialog();}关闭只做privatecloseDocumentPreview():void{this.documentPreviewnull;}这是清晰的两状态模型null为关闭对象为打开。弹层内部标题、页数和内容都从同一对象读取不要同时绑定列表索引。十三、删除必须同步列表与详情privateasyncdeleteDocument(documentId:string):Promisevoid{this.documentsawaitPhotoDocumentService.deleteDocument(documentId);if(this.documentPreviewthis.documentPreview.iddocumentId){this.documentPreviewnull;}this.captureStatusText已删除文档记录;}服务成功返回新列表后再判断详情是否指向被删除记录。这样不会出现列表已无该项、弹层仍展示并可再次删除的幽灵状态。十四、删除过程需要防重入快速点击两次会发起两个异步删除。加入目标级状态StateprivatedeletingDocumentId:string;privateasyncdeleteDocument(id:string):Promisevoid{if(this.deletingDocumentId.length0)return;this.deletingDocumentIdid;try{constnextawaitPhotoDocumentService.deleteDocument(id);this.documentsnext;if(this.documentPreview?.idid){this.documentPreviewnull;}}catch(error){this.captureStatusText删除失败请重试;}finally{this.deletingDocumentId;}}对应按钮.enabled(this.deletingDocumentId.length 0)避免重复提交。十五、失败时不要提前关闭详情如果在 await 之前把documentPreviewnullPreferences flush失败后文档仍存在但用户已被弹层踢出且看到成功提示。正确顺序是锁定操作 - 服务删除并flush - 返回新数组 - 更新列表 - 关闭对应详情 - 显示成功失败则保留详情和列表显示可重试提示。当前服务内部捕获 flush 错误但不向上抛出时页面无法判断是否真正落盘。服务可以返回persisted状态或抛出可识别的领域错误由页面根据实际结果展示对应反馈。十六、详情对象可能成为旧副本当前详情直接持有CapturedDocument克隆。若未来支持重命名或更新状态列表更新后详情仍是旧对象。可只保存 IDStateprivatedocumentPreviewId:string;privatepreviewDocument():CapturedDocument|undefined{returnthis.documents.find((item:CapturedDocument)item.idthis.documentPreviewId);}纯只读快照可以持有对象可编辑详情推荐 ID 派生查询确保只有一份事实来源。十七、删除确认要说明后果危险操作不应只靠粉色按钮区分。确认信息可以包含文档标题和来源状态但不展示过长或敏感水印内容删除“水印照片 3 文档” 该操作只删除本地文档记录不影响来源照片。如果后续实现级联关系提示必须同步变化。按钮文字可用“删除文档”比泛化“确定”更清楚。十八、列表数据上限与稳定排序文档服务把新记录放在首部再slice(0,80)constnextDocuments[document].concat(PhotoDocumentService.cachedDocuments);PhotoDocumentService.cachedDocumentsnextDocuments.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和圆角属性是否一致严格类型可空文档对象是否经过条件收窄PreferencesValueType读取与flush返回行为生命周期页面出现时的异步刷新是否仍受支持测试框架Hypium断言API与工程依赖版本领域服务不应依赖 ArkUI 组件对象。PhotoDocumentService只接收和返回CapturedDocument[]使页面组件升级时持久化代码不需要同步改写。二十四、PhotoDocumentService落盘闭环转换成功后服务把新文档放到首位并限制80条staticasyncconvertPhoto(photo:CapturedPhoto):PromiseCapturedDocument[]{awaitPhotoDocumentService.waitForInit();constdocument:CapturedDocumentPhotoDocumentService.createDocument(photo);constnextDocuments:CapturedDocument[][document].concat(PhotoDocumentService.cachedDocuments);PhotoDocumentService.cachedDocumentsnextDocuments.slice(0,80);awaitPhotoDocumentService.flushDocuments();returnPhotoDocumentService.cloneDocuments(PhotoDocumentService.cachedDocuments);}删除同样先按 ID 过滤再统一写回staticasyncdeleteDocument(documentId:string):PromiseCapturedDocument[]{awaitPhotoDocumentService.waitForInit();PhotoDocumentService.cachedDocumentsPhotoDocumentService.cachedDocuments.filter((document:CapturedDocument)document.id!documentId);awaitPhotoDocumentService.flushDocuments();returnPhotoDocumentService.cloneDocuments(PhotoDocumentService.cachedDocuments);}页面使用服务返回值替换整个数组而不是直接splice当前状态。这样内存缓存、Preferences和ArkUI列表在成功路径上使用同一份排序结果。二十五、可执行Hypium回归用例服务层可以对创建和删除纯逻辑做固定样本测试describe(PhotoDocumentService,(){it(creates an isolated watermark snapshot,0,(){constsource:CapturedPhotocreatePhotoFixture();constdocument:CapturedDocumentPhotoDocumentService.createDocument(source);source.watermark!.notechanged later;expect(document.watermark!.note).not().assertEqual(changed later);expect(document.photoId).assertEqual(source.id);expect(document.status).assertEqual(ready);});});页面回归重点验证状态收敛可以把删除服务替换为 fakeit(closes preview after deleting selected document,0,async(){page.documents[docA,docB];page.documentPreviewdocA;awaitpage.deleteDocumentForTest(docA.id);expect(page.documents.length).assertEqual(1);expect(page.documents[0].id).assertEqual(docB.id);expect(page.documentPreviewnull).assertTrue();});建议在 DevEco Studio 中分别运行本地单元测试与真机 UI 测试并保存以下断言结果新建文档 - 列表首项ID与服务返回一致 打开详情 - 标题、水印和来源时间来自同一快照 删除当前项 - 列表减少、计数更新、弹层关闭 删除失败 - 列表和弹层保持、提示可重试 进程重启 - Preferences恢复顺序与字段默认值正确二十六、端到端验收路径一次完整回归不应只从文档页开始进入拍摄页并获得真实照片 - 在结果页选择“转文档” - activeTab切到文档页 - 列表数量增加且新文档位于首项 - 打开详情核对来源标题、水印和时间 - 关闭详情后重新打开同一项 - 删除并确认列表、计数、详情同步收敛 - 重启应用确认删除结果已持久化这条链路同时覆盖 CameraKit结果、领域快照、Preferences写入和ArkUI状态更新。只有重启后结果仍一致才能证明页面成功提示与真实落盘形成闭环。总结图片文档页的稳定性来自状态关系而不是组件数量。documents负责列表事实当前详情只表达选择计数、List与空状态读取同一数组稳定 Key 保证删除后节点不串项删除成功后一次性更新列表并关闭匹配详情失败则保留上下文供用户重试。在此基础上用固定比例缩略图、长文本约束、明确确认文案和大字体检查完善交互文档页就能从简单记录列表变成可持续扩展的本地归档入口。