設計模式之Plugin模式

可樂爸發表於2019-09-23

早兩年所在公司有一個需求:針對視訊播放做一些視訊的播放事件上報,比如

  • 視訊自然緩衝上報(由於網路造成的自然卡頓),一旦進入緩衝狀態則上報一次事件
  • 視訊拖拽緩衝上報
  • 心跳上報,在播放開始後的第15秒、第45秒、第60秒分別上報一次,然後穩定在每2分鐘上報一次。當播放器暫停時,停止計時與上報,繼續播放後接著計時與上報
  • 使用者主動拖拽進度條操作上報

從這個需求裡,我們可以引申出一些關於設計的話題:

該如何設計程式碼以應對這種需求,以及未雨綢繆地應對產品下次類似需求?

剛從學校畢業一兩年的童鞋,最可能的做法應該是在播放頁直接取video的dom物件去監聽以上這一系列事件吧?很可惜的是,這些事件video並未直接提供給我們,而是需要我們設定一些變數去統計。

拿緩衝事件作比,video本身有一個timeupdate事件,只要視訊處於播放狀態,就會一直源源不斷的觸發這個事件。我們可以在每一次觸發時記錄一個當前時間戳,下次觸發時比較這個時間戳看是否超過1s(超過1s就是在緩衝)。此外,我們還要區別是自然播放造成的緩衝事件還是手動拖拽造成的緩衝事件,所以需要結合拖拽事件一起分析並區分。

所以,這些較為複雜的事件監聽程式碼會強耦合到我們的業務程式碼裡。

畢業兩三年的童鞋,應該會意識到這個問題,並將這一系列操作抽取出去形成一個獨立的模組或類。

比如:

EventReport.registe(videoDom);
複製程式碼

然後將這些監聽點的程式碼放到EeventReport模組裡,又或者更進一步再抽幾個模組用於分離上報事件。從我這些年見過不同公司的業務程式碼裡,基本上後者居多。

針對於完成需求,做到這些夠了嗎?

的確是夠了,哪怕產品再提一些相關需求,只要改改這些模組,反正不會影響業務程式碼。

但這不是本文需要拎出來說的重點,我要提的是設計模式

Plug-In模式

外掛模式是一個應用很普遍的軟體架構模式。經典例子如 Eclipse、jQuery、VS Code 等外掛機制。

外掛通常是對一個應用(host application,宿主應用)整體而言,通過外掛的擴充套件為一個應用新增新的功能或 features。一個外掛相當於一個 component,外掛內部可以包含許多緊密相關的物件。

為什麼要提到這個模式?因為我認為上面的這一系列需求(以及產品腦洞大開的後續相關需求),都屬於可以脫離業務程式碼存在的獨立個體!

  • 首先,提供支援外掛的"微核心"可以單獨釋出成第三方庫,只要是針對videoDom元素都可以監聽(以下簡稱微核心
  • 其次,不同的事件有不同的上報外掛,針對產品的需求可以增加外掛的類目
  • 最後,微核心可以隨時解除安裝,外掛也可以隨時解除安裝。

其實是一個非常簡單的微核心實現,沒有生命週期控制(只能註冊與解除安裝),程式碼不過百來行,但是它能將程式碼理得非常順暢,簡潔易讀易維護。

貼出微核心程式碼如下:

  var __EVENTS = ['play','timeupdate','ended','error','loadstart','loadedmetadata','playing','pause','seeking','seeked','waiting'];

  var VideoMonitor = function() {
    throw new TypeError('請使用monitor方法進行監測');
  };

  var seekingStart = 0;

  VideoMonitor.prototype = {
    constructor: VideoMonitor,
    init:function(videoDom, videoInfo){
      this.videoDom = videoDom;
      this.videoInfo = videoInfo;
      this.lastTimeupdate = 0;
      this.seekTime = -1;
      this.suuid = STK.$.getsUUID();
      this.firstBuffer = true;
      this.seekTimeout = null;
      this.bindContext();
      this.bindEvents();
    },
    destroy:function(){
      this.unbindEvents();
      setTimeout(()=>{
        this.videoDom = null;
        this.videoInfo = null;
      });
    },
    bind:function(fn, ctx) {
      return function (a) {
        var l = arguments.length;
        return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx);
      };
    },
    bindContext:function(){
      this.onEventHandler = this.bind(this.onEventHandler,this);
    },
    bindEvents:function(){
      let playerDom = this.videoDom;
      for(var event in __EVENTS){
        playerDom.addEventListener(__EVENTS[event], this.onEventHandler, false);
      }
    },
    unbindEvents:function(){
      let playerDom = this.videoDom;
      for(var event in __EVENTS){
        playerDom.removeEventListener(__EVENTS[event], this.onEventHandler, false);
      }
    },
    onEventHandler:function(e){
      //觸發自身回撥事件
      if(this[e.type] && typeof this[e.type] === 'function'){
        this[e.type].call(this,e);
      }
      //觸發外部註冊的控制程式碼回撥
      this.fireHandler(e);
    },
    fireHandler:function(e,data){
      for(var i = 0,len = handlerArray.length;i<len;i++){
        if(handlerArray[i][e.type] && typeof handlerArray[i][e.type] === 'function'){
          handlerArray[i][e.type](e,$.extend(this.videoInfo,data,{suuid:this.suuid}));
        }
      }
    },
    play:function(e){
      this.lastTimeupdate = +new Date();
      this.startHeartBeatCount();
    },
    playing(){
      this.lastTimeupdate = +new Date();
    },
    pause:function(){
      this.lastTimeupdate = +new Date();
      this.stopHeartBeatCount();
    },
    seeking(e){
      this.lastTimeupdate = +new Date();
      if (seekingStart == 0) {
        seekingStart = this.lastTimeupdate;
      }
      if (this.seekTime == -1 && e.target.currentTime != 0) {
        this.seekTime = e.target.currentTime;
      }
    },
    seeked(e){
      var self = this;
      var player = e.target;
      var td = 0;
      if (seekingStart > 0) {
        td = new Date().getTime() - seekingStart;
      }
      // 拖拽結束後上報drag時間
      this.lastTimeupdate = +new Date();
      if (player.currentTime != 0 && player.currentTime != this.videoInfo.info.duration && seekingStart > 0) {
        if (this.seekTimeout) {
            clearTimeout(this.seekTimeout);
            this.seekTimeout = null;
        }
        this.seekTimeout = setTimeout(
          e => {
              self.fireHandler({type:'drag',target:self.videoDom});
              this.seekTime = -1;
              seekingStart = 0; // 只有上報了才置0
          }, 
          1000
        );
      }   
    },
    timeupdate(e){
      var self = this;
      // 獲取兩次timeupdate事件間隔,用於卡頓判斷
      var now = +new Date();
      if (this.lastTimeupdate !== 0) {
        var d = now - this.lastTimeupdate;
        // 時間間隔超過1s,認為是在緩衝中
        if (d >= 1000) {
          self.fireHandler({type:'buffer',target:self.videoDom},{firstBuffer:self.firstBuffer});
          self.firstBuffer = false;//第一次緩衝已經發生過了
        }
      }
      this.lastTimeupdate = now;
    },

    //收集觀看時長並每秒通知一次
    currentCount:0,
    timer:null,
    startHeartBeatCount:function(){
      var self = this;
      self.timer = setTimeout(function(){
        self.currentCount++;
        self.fireHandler({type:'count',target:self.videoDom},{count:self.currentCount});
        self.startHeartBeatCount();
      },1000);
    },
    stopHeartBeatCount:function(){
      clearTimeout(this.timer);
      this.timer = null;
    }
  };

  VideoMonitor.prototype.init.prototype = VideoMonitor.prototype;

  var MonitorArray = [], handlerArray = [];

  VideoMonitor.monitor = function(videoDom, videoInfo ) {
    var monitor = new VideoMonitor.prototype.init(videoDom,videoInfo);
    MonitorArray.push({
      dom:videoDom,
      instance:monitor
    });
    return monitor;
  };

  VideoMonitor.listen = function(handler) {
    handlerArray.push(handler);
  };

  VideoMonitor.destroy = function(videoDom) {
    var monitor = findInstance(videoDom);
    removeInstance(videoDom);
    monitor && monitor.destroy();
  };

  function findInstance(videoDom){
    for(var index in MonitorArray){
      if(MonitorArray[index].dom === videoDom)
        return MonitorArray[index].instance;
    }
    return null;
  }

  function removeInstance(videoDom){
    for(var index in MonitorArray){
      if(MonitorArray[index].dom === videoDom)
        MonitorArray.splice(index,1);
    }
  }
複製程式碼

總結一下以上微核心程式碼,總共有四方面內容:

  • 通過monitor 監控videoDom元素並儲存例項引用
  • 通過listen 註冊不同的上報外掛
  • 微核心會監聽videoDom的所有事件並轉發到外掛裡
  • 微核心會分析videoDom的事件並整合出一些便於上報的合成事件,如
    • count 視訊播放計時器,每秒通知一次,暫停或拖拽時會暫停計時,便於外部handler進行視訊觀看時長的上報;
    • buffer 自然緩衝通知,由於網路問題造成的自然卡頓結束
    • drag 使用者拖拽通知,由於使用者拖拽造成的卡頓結束

貼一個心跳的上報外掛程式碼

  class HBStatHandler{
    ended(e,videoInfo){
      H._debug.log('HBStatHandler ended-----');
      var data = $.extend(base,{
        cf:videoInfo.info.clip_type,
        vts:videoInfo.info.duration,
        pay:videoInfo.info.paymark,
        ct:e.target.currentTime,
                suuid:videoInfo.suuid,
        idx:++idx,
        ht:2
      });
      stk.create(data,url);
    }

    count(e,videoInfo){
      var data = $.extend(base,{
        cf:videoInfo.info.clip_type,
        vts:videoInfo.info.duration,
        pay:videoInfo.info.paymark,
                suuid:videoInfo.suuid,
        ct:e.target.currentTime
      });
      //15秒上報
      if(videoInfo.count === 15){
        H._debug.log('HBStatHandler 15秒上報');
        data.idx = ++idx;
        data.ht = 3;
        stk.create(data,url);
        return;
      }
      //45秒上報
      if(videoInfo.count === 45){
        H._debug.log('HBStatHandler 45秒上報');
        data.idx = ++idx;
        data.ht = 4;
        stk.create(data,url);
        return;
      }
      //60秒上報
      if(videoInfo.count === 60){
        H._debug.log('HBStatHandler 60秒上報');
        data.idx = ++idx;
        data.ht = 5;
        stk.create(data,url);
        return;
      }
      //60秒後每2分鐘上報一次
      if(((videoInfo.count-60)/60)%2==0){
        H._debug.log('HBStatHandler 每2分鐘上報一次 videoInfo.count='+videoInfo.count);
        data.idx = ++idx;
        data.ht = 6;
        stk.create(data,url);
        return;
      }
    }
  }

複製程式碼

微核心監聽videoDom

  VideoMonitor.monitor(videoDom); // 第二個引數為視訊附加屬性,上報時使用
複製程式碼

微核心註冊外掛

  VideoMonitor.listen(new BufferStatHandler());
複製程式碼

從心跳上報的外掛程式碼大家可以很明顯看到,對於外掛而言,只要實現相應的與videoDom事件同名的方法,比如play, timeupdate, ended, error, playing, pause, seeking等,就可以被觸發到。除此之外,還有諸多合成事件,比如count, buffer, drag等,後期還可以擴充套件更多合成事件。

微核心只負責整合、派發事件,上報相關的事情全權交由外掛去解決,基本上百分百符合"開閉原則"。

業務程式碼(播放視訊)——微核心(整合派發事件)——外掛(上報不同事件),三方完全解耦。

外掛模式的使用雖然程式碼量(註冊、監聽,派發)比強耦合稍多一些,但它簡潔明瞭、清晰易懂、輕鬆插撥、隨意擴充套件。

視訊播放的事件監聽上報並不是一件非常難的事情,但是由於需求很雜,很容易寫成一團亂麻。正所謂前人栽樹,後人乘涼,相比起很多被後人罵***的程式碼,這段程式碼應該會減輕後人維護的難度了。

PS: 程式碼是2017年年初寫的,沒有使用class語法或TypeScript而是用的原型鏈。用了類似於jQuery裡的禁用構建函式處理方式。可以寫得更簡潔一些的。

一句話總結:設計模式帶來的好處很多,但是需要根據不同的場景靈活判斷該使用何種模式,這也是很多人學完一遍設計模式之後卻發現一臉蒙逼所面對的問題。

相關文章