第91篇 | HarmonyOS 空态与加载态:相册、视频、保险箱都不能空白
第91篇 | HarmonyOS 空态与加载态:相册、视频、保险箱都不能空白
一个成熟页面不能只设计“有数据”的样子。相册第一次打开、系统相册导入前、视频还没生成、保险箱还没有私密记录,这些状态如果只是白屏,用户会以为应用坏了。双镜记忆相机把加载态、空态和行动按钮都写进页面,帮助用户知道下一步该做什么。
这一篇从galleryLoading开始,串起相册、视频管理和保险箱三个页面。重点不在“写一句没有数据”,而在空态是否有下一步入口,加载态是否防止重复操作,失败态是否回到可理解文案。
本篇目标
- 区分 loading、empty、error 三类状态,避免都用空白页代替。
- 看懂
loadGalleryRecords如何保证加载开始和结束都能更新状态。 - 检查相册、视频、保险箱三处空态是否有行动入口。
- 把空态作为发布前必测项,而不是最后补文案。
对应源码位置
superImage/entry/src/main/ets/pages/Index.etssuperImage/entry/src/main/ets/services/GalleryRecordService.ets
加载态必须能结束
loadGalleryRecords里先判断是否正在加载,避免重复进入;随后把galleryLoading置为 true,读取完成或失败后都进入 finally 分支改回 false。这个结构比单纯在成功分支关 loading 更稳。
如果 finally 漏掉,用户遇到解析失败或存储异常时,页面可能一直显示“正在整理照片”。这类问题在真机上很难靠肉眼复现,所以需要从源码结构检查。
加载态和空态一起设计,页面才不会在无数据时变成白屏
private async loadGalleryRecords(): Promise<void> { if (this.galleryLoading) { this.galleryLoadQueued = true; return; } this.galleryLoading = true; try { do { this.galleryLoadQueued = false; const records = await GalleryRecordService.loadRecords(this.getAbilityContext()); await this.applyGalleryRecords(records); } while (this.galleryLoadQueued); } catch (error) { const err = error as BusinessError; this.galleryNoticeText = `读取相册失败 ${err.code ?? -1}`; } finally { this.galleryLoading = false; }相册空态要给出拍摄入口
相册页如果没有记录,页面不应该结束在“暂无照片”。项目里的空态会说明拍照完成后照片会进入这里,并提供“去相机拍摄”的入口。这样用户第一次安装后也能顺着路径走下去。
空态文案要和产品能力一致:这里不是系统相册浏览器,而是双镜记忆记录,所以空态要引导用户回到相机页生成第一条记忆。
相册页在 loading、分组列表、普通列表和空态之间明确分支
void this.importSystemAlbumPhotos('gallery'); }) } Scroll() { Column({ space: 14 }) { if (this.galleryLoading) { Text('正在加载...') .fontSize(13) .lineHeight(20) .fontColor($r('app.color.album_on_surface')) } if (this.getFeaturedGalleryRecord()) { this.buildGalleryMovieEntryCard() if (this.getGalleryGroups().length > 0) { Column({ space: 10 }) { Row() { Text('按时间地点') .fontSize(13) .fontColor($r('app.color.album_accent')) Blank() Text(`${this.getGalleryGroups().length}组`) .fontSize(11) .fontColor($r('app.color.album_on_surface_variant')) } .width('100%') ForEach(this.getGalleryGroups(), (group: GalleryDatePlaceGroup) => { this.buildGalleryAlbumGroupSection(group) }, (group: GalleryDatePlaceGroup) => group.key) } .width('100%') } else if (this.getGalleryListRecords().length > 0) { Column({ space: 10 }) { Text('全部照片') .fontSize(13) .fontColor($r('app.color.album_accent')) ForEach(this.getGalleryListRecords(), (record: GalleryMoment) => { this.buildGalleryRecordCard(record) }, (record: GalleryMoment) => record.id) } .width('100%') } } else {视频页空态要回到选照片
视频管理页的空态不能引导拍照就结束,因为用户可能已经有照片,只是还没有生成视频。这里更合适的下一步是“去选照片”,让用户从已有记录进入成片流程。
这就是空态设计的细节:同样是没有数据,相册页和视频页的下一步不同,不能复用一句统一文案。
视频页空态把下一步指向选照片,而不是简单提示为空
private buildGalleryVideoManagerPage() { Column({ space: 16 }) { Column({ space: 6 }) { Text('短片') .fontSize(30) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.album_on_surface')) .textAlign(TextAlign.Center) Text(this.getVideoManagerRecordsForRender().length > 0 ? `${this.getVideoManagerRecordsForRender().length}\u6761` : '') .fontSize(13) .lineHeight(20) .fontColor($r('app.color.album_on_surface_variant')) } .width('100%') .alignItems(HorizontalAlign.Center) this.buildGalleryMediaSwitch() this.buildGalleryCloudSyncCard() Scroll() { Column({ space: 14 }) { this.buildGalleryMovieEntryCard() if (this.getVideoManagerRecordsForRender().length === 0) { Column({ space: 12 }) { Text('\u8fd8\u6ca1\u6709\u89c6\u9891') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.album_on_surface')) Text('\u5148\u53bb\u7167\u7247\u91cc\u9009\u56fe') .fontSize(13) .lineHeight(20) .fontColor($r('app.color.album_on_surface_variant')) Button('\u53bb\u9009\u62e9') .height(42) .width('100%') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.album_on_primary')) .backgroundColor($r('app.color.album_primary_container')) .borderRadius(18) .onClick(() => { this.switchGalleryMediaTab('photo'); }) } .width('100%') .padding(18) .backgroundColor($r('app.color.album_panel')) .borderRadius(24) .alignItems(HorizontalAlign.Start) } else { ForEach(this.getVideoManagerRecordsForRender(), (record: GalleryVideoRecord) => { this.buildVideoManagerRecordCardV2(record) }, (record: GalleryVideoRecord) => record.id) } } .width('100%') } .layoutWeight(1) .scrollBar(BarState.Off) this.buildBottomNavigation()保险箱空态要尊重解锁状态
保险箱页还多一层隐私状态:没有私密记录、未解锁、有记录且已解锁,这三种状态不能混成一种。项目会先显示云同步卡片,再根据vaultUnlocked和记录数量决定导入、解锁或展示记录。
验收时不要只看默认未解锁状态。至少要测:没有私密记录、导入一张私密照片后未解锁、解锁后查看详情、重新上锁后状态恢复。
保险箱空态必须同时考虑隐私状态和记录数量
空态的标准不是“没有报错”,而是用户能理解当前为什么没有内容,并能找到下一步动作。
工程验收表
| 检查项 | 通过标准 |
|---|---|
| 加载态 | 读取成功、失败、空数据都会关闭 loading。 |
| 相册空态 | 首次进入能看到去相机拍摄的明确入口。 |
| 视频空态 | 没有成片时能进入选照片流程。 |
| 保险箱空态 | 未解锁、无私密记录、有私密记录三种状态不混淆。 |
真机复测口令
先清空相册记录,再关闭网络,随后分别进入相册页、视频管理页和保险箱页。预期结果是页面有明确空态或失败文案,按钮仍然给出下一步入口,loading 不会一直停留在屏幕上。
再做一次反向测试:导入一条记录后立即离开页面再返回,观察galleryLoading是否能正确结束,列表是否重新刷新。空态文章最怕只写“没有数据”,真正要验的是“没有数据时用户还能做什么”。
今日练习
- 手动制造一次相册读取失败,确认 finally 分支会关闭 loading。
- 清空视频任务列表,检查视频管理页是否有下一步提示。
- 在保险箱未解锁时进入页面,确认私密内容不会提前展示。
