HarmonyOS应用《民族图鉴》开发第29篇:音乐页——民族音乐播放

一、页面设计思路
音乐页是应用的"沉浸体验入口"——用户在这里浏览和聆听各民族的传统音乐。音乐是民族文化的灵魂,从蒙古族的长调到侗族的大歌,每个民族的音乐都承载着独特的文化记忆。
设计原则:
- 内容优先:音乐列表是主体,播放器是附随
- 操作便捷:播放、暂停、切歌一键完成
- 分类清晰:按音乐类型分类筛选,方便查找
页面结构
音乐页
├── 导航栏
├── 分类标签栏(横向滚动)
├── 音乐列表(可滚动)
└── 迷你播放器(有音乐播放时显示)
迷你播放器是音乐类应用的经典设计——它常驻页面底部,让用户在浏览列表的同时可以控制播放,随时切换歌曲,而不会中断当前的浏览上下文。
二、数据结构
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));
}
});
}
}
设计要点:
- 单例模式:全局唯一的播放服务,确保状态一致
- 监听器模式:支持多个 UI 组件同时监听播放状态
- 状态快照:通知时传状态副本,避免外部直接修改内部状态
- 异常保护:单个监听器报错不影响其他监听器
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?
- 统一接口:UI 层不直接依赖系统 API,换实现更容易
- 状态管理:在 Service 层统一管理播放状态
- 错误处理:集中处理播放错误,提供降级策略
- 事件分发:一个播放器事件可以通知多个监听器
四、状态管理
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;
}
}
监听器模式的要点:
- 注册时机:
aboutToAppear中注册,页面出现时开始接收状态更新 - 初始同步:注册后立即同步一次当前状态,避免"空白等待"
- 移除时机:
aboutToDisappear中移除,页面销毁后不再接收回调 - 空值保护:移除前判断是否存在,移除后置为 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,因为:
- List 有更好的性能(虚拟滚动、回收复用)
- List 有更多列表相关的功能(分割线、滑动删除等)
- 语义更明确——这就是一个列表
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 里。页面只负责:
- 把用户操作传给 Service
- 监听 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 为什么需要全屏播放器?
迷你播放器适合"边浏览边听",但如果用户想专注听歌、看歌词、调整更多设置,就需要全屏播放器了。
全屏播放器是音乐体验的"沉浸模式"——更大的封面、完整的歌词、更多的控制选项。
全屏播放器的设计目标:
- 沉浸感:大封面 + 模糊背景,营造氛围
- 信息全:歌名、艺术家、歌词、进度一目了然
- 控制全:播放/暂停/上一首/下一首/播放模式/播放列表
- 可返回:随时可以回到列表页,音乐不中断
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);
})
}
设计要点:
- 正在播放的指示:当前播放的歌曲用耳机图标 + 主色文字标记
- 可移除:每首歌右侧有删除按钮,方便管理
- 点击播放:点击列表项直接播放该歌曲
- 底部弹出:从底部弹出的面板,不打断当前播放
十二、歌词展示与滚动
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%')
}
设计要点:
- 居中显示:当前歌词滚动到视图中间位置
- 高亮对比:当前行字体更大、颜色更亮、字重更重
- 平滑滚动:滚动有 300ms 动画,不生硬
- 上下留白:歌词列表上下有 padding,确保第一行和最后一行也能滚到中间
十三、后台播放与锁屏控制
13.1 为什么需要后台播放?
用户听音乐时,不可能一直停留在音乐页——可能会浏览其他页面,甚至锁屏。这时候音乐不能停,而且用户需要能在锁屏界面控制播放。
鸿蒙系统提供了后台音频播放和锁屏控制的能力。
13.2 后台播放配置
要支持后台播放,需要:
- 申请后台音频权限
- 使用 AVPlayer 的后台模式
- 设置音频会话类型
核心代码框架:
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);
})
)
}
}
设计要点:
- 可拖动:用
PanGesture实现拖拽,边界限制在屏幕内 - 可点击:点击播放/暂停,拖动时不触发点击
- 自动靠边:可以加个"松手后自动吸附到左右边缘"的效果
- 全局显示:用最高的 zIndex,确保在所有内容之上
💡 注意:真正的"全局悬浮球"需要用悬浮窗能力(可以跨页面显示),而不是在单个页面里实现。鸿蒙系统提供了
Window相关的 API 来实现悬浮窗。
十五、架构思考
9.1 服务层模式
音乐页是"服务层模式"的典型案例:
MusicPage (UI)
↑ 监听状态 │
│ 发送指令 ↓
MusicService (服务层)
↑ 事件回调 │
│ 播放控制 ↓
AVPlayer / AudioRenderer (系统播放能力)
服务层的职责:
- 管理播放器实例
- 维护播放状态(播放列表、当前位置、播放模式)
- 提供播放控制 API(play/pause/prev/next/seek)
- 通知状态变更给监听者
- 处理播放错误和自动恢复
为什么需要服务层?
- 状态共享:多个页面可能需要播放状态(首页、音乐页、详情页都可能有迷你播放器)
- 逻辑复用:播放控制逻辑在 Service 中,不用每个页面写一份
- 生命周期独立:音乐播放不应该因为页面切换就停止
- 易测试:Service 层可以独立单元测试
9.2 监听器模式 vs @StorageLink
| 方案 | 适用场景 | 本项目 |
|---|---|---|
@StorageLink |
简单的全局状态(语言、主题) | ✅ 用于设置类 |
| 服务层 + 回调 | 复杂的持续变化状态(播放进度) | ✅ 用于音乐播放 |
播放进度每秒钟都在变,用 @StorageLink 虽然也能实现,但:
- AppStorage 是全局的,太"重"了
- 播放状态包含多个关联字段(音乐、位置、模式…)
- 服务层本身需要这些状态做逻辑处理
所以用"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 移除监听器 | ⭐⭐⭐ |
音乐页是整个"页面开发篇"中架构最完整、功能最丰富的页面之一——它展示了如何用服务层模式管理复杂状态、如何用监听器模式实现状态同步、如何设计一个功能完整的播放器界面。
从"简单的音乐列表"到"完整的音乐播放器生态",我们经历了这些层级的升级:
- 基础版:列表展示 + 点击播放 + 迷你播放器
- 进阶版:全屏播放器 + 播放模式 + 播放列表管理
- 沉浸版:歌词滚动 + 后台播放 + 锁屏控制
- 全局版:悬浮播放球 + 跨页面播放状态同步
每一层升级都围绕着"让用户更好地聆听民族音乐"这个核心目标。
下一篇是页面开发篇的总结篇,我们将回顾整个篇章的知识点,提炼出可复用的最佳实践和设计模式。
更多推荐


所有评论(0)