概要

一个 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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐