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. 其余一些经验