我们腾讯新闻在年底前搞了一波大的烟花活动和跨年红包雨活动,我正好负责了全屏红包雨的特效。在活动结束后,我们来复盘下整个红包雨的实现过程,或许对您有什么帮助呢!
在实现红包雨时,我们首先要明确实现什么样子的功能,要给上层的调用方什么配置,调用方需要做什么,就能启动这个红包雨。
- 为提高性能用 canvas 实现,用 div 的话,需要频繁地创建 div 并进行重绘,红包比较多时,可能会特别消耗性能;
- 每个红包要有不同的降落速度,一样的速度太单调;
- 确定红包的属性(如速度、位置坐标、大小、放大缩小和旋转的变形数据、是否已被销毁等);
- 可以手动启动和暂停红包雨;
- 点击时,告诉调用方是否命中了红包;
接下来我们一步步地来实现一套完整的红包雨。代码较多,请静下心来。
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 的值,我们制定了两个标准:
- 不与最近一次降落的红包重叠,红包的速度有快有慢,如果重叠的话,看起来不好看;
- 不出现在边界,避免用户不方便点击,同时也考虑到曲面屏手机的情况;
因此我专门写了一个方法,每次创建前,获取下即将降落的红包横坐标:
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();
}
}
}
由此,我们形成一个完整的类图:
4.5 最终的成果 #
我们把内部的原理,都一步步的实现了,那么来看下最终的效果吧。
RedpackRain
是一个类,在构建实例时,除selector
外,其他均为非必须参数。
参数 | 是否必填 | 类型 | 默认值 | 说明 |
---|---|---|---|---|
selector | 是 | string 或 HTMLElement | 要渲染红包雨的容器 可以是选择器也可以是 dom 元素 |
|
interval | number | 1600 | 下红包雨的间隔,单位毫秒 | |
onClick | function(hited: number){} | 空 | 整个红包雨区域的点击,hited 表示命中红包的个数 | |
onMonitor | function({fps}){} | 空 | 红包雨的 fps 监控 | |
redpack | 红包配置 | |||
redpack.speedMin | number | 10 | 红包下降速度的最小值 | |
redpack.speedMax | number | 10 | 红包下降速度的最大值 | |
redpack.imgUrl | string | 红包的图片 | ||
redpack.width | number | 192 | 红包的宽度 | |
redpack.height | number | 216 | 红包的高度 | |
bubble | 命中红包后的上升气泡的配置 | |||
bubble.imgUrl | string | 气泡的图片 | ||
bubble.width | number | 156 | 气泡的宽度 | |
bubble.height | number | 111 | 气泡的高度 | |
bubble.speed | number | 5 | 气泡每帧上升的高度 | |
bubble.opacitySpeed | number | 0.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 样例做参考吧。
点击链接查看demo:全屏红包雨。
5. 总结 #
虽然红包雨只是整个活动的一部分而已,但是也学到了很多,尤其是在 canvas 操作和组件的代码组织上。这次贴的代码也有点多,感谢大家能看到最后。