1. 需求分析與開發(fā)方案
1.1 需求簡(jiǎn)介
最近產(chǎn)品給我們提出了“在小程序中播放音頻課程”的需求,主要是有四個(gè)要點(diǎn):
-
課程管理:進(jìn)入某個(gè)課程的播放頁面,獲取全部音頻列表,但暫時(shí)不播放。
-
音頻管理:支持在播放頁面,點(diǎn)擊任意音頻進(jìn)行播放;可自動(dòng)播放下一首。比如這樣

-
進(jìn)度控件:支持拖動(dòng)修改進(jìn)度/上下首/暫停/播放,就像下面這樣。

-
全局播放:當(dāng)用戶暫時(shí)離開小程序時(shí),在微信聊天列表頁頂部展示背景音頻。
就像這樣子。

1.2 開發(fā)分析
好了,問題來了,怎么實(shí)現(xiàn)上面這幾個(gè)需求呢?
我陷入了沉思…………
先進(jìn)條“課程管理”不難,全局維護(hù)一個(gè)數(shù)組就好了。
第二條“音頻管理”看上去是個(gè)麻煩,一開始我想到了小程序提供的audio控件。
但是隨即我就否決掉了這種想法,理由主要有兩點(diǎn):
-
微信官方提供的audio控件有默認(rèn)的樣式,如下圖,這與設(shè)計(jì)稿的需求不相符。

-
經(jīng)過在微信官方提供的小程序?qū)嵗鼶emo中親測(cè),如果使用audio控件,那么當(dāng)我退出當(dāng)前頁面的時(shí)候,音頻會(huì)消失,這沒有辦法滿足PM要求的“全局播放”
因此,我決定采用微信提供的 backgroundAudioManager 。
1.2.1 backgroundAudioManager簡(jiǎn)介
按官方文檔的說法,backgroundAudioManager是:
全局先進(jìn)背景音頻管理器
下面列出它的部分重要屬性和重要的方法:
屬性:
-
duration:當(dāng)前音頻長(zhǎng)度,可以用來初始化播放控件的值。
-
currentTime:當(dāng)前播放的位置,可以用來更新播放控件的進(jìn)度值
-
paused:false為播放,true表示停止/暫停
-
src:音頻數(shù)據(jù)源,注意設(shè)置src的時(shí)候會(huì)自動(dòng)播放
-
title:音頻標(biāo)題(剛剛在微信聊天列表頁頂部展示的音頻title“為什么秋冬季節(jié)孩子易生病”,就是通過這里設(shè)置的)
方法:
-
play/pause/stop/seek:可以進(jìn)行音頻常見的播放控制,其中seek是跳轉(zhuǎn)到特定播放進(jìn)度的方法
-
onPlay/onPause/onStop/onEnded:響應(yīng)特定事件,其中onStop是主動(dòng)停止,onEnded是自動(dòng)播放完畢(這可用于實(shí)現(xiàn)“連續(xù)播放”)
-
onTimeUpdate:背景音頻播放進(jìn)度更新事件,可與前面的currentTime屬性結(jié)合在一起,去更新控件的值。
-
onWaiting/onCanplay:音頻通常不會(huì)立刻就能播放,這兩個(gè)方法可以在音頻加載的時(shí)候?yàn)橛脩糇鲆恍┨崾尽?/p>
更多的消息請(qǐng)查看它的官方文檔。
1.2.2 播放控件
第三條“播放控件”也不算太難,播放/暫停/上下首都用小圖片就可以了。
但是難點(diǎn)在于播放進(jìn)度條的模擬,前面已經(jīng)說到audio控件的樣式是不符合需求的。
那么我決定采用slider來模擬,應(yīng)該也可以搞定。
第四條,前面已經(jīng)說了,用backgroundAudioManager實(shí)現(xiàn)“全局播放”。
1.2.3 開發(fā)方案確定
好了,需求分析得差不多了,我們要開發(fā)這個(gè)需求,需要三個(gè)對(duì)象,
-
課程管理對(duì)象,負(fù)責(zé)維護(hù)課程信息和課程音頻列表,不負(fù)責(zé)播放

-
音頻管理對(duì)象,即backgroundAudioManager,負(fù)責(zé)管理音頻的播放,其中只有changeAudio方法具有修改音頻的權(quán)限

-
播放控件。

有了這幾個(gè)對(duì)象,課程管理/音頻管理/進(jìn)度控件/全局播放就都可以搞定啦。
不過,話雖然這么說,但是實(shí)際實(shí)現(xiàn)需求總是會(huì)碰到各種各樣的問題。
2. 功能實(shí)現(xiàn)
因?yàn)樾枨髮?shí)在太多了,我沒法一一列出,在這里就介紹一些需要技巧的需求
2.1 Slider控件模擬進(jìn)度
前面提到,控件大概長(zhǎng)這樣

所以得用slider來模擬,但是模擬并不容易。
哈?你說為什么?我慢慢告訴你。
2.1.1 需求一:控件隨著音頻播放,自動(dòng)更新
PM的需求是:控件隨著音頻播放,自動(dòng)更新進(jìn)度,左值隨著進(jìn)度更新,右值為音頻總長(zhǎng)度。
但是小程序自帶的slider不支持展示左右值,我們只能自己模擬。
<!-- 音頻進(jìn)度控件 --> <view class="course-control-process"> // 左值展示,currentProcess <text class="current-process">{{currentProcess}}</text> // 進(jìn)度條 <slider bindchange="hanleSliderChange" // 響應(yīng)拖動(dòng)事件 bindtouchstart="handleSliderMoveStart" bindtouchend="handleSliderMoveEnd" min="0" max="{{sliderMax}}" activeColor="#8f7df0" value="{{sliderValue}}"/> // 右值展示,totalProcess <text class="total-process">{{totalProcess}}</text> </view>
currentProcess為左值、totalProcess為右值、sliderMax控件最大值、sliderValue為當(dāng)前控件的value。
那么,怎么更新這些數(shù)值呢?前面提到backgroundAudioManager有一個(gè)onTimeUpdate方法,在這里面去更新進(jìn)度值就可以了。
// formatAudioProcess函數(shù)我就不放了,就是把時(shí)間格式化成00:15這樣就行了 onTimeUpdate() { // 省略一些判斷代碼 self.page.setData({ currentProcess: formatAudioProcess(globalBgAudioManager.currentTime), sliderValue: Math.floor(globalBgAudioManager.currentTime) }); },
這里有一件值得注意的是,就是在進(jìn)入同一個(gè)課程的播放頁時(shí),由于原page很可能已經(jīng)銷毀(比如你執(zhí)行navigateTo),因此需要在初始化的時(shí)候更新原有的data值,比如當(dāng)前的播放進(jìn)度currentProcess,這就要從當(dāng)前的backgroundAudioManager里去拿。
## 檢查是否同一個(gè)課程,如果是的話,更新進(jìn)度 if (id !== globalCourseAudioListManager.getCurrentCourseInfo().id) ## 更新方法 updateControlsInOldAudio() { // 獲取當(dāng)前音頻 const currentAudio = globalCourseAudioListManager.getCurrentAudio(); // 更新進(jìn)度和控件內(nèi)容 this.setData({ currentProcess: formatAudioProcess(globalBgAudioManager.currentTime), sliderValue: formatAudioProcess(globalBgAudioManager.currentTime), sliderMax: Math.floor(currentAudio.duration / 1000) - 1 || 0, totalProcess: formatAudioProcess(currentAudio.duration / 1000 || 0), hasNextAudio: !globalCourseAudioListManager.isRightEdge() && this.data.hasBuy, hasPrevAudio: !globalCourseAudioListManager.isLeftEdge() && this.data.hasBuy, paused: globalBgAudioManager.paused, currentPlayingAudioId: currentAudio.audio_id, courseChapterTitle: currentAudio.title }); },
2.1.2 需求二:拖動(dòng)進(jìn)度條,自動(dòng)跳轉(zhuǎn)到特定位置
注意到前面slider控件具有bindchange="hanleSliderChange",那么我們就可以拿到value值,然后去更新音頻了
hanleSliderChange(e) { const position = e.detail.value; this.seekCurrentAudio(position); }, // 拖動(dòng)進(jìn)度條控件 seekCurrentAudio(position) { // 更新進(jìn)度條 const page = this; // 音頻控制跳轉(zhuǎn) // 這里有一個(gè)詭異bug:seek在暫停狀態(tài)下無法改變currentTime,需要先play后pause const pauseStatusWhenSlide = globalBgAudioManager.paused; if (pauseStatusWhenSlide) { globalBgAudioManager.play(); } globalBgAudioManager.seek({ position: Math.floor(position), success: () => { page.setData({ currentProcess: formatAudioProcess(position), sliderValue: Math.floor(position) }); if (pauseStatusWhenSlide) { globalBgAudioManager.pause(); } console.log(`The process of the audio is now in ${globalBgAudioManager.currentTime}s`); } }); },
看上去有一點(diǎn)比較奇怪是不是?backgroundAudioManager的seek方法是沒有success回調(diào)的,這里被我改了。
seek(options) { wx.seekBackgroundAudio(options); // 這樣實(shí)現(xiàn),就可以配置success回調(diào)了 }
但是,“onTimeUpdate事件觸發(fā)slider控件更新”和“手動(dòng)拖動(dòng)觸發(fā)slider更新”是有沖突的,假如說兩個(gè)函數(shù)都要改slider,聽誰的?
但是,可以利用監(jiān)測(cè)touchstart和touchend事件,來檢查是否在滑動(dòng)。如果在滑動(dòng),禁止onTimeUpdate去修改slider控件更新就行了。
因此,我先設(shè)定一個(gè)變量,來標(biāo)記是否正在滑動(dòng)
handleSliderMoveStart() { this.setData({ isMovingSlider: true }); }, handleSliderMoveEnd() { this.setData({ isMovingSlider: false }); },
在滑動(dòng)期間禁止更新進(jìn)度條即可
onTimeUpdate() { // 在move的時(shí)候,不要更新進(jìn)度條控件 if (!self.page.data.isMovingSlider) { self.page.setData({ currentProcess: formatAudioProcess(globalBgAudioManager.currentTime), sliderValue: Math.floor(globalBgAudioManager.currentTime) }); } // 其他省略 },
2.2 backgroundAudioManager相關(guān)需求
在開始下一個(gè)需求介紹之前,不知道各位有沒有疑問:
我在哪兒設(shè)置的onTimeupdate方法?
OK,我來介紹下。
首先,全局獲取
this.backgroundAudioManager = wx.getBackgroundAudioManager();
其次,在play/index.js中引入backgroundAudioManager
let globalBgAudioManager = app.backgroundAudioManager;
在適當(dāng)?shù)臅r(shí)候,比如我就是onLoad,擴(kuò)展globalBgAudioManager對(duì)象?!@樣我就把具體的功能放進(jìn)了具體的page中,不同的page中針對(duì)backgroundAudioManager可以有不同的實(shí)現(xiàn)。
this.initBgAudioListManager();
接下來我們看看這個(gè)拓展到底干了什么。
initBgAudioListManager() { // options中的函數(shù)在執(zhí)行的時(shí)候,this指向函數(shù)本身(親測(cè)),因此這里需要保存Page對(duì)應(yīng)的this。 const page = this; const self = globalBgAudioManager; const options = { // options在后面會(huì)介紹 }; // decorateBgAudioListManager函數(shù),直接修改globalBgAudioManager對(duì)象,從而實(shí)現(xiàn)方法的拓展 globalBgAudioManager = decorateBgAudioListManager(globalBgAudioManager, options);
好了,怎么引入的現(xiàn)在已經(jīng)說完了,接下來就講需求,也就是介紹options里面干了什么。
其實(shí)options里面都是backgroundAudioManager已經(jīng)有的方法,具體可以參考文檔。我只是做了改寫
2.2.1 需求三:繞過onCanPlay,提醒用戶音頻在加載
眾所周知,音頻需要加載一段時(shí)間才可以播放,為此小程序的全局播放對(duì)象,即backgroundAudioManager提供了onWaiting和onCanplay,看上去天生就是為了音頻加載的交互實(shí)現(xiàn)的。
但不知道為什么,onCanplay無!法!觸!發(fā)!和社區(qū)提了這個(gè)問題也沒有人鳥我哎……心痛。
算了算了,他強(qiáng)由他強(qiáng),我繞我的墻。。。
首先,在options中,改寫onWaiting:先提示用戶正在加載當(dāng)中,isWaiting進(jìn)行標(biāo)記(“看!音頻在Waiting!”)
const options = { onWaiting() { wx.showLoading({ title: '音頻加載中…' }); globalBgAudioManager.isWaiting = true; }, }
然后接下來,在時(shí)間進(jìn)度發(fā)生更新的時(shí)候(這相當(dāng)于開始播放了),把Loading窗口關(guān)了就行。同樣是在options中去改寫onTimeUpdate。
onTimeUpdate() { if (self.isWaiting) { self.isWaiting = false; setTimeout(() => { wx.hideLoading(); }, 300); // 設(shè)置300ms是為了避免某些音頻加載過快而導(dǎo)致Loading效果一閃而過對(duì)用戶造成糟糕的體驗(yàn) } // 以下代碼省略 },
2.2.2 需求四:點(diǎn)擊某個(gè)音頻,實(shí)現(xiàn)播放
這個(gè)需求的麻煩之處,在于需要檢查點(diǎn)擊的音頻是什么,比如假定你在播放音頻A,你重新點(diǎn)擊A,那當(dāng)然不用重播了啊。
以及iOS版本的小程序和阿里云服務(wù)器似乎有點(diǎn)過節(jié),下面就會(huì)看到。
在pages/play/index內(nèi)部,先響應(yīng)點(diǎn)擊事件
## pages/play/index outlineOperation(e) { // 獲取音頻地址 const courseAudio = e.currentTarget.dataset.outline || {}; const targetAudioId = courseAudio.audio_id; // 中間省略一系列合法性檢查。 this.playTargetAudio(targetAudioId); },
然后執(zhí)行播放相關(guān)操作,這個(gè)globalCourseAudioListManager雖然前面提到過,但是一會(huì)兒再具體介紹,它做了什么就直接看注釋好了
## pages/play/index /** * 點(diǎn)擊/自動(dòng)播放 目標(biāo)音頻 * @param {*Number} targetAudioId * - 檢查是否點(diǎn)擊到同一個(gè)音頻 * - 檢查是否完全播放完畢 * - 若未播放完畢,或者點(diǎn)擊的不是同一個(gè)音頻,先暫停當(dāng)前音頻 * - 執(zhí)行音頻播放操作 */ playTargetAudio(targetAudioId) { const currentAudio = globalCourseAudioListManager.getCurrentAudio(); // 點(diǎn)擊未停止的原音頻的話,沒必要響應(yīng) if (targetAudioId === currentAudio.audio_id && !!globalBgAudioManager.currentTime) { return false; } else { this.getAudioSrc(targetAudioId).then(() => { // 若未暫停,則先暫停 if (!globalBgAudioManager.paused) { globalBgAudioManager.pause(); } // 全局切換當(dāng)前播放的音頻index(此時(shí)還沒有開始播放) globalCourseAudioListManager.changeCurrentAudioById(targetAudioId); // 更新當(dāng)前控件狀態(tài),比如新音頻的title和長(zhǎng)度,總要更新吧。 this.updateControlsInNewAudio(); // 更換并且播放背景音樂 globalBgAudioManager.changeAudio(); }); } },
好了,終于到這個(gè)changeAudio函數(shù)了,它也是剛剛提到的options里面的一部分。
## changeAudio是options的屬性,被擴(kuò)展進(jìn)入了backgroundAudioManager // 修改當(dāng)前音頻 changeAudio() { // 獲取并且 const { url, audio_id, title, content_type_signare_url } = globalCourseAudioListManager.getCurrentAudio(); const { doctor, name, image } = globalCourseAudioListManager.courseInfo; self.title = title; self.epname = name; self.audioId = audio_id; self.coverImgUrl = image; self.singer = doctor.nickname || '丁香醫(yī)生'; // iOS使用content_type_signare_url const src = isIOS() ? content_type_signare_url : url; if (!src) { showToast({ title: '音頻丟失,無法播放', icon: 'warn', duration: 2000 }); } else { self.src = src; } }
為什么這里iOS要用content_type_signare_url?(它是我們后端返回的一個(gè)字段)
因?yàn)閕OS小程序發(fā)起音頻文件請(qǐng)求的時(shí)候,會(huì)默認(rèn)帶上content-type:octet-stream,而我們的音頻文件URL又帶有Signatrue簽名參數(shù),阿里云服務(wù)器似乎會(huì)默認(rèn)把content-type加入到簽名當(dāng)中……于是我就遇上了403錯(cuò)誤。
解決方案有兩個(gè):
-
讓后端負(fù)責(zé)CDN服務(wù)器的同事,在我請(qǐng)求獲取音頻src地址之前,先請(qǐng)求一次資源,并且做好緩存。
-
把音頻地址改成公開的。
2.3 courseAudioListManager相關(guān)需求
前面提到,我需要維護(hù)一個(gè)全局的課程信息和音頻列表的管理對(duì)象,然后,就能操作音頻列表了。
## 在app.js當(dāng)中初始化 this.courseAudioListManager = createCourseAudioListManager(); ## 在pages/play/index.js里面引用 const globalCourseAudioListManager = app.courseAudioListManager;
這個(gè)對(duì)象其實(shí)沒有太多好介紹的,比較簡(jiǎn)單。
又比如,前面提到“點(diǎn)擊某個(gè)音頻并自動(dòng)播放”,其中有一步是這樣的。
// 全局切換當(dāng)前播放的音頻index(此時(shí)還沒有開始播放) globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);
就是根據(jù)id來修改音頻的索引,它是這么干的。
changeCurrentAudioById(audioId = -1) { this.currentIndex = this.audioList.findIndex(audio => audio.audio_id === audioId); },
其他,具體有哪些方法,可以看前面的1.2.3節(jié)“開發(fā)方案確定”中的腦圖。
不過,它有個(gè)addAudioSrc,可以解決重播失敗的問題。
2.3.1 用重新加載src的方法,解決重播失敗
當(dāng)一個(gè)音頻的播放被“停止”而不是“暫?!钡臅r(shí)候,再調(diào)用play()方法,是不會(huì)重播的,親測(cè)調(diào)用seek方法執(zhí)行跳轉(zhuǎn)也不行。
比如,當(dāng)我試聽完了一段音頻,想重新聽的時(shí)候,常規(guī)的play是無能的……怎么辦?當(dāng)然是繞過去啊
當(dāng)你點(diǎn)擊播放按鈕的時(shí)候,
-
首先通過一系列檢查,就會(huì)觸發(fā)下面這個(gè)playTargetAudio
handleStartPlayClick() { // 以上省略,若globalBgAudioManager.currentTime為false,表示認(rèn)為你在點(diǎn)擊一個(gè)已經(jīng)播放完畢的音頻 } else if (!globalBgAudioManager.currentTime) { this.playTargetAudio(currentAudio.audio_id); } else // 以下省略 }
-
在playTargetAudio內(nèi)部依次執(zhí)行g(shù)etAudioSrc/changeCurrentAudioById/changeAudio
this.getAudioSrc(targetAudioId).then(() => { // 省略 // 全局切換當(dāng)前播放的音頻index globalCourseAudioListManager.changeCurrentAudioById(targetAudioId); // 省略 // 更換并且播放背景音樂 globalBgAudioManager.changeAudio(); }); }
-
在getAudioSrc內(nèi)部,主要的作用就是,更新了一下新的src
globalCourseAudioListManager.addAudioSrc(res.items[0]);
然后我們看看addAudioSrc干了什么
## 現(xiàn)在在courseAudioListManager內(nèi)部 addAudioSrc(audioSrcObject) { this.audioList = this.audioList.map(audio => { // 強(qiáng)制更新特定id的audio對(duì)象 // 新的src隱藏在audioSrcObject里面 if (Number(audio.audio_id) === Number(audioSrcObject.id)) { return Object.assign(audio, audioSrcObject, { id: audio.id }); } else { return audio; } }); },
現(xiàn)在src已經(jīng)更新完了。看上去每次獲取到的音頻src都指向同一個(gè)音頻,但是,音頻的src地址是帶有時(shí)間戳的,這避免了緩存,backgroundAudioManager設(shè)置src的時(shí)候,就會(huì)重新加載了~
當(dāng)然這樣,就沒有緩存了,交互上會(huì)有所犧牲,每次重播的時(shí)候都會(huì)閃一下“音頻加載中”。
如果各位有好的辦法實(shí)現(xiàn)緩存,歡迎交流哈。
3. 其他一些經(jīng)驗(yàn)
-
如果代碼過長(zhǎng),不要用三目運(yùn)算符,很難讀。
-
音頻播放可能出現(xiàn)錯(cuò)誤,需要用onError加以捕獲。
-
最后,歡迎留言~!