鸿蒙原生应用实战(三):发现页与专辑详情 —— 多维筛选与曲目管理
鸿蒙原生应用实战(三):发现页与专辑详情 —— 多维筛选与曲目管理
前言
前两篇完成了项目初始化和首页开发。本篇将实现两个功能型页面——发现页(SearchPage) 和 专辑详情页(AlbumPage)。
发现页需要处理多维度筛选的联动逻辑,详情页则需要通过路由参数动态加载不同专辑的数据。这是ArkTS状态管理和组件通信的进阶实践。
一、发现页(SearchPage)完整实现
1.1 功能分析
SearchPage
├── 顶部:返回 + 标题"发现音乐"
├── 搜索栏:TextInput实时搜索 + 清除按钮
├── 热门标签:Wrap布局的热搜词云(点击填充搜索框)
├── 分类筛选:横向滚动的胶囊标签(全部/流行/摇滚...)
├── 年份筛选:横向滚动的胶囊标签(全部/2025/2024...)
└── 结果列表:歌曲卡片(带评分、时长、收藏状态)
1.2 数据结构
interface Song {
id: number;
title: string;
artist: string;
album: string;
albumId: number;
genre: string; // 类型用于筛选
year: number; // 年份用于筛选
duration: string;
rating: number;
}
相比首页的Song,这里新增了 album、genre、year 字段,因为筛选需要这些维度。
1.3 30首模拟数据
发现页的数据量比首页大得多——30首歌曲覆盖8种类型、6个年份、多位歌手:
this.allSongs = [
{ id: 101, title: '借过一下', artist: '周深', album: '浮光', albumId: 1,
genre: '流行', year: 2025, duration: '04:32', rating: 48 },
// ...共30条数据
];
数据分布策略:
- 歌手:周深(4首)、李荣浩(2首)、许嵩(2首)、新裤子(2首)、陈粒(2首)、邓紫棋(2首)、周杰伦(2首)、陈奕迅(2首)等
- 类型:流行(12首)、民谣(6首)、摇滚(4首)、R&B(4首)
- 年份:2023(8首)、2024(10首)、2025(12首)
1.4 状态变量设计
@Component
struct SearchPage {
@State searchKeyword: string = ''; // 搜索关键词
@State selectedGenre: string = '全部'; // 选中分类
@State selectedYear: string = '全部'; // 选中年份
@State allSongs: Song[] = []; // 全量数据
@State filteredSongs: Song[] = []; // 筛选后数据(UI渲染用)
@State hotTags: string[] = ['周深', '许嵩', '邓紫棋', '民谣', '经典', '睡前', '运动', '开车'];
@State genres: string[] = ['全部', '流行', '摇滚', '民谣', '电子', '古典', 'R&B', '嘻哈', '爵士'];
@State years: string[] = ['全部', '2025', '2024', '2023', '2022', '2021', '2020'];
}
1.5 路由参数接收
aboutToAppear(): void {
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
if (params && params['genre'] !== undefined) {
this.selectedGenre = params['genre'] as string; // 从首页分类跳转过来时自动选中
}
this.initAllSongs();
this.filterSongs();
}
关键设计:当从首页的分类格子点击跳转时(如点击"民谣"),发现页会自动选中"民谣"分类并展示筛选结果。这通过路由参数传递实现,让用户在首页的点击有连贯的体验。
1.6 核心:三维度组合筛选算法
filterSongs(): void {
let result: Song[] = this.allSongs;
// 维度一:关键词(模糊匹配标题和歌手,忽略大小写)
if (this.searchKeyword.length > 0) {
const keyword: string = this.searchKeyword.toLowerCase();
result = result.filter((item: Song) =>
item.title.toLowerCase().indexOf(keyword) >= 0 ||
item.artist.toLowerCase().indexOf(keyword) >= 0
);
}
// 维度二:分类(精确匹配)
if (this.selectedGenre !== '全部') {
result = result.filter((item: Song) => item.genre === this.selectedGenre);
}
// 维度三:年份(精确匹配)
if (this.selectedYear !== '全部') {
result = result.filter((item: Song) => item.year.toString() === this.selectedYear);
}
this.filteredSongs = result;
}
与上一个项目(追剧日历)的筛选差异:
| 对比维度 | 追剧日历 | 乐迷笔记 |
|---|---|---|
| 关键词匹配 | 仅匹配标题 | 匹配标题 + 歌手 |
| 大小写处理 | 无 | 全小写后匹配 |
| 分类匹配 | indexOf 模糊(“古装仙侠"含"古装”) |
=== 精确(“流行"必须等于"流行”) |
| 筛选维度 | 3维(关键词+分类+状态) | 3维(关键词+分类+年份) |
原因是音乐搜索中用户可能输入歌手名查找,忽略大小写更友好;而分类标签是标准化的,精确匹配更准确。
1.7 搜索栏与清除按钮
Stack() {
TextInput({ placeholder: '搜索歌曲、歌手...' })
.width('100%').height(40)
.backgroundColor('#FFFFFF').borderRadius(20)
.padding({ left: 20 }).fontSize(14)
.placeholderColor('#9CA3AF')
.onChange((value: string) => { this.onKeywordChange(value); })
if (this.searchKeyword.length > 0) {
Text('✕').fontSize(14).fontColor('#9CA3AF')
.position({ right: 16 })
.onClick(() => {
this.searchKeyword = '';
this.filterSongs();
})
}
}
position({ right: 16 }) 在Stack中将清除按钮定位在输入框右侧内部。
1.8 热门标签(Wrap布局)
@Builder buildHotTags() {
Column() {
Text('热门搜索').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#1F1B2E').width('100%').padding({ left: 20 })
Wrap() { // ← 自动换行布局
ForEach(this.hotTags, (tag: string) => {
Text(tag).fontSize(12).fontColor('#7C3AED')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#EDE9FE').borderRadius(16)
.margin({ right: 8, bottom: 8 })
.onClick(() => { this.onTagClick(tag); })
}, (tag: string) => tag)
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
}
.width('100%').margin({ top: 8 })
}
Wrap 组件:当子组件总宽度超过容器宽度时自动换行,适合标签云、关键词列表等场景。每个Tag点击后填充到搜索输入框并触发筛选。
1.9 分类+年份双筛选栏
// 分类标签横向滚动
Scroll() {
Row() {
ForEach(this.genres, (genre: string) => {
Text(genre).fontSize(12)
.fontColor(this.selectedGenre === genre ? '#FFFFFF' : '#6B7280')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.selectedGenre === genre ? '#7C3AED' : '#FFFFFF')
.borderRadius(16).margin({ right: 8 })
.onClick(() => { this.selectedGenre = genre; this.filterSongs(); })
}, (genre: string) => genre)
}.padding({ left: 20, right: 20 })
}
.scrollable(ScrollDirection.Horizontal).height(36).margin({ top: 8 })
// 年份标签(与分类标签结构相同)
Scroll() {
Row() {
ForEach(this.years, (year: string) => {
Text(year) // ... 同上
}, (year: string) => year)
}.padding({ left: 20, right: 20 })
}
.scrollable(ScrollDirection.Horizontal).height(36).margin({ top: 8 })
选中态与未选中态的视觉差异:
| 状态 | 背景色 | 文字色 |
|---|---|---|
| 选中 | #7C3AED(紫色) |
#FFFFFF(白色) |
| 未选中 | #FFFFFF(白色) |
#6B7280(灰色) |
1.10 空结果处理
if (this.filteredSongs.length === 0 && (this.searchKeyword.length > 0 || ...)) {
Column() {
Text('🔍').fontSize(48)
Text('未找到相关歌曲').fontSize(16).fontColor('#9CA3AF').margin({ top: 12 })
Text('换个关键词试试吧').fontSize(13).fontColor('#D1D5DB').margin({ top: 8 })
}
.width('100%').alignItems(HorizontalAlign.Center).padding({ top: 40 })
}
空状态的触发条件:只有在有筛选条件且结果为零时才显示空状态。首次进入时(无任何筛选条件)不显示。
二、专辑详情页(AlbumPage)完整实现
2.1 功能分析
AlbumPage
├── 头部:紫色背景 + 返回按钮 + 收藏按钮 + 专辑封面 + 专辑名/歌手/年份
├── 评分栏:评分 / 歌曲数 / 类型(三列)
├── 专辑信息:歌手、标签、专辑简介
└── 曲目列表:显示曲目 + 展开/收起 + 单曲收藏
2.2 数据结构
interface Track {
num: number; // 曲目编号
title: string; // 曲名
duration: string; // 时长
artist: string; // 歌手
isFavorite: boolean; // 是否收藏
}
interface AlbumDetail {
id: number;
title: string;
artist: string;
cover: string;
year: number;
genre: string;
songCount: number;
rating: number;
description: string; // 专辑简介
label: string; // 标签
tracks: Track[]; // 曲目列表
}
2.3 路由参数获取与数据加载
@Component
struct AlbumPage {
@State albumId: number = -1;
@State album: AlbumDetail | null = null;
@State isCollected: boolean = false;
@State showAllTracks: boolean = false;
aboutToAppear(): void {
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
if (params && params['albumId'] !== undefined) {
this.albumId = params['albumId'] as number;
}
this.loadAlbum();
}
loadAlbum(): void {
const albums: AlbumDetail[] = [ /* 6张专辑数据 */ ];
for (let i: number = 0; i < albums.length; i++) {
if (albums[i].id === this.albumId) {
this.album = albums[i];
break;
}
}
// 兜底
if (!this.album && albums.length > 0) {
this.album = albums[0];
}
}
}
6张专辑通过ID匹配动态加载,覆盖首页推荐的6个专辑。
2.4 头部紫色区域
@Builder buildHeader() {
Stack() {
Column().width('100%').height(240).backgroundColor('#7C3AED')
Column() {
// 顶栏:返回 + 收藏
Row() {
Text('←').fontSize(22).fontColor(Color.White)
.onClick(() => { router.back(); })
Blank()
Text(this.isCollected ? '❤️' : '🤍').fontSize(20)
.onClick(() => { this.toggleCollect(); })
}
.width('100%').padding({ left: 20, right: 20 }).position({ top: 44 })
// 居中内容:封面 + 标题 + 副信息
Column() {
Stack() { // 专辑封面占位
Column().width(100).height(100)
.backgroundColor('#EDE9FE').borderRadius(12)
Text('💿').fontSize(40)
}
Text(this.album ? this.album.title : '').fontSize(22)
.fontWeight(FontWeight.Bold).fontColor(Color.White).margin({ top: 10 })
Text(this.album ? `${this.album.artist} · ${this.album.year}` : '')
.fontSize(13).fontColor('#C4B5FD').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center).width('100%').position({ top: 80 })
}
.width('100%').height('100%')
}
.width('100%').height(240)
}
布局层次:紫色背景 → 内容层(绝对定位顶栏 + 居中文字),position({ top: 44 }) 和 position({ top: 80 }) 分别控制顶栏和居中内容的位置偏移。
2.5 评分栏
@Builder buildRatingBar() {
if (this.album) {
Row() {
Column() {
Text(`★ ${(this.album.rating / 10).toFixed(1)}`).fontSize(24)
.fontWeight(FontWeight.Bold).fontColor('#EF4444')
Text('评分').fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.album.songCount.toString()).fontSize(24)
.fontWeight(FontWeight.Bold).fontColor('#7C3AED')
Text('歌曲数').fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(this.album.genre).fontSize(24)
.fontWeight(FontWeight.Bold).fontColor('#3B82F6')
Text('类型').fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(16).backgroundColor('#FFFFFF')
.margin({ top: 8 }).borderRadius(12)
}
}
三个指标用 layoutWeight(1) 等分宽度,每个指标用大号数值 + 灰色标签。
2.6 专辑信息区
@Builder buildInfoSection() {
if (this.album) {
Column() {
Row() {
Text('🎤 歌手:').fontSize(13).fontColor('#9CA3AF')
Text(this.album.artist).fontSize(13).fontColor('#1F1B2E').margin({ left: 8 })
}.width('100%').margin({ top: 4 })
Row() {
Text('🏷️ 标签:').fontSize(13).fontColor('#9CA3AF')
Text(this.album.label).fontSize(13).fontColor('#1F1B2E').margin({ left: 8 })
}.width('100%').margin({ top: 6 })
Text('专辑简介').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#1F1B2E').width('100%').margin({ top: 12 })
Text(this.album.description).fontSize(13).fontColor('#6B7280')
.lineHeight(22).width('100%').margin({ top: 6 })
}
.width('100%').padding(16).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ top: 8 }).alignItems(HorizontalAlign.Start)
}
}
使用Emoji前缀 🎤 🏷️ 来增强可读性,替代传统的图标组件。
2.7 曲目列表(核心)
@Builder buildTrackList() {
if (this.album) {
Column() {
// 标题栏
Row() {
Text('曲目列表').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
Blank()
Text(this.showAllTracks ? '收起 ↑' : `全部 ${this.album.tracks.length} 首 ↓`)
.fontSize(12).fontColor('#7C3AED')
.onClick(() => { this.showAllTracks = !this.showAllTracks; })
}
.width('100%').padding({ left: 20, right: 20, top: 16 })
// 动态展示曲目(前5首或全部)
ForEach(this.getDisplayTracks(), (track: Track) => {
Row() {
Text(track.num.toString()).fontSize(12).fontColor('#9CA3AF').width(24)
Column() {
Text(track.title).fontSize(14).fontColor('#1F1B2E')
Text(`${track.artist} · ${track.duration}`)
.fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 8 })
Text(track.isFavorite ? '❤️' : '🤍').fontSize(16)
.onClick(() => { this.toggleFavorite(track.num); })
}
.width('100%').padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF').borderRadius(8).margin({ top: 4 })
}, (track: Track) => track.num.toString())
}
.width('100%').backgroundColor('#FFFFFF')
.borderRadius(12).margin({ top: 8 }).padding({ bottom: 12 })
}
}
展开/收起逻辑:
getDisplayTracks(): Track[] {
if (!this.album) return [];
if (this.showAllTracks || this.album.tracks.length <= 5) {
return this.album.tracks; // 显示全部
}
return this.album.tracks.slice(0, 5); // 只显示前5首
}
收藏切换:
toggleFavorite(trackNum: number): void {
if (!this.album) return;
for (let i: number = 0; i < this.album.tracks.length; i++) {
if (this.album.tracks[i].num === trackNum) {
this.album.tracks[i].isFavorite = !this.album.tracks[i].isFavorite;
break;
}
}
}
直接修改 @State 数组元素的属性,ArkTS会深度监听到变化并更新UI。
三、ArkTS严格模式实践
3.1 null安全
@State album: AlbumDetail | null = null;
// 所有使用 this.album 的地方都需要判空
if (this.album) {
Text(this.album.title) // 安全访问
}
// 或三元表达式
Text(this.album ? this.album.title : '')
3.2 路由参数类型
const params: Record<string, Object> = router.getParams() as Record<string, Object>;
const genre: string = params['genre'] as string;
这里不能直接写成 params.genre,因为 Record<string, Object> 的键需要用 [] 访问,并且值需要 as 转型。
四、性能优化
4.1 列表展开收起
getDisplayTracks() 是一个方法调用,返回新的数组切片。当 showAllTracks 变化时:
- 方法重新执行
- ForEach 检测到数组变化
- UI更新为展开/收起状态
这是"计算属性"模式——不增加额外的 @State 变量,直接从已有状态衍生。
4.2 数据量级考虑
6张专辑 × 每张10首曲目 = 60条track数据。全部展开也不过60项,ForEach渲染毫无压力。如果专辑有100+曲目(古典音乐专辑常见),建议用 LazyForEach。

五、篇末总结
本篇完成了发现页和专辑详情页,核心内容包括:
- ✅ 三维度组合筛选(关键词+分类+年份),忽略大小写匹配标题和歌手
- ✅ Wrap组件实现热门标签云布局
- ✅ 发现页空状态设计
- ✅ 路由参数接收(从首页分类带参跳转)
- ✅ 专辑详情页的布局层次(背景+内容叠放)
- ✅ 曲目列表展开/收起与收藏功能
下一篇将实现歌单管理页,讲解:
- 歌单卡片创建与删除
- 展开/收起曲目列表
- Modal弹窗新建歌单
- 从歌单删除歌曲
文章索引:
- (一)项目初始化与Stage模型架构设计
- (二)首页开发 —— Grid分类网格与热歌排行榜
- (三)发现页与专辑详情 —— 多维筛选与曲目管理 ← 当前
- (四)歌单管理 —— 创建歌单与歌曲编排
- (五)个人中心与数据可视化 —— 统计图表与成就徽章
更多推荐


所有评论(0)