简介:一套面向微信小程序开发者的仿网易云音乐源码项目,适合有一定前端基础、想学习完整小程序页面搭建与逻辑交互的学习者。资源共169个文件,包含107个png图片素材、16个js逻辑文件、15个wxml结构文件、14个wxss样式文件、13个json配置文件,以及项目配置文件与说明文档,压缩包整体4.65MB。内容涵盖启动页、发现页、排行榜等模块,预览可见app.js、util.js、toplist.js等核心脚本,便于对照剖析页面渲染、数据请求与样式适配。已有4036人学习/下载,适合作为课堂作业、个人练手或二次开发的参考基础,可根据需求替换接口与素材,快速搭建界面美观、功能完善的主流音乐App小程序。
1. 微信小程序 仿网易云音乐 (源码) 这个标题,最容易被低估的是括号里的两个字
很多人拿到项目包的第一反应是改 appid、能跑就行,然后很快遇到三件怪事:播放器切到后台就断,分享卡片打开的不是正在听的歌,iPhone 上音量键控制不了播放。问题不在界面还原度,而在于「仿网易云音乐」需要把播放器内核、页面栈、后台音频、导航栏适配、分享链路串成一个闭环,源码只是这个闭环的落点。这篇文章按闭环顺序给出一版可落地的小程序结构,适合刚学完小程序基础、准备做毕业设计或面试作品的人,也适合被播放器反复折磨的熟手对照参数排错。
2. 仿网易云音乐的微信小程序怎么选型:原生、uni-app 与播放器内核
2.1 为什么音乐类小程序很少用纯 Web 渲染
如果你只是想把一首歌塞进小程序页面里,<audio>组件就能出声。但网易云这类产品的核心体验是「切后台继续播、锁屏能控、列表不断流」,这三件事都不是页面渲染层能解决的。微信小程序的 web-view 在 iOS 上切入后台会被系统挂起,H5 的音频上下文直接暂停;Android 上进程一旦被回收,WebView 里的音频更加不可控。所以「仿网易云音乐」的底线不是界面像,而是底层播放链路必须走小程序原生能力。
这里说的原生能力,核心就是wx.createInnerAudioContext()。它返回一个脱离 WXML 的音频上下文实例,支持后台播放、进度查询、播放状态回调,并且不需要页面里有任何可见的 audio 组件。和<audio>组件的本质区别在于:<audio>是为页面内嵌播放器设计的,页面销毁它就没了;InnerAudioContext 是独立生命周期,页面销毁、音频不停,这正好是音乐类 App 的基本盘。
2.2 原生小程序与 uni-app 的边界怎么选
常见路线有两条:用原生 WXML/WXSS 写,或者用 uni-app 在 HBuilderX 里编译成微信小程序。我一般按交付范围判断——只做微信端,原生;要同时上 App、支付宝小程序、H5,uni-app。热词里出现「uniapp微信小程序」「hbuilderx开发微信小程序」的频率很高,说明用 Vue 语法写小程序已经成为不少团队的默认路径,这条路可行,但要清楚 uni-app 编译到微信端后,底层依然是wx.createInnerAudioContext,框架不会帮你多做任何播放层面的事。
| 对比项 | 原生小程序 | uni-app |
|---|---|---|
| 学习成本 | 需要掌握 WXML/WXSS/Page 生命周期 | 会 Vue 就能快速上手,但要理解编译差异 |
| 播放器能力 | 直接调用 InnerAudioContext | 编译后仍映射微信原生 API,能力一致 |
| 真机调试 | 微信开发者工具直达 | HBuilderX 运行到小程序模拟器后再拉真机 |
| 多端复用收益 | 无 | 一套代码可编译 App/H5/支付宝端 |
| 踩坑面 | 少一层编译,问题更直观 | 样式兼容、组件属性透传需要额外查文档 |
我的建议是:如果是拿这个标题做毕业设计或面试作品,原生代码更容易把控,代码量不大,还能展示对小程序生命周期的理解;如果团队已经在用 Vue,走 uni-app 也完全可行,但播放器模块一定要单独抽成文件,不要放进页面 data,否则每次 setData 都可能触发不必要的音频上下文操作。
2.3 播放器内核:后台播放需要 app.json 先点头
很多人写完播放页发现一切正常,一锁屏就断,第一反应是去查播放器 API,其实问题出在 app.json 少了关键配置。
{ "pages": [ "pages/index/index", "pages/player/index" ], "window": { "navigationStyle": "custom", "backgroundColor": "#f5f5f5" }, "requiredBackgroundModes": ["audio"] }requiredBackgroundModes是音乐类小程序最先要配的字段。没有它,InnerAudioContext 在前台能播,切后台很快会被系统挂起;配上audio之后,iOS 还能在控制中心显示播放信息。这个字段平时不被注意,是因为普通小程序用不到后台能力,但仿网易云音乐这个标题下它是前置条件。
同样容易踩的还有wx.setInnerAudioOption的obeyMuteSwitch参数,默认是true,也就是用户把 iPhone 侧边的静音键拨下去,音乐会直接没声音。音乐 App 的预期是「静音键只管铃声,不管媒体音量」,所以要显式设成false。另外,InnerAudioContext 是系统级资源,同一个时间点建议只保留一个实例,切歌时直接换src再play(),不要频繁create()再destroy(),否则快速连点切歌时会听到叠声,或者直接无声。
3. 仿网易云音乐微信小程序的最小可运行骨架:从 tabbar 到播放器
3.1 目录结构与页面栈:两个页面就够了
网易云的信息密度很高,但作为练手项目,页面拆两个就够——index做歌单广场,player做沉浸播放。再多页面只会放大状态同步问题。推荐目录结构:
miniprogram/ ├─ app.json ├─ app.js ├─ utils/mock.js # 本地歌单数据 ├─ services/player.js # 播放器单例 ├─ pages/ │ ├─ index/ # 歌单广场 │ └─ player/ # 播放页代码量不大,核心逻辑全部收敛到services/player.js。页面之间不直接传歌曲对象,只传 id,用 URL 参数串联。这样从列表进入、从分享卡片冷启动、从历史记录恢复,三种入口走的是同一条解析路径。首页如果要加 tabbar,网易云的「发现/播客/我的」三个 Tab 可以直接用原生 tabBar,但原生 tabBar 的样式定制能力有限,想做得更像,通常要自定义 tabBar 组件,这块可以等主流程跑通之后再补。
3.2 用 wx.createInnerAudioContext 包一个播放器单例
播放器是全局状态,不能挂在任何页面的 data 里。常见做法是单独建一个模块,页面只负责调用,不直接触碰 InnerAudioContext 实例。
// services/player.js const audio = wx.createInnerAudioContext() const player = { currentId: '', play(src, id) { this.currentId = id if (audio.src !== src) { audio.src = src } audio.play() }, pause() { audio.pause() }, toggle() { if (audio.paused) { audio.play() } else { audio.pause() } }, onTimeUpdate(cb) { audio.onTimeUpdate(() => { cb({ currentTime: audio.currentTime, duration: audio.duration, percent: audio.duration ? Math.round((audio.currentTime / audio.duration) * 100) : 0 }) }) }, onEnded(cb) { audio.onEnded(cb) } } audio.obeyMuteSwitch = false audio.autoplay = false module.exports = player逻辑说明:模块加载时创建唯一音频实例,全应用共享,页面销毁不会影响播放;src相同时不重复赋值,避免切歌时同一首歌被重新加载;用audio.paused判断状态而不是自己维护布尔值,防止 UI 状态和真实播放状态脱节。onTimeUpdate大约每 250ms 触发一次,拿到currentTime后驱动进度条和歌词滚动。
这个模块里onTimeUpdate有个隐藏问题:页面每次onLoad都注册回调,onUnload不注销就会叠加。常见做法是在页面onUnload里调用audio.offTimeUpdate(),但那是全局 off,会把其他页面的回调也清掉。更好的做法是让onTimeUpdate返回一个取消函数,或者在页面销毁时用标志位跳过过期回调。参数层面,autoplay建议设为false,由页面决定何时播放,避免冷启动场景下音频自动响起。
3.3 用 URL 参数把列表和播放页串起来
首页列表点击事件是整条链路的第一环:
// pages/index/index.js const mock = require('../../utils/mock') const player = require('../../services/player') Page({ data: { songs: [] }, onLoad() { this.setData({ songs: mock.songs }) }, onTapSong(e) { const { id } = e.currentTarget.dataset wx.navigateTo({ url: `/pages/player/index?id=${id}` }) } })对应 WXML 片段:
<view class="song-item" wx:for="{{songs}}" wx:key="id" >// pages/player/index.js const mock = require('../../utils/mock') const player = require('../../services/player') Page({ data: { song: null }, onLoad(options) { const song = mock.songs.find( s => String(s.id) === String(options.id) ) if (song) { this.setData({ song }) player.play(song.url, song.id) } } })options.id可能来自navigateTo的 query,也可能来自分享卡片的 path,两种来源在 iOS 和 Android 上对数字类型的处理不完全一致,统一String()比较最省事。演示项目的歌曲地址建议指向自己可控的测试音频资源,本地 mock 数据足够把整条链路跑通。
播放页还需要处理一个细节:当用户通过分享卡片冷启动进入时,小程序先加载pages/player/index,此时app.js的onLaunch和页面的onLoad几乎同时触发,播放器单例已经 ready,可以直接播。但如果是首次启动后先进首页再进播放页,navigateTo会保留首页在页面栈里,这时要注意onHide时不要顺手暂停播放,很多新手在首页onHide里写暂停逻辑,结果切到播放页歌就停了。
4. 微信小程序仿网易云音乐必调的 3 类参数:导航栏、滚动、分享
4.1 导航栏高度:按胶囊位置计算,别写死数值
网易云这种沉浸式播放页,一般要隐藏原生导航栏自己画界面,app.json 里navigationStyle设为custom。但自定义导航栏的第一个坑就是高度不能写死,状态栏高度和胶囊按钮位置在不同机型差异很大,iPhone 全面屏、灵动岛、Android 各家挖孔屏都不一致。小程序提供了胶囊位置接口,推荐这样算:
function getNavBarInfo() { const win = wx.getWindowInfo() const menu = wx.getMenuButtonBoundingClientRect() const statusBarHeight = win.statusBarHeight const navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height return { statusBarHeight, navBarHeight, menuRight: win.windowWidth - menu.right, menuTop: menu.top, menuHeight: menu.height } }说明:menu.top是胶囊距屏幕顶部的距离,减去状态栏高度,得到胶囊在导航栏区域内的 top;因为要保证胶囊上下留白一致,所以导航栏总高度等于「上方留白 × 2 + 胶囊高度」。右侧留白用windowWidth - menu.right,自定义右上角按钮(比如播放列表入口)时按这个值对齐。基础库较旧时wx.getWindowInfo()不可用,可以回退到wx.getSystemInfoSync(),字段名一致。这套计算建议在app.js的onLaunch里做一次放进globalData,不要在每帧或每个页面重复调用。
4.2 长列表滚动与播放高亮:scroll-view 参数与状态保持
网易云首页的推荐流很长,滚动性能直接影响第一印象。常见做法是页面级滚动,让原生 scroll 接管,减少嵌套。如果确实要在某个区块内滚动,scroll-view有 3 个参数值得关注:
| 参数 | 作用 | 建议 |
|---|---|---|
| enhanced | 开启增强滚动,提升 iOS 惯性滚动体验 | 长列表开启 |
| show-scrollbar | 是否显示滚动条 | 默认不显示,不用刻意改 |
| enable-passive | 滚动中是否被动接收 touch 事件 | 页面级滚动不开,避免和拖拽手势冲突 |
播放中的歌曲高亮需要跨页面保持状态,做法是首页onShow时从 player 单例读回currentId,再setData到列表项的 class 上,而不是在列表页额外维护一套播放状态。网易云歌单里长按拖动换序是加分功能,但小程序里在scroll-view容器内直接做 touchmove 拖动换位,会跟原生滚动抢手势,表现出来就是「拖着拖着列表自己滚走了」。轻量方案是bindlongpress进入编辑态,显示上移/下移按钮;真要自由拖拽,用movable-area包一层,但要接受它和列表滚动的兼容成本。
4.3 分享卡片与保存海报:图片先处理再传
分享是仿网易云音乐最容易出「看起来没做完」的地方。onShareAppMessage要返回带参数的 path:
// pages/player/index.js Page({ onShareAppMessage() { const song = this.data.song return { title: `${song.name} - ${song.artist}`, path: `/pages/player/index?id=${song.id}`, imageUrl: song.shareCover } } })分享图有固定比例要求,小程序分享卡片图片会按 5:4 显示,直接把歌曲封面原图丢进去,上下会被裁掉。开发期从设计稿里用图片提取工具抽出需要的封面和图标时,也要注意微信对图片域名有 downloadFile 合法域名限制,mock 阶段尽量把图片放本地 assets,或提前在管理后台配好域名。分享图建议预生成 500×400 的专用图,而不是用页面截图。
保存海报到相册是另一个高频需求,wx.saveImageToPhotosAlbum的常见失败原因是用户拒绝过相册权限。保存前先wx.getSetting查scope.writePhotosAlbum,拒绝之后不要直接再弹授权,而是引导用户点击open-type="openSetting"的按钮去设置页手动开启。在 uni-app 项目里对应报错是savelmagetophotosalbum:fail,原因大同小异:要么没配权限说明,要么海报 canvas 用的旧接口导致临时路径失效,canvasToTempFilePath记得按屏幕 DPR 设置destWidth和destHeight,不然部分机型导出的图是模糊的。
5. 仿网易云音乐小程序的验收方法:mock/real 切换与播放中断处理
5.1 把请求层做成可切换的 Promise
演示项目最怕现场断网,也怕面试官问「数据从哪来」。常见做法是封装一个请求层,默认走本地 mock,切换真实接口只改一个开关。
// utils/request.js const USE_MOCK = true function fetchSongs() { if (USE_MOCK) { return Promise.resolve(require('./mock').songs) } return new Promise((resolve, reject) => { wx.request({ url: 'https://api.example.com/songs', success: res => resolve(res.data.data), fail: reject }) }) }mock 分支用Promise.resolve包一层,调用方永远拿到 Promise,后续换真实接口时页面代码一行不用改。这个模式比在页面里堆if判断干净得多,也方便以后接后端。
5.2 真机验收 checklist
| 检查项 | 操作 | 预期 |
|---|---|---|
| 后台播放 | 播放后切后台锁屏 | 音频不断,iOS 控制中心显示歌曲信息 |
| 分享深链 | 分享卡片后从另一个微信打开 | 直接进入播放页并播放对应歌曲 |
| 锁屏控制 | iOS 控制中心暂停/下一首 | 状态与播放器同步 |
| 列表滚动 | 快速滑动首页 | 无明显掉帧,无播放器卡顿 |
| 相册授权 | 首次拒绝后再次保存 | 弹出引导按钮,打开设置后可保存 |
5.3 来电打断场景的暂停恢复
最后补一个最容易漏的中断处理。播放中突然来电,音频被打断,恢复通话后要能回到之前的状态。在 player 单例初始化时注册两个系统事件:onAudioInterruptionBegin时记录当前进度并暂停,onAudioInterruptionEnd时恢复播放。注意这里不能用audio.paused做恢复判断,因为手动暂停和来电打断都会让paused为true,要单独用一个interrupted标志位区分,来电打断恢复时才自动续播,手动暂停则保持暂停。这一组事件注册在播放器单例里只执行一次,不要在页面onLoad里重复注册,否则多个页面实例会导致恢复逻辑执行多次。
本文还有配套的精品资源,点击获取