📖 前言

音乐播放器是移动端最经典的应用之一,几乎每个人都离不开它。今天,我将带大家使用鸿蒙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 创建新项目

  1. 打开 DevEco Studio,选择 Create Project
  2. 选择 Empty Ability 模板
  3. 填写项目信息:
    • 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()
}

为什么必须在生命周期中清理定时器?

当组件被销毁(例如页面关闭、路由跳转离开)时,如果定时器仍在运行,会造成:

  1. 内存泄漏 - 定时器持有组件引用,阻止垃圾回收
  2. 空指针异常 - 定时器回调中访问已销毁的组件状态

因此在 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 中:

  1. 点击菜单 Build > Build Hap(s)/APP(s) > Build Hap(s)
  2. 等待构建完成

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设计

这个项目虽然功能简洁,但涵盖了定时器管理、状态联动、数组更新等核心开发技巧,是一个非常实用的练手项目!


参考资料


如果这篇文章对你有帮助,欢迎点赞、收藏、评论!你的支持是我创作的动力! 🎵

Logo

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

更多推荐