HarmonyOS趣味相机实战第27篇:ArkUI相册Grid筛选、稳定Key与空状态闭环

摘要

相册列表的难点不在“把数组画出来”,而在状态变化后仍然可预测:切换“仅水印照片”时计数与卡片要同步;删除元素不能复用错卡片;筛选无结果和相册本身为空要给出不同引导;从详情返回后列表不能跳动;数据增长后不能在每个组件中重复过滤。

本文基于 D:/APP/1quweixiangjiIndex.ets,围绕 visibleAlbum()albumFilterWatermarkOnly、ArkUI GridForEach 键函数、空状态和照片卡片操作展开。我们会把原始状态、派生数据、展示计数和交互出口串成一条闭环,并给出适合相册列表的测试与性能检查方法。

工程环境

项目 当前实现
开发语言 ArkTS
UI 框架 ArkUI
列表容器 Grid + GridItem
列数 1fr 1fr
列/行间距 12 vp
原始数据 album: CapturedPhoto[]
筛选状态 albumFilterWatermarkOnly
唯一标识 photo.id

HarmonyOS趣味相机相册列表

一、先区分原始状态和派生状态

相册原始状态只有两类:

@State private album: CapturedPhoto[] = [];
@State private albumFilterWatermarkOnly: boolean = false;

“当前可见照片”可以由这两项计算得到,不必再维护第三个可变数组:

private visibleAlbum(): CapturedPhoto[] {
  if (!this.albumFilterWatermarkOnly) {
    return this.album;
  }
  return this.album.filter((photo: CapturedPhoto) => {
    return photo.watermark?.enabled === true;
  });
}

若同时维护 filteredAlbum,保存、删除、恢复数据时就必须更新两个数组,遗漏一个分支就会让计数和卡片不一致。派生函数让原始数据保持单一事实来源。

二、可选链表达旧数据兼容

历史照片可能没有 watermark 字段。筛选条件使用:

photo.watermark?.enabled === true

它明确表示只有水印对象存在且启用时才进入筛选结果。不要写:

photo.watermark!.enabled

非空断言只消除了编译提示,并不能修复旧数据。也不要用 photo.watermark !== undefined 代替 enabled 判断,因为关闭水印拍摄的照片仍可能保存一份 enabled: false 的快照。

三、筛选动作只修改一个布尔状态

private toggleAlbumFilter(): void {
  this.albumFilterWatermarkOnly =
    !this.albumFilterWatermarkOnly;
  this.captureStatusText = this.albumFilterWatermarkOnly ?
    '相册仅显示水印照片' : '相册已显示全部照片';
}

按钮文字展示“点击后可执行的动作”还是“当前状态”,需要团队统一。项目当前用:

Button(this.albumFilterWatermarkOnly ? '全部' : '筛选')

即当前在水印模式时显示“全部”,告诉用户可切回全部。为了提高可访问性,可以增加状态描述或使用带选中态的筛选图标,但不能只依赖颜色变化传达状态。

四、计数必须来自同一份派生结果

顶部显示:

Text(`今日拍摄 ${this.visibleAlbum().length}`)

Grid 也遍历 visibleAlbum(),因此筛选后数字与卡片数量天然一致。常见错误是计数读取 album.length,列表却读取过滤数组,导致页面显示“今日拍摄 12 张”,屏幕上只有 3 张。

文案可以更精确:

private albumCountText(visibleCount: number): string {
  if (this.albumFilterWatermarkOnly) {
    return `带水印照片 ${visibleCount}`;
  }
  return `本地照片 ${visibleCount}`;
}

createdAt 只保存月日时分,严格意义上无法可靠判断“今日”,文案应改为“本地照片”或持久化时间戳后再做当天筛选。

五、避免一次构建中重复过滤

当前 Builder 多次调用 visibleAlbum():计数一次、空状态一次、Grid 一次。数据上限只有 60 条,成本很低;若未来扩展到数千张,应在一次构建逻辑中复用结果,或把派生值放在受控状态管理层。

不建议仅为了缓存而新增容易过期的 @State filteredAlbum。更稳妥的是在数据或筛选变化时显式刷新只读视图:

private refreshAlbumView(): void {
  this.albumView = this.albumFilterWatermarkOnly ?
    this.album.filter((photo: CapturedPhoto) =>
      photo.watermark?.enabled === true) :
    this.album.slice();
}

如果数组很小,保持纯派生函数反而更简单。优化必须由实际耗时和数据规模驱动。

六、Grid列定义要稳定

项目使用双列:

Grid() {
  // items
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.width('100%')

1fr 1fr 会让两列均分剩余宽度,配合外层左右 18 vp padding 和 12 vp gap。照片卡片内部应使用明确的宽高比,例如:

Stack() {
  // thumbnail and overlays
}
.width('100%')
.aspectRatio(0.82)

固定宽高比能避免标题长度、加载状态或角标出现后改变卡片高度,引发列表抖动。

七、ForEach必须提供业务稳定Key

项目写法:

ForEach(this.visibleAlbum(),
  (photo: CapturedPhoto) => {
    GridItem() {
      this.AlbumPhotoCard(photo)
    }
  },
  (photo: CapturedPhoto) => this.photoKey(photo)
)

键函数当前为:

private photoKey(photo: CapturedPhoto): string {
  return `${photo.id}-${photo.status}-${photo.createdAt}`;
}

Key 决定 ArkUI 是否复用已有节点。没有稳定键或使用数组下标时,删除中间一张照片可能让后续卡片复用错误的内部状态。

八、Key应稳定还是随状态变化

statuscreatedAt 放进键,字段变化时会让 ArkUI 把它视为新节点并重建。若卡片状态很轻,这可以确保展示刷新;但真正的实体标识通常只需 photo.id

private photoKey(photo: CapturedPhoto): string {
  return photo.id;
}

选择标准:

Key策略 优点 风险
id 最大化节点复用,语义稳定 子组件需正确响应属性更新
id + status 状态变化时强制重建 编辑状态可能丢失
数组下标 写法简单 插入删除后错位复用
整对象序列化 看似唯一 不稳定、成本高

照片 ID 已由时间与序列生成时,优先使用 id。只有明确需要重置组件内部状态时,才把版本字段加入 Key。

九、空相册与筛选无结果不是同一种状态

项目根据筛选状态显示不同文案:

if (this.visibleAlbum().length === 0) {
  Text(this.albumFilterWatermarkOnly ?
    '没有带水印照片' : '还没有保存的照片')

  Text(this.albumFilterWatermarkOnly ?
    '切换为全部可以查看普通照片。' :
    '拍一张带水印的照片,保存后会出现在这里。')
}

但两个状态的主操作也应不同:

  • 相册为空:按钮“去拍照”,切到拍摄 Tab。
  • 筛选无结果但相册有数据:按钮“查看全部”,关闭筛选。

可以写成:

Button(this.albumFilterWatermarkOnly ? '查看全部' : '去拍照')
  .onClick(() => {
    if (this.albumFilterWatermarkOnly) {
      this.albumFilterWatermarkOnly = false;
    } else {
      this.activeTab = 0;
    }
  })

空状态的操作必须直接解决当前原因,而不是一律跳到拍照页。

十、空状态也需要稳定占位

项目为空状态设置固定高度:

Column({ space: 10 }) {
  // title, description, action
}
.width('100%')
.height(260)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)

这样从空状态切换到 Grid 时,页面顶部结构不会完全塌陷。描述设置 maxLines(2) 和居中对齐,可避免窄屏溢出。

注意不要把整个页面区块都做成浮动卡片。空状态是列表内容的一种表现,视觉上应与相册区域连续,不需要再嵌套多层卡片。

十一、删除后状态需要同步收敛

删除照片:

private async deleteAlbumPhoto(photoId: string): Promise<void> {
  this.album = await PhotoAlbumService.deletePhoto(photoId);
  if (this.albumPreviewPhoto &&
      this.albumPreviewPhoto.id === photoId) {
    this.albumPreviewPhoto = null;
  }
  this.captureStatusText = '已从本地相册删除照片';
}

更新原始 album 后,visibleAlbum() 会自动得出新列表。若删除的是当前预览对象,还要关闭详情,防止页面继续操作已不存在的数据。

异步删除期间建议禁用当前卡片操作,并在失败时保留卡片。不要在服务确认前先从 UI 数组移除,除非实现了失败回滚。

十二、详情预览应持有标识还是对象

当前状态保存:

@State private albumPreviewPhoto: CapturedPhoto | null = null;

优点是渲染简单,缺点是相册数组更新后,详情可能持有旧对象副本。更严格的做法是只保存 ID:

@State private albumPreviewPhotoId: string = '';

private currentAlbumPreview(): CapturedPhoto | undefined {
  return this.album.find((photo: CapturedPhoto) =>
    photo.id === this.albumPreviewPhotoId);
}

对于只读详情,保存对象副本通常足够;如果详情支持编辑状态,ID + 派生查询能避免主列表与详情数据分叉。

十三、从历史照片恢复拍摄配置

项目支持复用水印:

private reuseAlbumPhoto(photo: CapturedPhoto): void {
  this.selectedTemplate = photo.watermark?.template ?? 'work';
  this.watermarkEnabled = photo.watermark?.enabled ?? true;
  this.customPlace = photo.watermark?.locationText ?? '';
  this.customNote = photo.watermark?.note ?? '';
  this.activeTab = 0;
  this.captureStatusText = '已将模板恢复到拍摄页';
}

这段代码对旧数据使用默认值,并只恢复配置,不修改历史照片。交互完成后切换到拍摄页,形成“找到照片 -> 复用模板 -> 再拍一张”的闭环。

若水印关闭且快照不存在,需要定义默认策略。当前 enabled ?? true 会让无快照照片恢复为开启水印,产品应确认是否符合预期。

十四、滚动容器与Grid职责分离

项目外层使用:

Scroll() {
  Column({ space: 18 }) {
    this.AlbumHeader()
    this.AlbumSummary()
    this.AlbumDocumentEntryCard()
    this.AlbumGridOrEmpty()
  }
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)

Grid 作为内容参与外层整体滚动,适合只有几十条的本地相册。如果数据扩展到数千条,应评估懒加载容器,避免一次构建全部卡片。不要同时让外层 Scroll 和内层 Grid 各自垂直滚动,否则手势竞争和高度测量会变得复杂。

十五、排序必须在数据层有明确定义

PhotoAlbumService.persistPhoto() 把新照片放到数组首部,因此页面自然按最新优先展示。不要在每次 Builder 执行时原地调用:

this.album.sort(...)

sort() 会修改状态数组,可能在构建阶段触发不可预测行为。需要排序时使用副本:

return this.album.slice().sort(
  (a: CapturedPhoto, b: CapturedPhoto) =>
    b.createdAtTimestamp - a.createdAtTimestamp
);

当前 createdAt 是显示字符串,不适合可靠排序。建议同时持久化数值时间戳,把格式化留给 UI。

十六、不要把真实图片存进Preferences列表

Grid 卡片需要缩略图,但 Preferences 适合存元数据,不适合存 PixelMap 或 Base64。扩展真实相册时可让 CapturedPhoto 保存:

interface CapturedPhotoAsset {
  id: string;
  mediaUri: string;
  thumbnailUri?: string;
  width: number;
  height: number;
}

卡片按 URI 异步加载缩略图,详情再加载原图。列表项要提供固定比例占位和失败状态,避免图片加载改变 Grid 尺寸。

十七、可访问性和大字体检查

相册卡片往往同时放图片、角标、标题和操作。发布前检查:

  • 筛选状态不只通过深浅颜色表达。
  • “+”按钮有可理解的无障碍描述。
  • 触控目标至少保持稳定尺寸。
  • 标题和地点最多两行并有省略。
  • 系统字体放大后卡片操作不会重叠。
  • 删除操作与普通点击有足够区分并需确认。

对纯符号按钮,界面可保持简洁,但应提供 accessibilityText 或等价语义属性。

十八、测试矩阵

场景 可见数量 空状态 操作
相册为空、全部模式 0 还没有保存的照片 去拍照
相册为空、水印模式 0 没有带水印照片 查看全部
5张中2张有水印 2 不显示 显示两张
筛选时删除最后一张水印图 0 没有带水印照片 查看全部
删除中间卡片 n-1 视结果而定 Key不串卡
旧照片无watermark 不进入水印结果 不崩溃 可查看全部
恢复历史模板 数量不变 不变 跳拍摄页
连续保存61张 最多60 不显示 最新优先

键稳定性测试可以记录每个卡片子组件的照片 ID,删除第二项后确认第三项仍绑定原 ID,而不是继承第二项的内部状态。

十九、性能观测点

数据量增加时优先测量:

  1. visibleAlbum() 每次执行耗时。
  2. 筛选开关到首帧更新的耗时。
  3. Grid 首屏创建的卡片数量。
  4. 缩略图解码峰值内存。
  5. 删除一项后实际重建的节点数量。

不要先用复杂缓存掩盖问题。若瓶颈来自原图解码,缓存过滤数组不会有效;应先生成缩略图并使用懒加载。

二十、发布前验收清单

  • 原始相册与筛选布尔值是单一事实来源。
  • 筛选条件兼容无水印字段的旧数据。
  • 计数、空状态和 Grid 使用同一派生结果。
  • ForEach 使用稳定业务 ID,不使用数组下标。
  • 空相册与筛选无结果提供不同操作。
  • 删除当前详情照片后会关闭详情。
  • 异步删除失败时不会误报成功。
  • Grid 卡片有稳定比例和加载占位。
  • Preferences 只保存元数据,不保存图片 Base64。
  • 窄屏、大字体和长文案下无重叠。

总结

一个可靠的 ArkUI 相册列表需要先控制状态数量:album 保存原始记录,筛选开关保存用户意图,visibleAlbum() 负责派生展示结果。计数、空状态和 Grid 都消费同一结果,才能避免页面自相矛盾;稳定业务 Key 则保证插入、删除和筛选后组件不会错位复用。

在此基础上,再区分“相册为空”和“筛选无结果”的操作,处理详情引用、异步删除、缩略图加载与大字体布局,相册才能从演示用双列网格变成可长期维护的本地内容入口。

Logo

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

更多推荐