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加以捕獲。

  • 最后,歡迎留言~!