☰
鸿蒙 ArkTS 仿网易云音乐播放器:状态管理与 AVPlayer 实战
2026/10/9 14:25:59 网站建设 项目流程

简介:这是一份面向鸿蒙应用开发初学者与进阶者的实战示例包,以ArkTS语言复刻网易云音乐核心功能,帮助开发者理解HarmonyOS应用从界面到数据的完整实现路径。包内共59个文件,以ets页面组件、json/json5配置、ts逻辑脚本为主,辅以png、gif、jpg等界面素材,压缩包约762KB,结构紧凑便于快速导入DevEco Studio运行调试。项目涵盖歌单列表、播放控制、网络请求、本地存储与模块化拆分等典型场景,可对照学习ArkTS语法特性、鸿蒙UI布局与多媒体API调用方式,并参考其多设备适配与性能优化思路。目前已有1117人学习下载,适合希望以完整项目为蓝本掌握鸿蒙开发流程、积累跨端应用实践经验的技术人员。

1. 鸿蒙 ArkTS 仿网易云:一个播放器外壳背后的真实工程账

打开 DevEco Studio,新建一个 ArkTS 工程,把首页做成音乐 App 的样子——这件事本身不难。难的是当你想让底部播放条真正响起来、让进度条跟着走、让歌词滚动不卡顿的时候,你会发现仿写一个音乐类 App 的 UI 只是入场券,真正吃时间的是音频会话管理、状态同步和列表性能这三块。标题里的「鸿蒙 ArkTS 仿网易云」本质上是一个综合练习:用 ArkTS 声明式 UI 复刻一套成熟的移动端音乐交互,同时把 HarmonyOS 的媒体能力、状态管理和组件生命周期串起来。它适合已经能写基础 ArkTS 页面、想找一个有足够复杂度的项目把知识点焊死的开发者。如果你只是想把静态页面画出来,那用不上这篇文章;但如果你想让播放、切歌、进度、歌词这几条链路真正跑通,下面这些内容就是我从零搭这套东西时踩出来的路径。

2. 先定架构再写页面:ArkTS 仿网易云的模块拆分与状态设计

很多人一上来就写首页,写着写着发现播放状态散落在四五个组件里,切歌时进度条不动、封面不换、列表高亮错位。这不是 ArkTS 的问题,是状态归属没定清楚。仿网易云这类 App 的核心状态其实只有三块:当前播放队列、当前播放索引与播放模式、播放进度与歌词行号。把这三块收拢到一个可观察对象里,页面组件只做订阅和渲染,后面加功能才不会互相打架。

2.1 用 AppStorage 还是自定义 Store:选型理由

ArkTS 里做跨组件状态共享,常见做法有三种:AppStorage、LocalStorage、以及自己写一个单例类配合 @Observed/@ObjectLink。AppStorage 适合存少量全局配置,比如主题色、登录态;LocalStorage 适合页面级共享;而播放器状态是高频变化且结构复杂的对象,塞进 AppStorage 会导致任何字段变化都触发大范围刷新,列表一长就掉帧。

我一般会建一个 PlayerStore 单例,内部用 @Observed 修饰 PlayerState 类,页面里用 @ObjectLink 订阅。这样只有真正引用了该对象的组件才会响应,列表项可以只订阅自己关心的字段。代价是要手动管理订阅关系,但对于播放器这种状态密集场景,这个代价换来的性能收益是值得的。

// PlayerStore.ets @Observed export class PlayerState { queue: SongItem[] = []; currentIndex: number = -1; isPlaying: boolean = false; progress: number = 0; // 当前毫秒 duration: number = 0; // 总毫秒 playMode: 'order' | 'loop' | 'single' | 'shuffle' = 'order'; lyricLine: number = 0; } export class PlayerStore { private static instance: PlayerStore; state: PlayerState = new PlayerState(); static getInstance(): PlayerStore { if (!PlayerStore.instance) { PlayerStore.instance = new PlayerStore(); } return PlayerStore.instance; } setQueue(list: SongItem[], startIndex: number) { this.state.queue = list; this.state.currentIndex = startIndex; this.state.progress = 0; } next() { const { queue, currentIndex, playMode } = this.state; if (queue.length === 0) return; if (playMode === 'single') { this.state.progress = 0; return; } if (playMode === 'shuffle') { this.state.currentIndex = Math.floor(Math.random() * queue.length); } else { this.state.currentIndex = (currentIndex + 1) % queue.length; } this.state.progress = 0; } }

这段代码的关键点在于:PlayerState 用 @Observed 修饰后,任何被 @ObjectLink 引用的组件在字段变化时都会收到通知。setQueue 里同时重置 progress,避免切歌后进度条还停在上一首的位置。next 方法里对 single 模式做了特殊处理——单曲循环时索引不变,只把进度归零,这是很多仿写项目容易漏掉的细节。

参数上需要注意:queue 存的是完整歌曲对象还是只存 id,取决于你的数据来源。如果歌曲列表可能很大,建议 queue 只存 id 和必要展示字段,详情按需拉取。playMode 用字符串联合类型而不是枚举,是因为 ArkTS 对字符串字面量类型的支持在模板渲染里更直观。

2.2 页面结构:三个 Tab 加一个全局播放条

仿网易云的页面骨架通常是底部三个 Tab(发现、播客、我的),外加一个悬浮在所有页面之上的迷你播放条。在 ArkTS 里,这个结构用 Tabs 组件加 Stack 叠层就能实现。关键是迷你播放条不能放在某个 Tab 内部,否则切 Tab 时会跟着销毁。

// MainPage.ets @Entry @Component struct MainPage { @State currentTab: number = 0; @ObjectLink playerState: PlayerState; build() { Stack({ alignContent: Alignment.Bottom }) { Tabs({ barPosition: BarPosition.End, index: this.currentTab }) { TabContent() { DiscoverPage() }.tabBar('发现') TabContent() { PodcastPage() }.tabBar('播客') TabContent() { MinePage() }.tabBar('我的') } .onChange((index: number) => { this.currentTab = index; }) // 迷你播放条,独立于 Tabs 之外 if (this.playerState.currentIndex >= 0) { MiniPlayerBar({ state: this.playerState }) .margin({ bottom: 56 }) } } .width('100%') .height('100%') } }

Stack 的 alignContent 设为 Bottom,让播放条贴底。margin bottom 56 是给 TabBar 留出高度,这个值需要根据你的 TabBar 实际高度调整,写死不是好习惯,可以抽成常量。MiniPlayerBar 通过 @ObjectLink 拿到 playerState,这样切歌时它自己会刷新,不需要父组件传参。

这里有个容易翻车的点:@ObjectLink 修饰的变量不能在组件内部被重新赋值,只能读取其属性。如果你在 MiniPlayerBar 里写 this.playerState = xxx,编译期就会报错。正确做法是所有修改都通过 PlayerStore 单例的方法进行。

2.3 列表性能:LazyForEach 与数据源实现

歌曲列表动辄几百条,用 ForEach 全量渲染会明显卡顿。ArkTS 提供了 LazyForEach 配合 IDataSource 接口做懒加载。实现一个基础的数据源类:

// SongDataSource.ets export class SongDataSource implements IDataSource { private listeners: DataChangeListener[] = []; private data: SongItem[] = []; totalCount(): number { return this.data.length; } getData(index: number): SongItem { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const pos = this.listeners.indexOf(listener); if (pos >= 0) { this.listeners.splice(pos, 1); } } appendList(list: SongItem[]) { const start = this.data.length; this.data.push(...list); this.listeners.forEach(l => l.onDataAdd(start)); } }

IDataSource 的四个方法是固定签名,不能改。appendList 里通知监听者时传的是起始索引,LazyForEach 会根据这个索引决定渲染哪些新项。注意 onDataAdd 只传一个索引表示新增一项,批量新增需要循环调用或者用 onDatasetChange(不同 API 版本支持情况不同,以你本地 SDK 为准)。

列表项组件里,封面图建议用 Image 的 syncLoad 设为 false,让图片异步加载;文字用 Text 的 maxLines 加 ellipsis 防止长标题撑破布局。这些细节单看不显眼,但列表滚动时的流畅度就是靠它们堆出来的。

3. 让声音真正出来:AVPlayer 播放链路与进度同步

页面画完只是壳子,播放器能不能用,取决于 AVPlayer 的状态机你有没有接对。HarmonyOS 的 AVPlayer 是一个状态驱动模型,idle、initialized、prepared、playing、paused、completed、stopped、released 这几个状态之间的迁移有严格顺序,跳步就会报错。我见过最常见的翻车是:在 prepared 之前调 play,或者在 completed 之后直接调 play 而不是 seek 回起点。

3.1 AVPlayer 初始化与状态监听的标准写法

// AudioController.ets import media from '@ohos.multimedia.media'; export class AudioController { private avPlayer: media.AVPlayer | null = null; private store = PlayerStore.getInstance(); async init() { this.avPlayer = await media.createAVPlayer(); this.bindEvents(); } private bindEvents() { if (!this.avPlayer) return; this.avPlayer.on('stateChange', (state: string) => { switch (state) { case 'prepared': this.avPlayer?.play(); break; case 'playing': this.store.state.isPlaying = true; break; case 'paused': this.store.state.isPlaying = false; break; case 'completed': this.store.state.isPlaying = false; this.store.next(); this.loadCurrent(); break; default: break; } }); this.avPlayer.on('timeUpdate', (time: number) => { this.store.state.progress = time; }); this.avPlayer.on('durationUpdate', (duration: number) => { this.store.state.duration = duration; }); this.avPlayer.on('error', (err: Error) => { console.error(`AVPlayer error: ${err.message}`); this.store.state.isPlaying = false; }); } async loadCurrent() { const { queue, currentIndex } = this.store.state; if (currentIndex < 0 || currentIndex >= queue.length) return; const song = queue[currentIndex]; if (!this.avPlayer) return; // 先重置再设置新源,避免状态残留 this.avPlayer.reset(); this.avPlayer.url = song.audioUrl; } }

stateChange 回调里,prepared 状态是唯一可以安全调 play 的时机。completed 时不要直接 play,而是走 next 逻辑重新 loadCurrent,让状态机从 idle 重新走一遍。timeUpdate 默认回调间隔是 100ms 左右,够进度条用,但如果你要做逐字歌词,这个精度不够,需要另想办法。

参数说明:avPlayer.url 支持本地路径和网络地址,网络地址需要申请 ohos.permission.INTERNET 权限。reset() 会把播放器打回 idle 状态,之后重新设 url 才会触发 initialized 到 prepared 的迁移。如果你在 playing 状态下直接改 url,部分设备上会静默失败,所以 reset 这步不能省。

3.2 进度条双向绑定:拖动与播放的冲突处理

进度条是播放器里交互最微妙的部分。用户拖动时,播放进度还在往前走,如果不做隔离,会出现拖到一半被 timeUpdate 拽回去的鬼畜现象。常见做法是加一个 isDragging 标志位。

// ProgressBar.ets @Component struct ProgressBar { @ObjectLink state: PlayerState; @State isDragging: boolean = false; @State dragValue: number = 0; build() { Slider({ value: this.isDragging ? this.dragValue : this.state.progress, min: 0, max: this.state.duration || 1, step: 1 }) .onChange((value: number, mode: SliderChangeMode) => { if (mode === SliderChangeMode.Begin) { this.isDragging = true; this.dragValue = value; } else if (mode === SliderChangeMode.Moving) { this.dragValue = value; } else if (mode === SliderChangeMode.End) { this.isDragging = false; AudioControllerInstance.seekTo(value); } }) } }

Slider 的 onChange 回调里,mode 参数区分了拖动阶段。Begin 和 Moving 阶段只更新本地 dragValue,不碰播放器;End 阶段才真正 seek。这样拖动过程中进度条跟手,松手后播放器跳转,timeUpdate 恢复驱动。

max 设为 duration || 1 是为了防止 duration 还没回调时 Slider 的 max 为 0 导致除零异常。seekTo 方法内部调 avPlayer.seek(value),注意 seek 在 paused 状态下也能用,但 completed 状态下需要先 reset 再 seek。

3.3 后台播放与音频焦点

默认情况下,App 切到后台音频就停了。要支持后台播放,需要在 module.json5 里声明 backgroundModes 为 audioPlayback,并在播放前申请长时任务。音频焦点方面,当有其他 App 抢占时,AVPlayer 会触发 audioInterrupt 事件,你需要监听并做相应处理。

this.avPlayer.on('audioInterrupt', (info: media.AudioInterrupt) => { if (info.eventType === media.InterruptEventType.INTERRUPT_HINT_PAUSE) { this.avPlayer?.pause(); } else if (info.eventType === media.InterruptEventType.INTERRUPT_HINT_RESUME) { this.avPlayer?.play(); } });

这段逻辑不复杂,但漏掉的话用户体验会很差——来电话时音乐还在响,或者电话挂断后音乐不恢复。audioInterrupt 的事件类型在不同 API 版本里命名可能有差异,以本地 SDK 的 d.ts 为准。

4. 歌词滚动与列表联动:那些让你加班到凌晨的细节

播放链路通了之后,歌词滚动和列表高亮是第二梯队的工作量。歌词解析本身不难,难的是滚动时机、行高计算和与用户手动滚动的冲突。

4.1 LRC 解析:时间戳格式的坑

LRC 格式看着简单,实际有一堆变体。标准格式是 [mm:ss.xx]歌词,但有的文件用 [mm:ss.xxx],有的用 [mm:ss],还有的一行多个时间戳。解析时用正则统一处理:

export interface LyricLine { time: number; // 毫秒 text: string; } export function parseLrc(raw: string): LyricLine[] { const lines = raw.split('\n'); const result: LyricLine[] = []; // 匹配 [mm:ss.xx] 或 [mm:ss.xxx] 或 [mm:ss] const timeReg = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g; for (const line of lines) { const matches = [...line.matchAll(timeReg)]; if (matches.length === 0) continue; const text = line.replace(timeReg, '').trim(); if (!text) continue; for (const m of matches) { const min = parseInt(m[1], 10); const sec = parseInt(m[2], 10); const msStr = m[3] || '0'; // 两位补到百毫秒,三位直接取 const ms = msStr.length === 2 ? parseInt(msStr, 10) * 10 : parseInt(msStr, 10); result.push({ time: min * 60000 + sec * 1000 + ms, text }); } } return result.sort((a, b) => a.time - b.time); }

关键在毫秒位的处理:两位表示百分秒,要乘 10;三位表示毫秒,直接用。不区分的话歌词会整体偏移。matchAll 需要 ES2020 支持,ArkTS 的编译目标一般没问题,如果报错就改用 exec 循环。

排序不能省,有些 LRC 文件行序是乱的,尤其是翻译歌词和原文混排的情况。

4.2 歌词滚动:用 List 的 scrollToIndex 还是手动偏移

歌词列表用 List 组件,每行一个 Text。滚动策略有两种:一是根据当前时间算出目标行号,调 List 的 scrollToIndex;二是用一个 Scroll 容器,手动算偏移量做动画。前者简单但滚动是跳变的,后者可以做平滑动画但计算量大。

我一般用 List 加 scrollToIndex 配合 animateTo 做过渡:

@Watch('onLyricLineChange') @State lyricLine: number = 0; onLyricLineChange() { if (this.isUserScrolling) return; // 用户手动滚动时不打断 this.lyricScroller.scrollToIndex(this.lyricLine, true, ScrollAlign.CENTER); }

ScrollAlign.CENTER 让当前行居中,这是音乐 App 的通用做法。isUserScrolling 标志在 List 的 onScrollStart 里置 true,在 onScrollStop 后延迟几秒置回 false,给用户留出阅读时间。

行高不固定的话,scrollToIndex 的定位会有偏差。解决办法是给每行设固定高度,或者用 ListItem 的 measure 能力。固定高度最省事,歌词一般也就一两行,设个 48vp 够用。

4.3 播放列表高亮与点击切歌

播放列表里当前播放项要高亮,点击其他项要切歌。高亮状态直接从 PlayerStore 的 currentIndex 读,列表项组件用 @ObjectLink 订阅 state,在 build 里判断 index === state.currentIndex 来决定文字颜色。

点击切歌时,先更新 store 的 currentIndex,再调 AudioController 的 loadCurrent。注意不要直接改 queue 数组,否则 LazyForEach 的数据源和 store 会不同步。正确顺序是:store 更新索引 → 通知数据源刷新 → 控制器加载新源。

这里有个隐蔽的坑:如果列表项用了 @ObjectLink 订阅整个 state,那么 progress 每 100ms 变化一次,所有列表项都会重新渲染。解决办法是列表项只订阅 currentIndex,把 progress 相关的渲染隔离到播放条组件里。ArkTS 的 @ObjectLink 不支持字段级订阅,所以实际做法是把 currentIndex 单独抽一个 @Observed 类,或者用 @Track 装饰器标记需要追踪的字段(API 11 及以上支持)。

5. 避坑与排查:仿写播放器时最容易翻车的五个点

5.1 切歌后进度条不回零

现象:点下一首,歌换了但进度条还停在上一首的位置,过几秒才跳回去。

原因:loadCurrent 里只改了 url,没有重置 store 的 progress。timeUpdate 回调在新歌开始前不会触发,所以进度条保持旧值。

解决:在 loadCurrent 开头就把 store.state.progress 和 duration 置零,不要等回调。同时把 isPlaying 设为 false,等 stateChange 到 playing 再置 true。

5.2 列表快速滑动时封面图错位

现象:快速滑动歌曲列表,封面图闪一下变成别的歌的图。

原因:LazyForEach 复用列表项组件时,Image 的异步加载回调回来时组件已经绑定了新数据,旧回调把新图覆盖了。

解决:给每个 Image 请求打上当前歌曲 id 标记,回调里比对 id,不匹配就丢弃。或者用 Image 的 syncLoad 设为 true(牺牲性能换正确性),再或者用第三方图片库的缓存机制。

5.3 后台播放被系统回收

现象:切到后台几分钟后音乐停了,回到前台发现 App 被重启。

原因:没有申请长时任务,或者 backgroundModes 配置了但没调 startBackgroundTask。

解决:在 module.json5 的 abilities 里加 backgroundModes: ["audioPlayback"],播放时调 backgroundTaskManager.startBackgroundTask,暂停时 stop。注意长时任务需要对应权限,且系统对后台时长有限制,具体以设备策略为准。

5.4 歌词与声音不同步

现象:歌词比声音慢半拍或快半拍,整体偏移。

原因:LRC 解析时毫秒位处理错误,或者 timeUpdate 的回调延迟累积。

解决:先检查解析结果,打印前几行的时间戳和预期对比。如果是回调延迟,可以在 timeUpdate 里用系统时间做补偿,但更简单的做法是接受 100ms 内的误差——人耳对歌词同步的容忍度大概在 200ms 左右。

5.5 播放器状态机报错 5400102

现象:调 play 或 seek 时报错,错误码 5400102,提示状态不支持。

原因:在错误的状态下调了方法。比如 idle 状态调 play,或者 completed 状态直接 seek 而不先 reset。

解决:在每次操作前检查 avPlayer.state,或者把操作包在状态判断里。最稳妥的做法是维护一个内部状态标志,只在 prepared/playing/paused 三个状态下接受 play/pause/seek 请求,其他状态先走 loadCurrent。

6. 进阶:把播放器做成可复用的音频服务

走到这一步,播放器功能基本齐了,但代码散在页面和控制器里,换个项目想复用就得复制粘贴。我的习惯是把音频能力抽成一个独立的 Service 模块,对外只暴露方法,内部状态完全封装。

具体做法是:AudioService 类持有 AVPlayer 和 PlayerStore,对外提供 play(song)、pause()、seek(ms)、setMode(mode) 四个方法,以及一个 getState() 返回只读状态快照。页面组件不直接碰 AVPlayer,只调 Service 方法。这样测试时可以用 mock 替换 Service,换 UI 框架时音频逻辑不用动。

验证 Service 是否解耦干净,有个简单标准:把 Service 单独编译成一个 har 包,看它有没有依赖任何 UI 组件。如果依赖了,说明状态和视图还没分干净。

另一个进阶方向是音频缓存。网络歌曲每次播放都重新下载体验很差,可以在 Service 里加一层缓存:播放前先查本地是否有缓存文件,有就直接用本地路径,没有就下载完再播。缓存目录用 context.cacheDir,文件名用歌曲 id 的哈希。清理策略可以按最近使用时间排序,超过阈值就删最旧的。

async getPlayableUrl(song: SongItem): Promise<string> { const cachePath = `${context.cacheDir}/${hash(song.id)}.mp3`; if (await fileExists(cachePath)) { return `file://${cachePath}`; } // 下载逻辑省略,下载完成后返回本地路径 return song.audioUrl; }

这个缓存层不复杂,但能把二次播放的起播时间从秒级降到毫秒级,体感提升明显。注意缓存文件要定期清理,否则用户手机空间会被悄悄吃掉。

最后说一个我自己的教训:早期做这个仿写项目时,我把所有状态都塞进 AppStorage,觉得方便,结果列表超过 50 条就开始掉帧,排查了两天才定位到是全局刷新导致的。后来改成 @Observed 加 @ObjectLink 的细粒度订阅,同样一台设备,列表滚到 500 条都不卡。状态管理这件事,省事的方案往往在后面收利息。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询