彈幕實現原理

直播是眼下最爲火爆的行業,而彈幕無疑是直播平臺中最流行、最重要的功能之一。本文將講述如何實現兼容 PC 瀏覽器和移動瀏覽器的彈幕。javascript

基本功能

併發與隊列

通常來講,彈幕數據會經過異步請求或 socket 消息傳到前端,這裏會存在一個隱患——數據量可能很是大。若是一收到彈幕數據就立刻渲染出來,在量大的時候:css

顯示區域不足以放置這麼多的彈幕,彈幕會堆疊在一塊兒;
渲染過程會佔用大量 CPU 資源,致使頁面卡頓。
因此在接收和渲染數據之間,要引入隊列作緩衝。把收到的彈幕數據都存入數組(即下文代碼中的 this._queue),再經過輪詢該數組,把彈幕逐條渲染出來:html

class Danmaku {
  // 省略 N 行代碼...

  add(data) {
    this._queue.push(this._parseData(data));
    if (!this._renderTimer) { this._render(); }
  }

  _render() {
    try {
      this._renderToDOM();
    } finally {
      this._renderEnd();
    }
  }

  _renderEnd() {
    if (this._queue.length > 0) {
      this._renderTimer = setTimeout(() => {
        this._render();
      }, this._renderInterval);
    } else {
      this._renderTimer = null;
    }
  }

  // 省略 N 行代碼...
}

彈幕的滾動

彈幕的滾動本質上是位移動畫,從顯示區域的右側移動到左側。前端實現位移動畫有兩種方案——DOM 和 canvas。前端

DOM 方案實現的動畫較爲流暢,且一些特殊效果(如文字陰影)較容易實現(只要在 CSS 中設置對應的屬性便可)。
Canvas 方案的動畫流暢度要差一些,要作特殊效果也不那麼容易,可是它在 CPU 佔用上有優點。java

本文將以 DOM 方案實現彈幕的滾動,並經過 CSS 的 transition 和 transform 來實現動畫,這樣能夠利用瀏覽器渲染過程當中的「合成層」機制(有興趣能夠查閱這篇文章),提升性能。彈幕滾動的示例代碼以下:node

var $item = $('.danmaku-item').css({
  left: '100%',
  'transition-duration': '2s',
  'transition-property': 'transform',
  'will-change': 'transform' 
});
setTimeout(function() {
  $item.css(
    'transform',
    `translateX(${-$item.width() + container.offsetWidth})`
  );
}, 1000);

彈幕的渲染

在 DOM 方案下,每條彈幕對應一個 HTML 元素,把元素的樣式都設定好以後,就能夠添加到 HTML 文檔裏面:git

class Danmaku {
  // 省略 N 行代碼...

  _renderToDOM() {
    const data = this._queue[0];
    let node = data.node;
    if (!node) {
      data.node = node = document.createElement('div');
      node.innerText = data.msg;
      node.style.position = 'absolute';
      node.style.left = '100%';
      node.style.whiteSpace = 'nowrap';
      node.style.color = data.fontColor;
      node.style.fontSize = data.fontSize + 'px';
      node.style.willChange = 'transform';
      this._container.appendChild(node);

      // 佔用軌道數
      data.useTracks = Math.ceil(node.offsetHeight / this._trackSize);
      // 寬度
      data.width = node.offsetWidth;
      // 總位移(彈幕寬度+顯示區域寬度)
      data.totalDistance = data.width + this._totalWidth;
      // 位移時間(若是數據裏面沒有指定,就按照默認方式計算)
      data.rollTime = data.rollTime ||
        Math.floor(data.totalDistance * 0.0058 * (Math.random() * 0.3 + 0.7));
      // 位移速度
      data.rollSpeed = data.totalDistance / data.rollTime;

      // To be continued ...
    }   
  }

  // 省略 N 行代碼...
}

因爲元素的 left 樣式值設置爲 100%,因此它在顯示區域以外。這樣能夠在用戶看到這條彈幕以前,作一些「暗箱操做」,包括獲取彈幕的尺寸、佔用的軌道數、總位移、位移時間、位移速度。接下來的問題是,要把彈幕顯示在哪一個位置呢?github

首先,彈幕的文字大小不必定一致,從而佔用的高度也不盡相同。爲了能充分利用顯示區域的空間,咱們能夠把顯示區域劃分爲多行,一行即爲一條軌道。一條彈幕至少佔用一條軌道。而存儲結構方面,能夠用二維數組記錄每條軌道中存在的彈幕。下圖是彈幕佔用軌道及其對應存儲結構的一個例子:web

彈幕佔用軌道及其對應存儲結構

其次,要防止彈幕重疊。原理其實很是簡單,請看下面這題數學題。假設有起點站、終點站和一條軌道,列車都以勻速運動方式從起點開到終點。列車 A 先發車,請問:若是在某個時刻,列車 B 發車的話,會不會在列車 A 徹底進站以前撞上列車 A?算法

列車碰撞數學題

聰明的你可能已經發現,這裏的軌道所對應的就是彈幕顯示區域裏面的一行,列車對應的就是彈幕。解題以前,先過一下已知量:

  • 路程 S,對應顯示區域的寬度;
  • 兩車長度 la 和 lb,對應彈幕的寬度;
  • 兩車速度 va 和 vb,已經計算出來了;
  • 前車已行走距離 sa,即彈幕元素當前的位置,能夠經過讀取樣式值獲取。

那在什麼狀況下,兩車不會相撞呢?

  • 其一,若是列車 A 沒有徹底出站(已行走距離小於車長),則列車 B 不具有發車條件;
  • 其二,若是列車 B 的速度小於等於列車 A 的速度,因爲 A 先發車,這是確定撞不上的;
  • 其三,若是列車 B 的速度大於列車 A 的速度,那就要看二者的速度差了:

    • 列車 A 追上列車 B 所需時間 tba = (sa - la) / (vb - va);
    • 列車 A 徹底到站所需時間 tad = (s + la - sa) / va;
    • tba > tad 時,兩車不會撞上。

有了理論支撐,就能夠編寫對應的代碼了。

class Danmaku {
  // 省略 N 行代碼...
  
  // 把彈幕數據放置到合適的軌道
  _addToTrack(data) {
    // 單條軌道
    let track;
    // 軌道的最後一項彈幕數據
    let lastItem;
    // 彈幕已經走的路程
    let distance;
    // 彈幕數據最終坐落的軌道索引
    // 有些彈幕會佔多條軌道,因此 y 是個數組
    let y = [];

    for (let i = 0; i < this._tracks.length; i++) {
      track = this._tracks[i];

      if (track.length) {
        // 軌道被佔用,要計算是否會重疊
        // 只須要跟軌道最後一條彈幕比較便可
        lastItem = track[track.length - 1];

        // 獲取已滾動距離(即當前的 translateX)
        distance = -getTranslateX(lastItem.node);

        // 計算最後一條彈幕所有消失前,是否會與新增彈幕重疊
        // (對應數學題分析中的三種狀況)
        // 若是不會重疊,則可使用當前軌道
        if (
          (distance > lastItem.width) &&
          (
            (data.rollSpeed <= lastItem.rollSpeed) ||
            ((distance - lastItem.width) / (data.rollSpeed - lastItem.rollSpeed) >
              (this._totalWidth + lastItem.width - distance) / lastItem.rollSpeed)
          )
        ) {
          y.push(i);
        } else {
          y = [];
        }

      } else {
        // 軌道未被佔用
        y.push(i);
      }

      // 有足夠的軌道能夠用時,就能夠新增彈幕了,不然等下一次輪詢
      if (y.length >= data.useTracks) {
        data.y = y;
        y.forEach((i) => {
          this._tracks[i].push(data);
        });
        break;
      }
    }
  }

  // 省略 N 行代碼...
}

只要彈幕成功入軌(data.y 存在),就能夠顯示在對應的位置並執行動畫了:

class Danmaku {
  // 省略 N 行代碼...

  _renderToDOM {
    const data = this._queue[0];
    let node = data.node;
    
    if (!data.node) {
      // 省略 N 行代碼...
    }

    this._addToTrack();

    if (data.y) {
      this._queue.shift();

      // 軌道對應的 top 值
      node.style.top = data.y[0] * this._trackSize + 'px';
      // 動畫參數
      node.style.transition = `transform ${data.rollTime}s linear`;
      node.style.transform = `translateX(-${data.totalDistance}px)`;
      // 動畫結束後移除
      node.addEventListener('transitionend', () => {
        this._removeFromTrack(data.y, data.autoId);
        this._container.removeChild(node);
      }, false);
    }
  }

  // 省略 N 行代碼...
}

至此,渲染流程結束,此時的彈幕效果見此 demo 頁。爲了可以讓你們看清楚渲染過程當中的「暗箱操做」,demo 頁中會把顯示區域之外的部分也展現出來。

完善

上一節已經實現了彈幕的基本功能,但仍有一些細節須要完善。

跳過彈幕

仔細觀察上文的彈幕 demo 能夠發現,同一條軌道內,彈幕之間的距離偏大。而該 demo 中,隊列輪詢的間隔爲 150ms,理應不會有這麼大的間距。

回顧渲染的代碼能夠發現,該流程老是先檢查第一條彈幕能不能入軌,假若不能,那後續的彈幕都會被堵塞,從而致使彈幕密集度不足。然而,每條彈幕的長度、速度等參數不盡相同,第一條彈幕不具有入軌條件不表明後續的彈幕都不具有。因此,在單次渲染過程當中,若是第一條彈幕還不能入軌,能夠日後多嘗試幾條。

相關的代碼改動也不大,只要加個循環就好了:

_renderToDOM() {
  // 根據軌道數量每次處理必定數量的彈幕數據。數量越大,彈幕越密集,CPU 佔用越高
  let count = Math.floor(totalTracks / 3), i;
  while (count && i < this._queue.length) {
    const data = this._queue[i];
    // 省略 N 行代碼...
    if (data.y) {
      this._queue.splice(i, 1);
      // 省略 N 行代碼...
    } else {
      i++;
    }
    count--;
  }
}

改動後的效果見此 demo 頁,能夠看到彈幕密集程度有明顯改善。

彈幕已滾動路程的獲取

防重疊檢測是彈幕渲染過程當中執行得最爲頻繁的部分,所以其優化顯得特別重要。JavaScript 性能優化的關鍵是:儘量避免 DOM 操做。而整個防重疊檢測算法中涉及的惟一一處 DOM 操做,就是彈幕已滾動路程的獲取:

distance = -getTranslateX(data.node);

而實際上,這個路程不必定要經過讀取當前樣式值來獲取。由於在勻速運動的狀況下,路程=速度×時間,速度是已知的,而時間嘛,只須要用當前時間減去開始時間就能夠得出。先記錄開始時間:

_renderToDOM() {
  // 根據軌道數量每次處理必定數量的彈幕數據。數量越大,彈幕越密集,CPU 佔用越高
  let count = Math.floor(totalTracks / 3), i;
  while (count && i < this._queue.length) {
    const data = this._queue[i];
    // 省略 N 行代碼...
    if (data.y) {
      this._queue.splice(i, 1);
      // 省略 N 行代碼...
      node.addEventListener('transitionstart', () => {
        data.startTime = Date.now();
      }, false);
      // 從設置動畫樣式到動畫開始有必定的時間差,因此加上 80 毫秒
      data.startTime = Date.now() + 80;

    } else {
      i++;
    }

    count--;
  }
}

注意,這裏設置了兩次開始時間,一次是在設置動畫樣式、綁定事件以後,另外一次是在 transitionstart 事件中。理論上只須要後者便可。之因此加上前者,仍是由於兼容性問題——並非全部瀏覽器都支持 transitionstart 事件

而後,獲取彈幕已滾動路程的代碼就能夠優化成:

distance = data.rollSpeed * (Date.now() - data.startTime) / 1000;

別看這個改動很小,先後只涉及 5 行代碼,但效果是立竿見影的(見此 demo 頁):

瀏覽器 getTranslateX 勻速公式計算
Chrome CPU 16%~20% CPU 13%~16%
Firefox 能耗影響 3 能耗影響 0.75
Safari CPU 8%~10% CPU 3%~5%
IE CPU 7%~10% CPU 4%~7%

暫停和恢復

首先要解釋一下爲何要作暫停和恢復,主要是兩個方面的考慮。

第一個考慮是瀏覽器的兼容問題。彈幕渲染流程會頻繁調用到 JS 的 setTimeout 以及 CSS 的 transition,若是把當前標籤頁切到後臺(瀏覽器最小化或切換到其餘標籤頁),二者會有什麼變化呢?請看測試結果:

瀏覽器 setTimeout transition
Chrome/Edge 延遲加大 若是動畫未開始,則等待標籤頁切到前臺後纔開始
Safari/IE 11 正常 若是動畫未開始,則等待標籤頁切到前臺後纔開始
Firefox 正常 正常

可見,不一樣瀏覽器的處理方式不盡相同。而從實際場景上考慮,標籤頁切到後臺以後,即便渲染彈幕用戶也看不見,白白消耗硬件資源。索性引入一個機制:標籤頁切到後臺,則彈幕暫停,切到前臺再恢復

let hiddenProp, visibilityChangeEvent;
if (typeof document.hidden !== 'undefined') {
  hiddenProp = 'hidden';
  visibilityChangeEvent = 'visibilitychange';
} else if (typeof document.msHidden !== 'undefined') {
  hiddenProp = 'msHidden';
  visibilityChangeEvent = 'msvisibilitychange';
} else if (typeof document.webkitHidden !== 'undefined') {
  hiddenProp = 'webkitHidden';
  visibilityChangeEvent = 'webkitvisibilitychange';
}

document.addEventListener(visibilityChangeEvent, () => {
  if (document[hiddenProp]) {
    this.pause();
  } else {
    // 必須異步執行,不然恢復後動畫速度可能會加快,從而致使彈幕消失或重疊,緣由不明
    this._resumeTimer = setTimeout(() => { this.resume(); }, 200);
  }
}, false);

先看下暫停滾動的主要代碼(注意已滾動路程 rolledDistance,將用於恢復播放和防重疊):

this._eachDanmakuNode((node, y, id) => {
  const data = this._findData(y, id);
  if (data) {
    // 獲取已滾動距離
    data.rolledDistance = -getTranslateX(node);
    // 移除動畫,計算出彈幕所在的位置,固定樣式
    node.style.transition = '';
    node.style.transform = `translateX(-${data.rolledDistance}px)`;
  }
});

接下來是恢復滾動的主要代碼:

this._eachDanmakuNode((node, y, id) => {
  const data = this._findData(y, id);
  if (data) {
    // 從新計算滾完剩餘距離須要多少時間
    data.rollTime = (data.totalDistance - data.rolledDistance) / data.rollSpeed;
    data.startTime = Date.now();
    node.style.transition = `transform ${data.rollTime}s linear`;
    node.style.transform = `translateX(-${data.totalDistance}px)`;
  }
});

this._render();

防重疊的計算公式也須要修改:

// 新增了 lastItem.rolledDistance
distance = lastItem.rolledDistance + lastItem.rollSpeed * (now - lastItem.startTime) / 1000;

修改後效果見此 demo 頁,能夠留意切換瀏覽器標籤頁後的效果並與前面幾個 demo 對比。

丟棄排隊時間過長的彈幕

彈幕併發量大時,隊列中的彈幕數據會很是多,而在防重疊機制下,一屏能顯示的彈幕是有限的。這就會出現「供過於求」,致使彈幕「滯銷」,用戶看到的彈幕將再也不「新鮮」(好比視頻已經播到第 10 分鐘,但還在顯示第 3 分鐘時發的彈幕)。

爲了應對這種狀況,要引入丟棄機制,若是彈幕的庫存比較多,並且這批庫存已經放了好久,就扔掉它。相關代碼改動以下:

while (count && i < this._queue.length) {
  const data = this._queue[i];
  let node = data.node;

  if (!node) {
    if (this._queue.length > this._tracks.length * 2 &&
      Date.now() - data.timestamp > 5000
    ) {
      this._queue.splice(i, 1);
      continue;
    }
  }

  // ...
}

修改後效果見此 demo 頁

最後

DOM 的渲染徹底是由瀏覽器控制的,也就是說實際渲染狀況與 JavaScript 算出來的存在誤差,通常狀況下誤差不大,渲染效果就是正常的。可是在極端狀況下,誤差較大時,彈幕就可能會出現輕微重疊。這一點也是 DOM 不如 canvas 的一個方面,canvas 的每一幀都是能夠控制的。

最後附上 demo 的 Github 倉庫:https://github.com/heeroluo/d...

本文同時發表於做者我的博客:https://mrluo.life/article/de...

相關文章
相關標籤/搜索