【鸿蒙ArkTS“踩坑”笔记】小白爆肝实战:一个“颜值在线”的相册App!
本文详细讲解HarmonyOS相册浏览器的完整开发流程,重点解决Grid组件滚动布局问题。通过14张图片展示案例,分享图片数据模型设计、分类筛选功能实现、网格/列表视图切换等核心技术。包含完整的代码示例和常见问题解决方案,帮助开发者快速掌握HarmonyOS移动应用图片展示功能,提升应用用户体验和开发效率。
文章目录
Hello,各位HarmonyOS的“准”大佬们!
我是一个刚入坑的ArkTS“练习生”,今天含泪(并爆肝)整理了我的第一个“能看”的Demo——一个相册浏览器。

都说好记性不如烂笔头,这篇笔记就是我“踩坑”和“爬坑”的真实写照。如果你也刚上路,那这篇“保姆级”的笔记(和我写的“屎山”级代码)希望能给你一点点启发。
废话不多说,发车!
准备工作:路由和“数据蓝图”

在开始“盖房子”之前,我们总得先把“传送门”(router)和“户型图”(数据类)准备好。
import router from '@ohos.router';:这就是我们的“传送门”。想去哪个页面,喊它一声就行。class PhotoItem:这就是我们的“户型图”。我们得告诉系统,我们的一张“照片”到底包含了哪些信息(ID、标题、描述、URL……)。
import router from '@ohos.router';
// 图片数据类
class PhotoItem {
id: number = 0;
title: string = '';
description: string = '';
thumbnailUrl: string = '';
fullSizeUrl: string = '';
category: string = '';
uploadTime: string = '';
likes: number = 0;
isLiked: boolean = false;
constructor(
id: number,
title: string,
description: string,
thumbnailUrl: string,
fullSizeUrl: string,
category: string,
uploadTime: string,
likes: number,
isLiked: boolean
) {
this.id = id;
this.title = title;
this.description = description;
this.thumbnailUrl = thumbnailUrl;
this.fullSizeUrl = fullSizeUrl;
this.category = category;
this.uploadTime = uploadTime;
this.likes = likes;
this.isLiked = isLiked;
}
}
核心“砖块”:自定义一个PhotoCard组件

在鸿蒙(或者说所有现代UI框架)里,我们要学会“化整为零”。一个复杂的页面,都是由一个个小的“组件”拼起来的。PhotoCard就是我们这个相册的最小“砖块”。
注意看,这里有两个“法宝”:
@Prop:这是“父组件”(就是后面的PhotoGalleryDemo)传给“子组件”(PhotoCard)的数据。@Prop声明的数据,就像你爸妈给你的钱,你(子组件)只能看,不能改。@State:这是“子组件”自己的“小金库”。@State声明的数据,子组件可以随便改。
小白提问:为啥有了
@Prop photo,还要搞个@State localPhoto再复制一遍?答:问得好!这就是“数据隔离”。
想象一下,
PhotoCard里的“点赞”功能。如果我直接修改@Prop photo的isLiked状态(先不说你改不了),那就会“污染”父组件传来的原始数据。而现在,我把“爸妈给的钱”(
@Prop photo) 在aboutToAppear(组件即将出现时)复制一份,存到我自己的“小金库”(@State localPhoto)里。接下来,我所有的操作(点赞、修改数量)都只动我自己的
localPhoto。这样既能独立更新卡片UI,又不会影响到“大部队”(父组件的数据源)。
// 图片卡片组件
@Component
struct PhotoCard {
@Prop photo: PhotoItem;
@State localPhoto: PhotoItem = new PhotoItem(0, '', '', '', '', '', '', 0, false);
aboutToAppear() {
if (this.photo) {
this.localPhoto = new PhotoItem(
this.photo.id,
this.photo.title,
this.photo.description,
this.photo.thumbnailUrl,
this.photo.fullSizeUrl,
this.photo.category,
this.photo.uploadTime,
this.photo.likes,
this.photo.isLiked
);
}
}
“装修”卡片:build 函数里的“堆叠”艺术

build 函数就是“装修”环节。这里我们用了 Stack(堆叠布局),Image(图片),Text(文字)。
Stack 就像是“叠叠乐”,后写的组件会叠在先写的组件上面。我们就是用它,把“分类标签”(Text) 叠在了“缩略图”(Image) 的左上角。
build() {
Column({ space: 0 }) {
// 图片区域
Stack() {
// 缩略图
Image(this.localPhoto.thumbnailUrl)
.width('100%')
.aspectRatio(1)
.objectFit(ImageFit.Cover)
.borderRadius(8)
.alt($r('app.media.avatar'))
.onComplete(() => {
console.log(`缩略图加载完成: ${this.localPhoto.title}`);
})
.onError(() => {
console.log(`缩略图加载失败: ${this.localPhoto.title}`);
})
// 分类标签
Text(this.localPhoto.category)
.fontSize(10)
.fontColor('#FFFFFF')
.backgroundColor('#007DFF')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.position({ x: 8, y: 8 })
// 图片覆盖层(点击效果)
Column() {
Blank()
.width('100%')
.height('100%')
.backgroundColor('#1A000000')
.opacity(0)
}
.width('100%')
.height('100%')
.onClick(() => {
this.navigateToDetail(this.localPhoto);
})
}
.width('100%')
.aspectRatio(1)
.borderRadius(8)
“装修”卡片(续):标题和“点赞”信息

Stack 下面,我们用 Column 垂直排布“标题”和“底部信息栏”。
“底部信息栏”内部,我们又用了 Row 水平排布“上传时间”和“点赞按钮”。
这一套“Column 嵌套 Row 嵌套 Stack”的操作,就是ArkTS(或者说声明式UI)的“常规操作”,多练练就熟了。
// 标题区域
Text(this.localPhoto.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.textAlign(TextAlign.Start)
.width('100%')
.margin({ top: 8 })
// 底部信息栏
Row() {
// 上传时间
Row({ space: 4 }) {
Image($r('app.media.shijian'))
.width(12)
.height(12)
Text(this.localPhoto.uploadTime)
.fontSize(10)
.fontColor('#999999')
}
.layoutWeight(1)
.alignItems(VerticalAlign.Center)
// 点赞按钮
Row({ space: 2 }) {
Image(this.localPhoto.isLiked ? $r('app.media.dianzan2') : $r('app.media.dianzan'))
.width(16)
.height(16)
Text(this.localPhoto.likes.toString())
.fontSize(10)
.fontColor(this.localPhoto.isLiked ? '#007DFF' : '#999999')
}
.onClick(() => {
this.toggleLike();
})
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
.backgroundColor(this.localPhoto.isLiked ? '#E6F2FF' : '#F5F5F5')
.borderRadius(4)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 4, color: '#0A000000', offsetX: 0, offsetY: 1 })
.onClick(() => {
this.navigateToDetail(this.localPhoto);
})
}
给卡片“通电”:交互功能

光有“装修”还不行,得让卡片“活”起来。
MapsToDetail:这里我们就用到了最开始导入的router。router.pushUrl就是我们的“传送门咒语”,告诉它要去pages/PhotoDetailPage,顺便把photoId传送过去(当然,详情页我们这里没写,先假装它存在)。toggleLike:这就是操作我们自己“小金库”(localPhoto)的逻辑。点一下,isLiked状态反转,likes数量加一或减一。因为localPhoto是@State,所以UI会自动更新,丝滑!
// 跳转到详情页
navigateToDetail(photo: PhotoItem) {
console.log(`跳转到图片详情: ${photo.title}`);
// 这里可以添加页面跳转逻辑
router.pushUrl({
url: 'pages/PhotoDetailPage',
params: { photoId: photo.id.toString() }
});
}
// 切换点赞状态
toggleLike() {
this.localPhoto.isLiked = !this.localPhoto.isLiked;
this.localPhoto.likes = this.localPhoto.isLiked ?
this.localPhoto.likes + 1 : Math.max(0, this.localPhoto.likes - 1);
}
}
登场!“总指挥”:PhotoGalleryDemo 页面

前面都是在做“砖块”,现在我们开始“盖房子”了。
@Entry 装饰器告诉系统:“这就是我们App的‘村口’(入口)!从这里开始!”
@Component 说明它也是一个组件。
这里我们又用了 @State,但这次是“总指挥”的 @State:
photoList:存放我们所有照片数据的“大部队”(这里我们先用假数据,实际开发中这些数据应该从网络或数据库获取)。selectedCategory:当前选中的分类。layoutMode:当前是“网格视图”(grid) 还是“列表视图”(list)。
@Entry
@Component
export struct PhotoGalleryDemo {
// 图片数据
@State photoList: PhotoItem[] = [
new PhotoItem(
1,
'日落美景',
'壮观的日落景色,天空呈现橙红色渐变',
'[https://img1.pconline.com.cn/piclib/200810/12/batch/1/13540/1223824495142il41huy1no.jpg](https://img1.pconline.com.cn/piclib/200810/12/batch/1/13540/1223824495142il41huy1no.jpg)',
'[https://img1.pconline.com.cn/piclib/200810/12/batch/1/13540/1223824495142il41huy1no.jpg](https://img1.pconline.com.cn/piclib/200810/12/batch/1/13540/1223824495142il41huy1no.jpg)',
'风景',
'10-13',
156,
false
),
// ... (此处省略 N 条假数据) ...
new PhotoItem(
14,
'沙漠探险',
'广袤沙漠中的驼队与落日',
'[https://img2.baidu.com/it/u=4222637984,2144309626&fm=253&app=138&f=JPEG](https://img2.baidu.com/it/u=4222637984,2144309626&fm=253&app=138&f=JPEG)',
'[https://img2.baidu.com/it/u=4222637984,2144309626&fm=253&app=138&f=JPEG](https://img2.baidu.com/it/u=4222637984,2144309626&fm=253&app=138&f=JPEG)',
'探险',
'10-08',
145,
false
),
];
// 选中的分类
@State selectedCategory: string = '全部';
// 布局模式
@State layoutMode: string = 'grid'; // grid | list
// 分类列表
categories: string[] = ['全部', '风景', '城市', '自然', '植物', '旅行', '天文', '人文', '美食', '动物', '建筑', '运动'];
“装修”总页面:@Builder 登场!

如果把所有“装修”代码都塞进 build 函数,那它会“臃肿”得像个“千层饼”。
为了代码的“可读性”(为了以后我们自己还能看懂),我们引入了 @Builder 装饰器。
@Builder 就像是把一大坨“装修”逻辑封装成一个“装修队”,build 函数里只需要“喊”这个装修队的名字(this.buildHeader())就行了,代码瞬间清爽!
build() {
Column({ space: 0 }) {
// 顶部标题栏
this.buildHeader()
// 工具栏
this.buildToolbar()
// 图片网格
this.buildPhotoGrid()
}
.width('100%')
.height('100%')
.backgroundColor('#F8F9FA')
}
// 构建顶部标题栏
@Builder
buildHeader() {
Row() {
Text('相册浏览器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Blank()
// 布局切换按钮
Row({ space: 8 }) {
Image(this.layoutMode === 'grid' ? $r('app.media.liebiao') : $r('app.media.wangge'))
.width(20)
.height(20)
.onClick(() => {
this.layoutMode = this.layoutMode === 'grid' ? 'list' : 'grid';
})
Image($r('app.media.yuyan'))
.width(20)
.height(20)
.onClick(() => {
console.info('打开搜索功能');
})
}
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
.shadow({
radius: 2,
color: '#1A000000',
offsetX: 0,
offsetY: 1
})
}
“装修”工具栏:Scroll 和 ForEach

buildToolbar 里有两个知识点:
Scroll:当你的Row(或Column)内容超出了屏幕宽度(或高度)时,用Scroll包裹它,就能实现“滑动”效果。ForEach:这是“循环渲染”的神器。我们不用一个一个地去写“全部”、“风景”、“城市”… 而是用ForEach遍历categories数组,自动“批量生产”Text组件。
// 构建工具栏
@Builder
buildToolbar() {
Column() {
// 分类筛选
Scroll() {
Row({ space: 8 }) {
ForEach(this.categories, (category: string) => {
Text(category)
.fontSize(14)
.fontColor(this.selectedCategory === category ? '#FFFFFF' : '#666666')
.backgroundColor(this.selectedCategory === category ? '#007DFF' : '#F0F0F0')
.borderRadius(16)
.padding({
left: 12,
right: 12,
top: 6,
bottom: 6
})
.onClick(() => {
this.selectedCategory = category;
})
})
}
.padding({
left: 16,
right: 16,
top: 12,
bottom: 12
})
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// 布局信息
Row() {
Text(`共 ${this.getFilteredPhotos().length} 张图片`)
.fontSize(12)
.fontColor('#999999')
Blank()
Text(this.layoutMode === 'grid' ? '列表视图':'网格视图' )
.fontSize(12)
.fontColor('#007DFF')
.onClick(()=>{
this.layoutMode = this.layoutMode === 'grid' ? 'list' : 'grid';
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 8 })
}
.backgroundColor('#FFFFFF')
}
“装修”主战场:Grid 网格布局

buildPhotoGrid 是我们的“主战场”。
Grid:网格布局组件。ForEach:又见面了!这次我们遍历的是getFilteredPhotos()(一个我们自定义的函数,马上讲)返回的“筛选后”的图片列表。GridItem:Grid里的每一项。PhotoCard({ photo: photo }):看到没!我们在这里“使用”了我们之前定义的“砖块”(PhotoCard),并把遍历到的photo数据通过@Prop传给了它!.columnsTemplate(this.layoutMode === 'grid' ? '1fr 1fr 1fr' : '1fr'):这是最精髓的一句!- 它通过判断
layoutMode(@State变量)的值,动态改变网格的“列模板”。 - 如果是
grid模式,就显示'1fr 1fr 1fr'(三列等宽)。 - 如果是
list模式,就显示'1fr'(一列,列表效果)。 - 因为
layoutMode是@State,所以只要我们点击“切换布局”按钮改变了它的值,UI就会自动重新渲染,实现网格/列表的丝滑切换!
- 它通过判断
// 构建图片网格
@Builder
buildPhotoGrid() {
Column({ space: 5 }) {
Text('scroll').fontColor(0xCCCCCC).fontSize(9).width('90%')
Grid() {
ForEach(this.getFilteredPhotos(), (photo: PhotoItem) => {
GridItem() {
PhotoCard({ photo: photo })
}
.aspectRatio(1)
}, (photo: PhotoItem) => photo.id.toString())
}
.columnsTemplate(this.layoutMode === 'grid' ? '1fr 1fr 1fr' : '1fr')
.columnsGap(12)
.rowsGap(40)
.width('100%')
.padding(16)
.scrollBar(BarState.On)
.scrollBarColor(Color.Grey)
.scrollBarWidth(4)
.onScrollIndex((start: number, end: number) => {
console.log(`滚动到索引: ${start} - ${end}`);
})
}
.width('100%')
.margin({ top: 5 })
.layoutWeight(1)
}
“大管家”:筛选逻辑

这就是 ForEach 里调用的“大管家”函数。它就像一个“筛子”。
它会检查当前的 selectedCategory(@State 变量)。
- 如果是“全部”,它就返回完整的
photoList。 - 如果不是“全部”(比如是“风景”),它就会用
.filter方法,只把category是“风景”的item筛选出来,返回一个新的数组。
因为 selectedCategory 也是 @State,所以每次点击分类按钮改变了它,buildPhotoGrid 里的 ForEach 就会自动重新执行 getFilteredPhotos(),拿到的就是“筛选后”的数据,UI 也就自动更新了。
这就是声明式UI的“响应式”魅力!
// 获取筛选后的图片列表
getFilteredPhotos(): PhotoItem[] {
return this.photoList.filter(item =>
this.selectedCategory === '全部' || item.category === this.selectedCategory
);
}
}
总结
呼——终于“盖”完了。

总结一下今天我们“踩”过的知识点:
- 数据驱动:
class PhotoItem定义数据结构。 - 组件化:
@Component(PhotoCard,PhotoGalleryDemo)拆分UI。 - 状态管理:
@Prop(“父传子,子只读”)和@State(“我的地盘我做主”)。 - 生命周期:
aboutToAppear里做“数据拷贝”。 - 布局:
Column(垂直)、Row(水平)、Stack(堆叠)、Grid(网格)。 - 循环渲染:
ForEach+Scroll构建动态列表/分类。 - 代码组织:
@Builder让build函数更“瘦身”。 - 响应式:通过改变
@State(layoutMode,selectedCategory)自动更新UI,而不需要手动操作DOM。 - 路由:
router.pushUrl实现页面“传送”。
好了,今天的“踩坑”笔记就到这。代码是死的,人是活的。赶紧CV…(不是)…赶紧动手敲一遍!
(溜了溜了,继续踩下一个坑…)
更多推荐

所有评论(0)