弹幕实现原理

Heero.Luo发表于4年前,已被查看2943次

直播是眼下最为火爆的行业,而弹幕无疑是直播平台中最流行、最重要的功能之一。本文将讲述如何实现兼容 PC 浏览器和移动浏览器的弹幕。

基本功能

并发与队列

一般来说,弹幕数据会通过异步请求或 socket 消息传到前端,这里会存在一个隐患——数据量可能非常大。如果一收到弹幕数据就马上渲染出来,在量大的时候:

  • 显示区域不足以放置这么多的弹幕,弹幕会堆叠在一起;
  • 渲染过程会占用大量 CPU 资源,导致页面卡顿。

所以在接收和渲染数据之间,要引入队列做缓冲。把收到的弹幕数据都存入数组(即下文代码中的 this._queue),再通过轮询该数组,把弹幕逐条渲染出来:

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 占用上有优势。

本文将以 DOM 方案实现弹幕的滚动,并通过 CSS 的 transition 和 transform 来实现动画,这样可以利用浏览器渲染过程中的「合成层」机制(有兴趣可以查阅这篇文章),提高性能。弹幕滚动的示例代码如下:

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 文档里面:

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%,所以它在显示区域之外。这样可以在用户看到这条弹幕之前,做一些“暗箱操作”,包括获取弹幕的尺寸、占用的轨道数、总位移、位移时间、位移速度。接下来的问题是,要把弹幕显示在哪个位置呢?

首先,弹幕的文字大小不一定一致,从而占用的高度也不尽相同。为了能充分利用显示区域的空间,我们可以把显示区域划分为多行,一行即为一条轨道。一条弹幕至少占用一条轨道。而存储结构方面,可以用二维数组记录每条轨道中存在的弹幕。下图是弹幕占用轨道及其对应存储结构的一个例子:

弹幕占用轨道及其对应存储结构

其次,要防止弹幕重叠。原理其实非常简单,请看下面这题数学题。假设有起点站、终点站和一条轨道,列车都以匀速运动方式从起点开到终点。列车 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 匀速公式计算
ChromeCPU 16%~20%CPU 13%~16%
Firefox能耗影响 3能耗影响 0.75
SafariCPU 8%~10%CPU 3%~5%
IECPU 7%~10%CPU 4%~7%

暂停和恢复

首先要解释一下为什么要做暂停和恢复,主要是两个方面的考虑。

第一个考虑是浏览器的兼容问题。弹幕渲染流程会频繁调用到 JS 的 setTimeout 以及 CSS 的 transition,如果把当前标签页切到后台(浏览器最小化或切换到其他标签页),两者会有什么变化呢?请看测试结果:

浏览器setTimeouttransition
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/danmaku-demo

评论 (2条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)