鸿蒙开发日记:我的第一个音乐播放器App

起因

周五晚上,刷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()
}

这是定时器管理的第二条规则:退出时一定要清理

两条规则总结:

  1. 启动前先停旧的 — 防止多个定时器叠加
  2. 退出时一定要清理 — 防止内存泄漏和资源浪费

这两条规则不仅适用于定时器,以后学监听器、订阅器也都适用。


周六傍晚:收藏功能

需求

每首歌旁边有个爱心按钮,点击切换收藏/未收藏状态。

第一版代码

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自己更新
防御性编程先判断再操作
资源管理有创建就有销毁
用户体验橙色标识当前播放

最重要的两条规则

做完这个项目,我印象最深的就是定时器管理的两条规则。这两条规则不仅适用于这个项目,以后做任何涉及资源管理(定时器、监听器、订阅器、动画)的项目都适用:

  1. 启动前先停旧的 — 防止重复叠加
  2. 退出时一定要清理 — 防止泄漏

后续计划

这个播放器还有好多可以完善的:

近期(下个周末)

  1. 进度拖动 - 加Slider组件,让用户拖动进度条
  2. 播放模式 - 顺序播放/随机播放/单曲循环
  3. 数据持久化 - @StorageLink保存收藏状态

中期

  1. 组件拆分 - 把歌曲列表项和底部控制栏拆成独立组件
  2. 真实音频 - 用AVPlayer播真实音乐
  3. 播放模式UI - 加个模式切换按钮

远期(有空再说)

  1. 歌词显示 - 解析LRC文件
  2. 搜索功能 - 快速找歌
  3. 专辑封面 - 用图片代替emoji
  4. 动画效果 - 播放时封面旋转

代码总览

最后贴一下完整代码,方便参考:

@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')
  }
}

总结

一个周末的时间,从零做了一个音乐播放器。虽然功能不复杂,但收获不少。

最深刻的收获:

  1. 定时器管理两条规则 — “启动前先停旧的,退出时一定要清理”,这条经验以后做任何资源管理都用得上
  2. @State数组更新规则 — 索引赋值不触发更新,要整体赋值,这个坑新手必踩
  3. 取模处理负数 — 加数组长度后再取模,确保结果非负
  4. 声明式UI的魅力 — 只管改数据,UI自己更新,开发效率真的高

ArkUI写起来确实舒服,声明式UI真的香。

这个项目还只是开始,后面要继续完善——加进度拖动、播放模式、真实音频……一步一步来。

有问题欢迎留言交流~


觉得这篇日记有帮助的话,点个赞再走吧! 🎵

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值