【鸿蒙开发实战】从零打造音乐播放器App - 进度条控制与定时器详解
📖 前言
音乐播放器是移动端最经典的应用之一,几乎每个人都离不开它。今天,我将带大家使用鸿蒙ArkUI从零开发一款音乐播放器App。这个项目涵盖了播放控制、进度条模拟、定时器管理、列表渲染、收藏交互等多个核心知识点,是学习状态管理和生命周期控制的绝佳练手项目。
先看最终效果:

一、项目概述
1.1 功能需求
这款音乐播放器App具备以下核心功能:
| 功能模块 | 详细说明 |
|---|---|
| 歌曲列表 | 展示8首歌曲,显示封面、标题、歌手、时长 |
| 播放控制 | 点击歌曲播放/暂停、上一首/下一首切换 |
| 进度模拟 | 播放进度条自动推进(百分比),播完自动停止 |
| 收藏功能 | 点击爱心图标收藏/取消收藏歌曲 |
| 底部控制栏 | 固定在底部,显示当前播放歌曲信息和控制按钮 |
| 底部导航 | 发现、喜欢、歌单三个Tab栏 |
1.2 技术亮点
- ⏯️ 播放控制 - 播放、暂停、上一首、下一首完整控制
- 📊 进度条模拟 - setInterval实现进度自动推进
- ❤️ 收藏交互 - 切换爱心状态,支持收藏/取消收藏
- 🔄 循环播放 - 到最后一首自动回到第一首
- 🧹 定时器管理 - aboutToDisappear生命周期中清理定时器
- 🎨 暗色主题 - 现代简约的暗色调设计
1.3 歌曲数据
| # | 歌曲 | 歌手 | 时长 |
|---|---|---|---|
| 1 | 起风了 | 买辣椒也用券 | 5:20 |
| 2 | 光年之外 | 邓紫棋 | 4:05 |
| 3 | Shape of You | Ed Sheeran | 3:53 |
| 4 | 加州旅馆 | Eagles | 6:31 |
| 5 | River Flows in You | Yiruma | 4:46 |
| 6 | 卡农 | Pachelbel | 5:42 |
| 7 | Fly Me to the Moon | Frank Sinatra | 4:13 |
| 8 | 南山南 | 马頔 | 5:12 |
1.4 技术栈
| 技术 | 说明 |
|---|---|
| 开发框架 | ArkUI(声明式UI) |
| 开发语言 | ArkTS(TypeScript扩展) |
| API版本 | HarmonyOS Next |
| 开发工具 | DevEco Studio |
| 项目模型 | Stage模型 |
二、项目创建与环境配置
2.1 创建新项目
- 打开 DevEco Studio,选择 Create Project
- 选择 Empty Ability 模板
- 填写项目信息:
- Project name: MusicApp
- Bundle name: com.example.musicapp
- Save location: E:\HMproject\Project\MusicApp
- Compile SDK: 选择最新的 HarmonyOS Next SDK
- Model: Stage
2.2 项目结构
MusicApp/
├── AppScope/
│ ├── app.json5 # 应用全局配置
│ └── resources/ # 全局资源
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets # 应用入口
│ │ │ └── pages/
│ │ │ └── Index.ets # 主页面(核心,唯一页面)
│ │ └── resources/
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
└── oh_modules/ # 依赖模块
本项目为单页面应用,所有功能集中在 Index.ets 中实现。
三、数据模型设计
3.1 歌曲数据结构
本项目使用四组平行数组存储歌曲信息:
// 封面Emoji
private readonly COVERS: string[] = [
'🎵', '🎤', '🎧', '🎸', '🎹', '🎻', '🎷', '🎶'
]
// 歌曲标题
private readonly TITLES: string[] = [
'起风了', '光年之外', 'Shape of You', '加州旅馆',
'River Flows in You', '卡农', 'Fly Me to the Moon', '南山南'
]
// 歌手名
private readonly ARTISTS: string[] = [
'买辣椒也用券', '邓紫棋', 'Ed Sheeran', 'Eagles',
'Yiruma', 'Pachelbel', 'Frank Sinatra', '马頔'
]
// 歌曲时长(秒)
private readonly DURATIONS: number[] = [320, 245, 233, 391, 286, 342, 253, 312]
3.2 数据结构说明
| 数组 | 类型 | 用途 |
|---|---|---|
COVERS |
string[] | 歌曲封面emoji图标 |
TITLES |
string[] | 歌曲标题 |
ARTISTS |
string[] | 歌手名称 |
DURATIONS |
number[] | 歌曲时长(秒) |
为什么用平行数组而不是对象数组?
本项目歌曲数据是静态的、固定的,使用平行数组可以简化ForEach索引访问,通过相同的下标对应同一首歌的所有信息。在实际项目中,建议使用对象数组。
四、状态管理设计
4.1 状态变量定义
@Entry
@Component
struct Index {
// 播放状态
@State playing: boolean = false // 是否正在播放
@State curIndex: number = 0 // 当前播放歌曲索引
@State prog: number = 0 // 播放进度(0-100百分比)
// 收藏状态
@State liked: boolean[] = [false, false, false, false, false, false, false, false]
// 定时器ID
private tid: number = -1
}
4.2 状态变量说明
| 状态变量 | 类型 | 初始值 | 作用 |
|---|---|---|---|
playing |
boolean | false | 控制播放/暂停状态 |
curIndex |
number | 0 | 当前播放歌曲的索引(0-7) |
prog |
number | 0 | 播放进度百分比(0-100) |
liked |
boolean[] | 全false | 每首歌是否被收藏 |
tid |
number | -1 | 定时器ID,用于清理 |
4.3 状态变量之间的关系
playing = true → 启动定时器 → prog 自动递增
playing = false → 停止定时器 → prog 保持不变
curIndex 改变 → 切换歌曲 → prog 归零 → 重新启动定时器
liked[idx] 切换 → 爱心图标变化
五、核心功能实现
5.1 播放控制流程
用户点击歌曲
↓
设置 curIndex = 当前歌曲索引
↓
设置 playing = true
↓
prog 归零
↓
停止旧定时器
↓
启动新定时器
↓
每100毫秒 prog++
↓
prog 到达 100
↓
停止定时器
↓
playing = false, prog = 0
5.2 播放指定歌曲
private playSong(idx: number): void {
this.curIndex = idx // 切换到目标歌曲
this.playing = true // 开始播放
this.prog = 0 // 进度归零
this.stopTimer() // 停止旧定时器
this.startTimer() // 启动新定时器
}
参数说明:
| 步骤 | 说明 |
|---|---|
| 设置curIndex | 切换底部控制栏显示的歌曲信息 |
| 设置playing | 更新播放/暂停按钮图标 |
| 进度归零 | 新歌曲从0%开始播放 |
| 停止旧定时器 | 防止多个定时器同时运行 |
| 启动新定时器 | 开始播放进度推进 |
5.3 播放/暂停切换
private togglePlay(): void {
if (this.playing) {
this.stopTimer() // 暂停:停止定时器
this.playing = false
} else {
this.startTimer() // 继续播放:启动定时器
this.playing = true
}
}
5.4 定时器管理
启动定时器:
private startTimer(): void {
this.stopTimer() // 先清理,防止重复启动
this.tid = setInterval(() => {
if (this.prog < 100) {
this.prog++ // 进度+1
} else {
// 播放完成
this.stopTimer()
this.playing = false
this.prog = 0
}
}, 100) // 每100毫秒执行一次
}
参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
| 定时器间隔 | 100ms | 进度推进频率 |
| 进度范围 | 0-100 | 百分比 |
| 单曲时长 | 10秒(100×100ms) | 模拟播放时长 |
为什么100ms?
100ms是一个平衡点——间隔太短会消耗过多资源,间隔太长进度条会不流畅。100ms让进度条每秒前进10%,视觉上足够平滑。
停止定时器:
private stopTimer(): void {
if (this.tid !== -1) {
clearInterval(this.tid)
this.tid = -1
}
}
为什么tid初始值是-1?
-1不是有效的定时器ID,用于标识"没有定时器在运行"。clearInterval(-1)不会出错,但加个判断更安全。
5.5 上一首/下一首
// 下一首
private nextSong(): void {
this.playSong((this.curIndex + 1) % this.TITLES.length)
}
// 上一首
private prevSong(): void {
this.playSong((this.curIndex - 1 + this.TITLES.length) % this.TITLES.length)
}
取模运算实现循环播放:
| 场景 | 计算 | 结果 |
|---|---|---|
| 当前第7首(索引7),下一首 | (7+1)%8 | 0(回到第一首) |
| 当前第0首(索引0),上一首 | (0-1+8)%8 | 7(跳到最后一首) |
| 当前第3首(索引3),下一首 | (3+1)%8 | 4 |
为什么上一首要加TITLES.length?
JavaScript中负数取模可能得到负数。(0-1)%8 = -1,加上8后得到7,确保结果始终为非负索引。
5.6 收藏功能
private toggleLike(idx: number): void {
const newLiked: boolean[] = []
for (let i = 0; i < this.liked.length; i++) {
newLiked.push(i === idx ? !this.liked[i] : this.liked[i])
}
this.liked = newLiked
}
为什么创建新数组而不是直接修改?
ArkUI中 @State 数组的响应式更新需要重新赋值。直接通过索引修改 this.liked[idx] = !this.liked[idx] 可能无法触发UI刷新。创建新数组再赋值是最安全的做法。
5.7 时间格式化
private fmt(d: number): string {
const m = Math.floor(d / 60) // 分钟
const s = d % 60 // 秒数
return (m < 10 ? '0' : '') + String(m) + ':' +
(s < 10 ? '0' : '') + String(s)
}
转换示例:
| 输入(秒) | 计算 | 输出 |
|---|---|---|
| 320 | 5分20秒 | 05:20 |
| 245 | 4分05秒 | 04:05 |
| 233 | 3分53秒 | 03:53 |
| 391 | 6分31秒 | 06:31 |
| 253 | 4分13秒 | 04:13 |
六、生命周期管理
aboutToDisappear(): void {
this.stopTimer()
}
为什么必须在生命周期中清理定时器?
当组件被销毁(例如页面关闭、路由跳转离开)时,如果定时器仍在运行,会造成:
- 内存泄漏 - 定时器持有组件引用,阻止垃圾回收
- 空指针异常 - 定时器回调中访问已销毁的组件状态
因此在 aboutToDisappear 中必须清理所有定时器。
七、UI布局实现
7.1 整体结构
build() {
Column() {
// 1. 标题栏
Row() { Text('🎵 音乐') ... }
// 2. 歌曲列表(占满中间空间)
List() { ForEach ... }
// 3. 底部控制栏(固定高度)
Row() { 当前歌曲信息 + 控制按钮 }
// 4. 底部导航栏(固定高度)
Row() { 发现 | 喜欢 | 歌单 }
}
}
7.2 标题栏
Row() {
Text('🎵 音乐')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Blank()
}
.width('100%')
.padding({ left: 16, right: 16, top: 30, bottom: 10 })
.backgroundColor('#1C1C1E')
7.3 歌曲列表
List() {
ForEach(this.TITLES, (title: string, idx: number) => {
ListItem() {
Row() {
// 封面图标(方形背景)
Text(this.COVERS[idx])
.fontSize(26)
.width(40).height(40)
.textAlign(TextAlign.Center).lineHeight(40)
.backgroundColor('#2C2C2E')
.borderRadius(8)
// 歌曲信息
Column() {
Text(title)
.fontSize(15).fontColor(Color.White).maxLines(1)
Text(this.ARTISTS[idx] + ' · ' + this.fmt(this.DURATIONS[idx]))
.fontSize(11).fontColor('#8E8E93').margin({ top: 2 })
}
.layoutWeight(1).margin({ left: 8 })
// 收藏按钮
Text(this.liked[idx] ? '❤️' : '🤍')
.fontSize(18)
.onClick(() => { this.toggleLike(idx) })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 12, right: 12 })
.onClick(() => { this.playSong(idx) })
}
}, (title: string, idx: number) => title + String(idx))
}
.layoutWeight(1) // 占满中间空间
.width('100%')
.backgroundColor('#000000')
ForEach第三个参数(键值生成函数):
(title: string, idx: number) => title + String(idx)
使用标题+索引作为唯一键,避免重复渲染问题。
7.4 底部控制栏
Row() {
// 当前歌曲封面(橙色背景,与列表中的灰色区分)
Text(this.COVERS[this.curIndex])
.fontSize(26).width(40).height(40)
.textAlign(TextAlign.Center).lineHeight(40)
.backgroundColor('#FF9F0A') // 橙色:标识当前播放
.borderRadius(8).margin({ left: 4 })
// 当前歌曲信息
Column() {
Text(this.TITLES[this.curIndex])
.fontSize(14).fontColor(Color.White).maxLines(1)
Text(this.ARTISTS[this.curIndex])
.fontSize(11).fontColor('#8E8E93').margin({ top: 1 })
}
.layoutWeight(1).margin({ left: 8 })
// 播放/暂停按钮
Text(this.playing ? '⏸️' : '▶️')
.fontSize(22).margin({ right: 12 })
.onClick(() => { this.togglePlay() })
// 下一首按钮
Text('⏭️')
.fontSize(22).margin({ right: 8 })
.onClick(() => { this.nextSong() })
}
.width('100%')
.height(52)
.backgroundColor('#1C1C1E')
视觉设计:
- 当前播放封面用橙色背景(#FF9F0A),与列表中的灰色背景区分
- 播放状态动态显示⏸️或▶️
- 控制按钮集中在右侧
7.5 底部导航栏
Row() {
ForEach([['🎵', '发现'], ['❤️', '喜欢'], ['📋', '歌单']], (a: string[]) => {
Column() {
Text(a[0]).fontSize(20)
Text(a[1]).fontSize(11).margin({ top: 2 }).fontColor('#8E8E93')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 4, bottom: 6 })
}, (a: string[]) => a[1])
}
.width('100%')
.height(52)
.backgroundColor('#1C1C1E')
.padding({ bottom: 4 })
八、完整代码实现
8.1 Index.ets 完整代码
@Entry
@Component
struct Index {
@State playing: boolean = false
@State curIndex: number = 0
@State prog: number = 0
@State liked: boolean[] = [false, false, false, false, false, false, false, false]
private tid: number = -1
private readonly COVERS: string[] = ['🎵', '🎤', '🎧', '🎸', '🎹', '🎻', '🎷', '🎶']
private readonly TITLES: string[] = ['起风了', '光年之外', 'Shape of You', '加州旅馆', 'River Flows in You', '卡农', 'Fly Me to the Moon', '南山南']
private readonly ARTISTS: string[] = ['买辣椒也用券', '邓紫棋', 'Ed Sheeran', 'Eagles', 'Yiruma', 'Pachelbel', 'Frank Sinatra', '马頔']
private readonly DURATIONS: number[] = [320, 245, 233, 391, 286, 342, 253, 312]
aboutToDisappear(): void {
this.stopTimer()
}
private toggleLike(idx: number): void {
const newLiked: boolean[] = []
for (let i = 0; i < this.liked.length; i++) {
newLiked.push(i === idx ? !this.liked[i] : this.liked[i])
}
this.liked = newLiked
}
private playSong(idx: number): void {
this.curIndex = idx
this.playing = true
this.prog = 0
this.stopTimer()
this.startTimer()
}
private togglePlay(): void {
if (this.playing) {
this.stopTimer()
this.playing = false
} else {
this.startTimer()
this.playing = true
}
}
private startTimer(): void {
this.stopTimer()
this.tid = setInterval(() => {
if (this.prog < 100) {
this.prog++
} else {
this.stopTimer()
this.playing = false
this.prog = 0
}
}, 100)
}
private stopTimer(): void {
if (this.tid !== -1) {
clearInterval(this.tid)
this.tid = -1
}
}
private nextSong(): void {
this.playSong((this.curIndex + 1) % this.TITLES.length)
}
private prevSong(): void {
this.playSong((this.curIndex - 1 + this.TITLES.length) % this.TITLES.length)
}
private fmt(d: number): string {
const m = Math.floor(d / 60)
const s = d % 60
return (m < 10 ? '0' : '') + String(m) + ':' + (s < 10 ? '0' : '') + String(s)
}
build() {
Column() {
// 标题栏
Row() {
Text('🎵 音乐').fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White)
Blank()
}
.width('100%').padding({ left: 16, right: 16, top: 30, bottom: 10 })
.backgroundColor('#1C1C1E')
// 歌曲列表
List() {
ForEach(this.TITLES, (title: string, idx: number) => {
ListItem() {
Row() {
Text(this.COVERS[idx]).fontSize(26).width(40).height(40)
.textAlign(TextAlign.Center).lineHeight(40)
.backgroundColor('#2C2C2E').borderRadius(8)
Column() {
Text(title).fontSize(15).fontColor(Color.White).maxLines(1)
Text(this.ARTISTS[idx] + ' · ' + this.fmt(this.DURATIONS[idx]))
.fontSize(11).fontColor('#8E8E93').margin({ top: 2 })
}.layoutWeight(1).margin({ left: 8 })
Text(this.liked[idx] ? '❤️' : '🤍').fontSize(18)
.onClick(() => { this.toggleLike(idx) })
}
.width('100%').padding({ top: 8, bottom: 8, left: 12, right: 12 })
.onClick(() => { this.playSong(idx) })
}
}, (title: string, idx: number) => title + String(idx))
}
.layoutWeight(1).width('100%').backgroundColor('#000000')
// 底部控制栏
Row() {
Text(this.COVERS[this.curIndex]).fontSize(26).width(40).height(40)
.textAlign(TextAlign.Center).lineHeight(40)
.backgroundColor('#FF9F0A').borderRadius(8).margin({ left: 4 })
Column() {
Text(this.TITLES[this.curIndex]).fontSize(14).fontColor(Color.White).maxLines(1)
Text(this.ARTISTS[this.curIndex]).fontSize(11).fontColor('#8E8E93').margin({ top: 1 })
}.layoutWeight(1).margin({ left: 8 })
Text(this.playing ? '⏸️' : '▶️').fontSize(22).margin({ right: 12 })
.onClick(() => { this.togglePlay() })
Text('⏭️').fontSize(22).margin({ right: 8 })
.onClick(() => { this.nextSong() })
}
.width('100%').height(52).backgroundColor('#1C1C1E')
// 底部导航
Row() {
ForEach([['🎵', '发现'], ['❤️', '喜欢'], ['📋', '歌单']], (a: string[]) => {
Column() {
Text(a[0]).fontSize(20)
Text(a[1]).fontSize(11).margin({ top: 2 }).fontColor('#8E8E93')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 4, bottom: 6 })
}, (a: string[]) => a[1])
}
.width('100%').height(52).backgroundColor('#1C1C1E').padding({ bottom: 4 })
}
.width('100%').height('100%').backgroundColor('#000000')
}
}
九、运行效果展示
9.1 构建项目
在 DevEco Studio 中:
- 点击菜单 Build > Build Hap(s)/APP(s) > Build Hap(s)
- 等待构建完成
9.2 运行效果
歌曲列表(初始状态):

十、核心知识点总结
10.1 定时器管理
| 知识点 | 说明 |
|---|---|
| setInterval | 创建定时器,返回定时器ID |
| clearInterval | 通过ID停止定时器 |
| 防重复启动 | 启动前先停止旧定时器 |
| 生命周期清理 | aboutToDisappear中停止定时器 |
| ID标识 | -1表示无定时器运行 |
10.2 状态管理
| 知识点 | 说明 |
|---|---|
| @State数组 | 需要整体赋值才能触发更新 |
| 数组更新方式 | 创建新数组赋值,而非索引修改 |
| 状态联动 | playing控制按钮图标,curIndex控制歌曲信息 |
| 进度模拟 | prog百分比,配合setInterval推进 |
10.3 UI组件
| 知识点 | 说明 |
|---|---|
| List + ForEach | 歌曲列表渲染 |
| layoutWeight | 列表占满中间空间 |
| Blank() | 标题栏右侧占位 |
| textAlign + lineHeight | 图标居中显示 |
| maxLines | 标题超长时单行截断 |
10.4 算法技巧
| 知识点 | 说明 |
|---|---|
| 取模循环 | 下一首/上一首循环播放 |
| 负数取模处理 | 加数组长度后取模 |
| 时间格式化 | 秒转分:秒格式 |
| 补零 | 三元表达式补零 |
十一、扩展思路
这个音乐播放器还可以继续完善:
11.1 功能扩展
| 扩展项 | 实现方案 |
|---|---|
| 进度拖动 | Slider组件支持拖动进度条 |
| 播放模式 | 顺序播放/随机播放/单曲循环 |
| 音量控制 | 音量滑块 |
| 歌词显示 | 解析LRC歌词文件 |
| 搜索歌曲 | 搜索框过滤列表 |
| 本地音乐 | 读取设备中的音乐文件 |
| 背景播放 | 后台任务+通知栏控制 |
| 均衡器 | 音频效果调节 |
| 歌单管理 | 自定义歌单 |
| 专辑封面 | 使用图片代替Emoji |
11.2 技术优化
| 优化项 | 说明 |
|---|---|
| 数据持久化 | @StorageLink保存播放状态和收藏 |
| LazyForEach | 歌曲列表使用懒加载优化性能 |
| 动画效果 | 播放时封面旋转动画 |
| 手势操作 | 滑动切歌 |
| 深色/浅色主题 | 支持主题切换 |
十二、常见问题
Q1: 点击暂停后进度还在走?
确保 togglePlay() 中正确调用 stopTimer(),并且定时器ID不是-1时才执行clearInterval。
Q2: 切歌后收藏状态丢失?
收藏状态存储在 liked 数组中,切换歌曲不会影响数组,如果丢失请检查组件是否被重建。
Q3: 到最后一首歌后下一首报错?
确认 nextSong() 中使用了取模运算 (curIndex + 1) % TITLES.length,确保索引不越界。
Q4: 数组修改后UI没更新?
ArkUI的@State数组需要整体赋值才能触发UI刷新。使用 this.liked = newLiked 而不是 this.liked[idx] = !this.liked[idx]。
十三、总结
通过这个音乐播放器App的开发,我们学习了:
✅ 定时器管理与生命周期
✅ 播放/暂停控制逻辑
✅ 上一首/下一首循环算法
✅ 收藏交互与状态更新
✅ 进度条模拟
✅ ForEach列表渲染
✅ 暗色主题UI设计
这个项目虽然功能简洁,但涵盖了定时器管理、状态联动、数组更新等核心开发技巧,是一个非常实用的练手项目!
参考资料
如果这篇文章对你有帮助,欢迎点赞、收藏、评论!你的支持是我创作的动力! 🎵
更多推荐

所有评论(0)