1. 需求分析與開發方案
1.1 需求簡介
最近產品給咱們提出了「在小程序中播放音頻課程」的需求,主要是有四個要點:
1.2 開發分析
好了,問題來了,怎麼實現上面這幾個需求呢?
第二條「音頻管理」看上去是個麻煩,一開始我想到了小程序提供的
audio控件。
1.2.1 backgroundAudioManager簡介
按官方文檔的說法,backgroundAudioManager是:
全局惟一的背景音頻管理器
-
duration:當前音頻長度,能夠用來初始化播放控件的值。
-
currentTime:當前播放的位置,能夠用來更新播放控件的進度值
-
paused:false爲播放,true表示中止/暫停
-
src:音頻數據源,注意設置src的時候會自動播放
-
title:音頻標題(剛剛在微信聊天列表頁頂部展現的音頻title「爲何秋冬季節孩子易生病」,就是經過這裏設置的)
-
play/pause/stop/seek:能夠進行音頻常見的播放控制,其中seek是跳轉到特定播放進度的方法
-
onPlay/onPause/onStop/onEnded:響應特定事件,其中onStop是主動中止,onEnded是自動播放完畢(這可用於實現「連續播放」)
-
onTimeUpdate:背景音頻播放進度更新事件,可與前面的currentTime屬性結合在一塊兒,去更新控件的值。
-
onWaiting/onCanplay:音頻一般不會馬上就能播放,這兩個方法能夠在音頻加載的時候爲用戶作一些提示。
1.2.2 播放控件
第三條「播放控件」也不算太難,播放/暫停/上下首都用小圖片就能夠了。
可是難點在於播放進度條的模擬,前面已經說到audio控件的樣式是不符合需求的。
那麼我決定採用slider來模擬,應該也能夠搞定。
第四條,前面已經說了,用backgroundAudioManager實現「全局播放」。
1.2.3 開發方案肯定
好了,需求分析得差很少了,咱們要開發這個需求,須要三個對象,
有了這幾個對象,課程管理/音頻管理/進度控件/全局播放就均可以搞定啦。
不過,話雖然這麼說,可是實際實現需求老是會碰到各類各樣的問題。
2. 功能實現
由於需求實在太多了,我無法一一列出,在這裏就介紹一些須要技巧的需求
2.1 Slider控件模擬進度
2.1.1 需求一:控件隨着音頻播放,自動更新
PM的需求是:控件隨着音頻播放,自動更新進度,左值隨着進度更新,右值爲音頻總長度。
可是小程序自帶的slider不支持展現左右值,咱們只能本身模擬。
<!-- 音頻進度控件 -->
<view class="course-control-process">
// 左值展現,currentProcess
<text class="current-process">{{currentProcess}}</text>
// 進度條
<slider
bindchange="hanleSliderChange" // 響應拖動事件
bindtouchstart="handleSliderMoveStart"
bindtouchend="handleSliderMoveEnd"
min="0"
max="{{sliderMax}}"
activeColor="#8f7df0"
value="{{sliderValue}}"/>
// 右值展現,totalProcess
<text class="total-process">{{totalProcess}}</text>
</view> 複製代碼
currentProcess爲左值、totalProcess爲右值、sliderMax控件最大值、sliderValue爲當前控件的value。
那麼,怎麼更新這些數值呢?前面提到backgroundAudioManager有一個onTimeUpdate方法,在這裏面去更新進度值就能夠了。
// formatAudioProcess函數我就不放了,就是把時間格式化成00:15這樣就好了
onTimeUpdate() {
// 省略一些判斷代碼
self.page.setData({
currentProcess: formatAudioProcess(globalBgAudioManager.currentTime),
sliderValue: Math.floor(globalBgAudioManager.currentTime)
});
}, 複製代碼
這裏有一件值得注意的是,就是在進入同一個課程的播放頁時,因爲原page極可能已經銷燬(好比你執行navigateTo),所以須要在初始化的時候更新原有的data值,好比當前的播放進度currentProcess,這就要從當前的backgroundAudioManager裏去拿。
if (id !== globalCourseAudioListManager.getCurrentCourseInfo().id)
updateControlsInOldAudio() {
// 獲取當前音頻
const currentAudio = globalCourseAudioListManager.getCurrentAudio();
// 更新進度和控件內容
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 需求二:拖動進度條,自動跳轉到特定位置
注意到前面slider控件具備bindchange="hanleSliderChange",那麼咱們就能夠拿到value值,而後去更新音頻了
hanleSliderChange(e) {
const position = e.detail.value;
this.seekCurrentAudio(position);
},
// 拖動進度條控件
seekCurrentAudio(position) {
// 更新進度條
const page = this;
// 音頻控制跳轉
// 這裏有一個詭異bug:seek在暫停狀態下沒法改變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`);
}
});
}, 複製代碼
看上去有一點比較奇怪是否是?backgroundAudioManager的seek方法是沒有success回調的,這裏被我改了。
seek(options) {
wx.seekBackgroundAudio(options); // 這樣實現,就能夠配置success回調了
} 複製代碼
可是,「onTimeUpdate事件觸發slider控件更新」和「手動拖動觸發slider更新」是有衝突的,假如說兩個函數都要改slider,聽誰的?
可是,能夠利用監測touchstart和touchend事件,來檢查是否在滑動。若是在滑動,禁止onTimeUpdate去修改slider控件更新就好了。
handleSliderMoveStart() {
this.setData({
isMovingSlider: true
});
},
handleSliderMoveEnd() {
this.setData({
isMovingSlider: false
});
}, 複製代碼
onTimeUpdate() {
// 在move的時候,不要更新進度條控件
if (!self.page.data.isMovingSlider) {
self.page.setData({
currentProcess: formatAudioProcess(globalBgAudioManager.currentTime),
sliderValue: Math.floor(globalBgAudioManager.currentTime)
});
}
// 其餘省略
}, 複製代碼
2.2 backgroundAudioManager相關需求
我在哪兒設置的onTimeupdate方法?
this.backgroundAudioManager = wx.getBackgroundAudioManager(); 複製代碼
其次,在play/index.js中引入backgroundAudioManager
let globalBgAudioManager = app.backgroundAudioManager; 複製代碼
在適當的時候,好比我就是onLoad,擴展globalBgAudioManager對象。——這樣我就把具體的功能放進了具體的page中,不一樣的page中針對backgroundAudioManager能夠有不一樣的實現。
this.initBgAudioListManager(); 複製代碼
initBgAudioListManager() {
// options中的函數在執行的時候,this指向函數自己(親測),所以這裏須要保存Page對應的this。
const page = this;
const self = globalBgAudioManager;
const options = {
// options在後面會介紹
};
// decorateBgAudioListManager函數,直接修改globalBgAudioManager對象,從而實現方法的拓展
globalBgAudioManager = decorateBgAudioListManager(globalBgAudioManager, options); 複製代碼
好了,怎麼引入的如今已經說完了,接下來就講需求,也就是介紹options裏面幹了什麼。
其實options裏面都是backgroundAudioManager已經有的方法,具體能夠參考
文檔。我只是作了改寫
2.2.1 需求三:繞過onCanPlay,提醒用戶音頻在加載
衆所周知,音頻須要加載一段時間才能夠播放,爲此小程序的全局播放對象,即backgroundAudioManager提供了onWaiting和onCanplay,看上去天生就是爲了音頻加載的交互實現的。
但不知道爲何,onCanplay無!法!觸!發!和社區提了這個問題也沒有人鳥我哎……心痛。
首先,在options中,改寫onWaiting:先提示用戶正在加載當中,isWaiting進行標記(「看!音頻在Waiting!」)
const options = {
onWaiting() {
wx.showLoading({
title: '音頻加載中…'
});
globalBgAudioManager.isWaiting = true;
},
} 複製代碼
而後接下來,在時間進度發生更新的時候(這至關於開始播放了),把Loading窗口關了就行。一樣是在options中去改寫onTimeUpdate。
onTimeUpdate() {
if (self.isWaiting) {
self.isWaiting = false;
setTimeout(() => {
wx.hideLoading();
}, 300);
// 設置300ms是爲了不某些音頻加載過快而致使Loading效果一閃而過對用戶形成糟糕的體驗
}
// 如下代碼省略
}, 複製代碼
2.2.2 需求四:點擊某個音頻,實現播放
這個需求的麻煩之處,在於須要檢查點擊的音頻是什麼,好比假定你在播放音頻A,你從新點擊A,那固然不用重播了啊。
以及iOS版本的小程序和阿里雲服務器彷佛有點過節,下面就會看到。
在pages/play/index內部,先響應點擊事件
outlineOperation(e) {
// 獲取音頻地址
const courseAudio = e.currentTarget.dataset.outline || {};
const targetAudioId = courseAudio.audio_id;
// 中間省略一系列合法性檢查。
this.playTargetAudio(targetAudioId);
}, 複製代碼
而後執行播放相關操做,這個globalCourseAudioListManager雖然前面提到過,可是一下子再具體介紹,它作了什麼就直接看註釋好了
/**
* 點擊/自動播放 目標音頻
* @param {*Number} targetAudioId
* - 檢查是否點擊到同一個音頻
* - 檢查是否徹底播放完畢
* - 若未播放完畢,或者點擊的不是同一個音頻,先暫停當前音頻
* - 執行音頻播放操做
*/
playTargetAudio(targetAudioId) {
const currentAudio = globalCourseAudioListManager.getCurrentAudio();
// 點擊未中止的原音頻的話,不必響應
if (targetAudioId === currentAudio.audio_id && !!globalBgAudioManager.currentTime) {
return false;
} else {
this.getAudioSrc(targetAudioId).then(() => {
// 若未暫停,則先暫停
if (!globalBgAudioManager.paused) {
globalBgAudioManager.pause();
}
// 全局切換當前播放的音頻index(此時尚未開始播放)
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);
// 更新當前控件狀態,好比新音頻的title和長度,總要更新吧。
this.updateControlsInNewAudio();
// 更換而且播放背景音樂
globalBgAudioManager.changeAudio();
});
}
}, 複製代碼
好了,終於到這個changeAudio函數了,它也是剛剛提到的options裏面的一部分。
// 修改當前音頻
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 || '丁香醫生';
// 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?(它是咱們後端返回的一個字段)
由於iOS小程序發起音頻文件請求的時候,會默認帶上content-type:octet-stream,而咱們的音頻文件URL又帶有Signatrue簽名參數,阿里雲服務器彷佛會默認把content-type加入到簽名當中……因而我就趕上了403錯誤。
2.3 courseAudioListManager相關需求
前面提到,我須要維護一個全局的課程信息和音頻列表的管理對象,而後,就能操做音頻列表了。
this.courseAudioListManager = createCourseAudioListManager();
const globalCourseAudioListManager = app.courseAudioListManager; 複製代碼
又好比,前面提到「點擊某個音頻並自動播放」,其中有一步是這樣的。
// 全局切換當前播放的音頻index(此時尚未開始播放)
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId); 複製代碼
changeCurrentAudioById(audioId = -1) {
this.currentIndex = this.audioList.findIndex(audio => audio.audio_id === audioId);
}, 複製代碼
其餘,具體有哪些方法,能夠看前面的1.2.3節「開發方案肯定」中的腦圖。
不過,它有個addAudioSrc,能夠解決重播失敗的問題。
2.3.1 用從新加載src的方法,解決重播失敗
當一個音頻的播放被「中止」而不是「暫停」的時候,再調用play()方法,是不會重播的,親測調用seek方法執行跳轉也不行。
好比,當我試聽完了一段音頻,想從新聽的時候,常規的play是無能的……怎麼辦?固然是繞過去啊
handleStartPlayClick() {
// 以上省略,若globalBgAudioManager.currentTime爲false,表示認爲你在點擊一個已經播放完畢的音頻
} else if (!globalBgAudioManager.currentTime) {
this.playTargetAudio(currentAudio.audio_id);
} else
// 如下省略
} 複製代碼
this.getAudioSrc(targetAudioId).then(() => {
// 省略
// 全局切換當前播放的音頻index
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);
// 省略
// 更換而且播放背景音樂
globalBgAudioManager.changeAudio();
});
} 複製代碼
globalCourseAudioListManager.addAudioSrc(res.items[0]); 複製代碼
addAudioSrc(audioSrcObject) {
this.audioList = this.audioList.map(audio => {
// 強制更新特定id的audio對象
// 新的src隱藏在audioSrcObject裏面
if (Number(audio.audio_id) === Number(audioSrcObject.id)) {
return Object.assign(audio, audioSrcObject, { id: audio.id });
} else {
return audio;
}
});
}, 複製代碼
如今src已經更新完了。看上去每次獲取到的音頻src都指向同一個音頻,可是,音頻的src地址是帶有時間戳的,這避免了緩存,backgroundAudioManager設置src的時候,就會從新加載了~
固然這樣,就沒有緩存了,交互上會有所犧牲,每次重播的時候都會閃一下「音頻加載中」。
3. 其餘一些經驗