1. 需求分析與開發方案
1.1 需求簡介
最近產品給我們提出了“在小程式中播放音訊課程”的需求,主要是有四個要點:
- 課程管理:進入某個課程的播放頁面,獲取全部音訊列表,但暫時不播放。
- 音訊管理:支援在播放頁面,點選任意音訊進行播放;可自動播放下一首。比如這樣
- 進度控制元件:支援拖動修改進度/上下首/暫停/播放,就像下面這樣。
- 全域性播放:當使用者暫時離開小程式時,在微信聊天列表頁頂部展示背景音訊。
就像這樣子。
1.2 開發分析
好了,問題來了,怎麼實現上面這幾個需求呢?
我陷入了沉思…………
第一條“課程管理”不難,全域性維護一個陣列就好了。
第二條“音訊管理”看上去是個麻煩,一開始我想到了小程式提供的audio控制元件。
但是隨即我就否決掉了這種想法,理由主要有兩點:
- 微信官方提供的audio控制元件有預設的樣式,如下圖,這與設計稿的需求不相符。
- 經過在微信官方提供的小程式例項Demo中親測,如果使用audio控制元件,那麼當我退出當前頁面的時候,音訊會消失,這沒有辦法滿足PM要求的“全域性播放”
因此,我決定採用微信提供的backgroundAudioManager。
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 開發方案確定
好了,需求分析得差不多了,我們要開發這個需求,需要三個物件,
- 課程管理物件,負責維護課程資訊和課程音訊列表,不負責播放
- 音訊管理物件,即backgroundAudioManager,負責管理音訊的播放,其中只有changeAudio方法具有修改音訊的許可權
- 播放控制元件。
有了這幾個物件,課程管理/音訊管理/進度控制元件/全域性播放就都可以搞定啦。
不過,話雖然這麼說,但是實際實現需求總是會碰到各種各樣的問題。
2. 功能實現
因為需求實在太多了,我沒法一一列出,在這裡就介紹一些需要技巧的需求
2.1 Slider控制元件模擬進度
前面提到,控制元件大概長這樣
所以得用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方法?
OK,我來介紹下。
首先,全域性獲取
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內部,先響應點選事件
## pages/play/index
outlineOperation(e) {
// 獲取音訊地址
const courseAudio = e.currentTarget.dataset.outline || {};
const targetAudioId = courseAudio.audio_id;
// 中間省略一系列合法性檢查。
this.playTargetAudio(targetAudioId);
}, 複製程式碼
然後執行播放相關操作,這個globalCourseAudioListManager雖然前面提到過,但是一會兒再具體介紹,它做了什麼就直接看註釋好了
## pages/play/index
/**
* 點選/自動播放 目標音訊
* @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是options的屬性,被擴充套件進入了backgroundAudioManager
// 修改當前音訊
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錯誤。
解決方案有兩個:
- 讓後端負責CDN伺服器的同事,在我請求獲取音訊src地址之前,先請求一次資源,並且做好快取。
- 把音訊地址改成公開的。
2.3 courseAudioListManager相關需求
前面提到,我需要維護一個全域性的課程資訊和音訊列表的管理物件,然後,就能操作音訊列表了。
## 在app.js當中初始化
this.courseAudioListManager = createCourseAudioListManager();
## 在pages/play/index.js裡面引用
const globalCourseAudioListManager = app.courseAudioListManager; 複製程式碼
這個物件其實沒有太多好介紹的,比較簡單。
又比如,前面提到“點選某個音訊並自動播放”,其中有一步是這樣的。
// 全域性切換當前播放的音訊index(此時還沒有開始播放)
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId); 複製程式碼
就是根據id來修改音訊的索引,它是這麼幹的。
changeCurrentAudioById(audioId = -1) {
this.currentIndex = this.audioList.findIndex(audio => audio.audio_id === audioId);
}, 複製程式碼
其他,具體有哪些方法,可以看前面的1.2.3節“開發方案確定”中的腦圖。
不過,它有個addAudioSrc,可以解決重播失敗的問題。
2.3.1 用重新載入src的方法,解決重播失敗
當一個音訊的播放被“停止”而不是“暫停”的時候,再呼叫play()方法,是不會重播的,親測呼叫seek方法執行跳轉也不行。
比如,當我試聽完了一段音訊,想重新聽的時候,常規的play是無能的……怎麼辦?當然是繞過去啊
當你點選播放按鈕的時候,
- 首先通過一系列檢查,就會觸發下面這個playTargetAudio
handleStartPlayClick() {
// 以上省略,若globalBgAudioManager.currentTime為false,表示認為你在點選一個已經播放完畢的音訊
} else if (!globalBgAudioManager.currentTime) {
this.playTargetAudio(currentAudio.audio_id);
} else
// 以下省略
} 複製程式碼
- 在playTargetAudio內部依次執行getAudioSrc/changeCurrentAudioById/changeAudio
this.getAudioSrc(targetAudioId).then(() => {
// 省略
// 全域性切換當前播放的音訊index
globalCourseAudioListManager.changeCurrentAudioById(targetAudioId);
// 省略
// 更換並且播放背景音樂
globalBgAudioManager.changeAudio();
});
} 複製程式碼
- 在getAudioSrc內部,主要的作用就是,更新了一下新的src
globalCourseAudioListManager.addAudioSrc(res.items[0]); 複製程式碼
然後我們看看addAudioSrc幹了什麼
## 現在在courseAudioListManager內部
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. 其他一些經驗
- 如果程式碼過長,不要用三目運算子,很難讀。
- 音訊播放可能出現錯誤,需要用onError加以捕獲。
- 最後,歡迎留言~!