HarmonyOS趣味相机实战第27篇:ArkUI相册Grid筛选、稳定Key与空状态闭环
HarmonyOS趣味相机实战第27篇:ArkUI相册Grid筛选、稳定Key与空状态闭环
摘要
相册列表的难点不在“把数组画出来”,而在状态变化后仍然可预测:切换“仅水印照片”时计数与卡片要同步;删除元素不能复用错卡片;筛选无结果和相册本身为空要给出不同引导;从详情返回后列表不能跳动;数据增长后不能在每个组件中重复过滤。
本文基于 D:/APP/1quweixiangji 的 Index.ets,围绕 visibleAlbum()、albumFilterWatermarkOnly、ArkUI Grid、ForEach 键函数、空状态和照片卡片操作展开。我们会把原始状态、派生数据、展示计数和交互出口串成一条闭环,并给出适合相册列表的测试与性能检查方法。
工程环境
| 项目 | 当前实现 |
|---|---|
| 开发语言 | ArkTS |
| UI 框架 | ArkUI |
| 列表容器 | Grid + GridItem |
| 列数 | 1fr 1fr |
| 列/行间距 | 12 vp |
| 原始数据 | album: CapturedPhoto[] |
| 筛选状态 | albumFilterWatermarkOnly |
| 唯一标识 | photo.id |

一、先区分原始状态和派生状态
相册原始状态只有两类:
@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应稳定还是随状态变化
把 status 和 createdAt 放进键,字段变化时会让 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,而不是继承第二项的内部状态。
十九、性能观测点
数据量增加时优先测量:
visibleAlbum()每次执行耗时。- 筛选开关到首帧更新的耗时。
- Grid 首屏创建的卡片数量。
- 缩略图解码峰值内存。
- 删除一项后实际重建的节点数量。
不要先用复杂缓存掩盖问题。若瓶颈来自原图解码,缓存过滤数组不会有效;应先生成缩略图并使用懒加载。
二十、发布前验收清单
- 原始相册与筛选布尔值是单一事实来源。
- 筛选条件兼容无水印字段的旧数据。
- 计数、空状态和 Grid 使用同一派生结果。
- ForEach 使用稳定业务 ID,不使用数组下标。
- 空相册与筛选无结果提供不同操作。
- 删除当前详情照片后会关闭详情。
- 异步删除失败时不会误报成功。
- Grid 卡片有稳定比例和加载占位。
- Preferences 只保存元数据,不保存图片 Base64。
- 窄屏、大字体和长文案下无重叠。
总结
一个可靠的 ArkUI 相册列表需要先控制状态数量:album 保存原始记录,筛选开关保存用户意图,visibleAlbum() 负责派生展示结果。计数、空状态和 Grid 都消费同一结果,才能避免页面自相矛盾;稳定业务 Key 则保证插入、删除和筛选后组件不会错位复用。
在此基础上,再区分“相册为空”和“筛选无结果”的操作,处理详情引用、异步删除、缩略图加载与大字体布局,相册才能从演示用双列网格变成可长期维护的本地内容入口。
更多推荐



所有评论(0)