ESLive的时间事件队列

ESLive的时间事件队列

前言

在ESLive的直播和直播回放中,撇开播放器的主播放逻辑和大多数控件的功能以外,比较主要的就是信令同步、回放的操作同步。一个较为准确的时间事件队列的维护能够保障信令同步/回放的操作同步。

  • 播放器的主播放逻辑

    • 换流刷新
    • swf和hls区分
  • 大多数控件的功能

    • 全屏
    • 直播的清晰度
    • 尺寸比例设置
    • 等等
  • 信令同步/回放的操作同步

内容导览

  • 一、时间事件队列的设计雏形

  • 二、解决精准度、回跳功能系列问题

一、设计初

业务需求:在具体业务中可以独自设立既定时间,要求在该时间内执行相应的操作,同时确保操作的精准度。

功能点分析

  • 队列内事件的添加

  • 事件执行

  • 队列内抛出

三个重点考虑因素

1. 在播放的时间轴中,事件是按时间顺序依次执行

通过设计对应的数据结构,保存依次执行的点的 时间数据具体方法内容,来保障依次执行。

  // time: function(){} => 时间点: 方法,这种数据格式可以同时保存时间和方法。
  let List = {
    event: {
      1: () => console.log(1),
      2: () => console.log(2),
      3: () => console.log(3)
    }
  };

  list.event[time]();   // 执行特定时间的方法只需如此

2. 当前播放时间点和事件添加时间点的误差

2.1 首先我们需要区分如下两个概念
  • 事件添加的时间点 —— 比如 event[time]中的 time,是注册事件的时间点。

  • 当前播放的时间点 —— 调用播放器的 getCurrentTime() 方法获取到的时间点。

2.2 两者在很大概率上不一致,具体原因如下:

1 video 标签和 flash 造成的延迟

  • 原生 video 标签 ontimeupdate 获取的速度大约是1s内4-5次

  • flash 可以通过内部自定义事件随意抛出,我们项目中是3s一次

2 直播过程中的骚操作导致的延迟

  • 换流操作

  • 播放器直接跳转时间点,可以跳过多个时间点,会导致两者存在较大的差异

2.3 解决思路 —— 筛选出小于等于当前时间点的所有时间

对象的形式遍历起来比较麻烦,所以需要另外一个参照物去取一个符合条件的时间点的集合。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);

3. 当获取精准度较低时,可能出现同一个时间点注册多个方法的情况

当同一个时间点注册多个方法时,会依次衍生出两个小问题。

问题1 相同时间点下多个事件的覆盖

使用原来 对象 的形式,会导致后加的事件会把前者覆盖,无法实现多个事件的添加。所以我们借助 数组 进行了适当的调整。

// 这样就能记录同一个时间点的多个方法
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),
    ]
  }
};
问题2 多次录入相同时间点的事件

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.9s1.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.5s3.5s 才被触发,分别延迟了 0.5s2.5s

优化方案

  • 筛选符合条件的时间段可以稍长于当前时间 (limit)
  • 把当前时间到稍长时间这一区间的方法做一个延迟处理(setTimeout)

举个例子,当前时间点为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

  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 等等操作。

队列细节优化

1 引入 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);
    })
  }

2 将 延迟处理的事件 加入到 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);
  }

3 waitingList、list的相关操作

这里先不涉及播放器逻辑,仅仅只把方法抛出来。

  // 取消 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();
  }

4. bug修复

如上内容在实际使用过程中,产生了一个较为严重的问题 —— 原有的消息执行顺序被打乱。

4.1 打乱原有消息执行顺序的主要原因

  • 在筛选执行内容的序列后要对所有可用方法做延迟/立即执行,因为直接使用了 splice 方法,所以在各自执行结束会对原有序列造成影响,先执行的会导致数组指针向后跳一位。

  • 有可能在上一组执行序列没有完全执行结束的情况下又添加新的内容,导致在 map/forEach/标准循环 下当前数组内元素序号有序但是序号所对应的元素已经改变,即所谓的 指针错乱

4.2 相应措施

4.2.1 解决上述问题需要的两组数据
  • promise执行队列

  • 正在执行中的方法索引队列

4.2.2 keyword
  • currentList : promise执行队列

  • doingList : 正在执行的方法索引队列

  • waitingList: 延迟方法队列

4.2.3 解决方案具体步骤分析

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);

4promise 队列的 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);
  });

4.2.4 完整代码
  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

你可能感兴趣的:(EduSoho,直播)