基于 ArkTS 语言的仿网易云音乐播放器开发实战
概要
一个 HarmonyOS 练手项目的完整复盘:需求拆解、Stage 模型工程架构、Tabs 首页容器、AVPlayer 播放状态机、唱片旋转动画,以及一路踩过的坑。
引言
在 HarmonyOS 的学习路径里,音乐播放器是一个非常划算的练手项目。它表面上只是几个页面,实际上把 ArkUI 声明式开发的主流组件(Tabs、Swiper、List、Grid)、状态管理(@State/@Prop/@Observed)、多媒体播放(AVPlayer 状态机)、Ability 生命周期联动、团队协作规范全部串了一遍。
我最近按团队项目的标准做了一套仿网易云音乐播放器:欢迎页、首页五大 Tab(推荐 / 发现 / 漫游 / 动态 / 我的)、歌单详情、播放页、播放控制类。本文把流程、架构和核心代码整理出来,供同样在啃 ArkTS 的同学参考。
项目的源码放在了Gitee上,有需要的可以从这里获取:https://gitee.com/hyh9639/harmonyos-music-player—app

1、开发流程与需求拆解
很多同学拿到项目就新建工程开始拖组件,这是最容易返工的做法。我遵循的流程是:
观看别人的项目得到需求 → 列出需求清单 → 做出开发计划 → 建立源代码管理(git / svn)→ 分工开发 → 接口文档对齐 → 联调。
第一步不是写代码,是"看别人怎么做"。把网易云音乐的每个页面手动点一遍,边点边记功能点,需求清单就出来了。
1.1 需求清单

1.2 页面与路由对照

2、技术选型与工程架构
技术选型:ArkTS + Stage 模型 + DevEco Studio,播控走 @ohos.multimedia.media 的 AVPlayer,动画走 animateTo,路由走 @ohos.router。
工程 ets 目录按下述方式分层,这一层设计直接决定了后期改需求时的痛苦程度:

分层原则一句话概括:models 管数据结构,constant 管数据源,components 管 UI 复用,pages 只做页面编排与业务组装。任何页面里出现 interface Song 或者一大段数组字面量,都应该判定为坏味道。
3、静态数据与类型定义
ArkTS 是强类型语言,先把类型定死,后面页面的自动补全和编译期检查都会顺畅很多。
export {songItemSearch,SongItemType,RecommendDailyType,RecommendListType,MomentListType,FavoriteListType,TabClass,PlayState}
/**
* 歌曲信息
*/
interface SongItemType{
id:string
url: string
name: string
author: string
img: string
}
// 每日推荐歌曲数据类型
interface RecommendDailyType{
img: string
title:string
type:string
top:string
bottom:string
}
// 推荐列表数据类型
interface RecommendListType{
img: string
title: string
count: string
}
// 评论列表数据类型
interface MomentListType{
author: string
avatar: string
content: string
comment:number
like :numberx
song:SongItemType
}
//......
// 音乐状态播放类
class PlayState{
img: string = "" // 音乐封面
name: string = "" // 音乐名称
author: string = "" // 作者
url: string = "" // 当前播放连接
playIndex: number = -1 // 当前在播放列表中的播放索引
time: number = 0 // 播放时间
duration: number = 0 // 音乐的播放时长
isPlay: boolean = false // 是否正在播放
playMode: 'auto' | 'repeat' | 'random' = "auto" // 播放模式
playList: SongItemType[] = [] // 当前的播放列表
cacheImg?: string // 缓存图片地址
}
4、首页与底部 Tab 容器
页是一个底部 Tabs 容器,五个 TabContent 分别复用五个页面组件。用 @Builder 自定义 tabBar,才能做出图标 + 文字 + 选中变红的效果。
import { pageSeek, pageSwiper, songsList, songSuggest, suggestList } from '../components/pageHome'
import { TabClass, tabsData } from '../constant/MusicConstants'
import { router } from '@kit.ArkUI'
import { PageComments } from './PageComments'
import { PageUserLogin } from './PageUserLogin'
@Entry
@Component
struct Index {
@State bottomItem: TabClass[] = tabsData
@State currentIdx: number = 0
@Builder
getTabBuilder(idx: number, title: string, img: Resource) {
Column() {
Image(img).fillColor(this.currentIdx == idx ? Color.Red : Color.Black).width(30).height(30)
Text(title)
.fontSize(15)
.fontColor(this.currentIdx == idx ? Color.Red : Color.Black)
.fontWeight(this.currentIdx === idx ? FontWeight.Bold : FontWeight.Normal)
}
}
build() {
Tabs() {
// 首页
TabContent() {
Column({ space: 15 }) {
// 搜索栏
pageSeek().layoutWeight(0.7)
// 轮播图
pageSwiper().layoutWeight(2.5)
// 每日推荐
songSuggest().layoutWeight(3.4)
// 推荐歌单
suggestList().layoutWeight(3.4)
}
.width('100%')
.padding({ left: 10, right: 10 })
.backgroundColor('#f3f4f5')
}.tabBar(this.getTabBuilder(0, tabsData[0].title, tabsData[0].icon,))
// 发现页面
TabContent() {
Column({ space: 15 }) {
pageSeek()
Divider().width('100%').strokeWidth(1)
songsList()
}
.justifyContent(FlexAlign.Start)
.width('100%')
}.tabBar(this.getTabBuilder(1, tabsData[1].title, tabsData[1].icon))
// 漫游页面
TabContent() {
}.tabBar(this.getTabBuilder(2, tabsData[2].title, tabsData[2].icon))
// 社区页面
TabContent() {
PageComments()
}.tabBar(this.getTabBuilder(3, tabsData[3].title, tabsData[3].icon))
// 用户页面
TabContent() {
PageUserLogin()
}.tabBar(this.getTabBuilder(4, tabsData[4].title, tabsData[4].icon))
}
.barPosition(BarPosition.End)
.height('100%')
.width('100%')
.onChange((index: number) => {
this.currentIdx = index
if (index == 2) {
router.pushUrl({ url: 'pages/PageRoam' })
}
})
}
}
5、播放页核心:播放控制类怎么设计
这一节是全项目的技术核心。漫游页承担的是"音乐播放功能":上一曲、下一曲、暂停、恢复、播放模式设置、播放列表展示与播放动画显示。
设计要点有三条:
播放器必须与 UI 解耦:把 AVPlayer 包成单例 AvPlayerUtils,页面只发指令、只订阅状态;
状态机驱动一切:AVPlayer 是显式状态机(idle → initialized → prepared → playing / paused → completed → released),任何跨状态调用都会抛错;
切歌不是重新 new 一个播放器:走 reset() 回到 idle,再重设资源,并先 off 掉旧事件。
5.1 PlayerManager:状态机与事件注册
// 音乐播放器初始化
static async init() {
try {
if (AvPlayerUtils.avplayer == null) {
AvPlayerUtils.avplayer = await media.createAVPlayer()
AvPlayerUtils.avplayer.on('stateChange', (state) => {
switch (state) {
case 'initialized':
AvPlayerUtils.avplayer?.prepare()
break
case 'prepared':
AvPlayerUtils.avplayer?.play()
break
}
})
// 总时长
AvPlayerUtils.avplayer.on('durationUpdate', (duration) => {
AvPlayerUtils.currentSong.duration = duration
})
// 实时播放时长
AvPlayerUtils.avplayer.on('timeUpdate', (time) => {
AvPlayerUtils.currentSong.time = time
emitter.emit({ eventId: EmitEventType.UPDATE_STATE }, {
data:
{ playStateStr: JSON.stringify(AvPlayerUtils.currentSong) }
})
})
}
} catch (error) {
console.log('rain', error);
}
}
5.2 7.2 播放模式与切歌逻辑
/ 切歌逻辑
static async changeMusic(song: SongItemType) {
await AvPlayerUtils.avplayer?.reset()
// 获取状态参数
AvPlayerUtils.currentSong.duration = 0
AvPlayerUtils.currentSong.time = 0
AvPlayerUtils.avplayer.url = song.url
AvPlayerUtils.currentSong.url = song.url
AvPlayerUtils.currentSong.name = song.name
AvPlayerUtils.currentSong.author = song.author
AvPlayerUtils.currentSong.img = song.img
}
// 暂停/继续播放
static async musicCS() {
//
if (AvPlayerUtils.currentSong.isPlay == true) {
AvPlayerUtils.avplayer?.pause()
} else {
AvPlayerUtils.avplayer.play()
}
AvPlayerUtils.currentSong.isPlay = !AvPlayerUtils.currentSong.isPlay
emitter.emit({ eventId: EmitEventType.UPDATE_STATE }, {
data:
{ playStateStr: JSON.stringify(AvPlayerUtils.currentSong) }
})
}
// 下一首
static nextSong() {
if (AvPlayerUtils.currentSong.playList.length == 0) {
return
}
// 随机模式
if (AvPlayerUtils.currentSong.playMode == 'random' && AvPlayerUtils.currentSong.playList.length > 1) {
let index = 0
do {
index = Math.floor(Math.random() * AvPlayerUtils.songList.length)
} while (index == AvPlayerUtils.currentIndex)
AvPlayerUtils.currentIndex = index
} else {
AvPlayerUtils.currentIndex++
}
AvPlayerUtils.currentIndex =
(AvPlayerUtils.currentIndex + AvPlayerUtils.songList.length) % AvPlayerUtils.songList.length
AvPlayerUtils.singlePlay(AvPlayerUtils.songList[AvPlayerUtils.currentIndex])
emitter.emit({ eventId: EmitEventType.UPDATE_STATE }, {
data:
{ playStateStr: JSON.stringify(AvPlayerUtils.currentIndex) }
})
}
// 上一首
static previousSong() {
if (AvPlayerUtils.currentSong.playList.length == 0) {
return
}
//随机模式,歌曲数> 1
if (AvPlayerUtils.currentSong.playMode == 'random' && AvPlayerUtils.currentSong.playList.length > 1) {
//随机非自身
let index = 0
do {
index = Math.floor(Math.random() * AvPlayerUtils.currentSong.playList.length)
} while (index == AvPlayerUtils.currentIndex)
AvPlayerUtils.currentIndex = index
} else {
AvPlayerUtils.currentIndex--
//超过播放列表
AvPlayerUtils.currentIndex = (AvPlayerUtils.currentIndex + AvPlayerUtils.currentSong.playList.length) %
AvPlayerUtils.currentSong.playList.length
}
//切歌
AvPlayerUtils.singlePlay(AvPlayerUtils.currentSong.playList[AvPlayerUtils.currentIndex])
// 发送播放状态数据给页面
emitter.emit({ eventId: EmitEventType.UPDATE_STATE }, {
data:
{ playStateStr: JSON.stringify(AvPlayerUtils.currentIndex) }
})
}
// 单曲循环
static repeatSong() {
if (AvPlayerUtils.currentSong.playMode == 'repeat') {
AvPlayerUtils.avplayer.url = songs[AvPlayerUtils.currentSong.playIndex].url
return
}
}
}
6、总结
这个项目让我把 ArkTS 声明式 UI、AVPlayer 状态机、Ability 生命周期、路由与组件复用一次性打通。真正花时间的不是页面布局,而是"播放状态机与 UI 状态的一致性"——播放器只能有一个事实源,页面负责显示它,不负责决定它。
目前来说,我对这个项目进行了大致述说,并未进行详细开展,我会在后面对这个项目进行详细解说,我把项目的源码放到了Gitee上,有需求的可以去上面拉取。
源码地址:https://gitee.com/hyh9639/harmonyos-music-player—app
更多推荐


所有评论(0)