在这里插入图片描述

一、页面设计思路

音乐页是应用的"沉浸体验入口"——用户在这里浏览和聆听各民族的传统音乐。音乐是民族文化的灵魂,从蒙古族的长调到侗族的大歌,每个民族的音乐都承载着独特的文化记忆。

设计原则:

  1. 内容优先:音乐列表是主体,播放器是附随
  2. 操作便捷:播放、暂停、切歌一键完成
  3. 分类清晰:按音乐类型分类筛选,方便查找

页面结构

音乐页
├── 导航栏
├── 分类标签栏(横向滚动)
├── 音乐列表(可滚动)
└── 迷你播放器(有音乐播放时显示)

迷你播放器是音乐类应用的经典设计——它常驻页面底部,让用户在浏览列表的同时可以控制播放,随时切换歌曲,而不会中断当前的浏览上下文。

二、数据结构

2.1 音乐模型

interface EthnicMusic {
  id: string;              // 音乐ID
  name: string;            // 中文名称
  nameEn: string;          // 英文名称
  artist: string;          // 艺术家(中文)
  artistEn: string;        // 艺术家(英文)
  groupId: string;         // 所属民族ID
  category: MusicCategory; // 音乐分类
  coverImage: string;      // 封面图片路径
  audioUrl: string;        // 音频文件URL
  duration: string;        // 时长显示文本(如 "3:45")
  durationSeconds: number; // 时长秒数(用于进度条)
}

type MusicCategory = 'folk_song' | 'instrumental' | 'dance_music' | 'festival_music';
type PlayMode = 'sequence' | 'repeat' | 'shuffle';

2.2 播放状态

interface PlayState {
  currentMusic: EthnicMusic | null;  // 当前播放的音乐
  isPlaying: boolean;                // 是否正在播放
  currentPosition: number;           // 当前播放位置(秒)
  playMode: PlayMode;                // 播放模式
  playlist: EthnicMusic[];           // 当前播放列表
  currentIndex: number;              // 当前播放索引
}

为什么需要独立的 PlayState 接口?

因为播放状态不只是页面的 UI 状态——它需要在 MusicService 中维护,通过回调同步给页面。定义统一的接口,确保服务层和 UI 层的数据结构一致。

2.3 歌词模型

interface LyricLine {
  time: number;      // 时间点(秒)
  text: string;      // 歌词文本
  textEn?: string;   // 英文翻译(可选)
}

interface LyricData {
  songId: string;
  lines: LyricLine[];
  hasTranslation: boolean;
}

歌词是音乐播放器的重要组成部分——用户听民族音乐时,看着歌词能更好地理解歌曲的含义。

民族音乐歌词的特点

  • 可能有少数民族语言歌词 + 中文翻译
  • 可能有汉语歌词 + 英文翻译
  • 部分传统民歌可能没有歌词(纯音乐)

三、音乐播放器完整架构

3.1 三层架构设计

一个完整的音乐播放器需要三层架构:

┌─────────────────────────────────────┐
│         UI 层(Presentation)        │
│  音乐列表页 / 全屏播放器 / 迷你播放器  │
│  歌词展示 / 播放列表 / 播放模式切换   │
├─────────────────────────────────────┤
│       服务层(Service/Business)      │
│  MusicService 播放状态管理           │
│  播放列表 / 播放模式 / 进度管理       │
│  监听器机制 / 错误恢复               │
├─────────────────────────────────────┤
│       能力层(Capability)           │
│  AVPlayer / AudioRenderer           │
│  音频解码 / 输出 / 焦点管理           │
│  后台播放 / 锁屏控制                 │
└─────────────────────────────────────┘

各层职责

层级 职责 不做什么
UI层 展示数据、响应用户操作 不直接控制播放器、不维护播放状态
服务层 管理播放状态、提供播放API 不处理UI展示、不直接操作系统API
能力层 封装系统播放能力 不包含业务逻辑、不管UI状态

3.2 MusicService 核心设计

export class MusicService {
  private static instance: MusicService;
  
  private player: AVPlayer | null = null;
  private playState: PlayState;
  private listeners: ((state: PlayState) => void)[] = [];
  private progressTimer: number = -1;
  
  private constructor() {
    this.playState = {
      currentMusic: null,
      isPlaying: false,
      currentPosition: 0,
      playMode: 'sequence',
      playlist: [],
      currentIndex: -1
    };
  }
  
  static getInstance(): MusicService {
    if (!MusicService.instance) {
      MusicService.instance = new MusicService();
    }
    return MusicService.instance;
  }
  
  addListener(listener: (state: PlayState) => void): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener);
    }
  }
  
  removeListener(listener: (state: PlayState) => void): void {
    const index = this.listeners.indexOf(listener);
    if (index !== -1) {
      this.listeners.splice(index, 1);
    }
  }
  
  private notifyListeners(): void {
    const state = { ...this.playState };
    this.listeners.forEach(listener => {
      try {
        listener(state);
      } catch (e) {
        console.error('[MusicService] listener error:', JSON.stringify(e));
      }
    });
  }
}

设计要点

  1. 单例模式:全局唯一的播放服务,确保状态一致
  2. 监听器模式:支持多个 UI 组件同时监听播放状态
  3. 状态快照:通知时传状态副本,避免外部直接修改内部状态
  4. 异常保护:单个监听器报错不影响其他监听器

3.3 播放引擎封装

鸿蒙系统提供了 AVPlayer 作为音频播放的核心能力。MusicService 对其进行封装:

export class MusicService {
  // ...
  
  private async initPlayer(): Promise<void> {
    if (this.player) {
      await this.player.release();
    }
    
    this.player = await media.createAVPlayer();
    
    this.player.on('stateChange', (state: AVPlayerState) => {
      console.info(`[MusicService] player state: ${state}`);
      this.handleStateChange(state);
    });
    
    this.player.on('timeUpdate', (time: number) => {
      this.playState.currentPosition = time / 1000000;
      this.notifyListeners();
    });
    
    this.player.on('finish', () => {
      console.info('[MusicService] playback finished');
      this.handlePlaybackComplete();
    });
    
    this.player.on('error', (error: Error) => {
      console.error('[MusicService] player error:', JSON.stringify(error));
      this.handlePlaybackError(error);
    });
  }
  
  private handleStateChange(state: AVPlayerState): void {
    switch (state) {
      case 'playing':
        this.playState.isPlaying = true;
        this.startProgressTimer();
        break;
      case 'paused':
      case 'stopped':
        this.playState.isPlaying = false;
        this.stopProgressTimer();
        break;
    }
    this.notifyListeners();
  }
}

为什么封装 AVPlayer?

  1. 统一接口:UI 层不直接依赖系统 API,换实现更容易
  2. 状态管理:在 Service 层统一管理播放状态
  3. 错误处理:集中处理播放错误,提供降级策略
  4. 事件分发:一个播放器事件可以通知多个监听器

四、状态管理

3.1 页面状态

@State currentCategory: MusicCategory | 'all' = 'all';
@State musicList: EthnicMusic[] = MUSIC_LIST;
@State currentMusic: EthnicMusic | null = null;
@State isPlaying: boolean = false;
@State currentPosition: number = 0;
@State playMode: PlayMode = 'sequence';
状态变量 作用
currentCategory 当前选中的分类
musicList 当前展示的音乐列表
currentMusic 当前播放的音乐
isPlaying 是否正在播放
currentPosition 当前播放进度(秒)
playMode 播放模式(顺序/循环/随机)

3.2 服务层回调

音乐播放是一个持续变化的状态——播放进度每秒钟都在变。所以不能靠页面轮询,要用回调机制

private musicService: MusicService = MusicService.getInstance();
private playStateListener: ((state: PlayState) => void) | null = null;

aboutToAppear(): void {
  // 注册监听器
  this.playStateListener = (state: PlayState) => {
    this.currentMusic = state.currentMusic;
    this.isPlaying = state.isPlaying;
    this.currentPosition = state.currentPosition;
    this.playMode = state.playMode;
  };
  this.musicService.addListener(this.playStateListener);

  // 初始同步一次状态
  this.currentMusic = this.musicService.getCurrentMusic();
  this.isPlaying = this.musicService.getIsPlaying();
  this.currentPosition = this.musicService.getCurrentPosition();
}

aboutToDisappear(): void {
  // 移除监听器,防止内存泄漏
  if (this.playStateListener) {
    this.musicService.removeListener(this.playStateListener);
    this.playStateListener = null;
  }
}

监听器模式的要点

  1. 注册时机aboutToAppear 中注册,页面出现时开始接收状态更新
  2. 初始同步:注册后立即同步一次当前状态,避免"空白等待"
  3. 移除时机aboutToDisappear 中移除,页面销毁后不再接收回调
  4. 空值保护:移除前判断是否存在,移除后置为 null

⚠️ 内存泄漏警示:如果不在页面销毁时移除监听器,MusicService 会一直持有页面的引用,导致页面无法被 GC 回收,造成内存泄漏。这是移动端开发中最常见的内存泄漏场景之一。

四、分类标签栏

@Builder
buildCategoryTabs(): void {
  Scroll() {
    Row({ space: $r('app.float.spacing_sm') }) {
      ForEach(this.categoryTabs, (tab: CategoryTab) => {
        Text(this.getLocalizedText(tab.zhLabel, tab.enLabel))
          .fontSize($r('app.float.font_size_sm'))
          .fontColor(this.currentCategory === tab.key
            ? $r('app.color.text_on_primary')
            : $r('app.color.text_secondary'))
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor(this.currentCategory === tab.key
            ? $r('app.color.primary_color')
            : $r('app.color.card_background'))
          .onClick(() => {
            this.onCategoryChange(tab.key);
          })
      }, (tab: CategoryTab) => tab.key)
      .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .padding({ top: $r('app.float.spacing_sm'), bottom: $r('app.float.spacing_sm') })
}

分类数据

private categoryTabs: CategoryTab[] = [
  { key: 'all', zhLabel: '全部', enLabel: 'All' },
  { key: 'folk_song', zhLabel: '民歌', enLabel: 'Folk Song' },
  { key: 'instrumental', zhLabel: '器乐', enLabel: 'Instrumental' },
  { key: 'dance_music', zhLabel: '舞曲', enLabel: 'Dance Music' },
  { key: 'festival_music', zhLabel: '节日音乐', enLabel: 'Festival Music' }
];

ForEach 的 key 参数

ForEach(this.categoryTabs, (...) => { ... }, (tab: CategoryTab) => tab.key)

第三个参数是 key 生成函数——告诉 ArkUI 用什么字段作为列表项的唯一标识。设置正确的 key 可以提升列表渲染性能,避免不必要的重建。

💡 最佳实践:只要数据有唯一标识(id/key),就应该给 ForEach 传 key 函数。这是列表性能优化的基础。

分类切换逻辑

private onCategoryChange(category: MusicCategory | 'all'): void {
  this.currentCategory = category;
  if (category === 'all') {
    this.musicList = MUSIC_LIST;
  } else {
    this.musicList = getMusicByCategory(category);
  }
}

分类切换的逻辑很简单:改变 currentCategory,然后根据分类过滤列表。

设计考量

  • 为什么不直接在 build 中过滤?因为过滤操作在 build 中会每次都执行,用 @State 缓存结果更好
  • 为什么用函数 getMusicByCategory?因为分类过滤逻辑可能被多处复用,抽到工具函数里更 DRY

五、音乐列表

5.1 列表结构

@Builder
buildMusicList(): void {
  List() {
    ForEach(this.musicList, (music: EthnicMusic, index: number) => {
      ListItem() {
        this.buildMusicItem(music, index)
      }
    }, (music: EthnicMusic) => music.id)
  }
  .width('100%')
  .layoutWeight(1)
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .scrollBar(BarState.Off)
}

List + ListItem 而不是 Scroll + Column,因为:

  1. List 有更好的性能(虚拟滚动、回收复用)
  2. List 有更多列表相关的功能(分割线、滑动删除等)
  3. 语义更明确——这就是一个列表

5.2 音乐条目

@Builder
buildMusicItem(music: EthnicMusic, index: number): void {
  Row({ space: $r('app.float.spacing_md') }) {
    // 封面图
    Image($rawfile(music.coverImage))
      .width(48)
      .height(48)
      .borderRadius($r('app.float.radius_sm'))
      .objectFit(ImageFit.Cover)

    // 歌曲信息
    Column({ space: 4 }) {
      Text(this.getLocalizedText(music.name, music.nameEn))
        .fontSize($r('app.float.font_size_md'))
        .fontWeight(FontWeight.Medium)
        .fontColor($r('app.color.text_primary'))
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Row({ space: 8 }) {
        Text(this.getEthnicName(music.groupId))
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_hint'))

        Text('·')
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_hint'))

        Text(music.duration)
          .fontSize($r('app.float.font_size_xs'))
          .fontColor($r('app.color.text_hint'))
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    // 播放按钮
    Text(this.currentMusic?.id === music.id && this.isPlaying ? '\u23F8' : '\u25B6')
      .fontSize($r('app.float.icon_size_md'))
      .fontColor($r('app.color.primary_color'))
      .padding(8)
      .onClick(() => {
        this.onMusicClick(music, index);
      })
  }
  .width('100%')
  .padding($r('app.float.spacing_md'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_md'))
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    this.onMusicClick(music, index);
  })
}

条目设计解析

经典的"左中右"三段式布局

区域 内容 宽度
左侧 封面图 固定 48vp
中间 歌名 + 民族 + 时长 自适应(layoutWeight)
右侧 播放/暂停按钮 自适应内容

文本截断处理

.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })

歌名太长怎么办?用 maxLines(1) 限制只显示一行,超出部分用省略号表示。这是列表项的标准处理方式。

民族名称获取

private getEthnicName(groupId: string): string {
  const ethnic = getEthnicById(groupId);
  if (ethnic) {
    return this.isChinese() ? ethnic.name : ethnic.nameEn;
  }
  return '';
}

音乐数据里只存了民族 ID,显示时通过 ID 查找民族名称。这是典型的外键关联模式——数据存储用 ID,展示时查表获取名称。

播放按钮的状态联动

this.currentMusic?.id === music.id && this.isPlaying ? '\u23F8' : '\u25B6'
  • 当前歌曲正在播放 → 显示暂停图标 ⏸
  • 其他情况 → 显示播放图标 ▶

这是一个很小但很重要的交互细节——用户能一眼看出"哪首歌在播放"。

六、迷你播放器

迷你播放器是音乐页的灵魂组件,它固定在页面底部,让用户可以随时控制播放。

6.1 整体结构

@Builder
buildMiniPlayer(): void {
  Column() {
    if (this.currentMusic) {
      // 进度条
      Slider({ ... })

      // 控制栏
      Row({ space: $r('app.float.spacing_md') }) {
        // 封面
        Image($rawfile(this.currentMusic.coverImage))
        // 歌曲信息
        Column() { Text(name); Text(artist) }
          .layoutWeight(1)
        // 控制按钮
        Row() { 上一首; 播放/暂停; 下一首 }
      }
      .width('100%')
      .padding(...)
    }
  }
  .width('100%')
  .backgroundColor($r('app.color.card_background'))
  .borderRadius({ topLeft: $r('app.float.radius_lg'), topRight: $r('app.float.radius_lg') })
  .shadow({
    color: '#00000015',
    radius: 8,
    offsetX: 0,
    offsetY: -2
  })
}

6.2 顶部圆角 + 阴影

.borderRadius({ topLeft: $r('app.float.radius_lg'), topRight: $r('app.float.radius_lg') })
.shadow({
  color: '#00000015',
  radius: 8,
  offsetX: 0,
  offsetY: -2
})

设计要点

  • 只有顶部两个圆角,底部是直角(因为贴在屏幕底部)
  • 阴影向上投射(offsetY: -2),营造"浮起"的感觉
  • 阴影颜色用带透明度的黑色(#00000015),柔和不刺眼

6.3 进度条

Slider({
  value: this.currentPosition,
  min: 0,
  max: this.currentMusic.durationSeconds,
  step: 1,
  style: SliderStyle.OutSet
})
  .blockColor($r('app.color.primary_color'))
  .trackColor($r('app.color.border_color'))
  .selectedColor($r('app.color.primary_color'))
  .width('100%')
  .onChange((value: number) => {
    this.onSeekChange(value);
  })

Slider 组件的核心参数:

参数 说明
value 当前值
min / max 最小值/最大值
step 步长(每次变化的最小单位)
style 样式:OutSet(滑块在轨道外)/ InSet(滑块在轨道内)
blockColor 滑块颜色
trackColor 轨道背景色
selectedColor 已选中部分的颜色

拖拽进度条跳转播放位置

private onSeekChange(value: number): void {
  this.musicService.seekTo(value);
}

用户拖动进度条时,实时调用 seekTo 跳转播放位置。这是 Slider 的 onChange 回调——每次值变化都会触发。

6.4 控制按钮

Row({ space: $r('app.float.spacing_md') }) {
  // 上一首
  Text('\u23EE')
    .fontSize($r('app.float.icon_size_lg'))
    .fontColor($r('app.color.text_primary'))
    .onClick(() => { this.playPrev(); })

  // 播放/暂停
  Text(this.isPlaying ? '\u23F8' : '\u25B6')
    .fontSize($r('app.float.icon_size_xl'))
    .fontColor($r('app.color.primary_color'))
    .onClick(() => { this.togglePlay(); })

  // 下一首
  Text('\u23ED')
    .fontSize($r('app.float.icon_size_lg'))
    .fontColor($r('app.color.text_primary'))
    .onClick(() => { this.playNext(); })
}

三个按钮:上一首 ⏮、播放/暂停 ⏸/▶、下一首 ⏭。

图标大小的层级

  • 播放/暂停按钮:最大(xl),视觉焦点
  • 上一首/下一首:中等(lg),次要操作

主操作按钮更大更醒目,这是按钮设计的基本原则。

6.5 播放控制方法

private togglePlay(): void {
  if (this.isPlaying) {
    this.musicService.pause();
  } else {
    this.musicService.play();
  }
}

private playPrev(): void {
  this.musicService.playPrev();
}

private playNext(): void {
  this.musicService.playNext();
}

private onMusicClick(music: EthnicMusic, index: number): void {
  this.musicService.setPlaylist(this.musicList, index);
  this.musicService.play();
}

页面层的播放控制只是"转发"——真正的逻辑都在 MusicService 里。页面只负责:

  1. 把用户操作传给 Service
  2. 监听 Service 的状态变化更新 UI

这就是单向数据流的思想:UI → 操作 → Service → 状态 → UI。

七、时间格式化工具

private formatTime(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = Math.floor(seconds % 60);
  return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
}

把秒数格式化成 “分:秒” 格式(如 125 秒 → “2:05”)。

注意秒数的补零处理——小于 10 秒前面补个 0,不然会显示成 “2:5”,不美观。

💡 最佳实践:类似 formatTime 这种纯工具函数,如果被多个页面使用,应该抽到 utils/ 目录下的工具文件中,不要每个页面写一份。

八、导航栏

@Builder
buildNavBar(): void {
  Row() {
    Text('<')
      .fontSize($r('app.float.font_size_xxl'))
      .fontColor($r('app.color.text_primary'))
      .onClick(() => { router.back(); })

    Text(this.getLocalizedText('民族音乐馆', 'Ethnic Music Hall'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))
      .layoutWeight(1)
      .textAlign(TextAlign.Center)

    Text('')
      .width(24)
  }
  .width('100%')
  .height(48)
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .alignItems(VerticalAlign.Center)
}

右边用一个固定宽度的空 Text 占位,保证标题真正居中。这是设置页里讲过的"对称占位"技巧。

九、全屏播放器

9.1 为什么需要全屏播放器?

迷你播放器适合"边浏览边听",但如果用户想专注听歌、看歌词、调整更多设置,就需要全屏播放器了。

全屏播放器是音乐体验的"沉浸模式"——更大的封面、完整的歌词、更多的控制选项。

全屏播放器的设计目标

  1. 沉浸感:大封面 + 模糊背景,营造氛围
  2. 信息全:歌名、艺术家、歌词、进度一目了然
  3. 控制全:播放/暂停/上一首/下一首/播放模式/播放列表
  4. 可返回:随时可以回到列表页,音乐不中断

9.2 全屏播放器布局

┌─────────────────────────────────┐
│  ←          正在播放        ⋮   │  ── 导航栏
├─────────────────────────────────┤
│                                 │
│        ╭──────────────╮         │
│        │              │         │
│        │   专辑封面    │         │  ── 大封面
│        │              │         │
│        ╰──────────────╯         │
│                                 │
│      《鸿雁》                    │
│      蒙古族民歌                  │  ── 歌曲信息
│                                 │
│  ───●────────────────────  3:45 │  ── 进度条
│  1:23                       4:30│
│                                 │
│     ⏮    ⏯    ⏭    🔁          │  ── 控制按钮
│                                 │
│  ─── 歌词区域 ───               │
│  鸿雁 天空上                    │
│  对对排成行                     │  ── 歌词展示
│  江水长 秋草黄                  │
│                                 │
└─────────────────────────────────┘

9.3 核心代码结构

@State showFullPlayer: boolean = false;
private fullPlayerScroller: Scroller = new Scroller();

@Builder
buildFullPlayer(): void {
  if (this.showFullPlayer && this.currentMusic) {
    Column() {
      // 导航栏
      this.buildFullPlayerNavBar()
      
      // 封面
      this.buildFullPlayerCover()
      
      // 歌曲信息
      this.buildFullPlayerSongInfo()
      
      // 进度条
      this.buildFullPlayerProgress()
      
      // 控制按钮
      this.buildFullPlayerControls()
      
      // 歌词
      this.buildLyricsView()
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.background'))
    .position({ x: 0, y: 0 })
    .zIndex(100)
    .transition(TransitionEffect.move(TransitionEdge.Bottom))
  }
}

进入全屏播放器的触发方式:点击迷你播放器 → 全屏播放器从底部滑入。

9.4 模糊背景效果

全屏播放器的背景可以用当前歌曲的封面做模糊处理,营造沉浸感:

@Builder
buildFullPlayerCover(): void {
  Stack({ alignContent: Alignment.Center }) {
    // 背景模糊层
    Image($rawfile(this.currentMusic.coverImage))
      .width('100%')
      .height('100%')
      .objectFit(ImageFit.Cover)
      .blur(20)
      .opacity(0.3)
    
    // 主封面
    Image($rawfile(this.currentMusic.coverImage))
      .width(240)
      .height(240)
      .borderRadius(16)
      .objectFit(ImageFit.Cover)
      .shadow({
        color: '#00000040',
        radius: 20,
        offsetX: 0,
        offsetY: 8
      })
  }
  .width('100%')
  .height(300)
}

设计要点

  • 背景用同一张图,放大 + 模糊 + 低透明度
  • 主封面清晰展示,带阴影增加层次感
  • 整体色调和封面颜色一致,视觉和谐

十、播放模式

10.1 三种播放模式

音乐播放器通常有三种播放模式:

模式 图标 说明 下一首逻辑
顺序播放 🔁(列表循环) 按列表顺序播放,到最后一首后停止或回到第一首 index + 1,到末尾回到 0
单曲循环 🔂 反复播放同一首歌 不变,重新播放当前
随机播放 🔀 随机选择下一首 随机生成一个不等于当前的 index

10.2 播放模式的实现

export class MusicService {
  // ...
  
  setPlayMode(mode: PlayMode): void {
    this.playState.playMode = mode;
    this.notifyListeners();
  }
  
  getPlayMode(): PlayMode {
    return this.playState.playMode;
  }
  
  togglePlayMode(): PlayMode {
    const modes: PlayMode[] = ['sequence', 'repeat', 'shuffle'];
    const currentIndex = modes.indexOf(this.playState.playMode);
    const nextIndex = (currentIndex + 1) % modes.length;
    this.playState.playMode = modes[nextIndex];
    this.notifyListeners();
    return this.playState.playMode;
  }
  
  private handlePlaybackComplete(): void {
    switch (this.playState.playMode) {
      case 'sequence':
        this.playNextInSequence();
        break;
      case 'repeat':
        this.replayCurrent();
        break;
      case 'shuffle':
        this.playRandom();
        break;
    }
  }
  
  private playNextInSequence(): void {
    const nextIndex = this.playState.currentIndex + 1;
    if (nextIndex < this.playState.playlist.length) {
      this.playAtIndex(nextIndex);
    } else {
      this.playState.isPlaying = false;
      this.notifyListeners();
    }
  }
  
  private replayCurrent(): void {
    if (this.player) {
      this.player.reset();
      this.player.play();
    }
  }
  
  private playRandom(): void {
    if (this.playState.playlist.length <= 1) return;
    
    let nextIndex: number;
    do {
      nextIndex = Math.floor(Math.random() * this.playState.playlist.length);
    } while (nextIndex === this.playState.currentIndex);
    
    this.playAtIndex(nextIndex);
  }
}

10.3 UI 中的模式切换

@Builder
buildPlayModeButton(): void {
  Text(this.getPlayModeIcon())
    .fontSize($r('app.float.icon_size_md'))
    .fontColor($r('app.color.text_secondary'))
    .padding(8)
    .onClick(() => {
      const newMode = this.musicService.togglePlayMode();
      promptAction.showToast({
        message: this.getPlayModeName(newMode)
      });
    })
}

private getPlayModeIcon(): string {
  switch (this.playMode) {
    case 'sequence': return '\u{1F501}';  // 🔁
    case 'repeat': return '\u{1F502}';    // 🔂
    case 'shuffle': return '\u{1F500}';   // 🔀
    default: return '\u{1F501}';
  }
}

private getPlayModeName(mode: PlayMode): string {
  if (!this.isChinese()) {
    switch (mode) {
      case 'sequence': return 'Sequence Play';
      case 'repeat': return 'Repeat One';
      case 'shuffle': return 'Shuffle';
      default: return '';
    }
  }
  switch (mode) {
    case 'sequence': return '顺序播放';
    case 'repeat': return '单曲循环';
    case 'shuffle': return '随机播放';
    default: return '';
  }
}

点击播放模式按钮时,循环切换三种模式,并用 Toast 提示当前模式。

十一、播放列表管理

11.1 播放列表面板

播放列表是音乐播放器的重要组成部分——用户可以查看当前播放列表、选择歌曲、删除歌曲、清空列表。

播放列表面板设计

┌─────────────────────────┐
│  播放列表 (20)    清空 ✕ │
├─────────────────────────┤
│  ◉ 1. 鸿雁 - 蒙古族民歌 │  ← 正在播放
│    2. 天堂 - 腾格尔     │
│    3. 在那遥远的地方     │
│    4. 康定情歌          │
│    ...                 │
└─────────────────────────┘

11.2 播放列表的操作

操作 说明 触发方式
播放指定歌曲 点击列表项播放 点击列表项
移除歌曲 从播放列表中移除 左滑删除 / 长按菜单
清空列表 清空整个播放列表 点击"清空"按钮
添加歌曲 把歌曲加入播放列表 从音乐列表添加

11.3 核心代码

@State showPlaylist: boolean = false;

@Builder
buildPlaylistPanel(): void {
  if (this.showPlaylist) {
    Column() {
      // 标题栏
      Row() {
        Text(`${this.isChinese() ? '播放列表' : 'Playlist'} (${this.playlist.length})`)
          .fontSize($r('app.float.font_size_md'))
          .fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.text_primary'))
          .layoutWeight(1)
        
        Text(this.isChinese() ? '清空' : 'Clear')
          .fontSize($r('app.float.font_size_sm'))
          .fontColor($r('app.color.primary_color'))
          .onClick(() => { this.clearPlaylist(); })
        
        Text('✕')
          .fontSize($r('app.float.font_size_lg'))
          .fontColor($r('app.color.text_secondary'))
          .margin({ left: 16 })
          .onClick(() => { this.showPlaylist = false; })
      }
      .width('100%')
      .padding($r('app.float.spacing_md'))
      
      // 歌曲列表
      List() {
        ForEach(this.playlist, (music: EthnicMusic, index: number) => {
          ListItem() {
            this.buildPlaylistItem(music, index)
          }
        }, (music: EthnicMusic) => music.id)
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('60%')
    .position({ x: 0, y: '40%' })
    .backgroundColor($r('app.color.card_background'))
    .borderRadius({ topLeft: 16, topRight: 16 })
    .zIndex(200)
  }
}

@Builder
buildPlaylistItem(music: EthnicMusic, index: number): void {
  Row({ space: 12 }) {
    // 序号 / 播放指示
    if (this.currentMusic?.id === music.id && this.isPlaying) {
      Text('\u{1F3A7}')  // 🎧
        .fontSize(16)
        .width(24)
        .textAlign(TextAlign.Center)
    } else {
      Text(`${index + 1}`)
        .fontSize($r('app.float.font_size_sm'))
        .fontColor($r('app.color.text_hint'))
        .width(24)
        .textAlign(TextAlign.Center)
    }
    
    // 歌曲信息
    Column({ space: 2 }) {
      Text(this.getLocalizedText(music.name, music.nameEn))
        .fontSize($r('app.float.font_size_md'))
        .fontColor(this.currentMusic?.id === music.id
          ? $r('app.color.primary_color')
          : $r('app.color.text_primary'))
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      
      Text(this.getLocalizedText(music.artist, music.artistEn))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    
    // 移除按钮
    Text('✕')
      .fontSize($r('app.float.font_size_sm'))
      .fontColor($r('app.color.text_hint'))
      .padding(8)
      .onClick(() => {
        this.removeFromPlaylist(index);
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .onClick(() => {
    this.musicService.playAtIndex(index);
  })
}

设计要点

  1. 正在播放的指示:当前播放的歌曲用耳机图标 + 主色文字标记
  2. 可移除:每首歌右侧有删除按钮,方便管理
  3. 点击播放:点击列表项直接播放该歌曲
  4. 底部弹出:从底部弹出的面板,不打断当前播放

十二、歌词展示与滚动

12.1 歌词数据解析

常见的歌词格式是 LRC(LyRiCs),格式如下:

[ti:鸿雁]
[ar:蒙古族民歌]
[00:00.00]鸿雁 天空上
[00:06.50]对对排成行
[00:12.80]江水长 秋草黄
[00:19.20]草原上琴声忧伤

LRC 解析函数

function parseLrc(lrcText: string): LyricLine[] {
  const lines = lrcText.split('\n');
  const result: LyricLine[] = [];
  
  const timeRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/g;
  
  for (const line of lines) {
    const matches = [...line.matchAll(timeRegex)];
    if (matches.length === 0) continue;
    
    const text = line.replace(timeRegex, '').trim();
    if (!text) continue;
    
    for (const match of matches) {
      const minutes = parseInt(match[1], 10);
      const seconds = parseInt(match[2], 10);
      const milliseconds = parseInt(match[3].padEnd(3, '0'), 10);
      const totalSeconds = minutes * 60 + seconds + milliseconds / 1000;
      
      result.push({ time: totalSeconds, text });
    }
  }
  
  result.sort((a, b) => a.time - b.time);
  return result;
}

12.2 歌词滚动实现

歌词需要跟随播放进度滚动,当前播放的那行高亮显示:

@State currentLyricIndex: number = -1;
@State lyrics: LyricLine[] = [];
private lyricScroller: Scroller = new Scroller();

private updateCurrentLyric(position: number): void {
  if (this.lyrics.length === 0) return;
  
  let newIndex = -1;
  for (let i = this.lyrics.length - 1; i >= 0; i--) {
    if (position >= this.lyrics[i].time) {
      newIndex = i;
      break;
    }
  }
  
  if (newIndex !== this.currentLyricIndex) {
    this.currentLyricIndex = newIndex;
    this.scrollLyricToCurrent();
  }
}

private scrollLyricToCurrent(): void {
  if (this.currentLyricIndex < 0) return;
  
  const lineHeight = 36;
  const offset = this.currentLyricIndex * lineHeight;
  this.lyricScroller.scrollTo({ x: 0, y: offset - 100, animation: { duration: 300 } });
}

歌词视图组件

@Builder
buildLyricsView(): void {
  Scroll(this.lyricScroller) {
    Column({ space: 8 }) {
      ForEach(this.lyrics, (line: LyricLine, index: number) => {
        Text(line.text)
          .fontSize(index === this.currentLyricIndex
            ? $r('app.float.font_size_md')
            : $r('app.float.font_size_sm'))
          .fontWeight(index === this.currentLyricIndex
            ? FontWeight.Medium
            : FontWeight.Normal)
          .fontColor(index === this.currentLyricIndex
            ? $r('app.color.primary_color')
            : $r('app.color.text_hint'))
          .textAlign(TextAlign.Center)
          .width('100%')
          .padding({ top: 4, bottom: 4 })
          .transition(TransitionEffect.OPACITY)
      })
    }
    .width('100%')
    .padding({ top: 100, bottom: 100 })
  }
  .scrollBar(BarState.Off)
  .layoutWeight(1)
  .width('100%')
}

设计要点

  1. 居中显示:当前歌词滚动到视图中间位置
  2. 高亮对比:当前行字体更大、颜色更亮、字重更重
  3. 平滑滚动:滚动有 300ms 动画,不生硬
  4. 上下留白:歌词列表上下有 padding,确保第一行和最后一行也能滚到中间

十三、后台播放与锁屏控制

13.1 为什么需要后台播放?

用户听音乐时,不可能一直停留在音乐页——可能会浏览其他页面,甚至锁屏。这时候音乐不能停,而且用户需要能在锁屏界面控制播放。

鸿蒙系统提供了后台音频播放锁屏控制的能力。

13.2 后台播放配置

要支持后台播放,需要:

  1. 申请后台音频权限
  2. 使用 AVPlayer 的后台模式
  3. 设置音频会话类型

核心代码框架

export class MusicService {
  private audioSession: audio.AudioSession | null = null;
  
  async initAudioSession(): Promise<void> {
    try {
      this.audioSession = await audio.getAudioManager().getAudioSession(audio.AudioVolumeType.STREAM_MUSIC);
      
      await this.audioSession.setAudioRendererInfo({
        content: audio.ContentType.CONTENT_TYPE_MUSIC,
        usage: audio.StreamUsage.STREAM_USAGE_MEDIA
      });
      
      this.audioSession.on('interrupt', (event: audio.InterruptEvent) => {
        this.handleAudioInterrupt(event);
      });
    } catch (e) {
      console.error('[MusicService] init audio session failed:', JSON.stringify(e));
    }
  }
  
  private handleAudioInterrupt(event: audio.InterruptEvent): void {
    if (event.forceType === audio.InterruptForceType.INTERRUPT_FORCE) {
      switch (event.hintType) {
        case audio.InterruptHint.INTERRUPT_HINT_PAUSE:
          this.pause();
          break;
        case audio.InterruptHint.INTERRUPT_HINT_STOP:
          this.stop();
          break;
      }
    }
  }
}

13.3 锁屏控制

锁屏控制允许用户在锁屏界面看到正在播放的歌曲信息,并控制播放/暂停/上一首/下一首。

实现要点

  • 使用 avSession 模块创建媒体会话
  • 设置当前播放的媒体信息(标题、艺术家、封面)
  • 注册播放控制回调
export class MusicService {
  private avSession: avSession.AVSession | null = null;
  
  async initAVSession(): Promise<void> {
    try {
      this.avSession = await avSession.createAVSession('music_playback', 'audio');
      
      this.avSession.on('play', () => { this.play(); });
      this.avSession.on('pause', () => { this.pause(); });
      this.avSession.on('playNext', () => { this.playNext(); });
      this.avSession.on('playPrevious', () => { this.playPrev(); });
    } catch (e) {
      console.error('[MusicService] init AV session failed:', JSON.stringify(e));
    }
  }
  
  private updateNowPlayingMetadata(): void {
    if (!this.avSession || !this.playState.currentMusic) return;
    
    this.avSession.setAVMetaData({
      title: this.playState.currentMusic.name,
      artist: this.playState.currentMusic.artist,
      duration: this.playState.currentMusic.durationSeconds * 1000
    });
    
    this.avSession.setAVPlaybackState({
      state: this.playState.isPlaying
        ? avSession.PlaybackState.PLAYBACK_STATE_PLAY
        : avSession.PlaybackState.PLAYBACK_STATE_PAUSE,
      position: this.playState.currentPosition * 1000,
      speed: 1.0
    });
  }
}

💡 注意:后台播放和锁屏控制涉及到系统权限和后台任务管理,具体实现需要参考鸿蒙官方文档。在「民族图鉴」v1.0 中,这是进阶功能,可以后续迭代加入。

十四、悬浮播放球

14.1 什么是悬浮播放球?

迷你播放器是"页面底部常驻",但如果用户离开了音乐页(比如去看民族详情、看地图),迷你播放器就看不到了。这时候需要一个全局悬浮的播放控件——悬浮播放球。

悬浮播放球的特点

  • 全局显示,在所有页面上都能看到
  • 可以拖动到屏幕任意位置
  • 点击展开迷你播放器或全屏播放器
  • 只显示封面 + 播放/暂停按钮

14.2 悬浮球的实现思路

@State showFloatBall: boolean = false;
@State floatBallX: number = 20;
@State floatBallY: number = 300;
private isDragging: boolean = false;
private dragStartX: number = 0;
private dragStartY: number = 0;

@Builder
buildFloatBall(): void {
  if (this.showFloatBall && this.currentMusic) {
    Stack({ alignContent: Alignment.Center }) {
      Image($rawfile(this.currentMusic.coverImage))
        .width(56)
        .height(56)
        .borderRadius(28)
        .objectFit(ImageFit.Cover)
        .shadow({ color: '#00000040', radius: 8, offsetX: 0, offsetY: 2 })
      
      Text(this.isPlaying ? '\u23F8' : '\u25B6')
        .fontSize(20)
        .fontColor('#FFFFFF')
        .textAlign(TextAlign.Center)
        .width(56)
        .height(56)
        .onClick(() => {
          if (!this.isDragging) {
            this.togglePlay();
          }
        })
    }
    .position({ x: this.floatBallX, y: this.floatBallY })
    .zIndex(999)
    .gesture(
      PanGesture()
        .onActionStart((event: GestureEvent) => {
          this.isDragging = false;
          this.dragStartX = event.offsetX;
          this.dragStartY = event.offsetY;
        })
        .onActionUpdate((event: GestureEvent) => {
          const dx = event.offsetX - this.dragStartX;
          const dy = event.offsetY - this.dragStartY;
          if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
            this.isDragging = true;
          }
          this.floatBallX = Math.max(0, Math.min(this.screenWidth - 56, this.floatBallX + dx));
          this.floatBallY = Math.max(0, Math.min(this.screenHeight - 56, this.floatBallY + dy));
          this.dragStartX = event.offsetX;
          this.dragStartY = event.offsetY;
        })
        .onActionEnd(() => {
          setTimeout(() => { this.isDragging = false; }, 100);
        })
    )
  }
}

设计要点

  1. 可拖动:用 PanGesture 实现拖拽,边界限制在屏幕内
  2. 可点击:点击播放/暂停,拖动时不触发点击
  3. 自动靠边:可以加个"松手后自动吸附到左右边缘"的效果
  4. 全局显示:用最高的 zIndex,确保在所有内容之上

💡 注意:真正的"全局悬浮球"需要用悬浮窗能力(可以跨页面显示),而不是在单个页面里实现。鸿蒙系统提供了 Window 相关的 API 来实现悬浮窗。

十五、架构思考

9.1 服务层模式

音乐页是"服务层模式"的典型案例:

MusicPage (UI)
  ↑ 监听状态 │
  │ 发送指令 ↓
MusicService (服务层)
  ↑ 事件回调 │
  │ 播放控制 ↓
AVPlayer / AudioRenderer (系统播放能力)

服务层的职责

  • 管理播放器实例
  • 维护播放状态(播放列表、当前位置、播放模式)
  • 提供播放控制 API(play/pause/prev/next/seek)
  • 通知状态变更给监听者
  • 处理播放错误和自动恢复

为什么需要服务层?

  1. 状态共享:多个页面可能需要播放状态(首页、音乐页、详情页都可能有迷你播放器)
  2. 逻辑复用:播放控制逻辑在 Service 中,不用每个页面写一份
  3. 生命周期独立:音乐播放不应该因为页面切换就停止
  4. 易测试:Service 层可以独立单元测试

9.2 监听器模式 vs @StorageLink

方案 适用场景 本项目
@StorageLink 简单的全局状态(语言、主题) ✅ 用于设置类
服务层 + 回调 复杂的持续变化状态(播放进度) ✅ 用于音乐播放

播放进度每秒钟都在变,用 @StorageLink 虽然也能实现,但:

  1. AppStorage 是全局的,太"重"了
  2. 播放状态包含多个关联字段(音乐、位置、模式…)
  3. 服务层本身需要这些状态做逻辑处理

所以用"Service + 回调监听器"更合适。

十、性能与体验优化

10.1 列表性能

  • 使用 List + ListItem 而非 Scroll + Column
  • ForEach 设置 key 函数,提升 diff 效率
  • 封面图设置固定尺寸,避免布局抖动
  • 文本设置 maxLines 和 ellipsis,防止长文本撑破布局

10.2 播放体验

  • 迷你播放器常驻:浏览列表时可以随时控制播放
  • 进度条可拖拽:支持跳转到任意位置
  • 播放列表联动:点击歌曲即加入播放列表并播放
  • 状态同步:从其他页面跳转回来,播放状态保持一致

16.3 内存管理

  • aboutToDisappear 中移除监听器,防止内存泄漏
  • 播放器实例由 Service 管理(单例),不会重复创建
  • 页面销毁不影响播放继续(服务层独立生命周期)

十七、小结

技术点 关键方法/组件 难度
音乐列表 List + ListItem + ForEach key ⭐⭐
分类标签 横向 Scroll + 胶囊标签 ⭐⭐
迷你播放器 底部常驻 + 阴影 + 圆角 ⭐⭐⭐
全屏播放器 模糊背景 + 大封面 + 歌词 ⭐⭐⭐⭐
进度条控制 Slider 组件 + onChange + seekTo ⭐⭐⭐
播放模式 顺序/循环/随机 + 状态切换 ⭐⭐⭐
播放列表管理 底部面板 + 增删改查 ⭐⭐⭐
歌词展示 LRC解析 + 滚动 + 高亮跟随 ⭐⭐⭐⭐
后台播放 AudioSession + AVSession ⭐⭐⭐⭐
悬浮播放球 PanGesture + 全局悬浮 ⭐⭐⭐⭐
服务层回调 addListener / removeListener ⭐⭐⭐
服务层架构 单例模式 + 状态封装 + 事件分发 ⭐⭐⭐⭐
内存管理 aboutToDisappear 移除监听器 ⭐⭐⭐

音乐页是整个"页面开发篇"中架构最完整、功能最丰富的页面之一——它展示了如何用服务层模式管理复杂状态、如何用监听器模式实现状态同步、如何设计一个功能完整的播放器界面。

从"简单的音乐列表"到"完整的音乐播放器生态",我们经历了这些层级的升级:

  1. 基础版:列表展示 + 点击播放 + 迷你播放器
  2. 进阶版:全屏播放器 + 播放模式 + 播放列表管理
  3. 沉浸版:歌词滚动 + 后台播放 + 锁屏控制
  4. 全局版:悬浮播放球 + 跨页面播放状态同步

每一层升级都围绕着"让用户更好地聆听民族音乐"这个核心目标。

下一篇是页面开发篇的总结篇,我们将回顾整个篇章的知识点,提炼出可复用的最佳实践和设计模式。

Logo

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

更多推荐