资讯动态

鸿蒙 ArkTS 仿网易云音乐播放器:状态管理与 AVPlayer 实战

发布时间:2026/10/9 14:26:05 来源:尧图企业网站定制
简介这是一份面向鸿蒙应用开发初学者与进阶者的实战示例包以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): Promisestring { const cachePath ${context.cacheDir}/${hash(song.id)}.mp3; if (await fileExists(cachePath)) { return file://${cachePath}; } // 下载逻辑省略下载完成后返回本地路径 return song.audioUrl; }这个缓存层不复杂但能把二次播放的起播时间从秒级降到毫秒级体感提升明显。注意缓存文件要定期清理否则用户手机空间会被悄悄吃掉。最后说一个我自己的教训早期做这个仿写项目时我把所有状态都塞进 AppStorage觉得方便结果列表超过 50 条就开始掉帧排查了两天才定位到是全局刷新导致的。后来改成 Observed 加 ObjectLink 的细粒度订阅同样一台设备列表滚到 500 条都不卡。状态管理这件事省事的方案往往在后面收利息。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑