起因
周五晚上,刷B站看到有人用Flutter做了一个音乐播放器,界面很好看。我想了想,我学了这么久鸿蒙,做了几个小Demo,但还没做过一个像样的"App"。什么才算像样?我觉得至少得有:
- 不止一个功能
- 有交互反馈
- 看起来像个真正的App
想了想,音乐播放器最合适。谁没用过音乐App?功能明确,交互丰富,而且不用真的播放音频(用进度条模拟就行)。
说干就干,周六一早就坐到电脑前开始搞。
周六上午:项目创建和功能规划
创建项目
打开DevEco Studio,Create Project,选Empty Ability模板,填信息:
- 项目名:MusicApp
- 包名:com.example.musicapp
- 路径:E:\HMproject\Project\MusicApp
等了几秒钟,项目就建好了。整个项目结构很简洁,核心就一个页面:Index.ets。
功能规划
我在笔记本上画了个草图,列出要做哪些功能:
✅ 歌曲列表 - 8首歌,显示封面、歌名、歌手、时长
✅ 播放控制 - 点击播放、暂停、上一首、下一首
✅ 进度模拟 - 进度条自动走
✅ 收藏功能 - 爱心收藏
✅ 底部控制栏 - 显示当前播放的歌
✅ 底部导航 - 发现、喜欢、歌单
不做的功能(保持简单):
- ❌ 真实音频播放
- ❌ 歌词
- ❌ 搜索
- ❌ 数据持久化
OK,目标明确了,开始写代码。
周六上午:数据设计
歌曲数据
选了8首歌,中英文各半:
🎵 起风了 - 买辣椒也用券 (5:20)
🎤 光年之外 - 邓紫棋 (4:05)
🎧 Shape of You - Ed Sheeran (3:53)
🎸 加州旅馆 - Eagles (6:31)
🎹 River Flows in You - Yiruma (4:46)
🎻 卡农 - Pachelbel (5:42)
🎷 Fly Me to the Moon - Frank Sinatra (4:13)
🎶 南山南 - 马頔 (5:12)
封面用emoji代替。虽然不够好看,但胜在简单——不用导入图片资源。
数据存储方式
一开始想定义一个Song接口:
interface Song {
cover: string
title: string
artist: string
duration: number
}
但后来想想,这些数据是固定的,不会变。用四个平行数组更简单:
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]
这样在ForEach里用同一个索引就能取到封面、歌名、歌手、时长,代码更短。
状态变量
想清楚需要哪些状态:
@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]
private tid: number = -1 // 定时器ID
五个变量,每个都有明确用途。playing控制播放状态,curIndex控制显示哪首歌,prog控制进度,liked控制收藏图标,tid管理定时器。
周六下午:核心逻辑——播放和定时器
先做最简单的:歌曲列表
先把8首歌显示出来再说。
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)
Text(this.ARTISTS[idx] + ' · ' + this.fmt(this.DURATIONS[idx]))
.fontSize(11).fontColor('#8E8E93').margin({ top: 2 })
}
.layoutWeight(1).margin({ left: 8 })
}
}
})
}
运行一下,8首歌都出来了。嗯,看着还不错。
但时长显示的是"320",不是"05:20"。加个格式化函数:
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”。完美。
定时器——最关键的部分
进度条怎么自动走?我想了想,最简单的方式就是用 setInterval 定时器。
每隔一段时间执行一次回调,在回调里让进度+1:
private startTimer(): void {
this.tid = setInterval(() => {
if (this.prog < 100) {
this.prog++
} else {
// 播完了
this.stopTimer()
this.playing = false
this.prog = 0
}
}, 100) // 每100毫秒执行一次
}
每100毫秒进度+1,从0到100就是10秒。虽然不是真实歌曲时长,但演示够用了。
停止定时器:
private stopTimer(): void {
if (this.tid !== -1) {
clearInterval(this.tid)
this.tid = -1
}
}
tid初始值是-1,表示没有定时器在跑。clearInterval(-1) 虽然不会报错,但先判断一下更安全。
播放功能
点击一首歌,应该:切到这首歌 → 开始播放 → 进度从0开始 → 启动定时器。
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 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+1)%8 = 0,最后一首的下一首回到第一首。
上一首稍微绕:(0-1+8)%8 = 7。为什么加8?因为(0-1)%8在JavaScript里可能是-1,加8就变成7了。
踩坑!
运行测试,快速切了几首歌,进度条突然飞快!
想了半天,发现问题出在 startTimer() 上——每次调用都创建新定时器,但旧的还在跑。快速切3首歌就有3个定时器同时在跑,进度条当然飞快。
修复: 在 startTimer() 第一行加上 this.stopTimer():
private startTimer(): void {
this.stopTimer() // ← 加了这行
this.tid = setInterval(...)
}
这是定时器管理的第一条规则:启动前先停旧的。
还有个坑
测试暂停功能,切到别的页面再回来,进度条又飞快了。
问题是页面退出时定时器没有清理。
修复: 在组件销毁时清理:
aboutToDisappear(): void {
this.stopTimer()
}
这是定时器管理的第二条规则:退出时一定要清理。
两条规则总结:
- 启动前先停旧的 — 防止多个定时器叠加
- 退出时一定要清理 — 防止内存泄漏和资源浪费
这两条规则不仅适用于定时器,以后学监听器、订阅器也都适用。
周六傍晚:收藏功能
需求
每首歌旁边有个爱心按钮,点击切换收藏/未收藏状态。
第一版代码
private toggleLike(idx: number): void {
this.liked[idx] = !this.liked[idx]
}
运行一下……点击爱心,图标没变!
为什么?
查了一下文档才知道,ArkUI的 @State 数组有个特殊规则:通过索引修改单个元素不会触发UI更新。
// ❌ 不触发更新
this.liked[idx] = !this.liked[idx]
// ✅ 触发更新
this.liked = newArray
必须对数组变量本身重新赋值才行。
第二版代码
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
}
这次OK了,点击爱心,图标正常切换。
UI中的显示:
Text(this.liked[idx] ? '❤️' : '🤍').fontSize(18)
收藏了就显示红心❤️,没收藏就显示白心🤍。
周六晚上:底部控制栏和导航
底部控制栏
底部控制栏固定显示当前播放的歌曲,加上播放/暂停和下一首按钮。
Row() {
// 当前歌曲封面(橙色背景)
Text(this.COVERS[this.curIndex])
.backgroundColor('#FF9F0A')
// 歌曲信息
Column() {
Text(this.TITLES[this.curIndex])
Text(this.ARTISTS[this.curIndex])
}
// 播放/暂停
Text(this.playing ? '⏸️' : '▶️')
.onClick(() => { this.togglePlay() })
// 下一首
Text('⏭️')
.onClick(() => { this.nextSong() })
}
.height(52).backgroundColor('#1C1C1E')
设计细节:
- 当前播放的封面用橙色背景(#FF9F0A),和列表里的灰色区分
- 播放中显示⏸️,暂停显示▶️
底部导航
Row() {
ForEach([['🎵', '发现'], ['❤️', '喜欢'], ['📋', '歌单']], (a: string[]) => {
Column() {
Text(a[0]).fontSize(20)
Text(a[1]).fontSize(11).fontColor('#8E8E93')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
})
}
.height(52).backgroundColor('#1C1C1E')
三个Tab目前只是静态展示。后续可以:
- “发现” → 推荐歌曲
- “喜欢” → 只显示收藏的歌曲
- “歌单” → 自定义歌单列表
暂时不做,先把核心功能跑通。
周日上午:UI调整和细节打磨
整体布局
页面结构:
┌─────────────────────────┐
│ 标题栏 │ 固定高度
├─────────────────────────┤
│ │
│ 歌曲列表 │ 占满剩余空间
│ (可滚动) │
│ │
├─────────────────────────┤
│ 底部控制栏 │ 固定高度52px
├─────────────────────────┤
│ 底部导航 │ 固定高度52px
└─────────────────────────┘
歌曲列表用 layoutWeight(1) 占满中间空间。这样不管屏幕大小,列表都能滚动。
暗色主题
整体采用暗色:
| 区域 | 颜色 |
|---|---|
| 页面背景 | #000000(纯黑) |
| 标题栏/控制栏 | #1C1C1E(深灰) |
| 封面背景 | #2C2C2E(灰色) |
| 当前播放封面 | #FF9F0A(橙色) |
| 主文字 | 白色 |
| 辅助文字 | #8E8E93 |
看着挺现代的。橙色标识当前播放,一眼就能看到在播哪首。
标题栏
Row() {
Text('🎵 音乐').fontSize(22).fontWeight(FontWeight.Bold).fontColor(Color.White)
Blank()
}
.backgroundColor('#1C1C1E')
Blank()把标题挤到左边,右侧留空。以后可以加搜索、设置图标。
细节检查
- ✅ 歌名太长时maxLines(1)截断
- ✅ 爱心按钮的onClick不影响整行点击播放
- ✅ 进度到100自动停止
- ✅ 暂停不重置进度
- ✅ 切歌进度归零
周日下午:运行测试和截图
功能测试
| 测试 | 结果 |
|---|---|
| 启动显示8首歌 | ✅ |
| 点击播放 | ✅ 底部栏更新 |
| 暂停/继续 | ✅ 进度保持 |
| 下一首 | ✅ 切换并归零 |
| 上一首 | ✅ 从第0首回到第7首 |
| 收藏/取消 | ✅ 爱心切换 |
| 播放完成 | ✅ 自动停止 |
截图

回顾:踩坑总结
这个项目虽然不大,但踩了四个坑,每个都印象深刻。
坑1:多个定时器叠加
现象: 快速切歌后进度飞快
原因: 没有先停旧定时器
解决: startTimer()第一行加 stopTimer()
教训: 有创建就有销毁,防重复启动
坑2:数组修改UI不更新
现象: 点击爱心图标没反应
原因: this.liked[idx] = !this.liked[idx] 不触发UI刷新
解决: 创建新数组整体赋值
教训: @State数组要整体赋值才能触发更新
坑3:退出后定时器没停
现象: 切页面再回来进度飞快
原因: aboutToDisappear没清理定时器
解决: aboutToDisappear中调用stopTimer()
教训: 组件销毁必须清理所有资源
坑4:上一首索引变负数
现象: 第一首点上一首显示异常
原因: (0-1)%8 = -1
解决: 加数组长度:(0-1+8)%8 = 7
教训: 负数取模加length处理
回顾:学到了什么
技术层面
| 知识点 | 掌握程度 | 说明 |
|---|---|---|
| setInterval/clearInterval | ⭐⭐⭐ | 定时器的基本使用 |
| 防重复启动 | ⭐⭐⭐ | startTimer里先stopTimer |
| 生命周期清理 | ⭐⭐⭐ | aboutToDisappear |
| @State数组更新 | ⭐⭐⭐ | 整体赋值触发刷新 |
| 取模循环 | ⭐⭐⭐ | 负数处理+length |
| ForEach列表渲染 | ⭐⭐ | 基本用法 |
| layoutWeight | ⭐⭐ | 弹性布局 |
| 时间格式化 | ⭐ | 秒转分:秒 |
设计层面
| 思维 | 说明 |
|---|---|
| 状态驱动UI | 改数据就行,UI自己更新 |
| 防御性编程 | 先判断再操作 |
| 资源管理 | 有创建就有销毁 |
| 用户体验 | 橙色标识当前播放 |
最重要的两条规则
做完这个项目,我印象最深的就是定时器管理的两条规则。这两条规则不仅适用于这个项目,以后做任何涉及资源管理(定时器、监听器、订阅器、动画)的项目都适用:
- 启动前先停旧的 — 防止重复叠加
- 退出时一定要清理 — 防止泄漏
后续计划
这个播放器还有好多可以完善的:
近期(下个周末)
- 进度拖动 - 加Slider组件,让用户拖动进度条
- 播放模式 - 顺序播放/随机播放/单曲循环
- 数据持久化 - @StorageLink保存收藏状态
中期
- 组件拆分 - 把歌曲列表项和底部控制栏拆成独立组件
- 真实音频 - 用AVPlayer播真实音乐
- 播放模式UI - 加个模式切换按钮
远期(有空再说)
- 歌词显示 - 解析LRC文件
- 搜索功能 - 快速找歌
- 专辑封面 - 用图片代替emoji
- 动画效果 - 播放时封面旋转
代码总览
最后贴一下完整代码,方便参考:
@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')
}
}
总结
一个周末的时间,从零做了一个音乐播放器。虽然功能不复杂,但收获不少。
最深刻的收获:
- 定时器管理两条规则 — “启动前先停旧的,退出时一定要清理”,这条经验以后做任何资源管理都用得上
- @State数组更新规则 — 索引赋值不触发更新,要整体赋值,这个坑新手必踩
- 取模处理负数 — 加数组长度后再取模,确保结果非负
- 声明式UI的魅力 — 只管改数据,UI自己更新,开发效率真的高
ArkUI写起来确实舒服,声明式UI真的香。
这个项目还只是开始,后面要继续完善——加进度拖动、播放模式、真实音频……一步一步来。
有问题欢迎留言交流~
觉得这篇日记有帮助的话,点个赞再走吧! 🎵

2913

被折叠的 条评论
为什么被折叠?



