0

如何打造一款高可用的全屏红包雨

 3 years ago
source link: https://www.xiabingbao.com/post/canvas/canvas-redpackrain.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
在各个大活动中,经常会遇到红包雨的特效,那么红包雨具体是怎么实现的呢?

我们腾讯新闻在年底前搞了一波大的烟花活动和跨年红包雨活动,我正好负责了全屏红包雨的特效。在活动结束后,我们来复盘下整个红包雨的实现过程,或许对您有什么帮助呢!

如何打造一款高可用的全屏红包雨-厉害厉害-蚊子的前端博客

在实现红包雨时,我们首先要明确实现什么样子的功能,要给上层的调用方什么配置,调用方需要做什么,就能启动这个红包雨。

  1. 为提高性能用 canvas 实现,用 div 的话,需要频繁地创建 div 并进行重绘,红包比较多时,可能会特别消耗性能;

  2. 每个红包要有不同的降落速度,一样的速度太单调;

  3. 确定红包的属性(如速度、位置坐标、大小、放大缩小和旋转的变形数据、是否已被销毁等);

  4. 可以手动启动和暂停红包雨;

  5. 点击时,告诉调用方是否命中了红包;

接下来我们一步步地来实现一套完整的红包雨。代码较多,请静下心来。

1. 创建红包雨的画布

我们设置组件的原则是要开箱即用,让调用方尽可能少的配置就能使用我们的组件。我们要实现红包雨,首先就要创建一个画布来画这个红包雨。

class RedpackRain {
  private rainCtx: CanvasRenderingContext2D | null = null;
  private containerRect = { width: 0, height: 0, top: 0, left: 0 };
  private ratio = window.devicePixelRatio || 3; // 画布放大的倍数

  // 在指定容器内创建canvas
  private creatCanvas = () => {
    // 传入一个选择器,表示要在哪个里面展示红包雨
    const selector: HTMLElement = this.config.selector as HTMLElement;

    // 获取外层容器的属性,根据容器的属性来设置画布
    const { top, left, width, height } = selector.getBoundingClientRect();

    // 将容器的属性记下来
    // ratio表示放大的倍数,这是为了避免在一些高清屏下canvas模糊的问题
    this.containerRect.width = width * this.ratio;
    this.containerRect.height = height * this.ratio;
    this.containerRect.top = top;
    this.containerRect.left = left;

    // 容器中还没有创建canvas画布时,就创建一个
    // 若已经有了,则不再创建
    if (selector.getElementsByTagName('canvas').length === 0) {
      const canvasRain = document.createElement('canvas');
      canvasRain.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2';
      canvasRain.width = this.containerRect.width;
      canvasRain.height = this.containerRect.height;

      const div = document.createElement('div');
      div.style.cssText = 'position: relative; height: 100%';
      div.appendChild(canvasRain);
      selector.appendChild(div);

      const rainCanvasSelector: HTMLCanvasElement | null = selector.querySelector('.rain-redpack-canvas');

      if (rainCanvasSelector) {
        // 获取画布
        this.rainCtx = rainCanvasSelector.getContext('2d');
      }
    }
  }
}

这里其实我们要注意两点:

  • 获取容器的属性(top, left, width, height),width 和 height 是为了设置画布的宽度和高度,而 top 和 left 的值,是为了在计算用户的点击坐标时,要减去容器的 top 和 left,才是用户在画布上的坐标;为了通用性,也是考虑了容器不是全屏的情况;

  • 一个是 ratio 属性,为了避免在一些高清屏下 canvas 模糊的问题,对 canvas 进行了放大处理;那么 canvas 中所有的数据都是等比例放大了,这点在后面判断点击是否命中红包时非常有用,因为红包的坐标、宽度和高度都是等比例处理过了,点击事件产生的 top 和 left 也要先乘以对应的系数后,才能跟红包的坐标对应上。

如何打造一款高可用的全屏红包雨-继续继续-蚊子的前端博客

2. 红包元素

在创建完成画布后,我们就可以画红包元素了,在 canvas 中,我们直接画一个图片即可。这里我们分成几个小节来分开将红包元素。

2.1 红包的创建

一个红包特有的几个属性:

红包实例-蚊子的前端博客

具体代码如下:

class Item {
  redpackId = 1; // 红包标识
  x = 100; // 红包的横坐标
  y = 200; // 红包的竖坐标
  width = 100;
  height = 150;
  speed = 10; // 下降的速度,创建后则确定
  angle = 2; // 渲染的角度
  ratio = 1.1; // 放大的系数
  imgurl = ''; // 红包素材的地址
  redpackCtx: CanvasRenderingContext2D | null = null; // 画布

  constructor({ redpackId, x, y, imgurl, width, height, speedMax, speedMin, redpackCtx, onDestoryed }) {
    this.redpackId = redpackId;
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
    this.speed = (speedMax - speedMin) * Math.random() + speedMin;
    this.imgurl = imgurl;
    this.redpackCtx = redpackCtx;
    this.containerHeight = containerHeight;
    this.onDestoryed = onDestoryed;

    const random = Math.random();
    const angle = ((random * 30 + 10) * Math.PI) / 180; // 随机一个旋转偏移角度
    this.angle = random < 0.5 ? angle : 0 - angle; // 随机向左或者向右旋转
    this.ratio = random * 0.4 + 0.8; // 随机放大的效果,0.8~1.2倍之间
  }
}

虽然整个红包雨中,有的红包落的快,有的红包落的慢,但具体到某一个红包中,我们则在创建时,就已经确定了这个红包的速度,一直到当前红包被销毁。

为了不让红包显得过于单调,我们在红包素材固定的情况下,对元素进行了渲染和放大处理:

const random = Math.random();
const angle = ((random * 30 + 10) * Math.PI) / 180; // 随机一个旋转偏移角度
this.angle = random < 0.5 ? angle : 0 - angle; // 随机向左或者向右旋转
this.ratio = random * 0.4 + 0.8; // 随机放大的效果,0.8~1.2倍之间

2.2 红包的移动

在 canvas 中的移动,其实就是擦除刚才坐标的图像,然后在新的坐标重新画一个。将这个行为一直持续下去,红包就会一直动起来了。

class Item {
  render() {
    const { redpackCtx } = this;

    // 还在画布内时
    if (this.y < this.containerHeight) {
      // 绘制下一个位置
      const nextx = this.x; // 横轴坐标不变
      const nexty = this.y + this.speed; // 根据速度获取下一个竖轴坐标

      redpackCtx.save();
      redpackCtx.beginPath();
      redpackCtx.rect(nextx, nexty, this.width, this.height);
      redpackCtx.translate(nextx + this.width / 2, nexty + this.height / 2);
      redpackCtx.scale(this.ratio, this.ratio);
      redpackCtx.rotate(this.angle);
      redpackCtx.drawImage(this.redpackImg, -this.width / 2, -this.height / 2, this.width, this.height);
      redpackCtx.strokeStyle = 'transparent';
      redpackCtx.stroke();
      redpackCtx.restore();
      this.y = nexty;
    } else {
      // 超过边界,回调该元素被销毁的事件
      // 告诉主流程当前红包已销毁,可以将其剔除
      if (typeof this.onDestoryed === 'function') {
        this.onDestoryed(this.redpackId);
      }
    }
  }
}

当红包一直绘制到边界了,还没有被用户点中,则告诉主控制流程,这个红包已经达到边界了,可以将其从列表中删除了。细心的人也发现了,这个 render()只是一次的绘制操作,它怎么能动的起来呢?

本来做 demo 的过程中,我是把 requestAnimationFrame 放在红包 Item 中,也就是说每一个红包都有自己完整的一套绘制流程;而且每次移动的过程中,只擦除自己刚才所在的位置,并不擦除整个画布。同时 canvas 中还有一个isPointInPath方法用来判断点击是否在画出的封闭路径内,那么我正好可以用isPointInPath方法用户是否命中了红包?

然而这里有一个问题:isPointInPath方法只能判断最后一次绘制的图形,如果画布中有多个红包时,需要把每个红包都挨个儿重新绘制一次,然后一个一个进行判断。这样就会多次的重绘,每个红包运动时,自己都有一个 requestAnimationFrame 在重复绘制;用户点击时,还是会清除重新绘制,如果用户点击比较快,那么画布就需要频繁的擦除和重绘。

而且在真机测试的过程中发现,准确率不高,手指明明点中了红包,程序却判定没有点中。体验效果不好。

如何打造一款高可用的全屏红包雨-学不动了-蚊子的前端博客

是不是有点跟不上了,别急,还没到最后呢!

3. 主流程

针对上面存在的问题,这里我在进行了下一步的优化。

主流程中一个变量redpackItemList保存着当前整个画布中正在存在的红包实例,若被点击命中或者降落到查出了画布,则将其移除。

3.1 全局只有一个 rAF 来控制

把用 requestAnimationFrame 控制红包下一次绘制的操作,放在了主流程中,整个红包雨只有一个 requestAnimationFrame 来控制所有的红包绘制,每次绘制前,先把整个画布擦除掉,然后循环redpackItemList,调用每个红包实例中的 render()再重新绘制出来。如果 redpackMap 已经不存在某个红包实例,则下次绘制时,则不会再进行绘制。

在修改了绘制流程后,我并没有同步修改对点击命中的判断逻辑,发现在 PC 上调试时命中率更低了,isPointInPath不好使了。主要是因为所有的红包都是在一次的渲染中完成的,点击的红包在 for 循环的渲染过程中,非常快地就过去了,根本就没有被做为当时最后一个画布的元素来判断。为了解决判定不准的问题,这里改成了通过坐标进行判断。

class RedpackRain {
  clickListener() {
    // 获取所有的手指坐标
    touchClients.forEach(({ clientX, clientY }) => {
      // 减去外层容器的偏移,并乘以画布放大的系数
      const myClientX = (clientX - left) * this.ratio;
      const myClientY = (clientY - top) * this.ratio;

      // 循环当前所有存在画布上的红包坐标
      for (const key in this.redpackItemList) {
        const redpackItem = this.redpackItemList[key];
        const { x, y, width, height } = redpackItem;

        const diff = 14; // 比红包区域大一点,类似于padding
        let hitedNum = 0; // 被命中的红包个数
        if (
          myClientX >= x - diff &&
          myClientX <= x + width + diff &&
          myClientY >= y - diff &&
          myClientY <= y + height + diff
        ) {
          delete this.redpackItemList[key];
          redpackItem.addBubble();
          hitedNum += 1;
        }
        if (typeof this.config.onClick === 'function') {
          this.config.onClick(hitedNum); // 返回当前次点击,一共命中了几个红包
        }
      }
    });
  }
}

我们这里特意进行了一个坐标的转换,首先减去外层容器相对可视窗口的偏移量,然后再乘以画布的放大系数,使点击的坐标能正好和画布的坐标能对应上:

// 减去外层容器的偏移,并乘以画布放大的系数
const myClientX = (clientX - left) * this.ratio;
const myClientY = (clientY - top) * this.ratio;

3.2 创建红包实例

每个降落的红包,都是一个红包实例,那么如何创建一个红包实例呢?又要考虑哪些问题呢?

其实创建红包实例很简单,我们在上面已经定义好红包类需要的属性,把这些数据传进去并初始化,初始化的实例放到redpackItemList中。

额外特别需要注意的就是红包降落的横轴 x 的值,我们制定了两个标准:

  1. 不与最近一次降落的红包重叠,红包的速度有快有慢,如果重叠的话,看起来不好看;

  2. 不出现在边界,避免用户不方便点击,同时也考虑到曲面屏手机的情况;

因此我专门写了一个方法,每次创建前,获取下即将降落的红包横坐标:

class RedpackRain {
  /**
   * 返回红包创建时的x轴坐标
   * @param width 红包的宽度
   * @return x轴坐标
   */
  private getRedpackItemX(width: number): number {
    let x = this.lastRedpackX; // 上次红包的坐标
    do {
      x = Math.floor(Math.random() * (this.containerRect.width - width * 3) + width); // 避免红包产生在边界
    } while (Math.abs(this.lastRedpackX - x) <= width * 1.5); // 避免先后两个红包重叠

    this.lastRedpackX = x;
    return x;
  }
}

这个方法产生的坐标坐标会在 width <= x < containerRect.width-width*2 中间产生,右边为什么是减去 2 个红包的宽度呢,因为红包本身还占有一个宽度,红包实例使用这样计算出来的 x 值,会在两边各留下一个红包宽度的间隙。

有读者可能会有疑问,这里通过 while 循环和 Math.random()算出一个范围内的数值,会不会出现每次随机出来的数值,都不满足要求,导致 while 循环次数过多的情况呢?其实并不会,我们在测试过程发现,大部分最多循环 2-3 次就能得到符合要求的数据,少部分会有 4 次的情况。

得到横轴的数值后,就可以创建红包了。

class RedpackRain {
  // 创建红包
  private createRedpackItem() {
    const { width, height, speedMax, speedMin, imgUrl } = this.config.redpack || {}; // 配置
    const redpackItemId = Date.now(); // 红包实例的ID,这里我们使用时间戳来作为ID
    const x = this.getRedpackItemX(width); // 获取横轴的坐标
    const redpackItem = new RedpackItem({
      redpackId: redpackItemId,
      x,
      y: -this.config.redpack.height,
      imgUrl,
      width,
      height,
      speedMax,
      speedMin,
      containerHeight: this.containerRect.height,
      redpackCtx: this.rainCtx, // 当前画布
      onDestoryed: (id) => {
        // 自身被销毁时的回调
        // 从列表中删除
        delete this.redpackItemList[id];
      },
    });

    // 将红包实例推送到列表中
    this.redpackItemList[redpackItemId] = redpackItem;

    // start中先判断红包图片加载完毕,然后调用render方法
    redpackItem.start();
  }
}

3.3 页面不可见时

使用 requestAnimationFrame 来循环对页面渲染,而不是使用 setTimeout 或者 setInterval,想必大家也能知道原因。这里我们主要探讨页面可见性发生变化时,对红包雨造成的影响。

我们考虑一个这样的场景:用户已经开启了红包雨,点击了几个红包,但是女朋友在微信忽然发了一条很重要的消息,必须马上要回复;这时就要从腾讯新闻切换到微信页面进行回复,回复完毕后再重新回到腾讯新闻。这里就涉及到了页面可见性的问题。

我们在测试的过程中发现,当前页面不可见时,requestAnimationFrame 在大多数浏览器中会停止执行,如果不做特殊处理,用户重新回来后,会发现在页面的顶部积攒了好几个红包。因为创建红包的 setInterval 定时器并没有停止执行(虽然定时器的时间间隔增加,但并没有彻底停止),而 requestAnimationFrame 则是直接停止了执行,这就造成虽然创建了好几个红包,但没有降落的操作。

这里我们就需要监听页面的可见性,当页面不可见时,则停止创建红包,当页面重新可见时,则继续创建。

// 我们已经封装了监听页面可见性的方法
// 这里就是看下在页面可见性发生变化时的定时器的处理
pageVisibility.visibilityChange((isShow) => {
  if (this.timer) {
    clearInterval(this.timer);
    this.timer = null;
  }
  if (isShow) {
    // 创建一个新的红包雨
    this.timer = setInterval(() => {
      this.createRedpackItem();
    }, this.config.interval);
  }
});

这里我做了一个比较粗暴的处理,凡是页面可见性发生变化时,都会清除定时器,然后页面可见时再重新创建定时器。

如何打造一款高可用的全屏红包雨-摩羯三连-蚊子的前端博客

4. 红包雨的使用

目前我们已经基本把红包雨的流程跑通了,红包雨已经可以下了。我们就要考虑对外提供什么方法,方便调用者使用了。

这里我提供了 3 个方法来供调用者使用。

4.1 start

启动整个红包雨。用户可能会重复多次启动红包雨,这里我们就要先清除上次的配置,然后重新设置:

class RedpackRain {
  start() {
    // 先停止上一个
    /**
     * 1. 清除定时器;
     * 2. 移除点击事件的监听;
     * 3. 移动页面可见性的监听;
     * 4. 停止创建红包 & 停止画布渲染;
     */
    this.clear(); // 下面会讲

    // 注册画布的点击事件
    this.config.selector.addEventListener(this.config.eventType, this.clickListener, false);

    // 创建红包
    this.createRedpackItem();

    // 创建一个新的红包雨
    this.timer = setInterval(() => {
      this.createRedpackItem();
    }, this.config.interval);
    this.render();

    // 注册监听页面可见性事件
    this.pageVisibility?.visibilityChange((isShow) => {
      if (this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
      if (isShow) {
        // 创建一个新的红包雨
        this.timer = setInterval(() => {
          this.createRedpackItem();
        }, this.config.interval);
      }
    });
  }
}

4.2 clear

该方法类似于暂停事件,将清除画布中所有的事件和红包。但红包数据还在,若重新 start 时,则还继续上次的红包雨。

4.3 stop

先调用 clear()方法,然后清除所有的红包数据。当调用该方法后并重新 start 时,则是一场新的红包雨。

4.4 setOptions

在红包雨的过程中,可能还会有红包雨越来越快的情况,这就需要随时可以修改配置。于是我就提供了一个setOptions方法,可以随时修改任何配置(除所在的容器外):

class RedpackRain {
  setOptions(options) {
    if (!this.timer) {
      // 若没有启动红包雨,则无法调用该方法
      return console.error('please use start() before setOptions');
    }
    const beforeInterval = this.config.interval;
    this.createConfig(options);

    // 当下红包雨的间隔变化后,则重置定时器;
    // 若间隔不变,则还是使用之前的定时器
    if (beforeInterval !== this.config.interval) {
      this.start();
    }
  }
}

由此,我们形成一个完整的类图:

canvas红包雨-类图-蚊子的前端博客

4.5 最终的成果

我们把内部的原理,都一步步的实现了,那么来看下最终的效果吧。

RedpackRain是一个类,在构建实例时,除selector外,其他均为非必须参数。

参数是否必填类型默认值说明selector是string 或 HTMLElement 要渲染红包雨的容器
可以是选择器也可以是 dom 元素interval number1600下红包雨的间隔,单位毫秒onClick function(hited: number){}空整个红包雨区域的点击,hited 表示命中红包的个数onMonitor function({fps}){}空红包雨的 fps 监控redpack 红包配置redpack.speedMin number10红包下降速度的最小值redpack.speedMax number10红包下降速度的最大值redpack.imgUrl string 红包的图片redpack.width number192红包的宽度redpack.height number216红包的高度bubble 命中红包后的上升气泡的配置bubble.imgUrl string 气泡的图片bubble.width number156气泡的宽度bubble.height number111气泡的高度bubble.speed number5气泡每帧上升的高度bubble.opacitySpeed number0.04气泡每帧减少的透明度

使用方式:

const rain = new RedpackRain({
  selector: document.body,
  interval: 1600,
  redpack: {
    speedMin: 10,
    speedMax: 10,
    imgUrl: 'https://sola.gtimg.cn/aoi/sola/20201226100322_I1ltnkzJVc.png',
    width: 126,
    height: 174,
  },
  bubble: {
    imgUrl: 'https://sola.gtimg.cn/aoi/sola/20201225103914_2QQ9bXg2rU.png',
    width: 156,
    height: 111,
    speed: 5,
    opacitySpeed: 0.04,
  },
  onClick: (isHited) => {
    console.log(isHited);
  },
  onMonitor(monitors) {
    console.log(monitors);
  },
});

rain.start();

// rain.stop();

// rain.setOptions({
//   interval: 400,
// })

当时线上的效果没有截图,这里就用 demo 样例做参考吧。

canvas红包雨-蚊子的前端博客

虽然红包雨只是整个活动的一部分而已,但是也学到了很多,尤其是在 canvas 操作和组件的代码组织上。这次贴的代码也有点多,感谢大家能看到最后。

如何打造一款高可用的全屏红包雨-迟早骚死-蚊子的前端博客


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK