鸿蒙原生应用实战(三):发现页与专辑详情 —— 多维筛选与曲目管理

前言

前两篇完成了项目初始化和首页开发。本篇将实现两个功能型页面——发现页(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,这里新增了 albumgenreyear 字段,因为筛选需要这些维度。

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


在这里插入图片描述

五、篇末总结

本篇完成了发现页和专辑详情页,核心内容包括:

  1. ✅ 三维度组合筛选(关键词+分类+年份),忽略大小写匹配标题和歌手
  2. ✅ Wrap组件实现热门标签云布局
  3. ✅ 发现页空状态设计
  4. ✅ 路由参数接收(从首页分类带参跳转)
  5. ✅ 专辑详情页的布局层次(背景+内容叠放)
  6. ✅ 曲目列表展开/收起与收藏功能

下一篇将实现歌单管理页,讲解:

  • 歌单卡片创建与删除
  • 展开/收起曲目列表
  • Modal弹窗新建歌单
  • 从歌单删除歌曲

文章索引:

Logo

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

更多推荐