微信小程序音頻功能開發實(cai)踐(keng)

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加以捕獲。
  • 最後,歡迎留言~!
相關文章
相關標籤/搜索