简介:一款仿手机QQ音乐播放器的项目源码包,面向移动端应用开发学习者与入门者,也可作为课程设计或毕业设计的参考资料。资源以zip格式打包,共349个文件,压缩后仅3.82MB,其中png图片174个,主要存放界面图标与切图;xml文件51个,对应布局与配置;java源码26个,77个class为编译产物,另有jar依赖、txt说明与apk安装包等,整体结构清晰。项目核心功能覆盖主页Tab切换、播放控制、后台媒体服务、音乐下载、列表适配与工具类封装,从预览信息可看出已具备完整的播放与下载链路,并附带可直接安装的APK,方便在真机或模拟器上体验效果。对希望研究音乐App界面搭建、Service后台播放、多媒体状态管理以及列表适配的开发者而言,这套源码具有直接拆解与复用价值。目前已有33人学习下载,适合边读代码边对照效果进行二次开发。
1. 这个播放器 zip 值得解压,但先别急着看页面
这个标题里的 zip 不只是几张页面截图,解开之后是一个能直接跑进微信开发者工具的完整工程:pages 目录、utils 工具函数、本地 mock 音乐数据,以及一条从音频播放到歌词滚动的完整链路。仿手机 QQ 音乐播放器这类小程序项目,页面反而是最不花时间的部分,真正有门槛的是音频实例的生命周期、进度条和播放状态怎么同步、切到后台后音乐还能不能继续响。这篇顺着这几条线往下拆,适合三类人:拿小程序源码练手的初级开发者、想二开改成自己音乐产品的工程师、以及正在做作品集准备面试的前端。会给出可运行的关键代码,也把真机上才会暴露的坑一并说清。
2. 播放内核选型:InnerAudioContext 与 BackgroundAudioManager 的分工
拿到一份播放器源码,第一件事不是看 WXML,而是看它用哪个 API 管声音。现在微信生态里还剩两条音频路线,选错一条,后面改起来都是伤筋动骨。
2.1 为什么“仿 QQ 音乐”要避开 audio 组件
audio 组件是早期小程序内置的播放器 UI,自带一套几乎没法自定义的控件,在真机上不同安卓机型的表现差异很大,圆角、按钮密度、loading 样式都不可控,做仿 QQ 音乐这种自定义界面基本是死路。更关键的是它没有真正的全局音频上下文,页面切换时状态容易丢。
主流做法是使用wx.createInnerAudioContext()创建全局音频实例,配合wx.getBackgroundAudioManager()处理后台播放。两个 API 的定位完全不同,先看下面的选型表:
| API | 前台页面播放 | 后台播放 | 系统控制中心 | 典型用途 |
|---|---|---|---|---|
| audio 组件 | 支持,样式固定 | 不支持 | 不支持 | 极简页面,已不推荐 |
| InnerAudioContext | 支持,完全自定义 | 退出页面即停 | 不支持 | 播放页内的进度、歌词联动 |
| BackgroundAudioManager | 支持 | 支持 | 支持 | 锁屏/通知栏控制,持久播放 |
仿 QQ 音乐的项目里,两个 API 经常同时在用:页面内用 InnerAudioContext 获得精准的onTimeUpdate回调和实时进度,切后台后再无缝转到 BackgroundAudioManager 接管。
提示:InnerAudioContext 不是组件,是全局 API 创建的实例,创建后不会自动销毁,必须在页面
onUnload时手动调destroy(),否则会出现退出页面后声音还在响的情况。
2.2 在 app.js 里维护一个音频单例,所有页面共享
常见的一个反面写法是:每个页面onLoad时都wx.createInnerAudioContext()一次,页面跳转越多,音频实例越多,最终出现两个页面同时播放的灵异现象。播放器必须有全局唯一的音频上下文。
正确做法是在app.js挂实例:
// app.js App({ globalData: { audioCtx: null, playList: [], currentIndex: 0, isPlaying: false, currentTime: 0, duration: 0 }, onLaunch() { const audioCtx = wx.createInnerAudioContext(); audioCtx.obeyMuteSwitch = false; // iOS 静音键不拦截播放 audioCtx.mixWithOther = true; // 不打断其他 App 的音频 audioCtx.volume = 1; this.globalData.audioCtx = audioCtx; } })说明:obeyMuteSwitch决定 iOS 上是否跟随静音拨片,播放器场景一般设为false;mixWithOther控制是否与其他 App 声音混音,设为true后,用户切到抖音之类应用再回来,两边声音不会互相掐断。globalData里的playList和currentIndex让列表页、播放页、悬浮胶囊都能读取同一份播放状态。
访问方式:任意页面里const audioCtx = getApp().globalData.audioCtx就能拿到唯一实例。在播放器这样的多页面应用里,这比事件总线更直观。
2.3 InnerAudioContext 高频事件速查表
拿到别人写的源码,经常会在onTimeUpdate和onEnded里看到一堆 setData,但如果没弄清回调频率,就会写出卡顿的进度条。下面是高频事件的触发特征:
| 事件 | 触发时机 | 频率 | 常见用途 |
|---|---|---|---|
| onCanplay | 音频进入可播放状态 | 一次 | 隐藏 loading,展示总时长 |
| onPlay | 调用 play 且真正开始 | 一次 | 更新播放按钮状态 |
| onPause | 暂停成功 | 一次 | 同步 UI |
| onTimeUpdate | 播放位置变化 | 约 250ms 一次 | 更新进度条、歌词行 |
| onEnded | 播放到末尾 | 一次 | 自动切下一首 |
| onError | 加载/解码失败 | 异常时 | 弹提示、自动跳过当前曲目 |
onTimeUpdate约 250ms 一次,这意味着一分钟里回调约 240 次。如果每次回调都setData({ currentTime })并引起页面局部重渲染,低端安卓机很快就会卡。下面的第 3 章会给出具体的节流与拖动处理。
2.4 用第二个实例“假预载”下一首,切换更跟手
音乐播放器一个影响体验的细节是切歌速度。常见做法是:创建第二个 InnerAudioContext,在播放当前歌曲时就给它的src赋上下一首的资源地址。InnerAudioContext 创建后只要赋值src就会开始加载,不调用play()不会出声,这部分网络请求和缓冲会在后台偷偷完成。
// 播放器页面内部 let preloadCtx = null; function preloadNextSong(url) { if (preloadCtx) { preloadCtx.destroy(); } preloadCtx = wx.createInnerAudioContext(); preloadCtx.src = url; preloadCtx.volume = 0; }说明:volume = 0是为了在极端情况下防止预加载实例意外发声,多一道保险。切歌时,直接把主实例的src切换为预载地址,并调用play(),用户感知到的加载等待时间会明显缩短。需要注意,这个预载实例不需要绑定任何回调,加载失败也不影响当前播放,正如它的定位就是一个临时搬运工。
3. 播放页实现:旋转碟片、进度条拖动与切歌状态同步
播放页是整个项目里 UI 交互最密集的一层。旋转碟片要跟播放状态联动,进度条拖动时不能被onTimeUpdate的回调打扰,切歌时封面、标题、进度、歌词全部要同步更新。这一章按三个模块拆开讲。
3.1 旋转碟片:animation-play-state 是精髓
封面旋转用 CSS 动画最省资源,不要用 setData 驱动 js 定时器去改变旋转角度,那会让小程序主线程一直处于忙碌状态。
/* 播放页样式片段 */ .cover { width: 480rpx; height: 480rpx; border-radius: 50%; animation: spin 20s linear infinite; animation-play-state: paused; } .cover.playing { animation-play-state: running; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }WXML 结构里根据播放状态切换 class:
<image class="cover {{ isPlaying ? 'playing' : '' }}" src="{{ currentSong.cover }}" mode="aspectFill" />说明:animation-play-state: paused可以让动画停在当前帧,暂停后继续播放时碟片从暂停位置接着转,而不是跳回起点。CSS 动画跑在渲染层,不占用 JS 线程,比每帧 setData 的方式省电省性能。20s 转一圈是 QQ 音乐默认视觉效果,想更灵动可以改成 16s。
3.2 进度条拖动:bindchanging 和 onTimeUpdate 的冲突
slider 组件有两个关键事件:bindchanging(拖动过程中持续触发)和bindchange(松手时触发一次)。如果没有拖动状态标记,会出现这样的 bug:用户把进度条拖到 90 秒,手还没松,onTimeUpdate回调又把进度条拽回 60 秒。
处理方案是在 Page 实例上挂一个isSeeking标志:
<slider min="0" max="{{ duration }}" value="{{ currentTime }}" activeColor="#31c27c" backgroundColor="#e5e5e5" block-size="16" bindchanging="onSliderChanging" bindchange="onSliderChange" />const audioCtx = getApp().globalData.audioCtx; Page({ isSeeking: false, onSliderChanging(e) { this.isSeeking = true; // 拖动时只更新 UI,不打断音频 this.setData({ currentTime: e.detail.value }); }, onSliderChange(e) { audioCtx.seek(e.detail.value); // seek 触发后等 onSeeked 再放开标记更稳 this.isSeeking = false; } })说明:onTimeUpdate的回调里必须先判断if (this.isSeeking) return,否则它会覆盖用户正在拖动的值。真正执行seek()的动作放在bindchange里,保证只触发一次跳转。duration在onCanplay回调里通过audioCtx.duration获取并 setData 到页面上。
3.3 播放顺序、切歌与播放模式
切歌本质是修改globalData.currentIndex,再重新给音频实例赋值src。顺序播放、单曲循环、随机播放三种模式,只需要在取下一首索引时做判断。
function getNextIndex(mode, currentIndex, length) { if (mode === 'single') return currentIndex; // 单曲循环 if (mode === 'random') { let index = currentIndex; while (index === currentIndex) { index = Math.floor(Math.random() * length); } return index; } return (currentIndex + 1) % length; // 列表循环 }说明:单曲循环不需要重新赋值src,调用audioCtx.seek(0)再play()即可完整体验;随机模式下要防止随机数撞上当前 index,所以用 while 循环重新取。切歌时onEnded回调里通过getApp().globalData读取当前模式,再调用上面函数更新索引。播放状态统一放在globalData,歌词页和悬浮胶囊才能同步感知。
3.4 动态修改页面标题,跟当前歌曲绑定
播放器页面的标题如果写死成“播放器”,切歌后顶部栏跟歌曲名对不上,显得很业余。小程序提供wx.setNavigationBarTitle,在每次歌曲切换成功后调用即可:
wx.setNavigationBarTitle({ title: `${song.name} - ${song.singer}` })说明:这个 API 只能修改当前页面的导航栏标题,切页后自动恢复为app.json里的配置。所以每次切歌、每次进入播放页都需要调用一次。如果项目用的是 uniapp 编译到微信小程序,对应方法是uni.setNavigationBarTitle,参数完全一致。
4. 歌词滚动与后台播放:把体验往“QQ 音乐”再推一步
歌词滚动是播放器项目的加分项,也是很多二次开发者容易写崩的地方。这一章给出 LRC 解析、滚动实现和后台播放接入,三块都直接落代码。
4.1 LRC 歌词解析:先把它变成带时间戳的数组
LRC 格式本质是[分钟:秒.毫秒] 歌词文本的多行文本,解析目标是变成{ time, text }数组,并按时间升序排序。下面是兼容[00:12.34]和[00:12:34]两种写法的解析函数:
function parseLrc(lrcText) { const lines = lrcText.split('\n'); const result = []; const lineReg = /\[(\d{2}):(\d{2})[.:](\d{1,3})\]/; for (const line of lines) { const match = line.match(lineReg); if (!match) continue; const minutes = parseInt(match[1], 10); const seconds = parseInt(match[2], 10); const msPart = match[3].padEnd(3, '0'); const time = minutes * 60 + seconds + parseInt(msPart, 10) / 1000; const text = line.replace(lineReg, '').trim(); if (text) { result.push({ time, text }); } } return result.sort((a, b) => a.time - b.time); }说明:\d{1,3}兼顾两位和三位毫秒数,padEnd把5补成500毫秒,避免时间计算漂移。同一句歌词可能重复出现在多个时间点,比如副歌部分,解析出来就是多个带相同text的元素,后面滚动时自然会有“同一句高亮两次”的效果,不需要特殊处理。
4.2 歌词滚动:scroll-into-view 的节流写法
拿到解析后的数组,用 scroll-view 渲染,播放时定位到当前行。最直接的实现是给每一行设id="line-0"这种格式,再用scroll-into-view跳转。
<scroll-view scroll-y class="lrc-wrap" scroll-into-view="{{ activeLineId }}" scroll-with-animation > <view wx:for="{{ lrcList }}" wx:key="index" id="line-{{ index }}" class="lrc-line {{ index === activeIndex ? 'lrc-active' : '' }}" >{{ item.text }}</view> </scroll-view>onAudioTimeUpdate(currentTime) { // 用节流控制 setData 频率,避免 250ms 一次高频渲染 if (currentTime - this.lastLrcUpdateTime < 300) return; this.lastLrcUpdateTime = currentTime; let activeIndex = 0; for (let i = 0; i < this.data.lrcList.length; i++) { if (this.data.lrcList[i].time <= currentTime) { activeIndex = i; } else { break; } } this.setData({ activeIndex, activeLineId: `line-${activeIndex}` }); }说明:线性查找已够用,因为歌词一般不会超过 80 行,二分法带来的收益可以忽略。scroll-into-view每次 setData 都会触发滚动,所以必须用节流压住频率。.lrc-active的样式要同时处理放大和颜色变化,比如font-size: 36rpx; color: #31c27c; transition: all 0.3s;,这样歌词切换时有轻微放大动画,观感更接近原生播放器。
4.3 切后台不断流:换到 BackgroundAudioManager
微信小程序的 InnerAudioContext 在页面退出后会被系统挂起,要继续播放必须换用wx.getBackgroundAudioManager()。常见方案是监听app.onHide,把当前播放位置保存,再让 BackgroundAudioManager 接管同一音源。
const bgAudio = wx.getBackgroundAudioManager(); function switchToBackground(song, currentTime) { bgAudio.title = song.name; bgAudio.singer = song.singer; bgAudio.epname = song.album; bgAudio.coverImgUrl = song.cover; bgAudio.src = song.url; // 保持切换前后的播放位置连续 bgAudio.seek(currentTime); bgAudio.play(); }说明:epname是专辑名字段,不传也能播,但控制中心显示的信息会缺一行。src赋值后会自动开始播放,所以要先填好所有元数据再赋值。切回前台时,再从bgAudio读回currentTime,把 InnerAudioContext 的src指回去继续播放,注意相同源地址在 iOS 上从中间续播要调用seek(bgAudio.currentTime),直接play()可能会从头播。
4.4 requiredBackgroundModes 在原生和 uniapp 里的配置位置
只换 API 还不够,app.json里要声明音频后台运行能力:
{ "requiredBackgroundModes": ["audio"] }uniapp 工程则是在manifest.json的 mp-weixin 配置块里加同样的字段,编译时会自动合入小程序 app.json。需要注意的是:这个字段开通后,用户在小程序切到微信会话页或锁屏时,音乐可以继续播放,控制中心也能显示歌曲信息和暂停/切歌按钮。但 iOS 上彻底杀掉微信进程后播放仍然会停止,这是平台限制,代码层面无法绕过。
5. 解压导入与真机排错:zip 项目的最后一公里
拿到“小程序源码 仿手机QQ音乐播放器项目.zip”后,最常见的卡点其实不在代码里,而在解压和导入这两个动作上。另外几个坑要在真机上才能复现,用模拟器永远测不出来。
5.1 从 zip 到可运行工程的三步
先在命令行解压:
unzip 小程序源码-仿手机QQ音乐播放器项目.zip -d qq-music-app cd qq-music-app ls说明:-d指定解压目录,避免压缩包内文件直接散落在当前目录。如果源码包是从网盘下载的,解压前先在ls -lh看文件大小,常见素材缺失问题基本都是一边下一边解压导致的。Windows 下用“全部解压缩”效果相同,但要注意路径不能含中文与空格,微信开发者工具对特殊路径的兼容不算好。
导入时选目录的核心原则是:所选目录下必须直接能看到app.json。很多 zip 包会多套一层同名文件夹,比如解压后是qq-music-app/仿手机QQ音乐播放器项目/app.json,如果直接导入外层目录,工具会报“app.json 未找到”。解决办法是进入内层目录再选。导入后先看控制台有没有编译报错,再在详情里把 AppID 换成测试号,避免没注册小程序账号导致无法运行。
5.2 真机音频排错:静音键、域名与回调频率
开发工具勾选“不校验合法域名”后,模拟器里能放出声,但真机预览大概率会报url not in domain list。音乐的 mp3 文件放在云开发存储时,要确保控制台把存储域名加进了 downloadFile 合法域名,否则<audio>与 InnerAudioContext 的 src 请求会被拦截。
第二个高频坑是 iOS 静音拨片导致的“有声变无声”。obeyMuteSwitch必须在音频实例创建后立刻设置,等播放中再改不生效。调试时把代码改成audioCtx.obeyMuteSwitch = false,然后锁屏拨动静音键做对比测试,能明显感知差异。
第三个坑是进度条回调频率。真机上 onTimeUpdate 的触发间隔不稳定,低端机可能超过 300ms,如果在回调里直接写setData({ currentTime: e.detail.currentTime }),进度条会是跳帧式的移动。改进方法在前面章节已经提过,把回调频率降到 500ms 一次,结合 slider 的 changep 事件做视觉补间,手感会平滑很多。
最后一个实用验证技巧:把歌词文件故意改错格式,比如删除所有时间戳,观察解析函数是否返回空数组。如果页面白屏,多半是lrcList为空时wx:for渲染正常,但scroll-into-view的目标不存在导致滚动失效。给 scroll-view 套一层空数据的 v-if,比在解析函数里抛异常更稳妥。
本文还有配套的精品资源,点击获取