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就是我们这个相册的最小“砖块”。

注意看,这里有两个“法宝”:

  1. @Prop:这是“父组件”(就是后面的PhotoGalleryDemo)传给“子组件”(PhotoCard)的数据。@Prop 声明的数据,就像你爸妈给你的钱,你(子组件)只能看,不能改
  2. @State:这是“子组件”自己的“小金库”。@State 声明的数据,子组件可以随便改

小白提问:为啥有了 @Prop photo,还要搞个 @State localPhoto 再复制一遍?

:问得好!这就是“数据隔离”。

想象一下,PhotoCard 里的“点赞”功能。如果我直接修改 @Prop photoisLiked 状态(先不说你改不了),那就会“污染”父组件传来的原始数据。

而现在,我把“爸妈给的钱”(@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:这里我们就用到了最开始导入的 routerrouter.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
})
}

“装修”工具栏:ScrollForEach

buildToolbar 里有两个知识点:

  1. Scroll:当你的 Row(或 Column)内容超出了屏幕宽度(或高度)时,用 Scroll 包裹它,就能实现“滑动”效果。
  2. 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 是我们的“主战场”。

  1. Grid:网格布局组件。
  2. ForEach:又见面了!这次我们遍历的是 getFilteredPhotos()(一个我们自定义的函数,马上讲)返回的“筛选后”的图片列表。
  3. GridItemGrid 里的每一项。
  4. PhotoCard({ photo: photo }):看到没!我们在这里“使用”了我们之前定义的“砖块”(PhotoCard),并把遍历到的 photo 数据通过 @Prop 传给了它!
  5. .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
);
}
}

总结

呼——终于“盖”完了。

总结一下今天我们“踩”过的知识点:

  1. 数据驱动class PhotoItem 定义数据结构。
  2. 组件化@ComponentPhotoCard, PhotoGalleryDemo)拆分UI。
  3. 状态管理@Prop(“父传子,子只读”)和 @State(“我的地盘我做主”)。
  4. 生命周期aboutToAppear 里做“数据拷贝”。
  5. 布局Column(垂直)、Row(水平)、Stack(堆叠)、Grid(网格)。
  6. 循环渲染ForEach + Scroll 构建动态列表/分类。
  7. 代码组织@Builderbuild 函数更“瘦身”。
  8. 响应式:通过改变 @StatelayoutMode, selectedCategory)自动更新UI,而不需要手动操作DOM。
  9. 路由router.pushUrl 实现页面“传送”。

好了,今天的“踩坑”笔记就到这。代码是死的,人是活的。赶紧CV…(不是)…赶紧动手敲一遍!

(溜了溜了,继续踩下一个坑…)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐