在ESLive的直播和直播回放中,撇开播放器的主播放逻辑和大多数控件的功能以外,比较主要的就是信令同步、回放的操作同步。一个较为准确的时间事件队列的维护能够保障信令同步/回放的操作同步。
播放器的主播放逻辑
大多数控件的功能
信令同步/回放的操作同步
一、时间事件队列的设计雏形
二、解决精准度、回跳功能系列问题
业务需求:在具体业务中可以独自设立既定时间,要求在该时间内执行相应的操作,同时确保操作的精准度。
队列内事件的添加
事件执行
队列内抛出
通过设计对应的数据结构,保存依次执行的点的 时间数据
和 具体方法内容
,来保障依次执行。
// time: function(){} => 时间点: 方法,这种数据格式可以同时保存时间和方法。
let List = {
event: {
1: () => console.log(1),
2: () => console.log(2),
3: () => console.log(3)
}
};
list.event[time](); // 执行特定时间的方法只需如此
事件添加的时间点 —— 比如 event[time]
中的 time
,是注册事件的时间点。
当前播放的时间点 —— 调用播放器的 getCurrentTime()
方法获取到的时间点。
1 video
标签和 flash
造成的延迟
原生 video
标签 ontimeupdate
获取的速度大约是1s内4-5次
flash
可以通过内部自定义事件随意抛出,我们项目中是3s一次
2 直播过程中的骚操作导致的延迟
换流操作
播放器直接跳转时间点,可以跳过多个时间点,会导致两者存在较大的差异
对象的形式遍历起来比较麻烦,所以需要另外一个参照物去取一个符合条件的时间点的集合。so,感觉一个数据结构已经无法满足这个需求了。
let List = {
time: [1, 2, 3], // 播放的时间轴
event: {
1: () => console.log(1),
2: () => console.log(2),
3: () => console.log(3)
}
};
setList
方法以上数据格式的关键在于 time
这个数据必须始终保持有序状态,即在注册事件的时候就必须让 time
的数据保持有序。为了方便后续获取符合条件的时间区间,我们定义了一个 setList
方法返回当前数据所在的下标。
setList(list, value) {
if (isArray(list)) {
if (!list.length) {
list.push(value);
return 1;
}
for (var i = 0; i < list.length; i++) {
if (list[i] > value) {
list.splice(i, 0, value);
return i + 1;
} else if (i == list.length - 1) {
list.push(value);
return i + 1;
}
}
}
}
// 相应的触发也需要作出适当调整(篇幅较长)
getTimeIndex(time) {
// 为了不让视频时间污染原有的时间点,这里采用深拷贝进行了处理。感觉上可以修改setList避开这里的深拷贝
let timeCurrentList = Compute.cloneObj(this.timeList);
let eventIndex = Compute.setList(timeCurrentList, time);
if (eventIndex >= 0) {
return {
index: eventIndex,
list: timeCurrentList.slice(0, eventIndex)
};
}
return {
index: -1,
list: []
}
}
triggerByLimit(time) {
let currentTrigger = getTimeIndex(time);
if (!currentTrigger.index) {
return;
}
list.time.map((value) => {
triggerByTime(value);
})
}
// 时间点触发事件
triggerByTime(time) {
if(this.list.event[time]) {
this.list.event[time]();
}
}
// 在这里就衍生出一个细节,已执行的方法不应该多次执行,这里需要抛出已执行的方法。
removeFromList(list, value) {
if (this.isArray(list)) {
if (list.indexOf(value) >= 0) {
list.splice(list.indexOf(value), 1);
return list;
}
}
}
// 执行完方法后需要做抛出操作
delete list.event[time];
removeFromList(list.time, time);
当同一个时间点注册多个方法时,会依次衍生出两个小问题。
使用原来 对象
的形式,会导致后加的事件会把前者覆盖,无法实现多个事件的添加。所以我们借助 数组
进行了适当的调整。
// 这样就能记录同一个时间点的多个方法
let List = {
time: [1, 2, 3],
event: {
1: [
() => console.log(1-1),
() => console.log(1-2)
],
2: [
() => console.log(2)
],
3: [
() => console.log(3-1),
() => console.log(3-2),
() => console.log(3-3),
]
}
};
在 time
列表添加时,遇到相同时间点的事件其实没有必要多次录入,所以对 setList
进行了细微调整。
setList(list, value) {
if (this.isArray(list)) {
if (list.indexOf(value) >= 0) {
return list.indexOf(value) + 1;
}
if (!list.length) {
list.push(value);
return 1;
}
for (var i = 0; i < list.length; i++) {
if (list[i] > value) {
list.splice(i, 0, value);
return i + 1;
} else if (i == list.length - 1) {
list.push(value);
return i + 1;
}
}
} else {
return 0;
}
}
// 相应的,触发也要做出适当修改,原先的this.list.event[time]();也要修改,抛出也要修改。。。。
this.eventList[time].map((value) => {
value();
removeFromList(this.eventList[time], value);
if (!this.eventList[time].length) {
removeFromList(this.timeList, time);
delete this.eventList[time];
}
});
通过上述的修改,需要事件列表的格式、注册行为差不多成形了。
setTimeList(time) {
setList(this.list.time, time);
}
on(event) {
this.setTimeList(event.time);
if (!this.eventList[event.time]) {
this.eventList[event.time] = [event.callBack];
} else {
this.eventList[event.time].push(event.callBack);
}
}
注册一个在 1.0s
执行的方法,对比一下 video
标签 和 flash
产生的延迟现象。
video
标签 ontimeupdate
时间戳跑的时候,获取到的当前时间点是 0.9s
, 1.5s
的顺序
flash
差不多每隔3s执行一次抛出,例如 0.7s
, 3.5s
的顺序
类型 | 事件注册时间点 | 获取到的当前时间点 | 触发时间 | 延迟时间 |
---|---|---|---|---|
video 标签 | 1.0s | 0.9s, 1.5s | 1.5s | 0.5s |
flash | 1.0s | 0.7s, 3.5s | 3.5s | 2.5s |
按照以上逻辑,事件分别是在 1.5s
和 3.5s
才被触发,分别延迟了 0.5s
和 2.5s
。
举个例子,当前时间点为3s,设置稍长时间为1s,那么当前时间到稍长时间这一区间为4s之前。
1 注册了一个 10.0s
执行的方法,实际上 video
标签 ontimeupdate
时间戳跑的时候获取到的当前时间是 9.5s
, 10.1s
的顺序。
2 然后稍长时间设定的 1s
,这样事件在 9.5s
被注册进队列内。
3 接着用户操作在 9.8s
回跳至 3s
,但是已经延时处理的内容并没有取消。
4 导致最终本应该在 10s
出现的方法在 3.2s
还是照常执行。
考虑到这种情况,我们需要在回跳方法中操作那些延迟处理的方法集合。这样就需要引入一个 等待执行的事件队列
。
waitingList = {
// 同一个时间点也会存在多个回跳事件。
1: [
() => console.log(1),
() => console.log(2)
]
}
这里直接放一个小工具,处理
setTimeout
,方便操作。
class WaitingExecut {
constructor(options) {
this.callBack = options.callBack;
this.waitTime = options.waitTime;
this.init();
}
init() {
if (this.waitTime > 0) {
this.waiting = setTimeout(() => {
this.callBack();
this.doneFlag = true;
},this.waitTime);
} else {
this.callBack();
this.doneFlag = true;
}
}
// 执行
execut() {
if (!this.doneFlag) {
this.callBack();
clearTimeout(this.waiting);
this.doneFlag = true;
}
}
// 中止
abort() {
if (!this.doneFlag) {
clearTimeout(this.waiting);
this.doneFlag = true;
}
}
// 重新设定
reset() {
if (!this.doneFlag) {
clearTimeout(this.waiting);
}
this.doneFlag = false;
this.init();
}
// 重启
refresh(callBack, waitTime) {
this.abort();
this.callBack = callBack;
this.waitTime = waitTime;
this.init();
}
}
export default WaitingExecut;
这样就形成了 waitingList
队列,继而能够较为方便地设置延时行为,例如取消 abort
、立即执行 execut
、重启 refresh
、重新设定 reset
等等操作。
limit
概念延时任务要出现,就要有一个limit的概念。所以,筛选需要的事件要做适当修改,筛选符合条件的时间段可以稍长于当前时间。
triggerByLimit(time, limit) {
// 要把需要的事件稍稍延长,当然这个limit可以当作对象的固定属性,这里可能按照实际获取的时间间隔为参照更好一些,所以仅仅当作参数方法使用。
let currentTrigger = this.getTimeIndex(time + limit);
if(!currentTrigger.index) {
return;
}
currentTrigger.list.map((value, index, array) => {
// 这里也需要把当前时间给引入,方便做延时处理。
this.triggerByTime(value, time);
})
}
waitingList
中单独的某一点的触发也要适当修改,不仅仅是要把立即执行和延迟处理加入,还有要把延迟处理的事件加入 waitingList
。
立即执行
延迟处理
延迟处理的事件
triggerByTime(time, triggerTime) {
if(this.eventList[time]) {
this.eventList[time].map((value, index, array) => {
// 把延迟处理的事件加入到 waitingList 中
if (!this.waitingList[time]) {
this.waitingList[time] = [triggerItem];
} else {
this.waitingList[time].push(triggerItem);
}
let triggerItem = new WaitingExecut({
waitTime: time - triggerTime,
callBack: () => {
value();
// 下面这个方法作用是抛出 waitingList 内部的内容
this.clearTriggerItem(time, value);
}
});
Compute.removeFromList(this.eventList[time], value);
if (!this.eventList[time].length) {
Compute.removeFromList(this.timeList, time);
}
});
}
}
clearTriggerItem(time, value) {
Compute.removeFromList(this.waitingList[time], value);
}
这里先不涉及播放器逻辑,仅仅只把方法抛出来。
// 取消 waitingList 内的所有方法(回跳操作应用)
waitingCancel() {
for (let i in this.waitingList) {
let item = this.waitingList[i];
item.map((value, index, array) => {
// 取消延迟方法
value.abort();
// 当然,延迟处理的方法还需要返回原先的list中
this.on({
time: i,
callBack: value
});
})
}
}
// 立即执行 waitingList 的所有方法(后跳操作应用)
waitingFinish() {
for (let i in this.waitingList) {
let item = this.waitingList[i];
item.map((value, index, array) => {
value.execut();
})
}
}
// 立即全部执行所有list内的方法(播放结束应用)
finishList() {
this.timeList.map((value, index, array) => {
this.triggerByTime(value);
})
waitingFinish();
}
如上内容在实际使用过程中,产生了一个较为严重的问题 —— 原有的消息执行顺序被打乱。
在筛选执行内容的序列后要对所有可用方法做延迟/立即执行,因为直接使用了 splice
方法,所以在各自执行结束会对原有序列造成影响,先执行的会导致数组指针向后跳一位。
有可能在上一组执行序列没有完全执行结束的情况下又添加新的内容,导致在 map/forEach/标准循环
下当前数组内元素序号有序但是序号所对应的元素已经改变,即所谓的 指针错乱
。
promise执行队列
正在执行中的方法索引队列
currentList
: promise执行队列
doingList
: 正在执行的方法索引队列
waitingList
: 延迟方法队列
1 将原有的延时/立即执行的方法封装为 promise
。
const itemPromise = new Promise((resolve) => {
const triggerItem = new WaitingExecut({
waitTime: time - triggerTime,
callBack: () => {
item.callBack();
this.clearTriggerItem(time, item);
resolve();
},
});
if (!this.waitingList[time]) {
this.waitingList[time] = [triggerItem];
} else {
this.waitingList[time].push(triggerItem);
}
}).catch((e) => {
console.warn(e);
});
2 在触发序列筛选的方法执行前,先判断 promise
执行队列是否清空,如果未清空则不走下一步。
if (this.currentList.length) {
return;
}
...
3 在独立事件 promise
定义好后,立即将 promise
塞入 promise
队列中,然后将方法和当前时间塞入方法索引队列
,用以在全部执行完后的清除操作。
this.doingList.push(itemPromise);
4 在 promise
队列的 all
方法,即所有 promise
都为 resolve
时,利用 方法索引队列
在原数据上对已执行内容做清除操作,之后清空 promise队列
和 方法索引队列
。
Promise.all(this.doingList).then(() => {
if (!this.options.needReplay) {
this.finishedTrigger(this.currentList);
}
this.doingList = [];
this.currentList = [];
if (callBack) {
callBack();
}
}).catch((e) => {
console.error(e);
});
triggerByLimit(time, limit = 0, customRule, callBack) {
// promise list not finished 对应 步骤2
if (this.currentList.length) {
return;
}
// get the current conformance information queue
this.currentTime = time;
const currentTrigger = this.getTimeIndex(time + limit);
this.lastTime = time;
if (!currentTrigger.list.length) {
return;
}
if (this.options.needReplay) {
this.lastIndex = Math.max(currentTrigger.index - 1, 0);
// console.error(this.lastIndex, currentTrigger);
}
let resultList = [].concat(this.resultList);
this.resultList = [];
currentTrigger.list.map((value) => {
resultList = resultList.concat(this.eventList[value]);
});
if (customRule) {
resultList = customRule(resultList);
}
if (resultList.length > 500) {
this.resultList = resultList.slice(500);
resultList = resultList.slice(0, 500);
}
resultList.map((value) => {
if (!value) {
return;
}
this.triggerByItem(value, time);
});
// 对应步骤4
if (this.doingList.length) {
// const currentList = this.doingList.slice(0, 500);
Promise.all(this.doingList).then(() => {
if (!this.options.needReplay) {
this.finishedTrigger(this.currentList);
}
this.doingList = [];
this.currentList = [];
if (callBack) {
callBack();
}
}).catch((e) => {
console.error(e);
});
}
}
triggerByItem(item, triggerTime) {
if (!item) {
return;
}
const time = item.time;
this.currentList.push({
time,
item,
});
// 对应步骤1 将原有的延时/立即执行的方法封装成 promise
const itemPromise = new Promise((resolve) => {
const triggerItem = new WaitingExecut({
waitTime: time - triggerTime,
callBack: () => {
item.callBack();
this.clearTriggerItem(time, item);
resolve();
},
});
if (!this.waitingList[time]) {
this.waitingList[time] = [triggerItem];
} else {
this.waitingList[time].push(triggerItem);
}
}).catch((e) => {
console.warn(e);
});
// 对应步骤3 此处是将方法和当前时间塞入方法索引队列
this.doingList.push(itemPromise);
}
我们正在寻求外包团队
EduSoho官方开发文档地址
EduSoho官网 https://www.edusoho.com/
EduSoho开源地址 https://github.com/edusoho/edusoho