一、背景

2019年底公司计划推出一个地图需求,整个需求大致分为三幕动画:

1、第一幕
根据用户所在省份,画出用户的头像,然后画出一条贝塞尔曲线,箭头最终指向用户第一个受助人所在的省份。

2、第二幕
受助人头像开始下落变成红点,同时放大所在区域,到指定比例。 此时出现录音弹框,开始自动播放受助人录音。录音播放完毕后,背景地图恢复原始尺寸。

3、第三幕

其他受助人头像开始一个个出现的同时,并且下落到地图上,逐渐变小成为地图上的一个点,这个过程重复出现,速度越来越快,最终形成一个红点闪闪的地图。

由于页面采用大量的动画进行交互,所以第一步主要考虑如何选择合适的动画实现方案。

二、技术选型

1、实现动画的方法

前端可以使用哪些方式实现动画呢?

1、最省事的办法就是vedio或gif图片了,优点是,沟通成本低;缺点就是在色彩,透明度表现力不如png, 且交互性差。
2、Flash,手机端就别考虑了。不久的未来,也会被淘汰。
3、js + CSS3 animation, 应该是目前前端广泛使用的方式了。具有开发成本低、兼容性好、具备互动性,加上 PNG 图片很好的色彩与透明度的表现力基本能满足很多动画需求
4、Canvas, 无须插件,浏览器支持良好,可以绘制各种图形以及具备高性能图片渲染能力,缺点是,纯手工编写canvas成本较高,动画实现较为繁琐。
5、WebGL 在Canvas的基础上,增加了 OpenGL ES 的 3D 绘制能力,并且能开启硬件 3D 加速渲染,让前端动画的世界充满了更多想象的空间。

2、为什么选择Canvas

vedio或gif的适合与用户数据无关的交互。

通过JS改变Dom节点的css属性虽然具有开发成本低、兼容性好、具备互动性等优点,但是在复杂场景下,性能会是问题,不是特别流畅。

Canvas, 无须插件,浏览器支持良好,可以绘制各种图形以及具备高性能图片渲染能力,缺点是,纯手工编写canvas成本较高,动画实现较为繁琐,

不过可以通过一定的方法封装解决。

3、面临的挑战

这个需求要同时满足性能和业务的要求,面临不少的挑战:

场景多:每个用户看到的动画都是不一样的,还要考虑受助人与用户头像省份重叠的问题,箭头的指向角度问题等等。

动画多:很多的精灵动画,包括头像的出现,贝塞尔曲线,头像的下落,地图局部放大缩小等。

图片多:设计提供100多张图片。前端要想办法减少加载的图片的数量,为了保证动画的流畅,需要提前把第一幕图片提前加载好。

三、实现方案

3.1 图片资源预加载

预加载

1、为确保用户看到的动画是平滑流畅的,需要进行图片预加载,包括:用户头像(受助人20个头像),地图背景(含:大,小两套),头像外框角标等:

图片预加载函数通过Promise实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 预加载图片url
* @param {图片地址} url
*/
const loadImage = (url) => {
return new Promise((resolve, reject) => {
const newImage = new Image()
newImage.loadTimes = 0
newImage.src = url
newImage.crossOrigin = 'Anonymous'
newImage.onload = () => {
resolve(newImage)
}
newImage.onerror = () => {
newImage.loadTimes++
if (newImage.loadTimes <= 2) {
newImage.src = defaultAvator
} else {
newImage.onerror = null
newImage.onload = null
newImage = null
}
}
})
}

函数做了一个小优化,如果图片第一次由于流量大等原因没有加载成功,会进行二次加载,为避免循环加载,限制了每张图片最多加载的次数为3.

利用 Promise.all 等待所有图片全部加载成功, 将所有图片对象全部返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const loadImages = async (avatorUrl ...) => {
const renderList = []

for (let i = 0; i < aidedAvators.length; i++) {
renderList.push(await loadImage(aidedAvators[i]))
}
const myAvator = await loadImage(avatorUrl)

return Promise.all([myAvator ..., ...renderList]).then(() => {
return {
myAvator: myAvator,
...
avator20: renderList
}
})
}

执行时机

首次进入活动页面时会先进入 loading 页面,所以说 loading 页面是最好的执行资源预加载的时机:

这块的具体实现逻辑:

1
2
3
4
5
6
7
<!-- loading组件 -->
<div v-if="showLoading" class="process">
<p class="process-number">
<countTo :startVal="0" :endVal="99" :duration="3000" suffix="%" @callback="loadFinish"></countTo>
<span class="ani_dot">...</span>
</p>
</div>

countTo组件定义了初始值0,最终值99,整个动画完成时间3s

loadFinish() 将在动画完成时调用:

1
2
3
4
5
6
7
8
9
10
11
12
export default Vue.extend({
methods: {
...
loadFinish() {
if (this.loadFinished) {
this.showContent()
} else {
this.loadFinished = true
}
}
}
})

这里定义了一个 this.loadFinished 变量,初始值为 false。 这里为什么需要设计这个变量呢?

还记得我们之前定义的图片预加载函数吗,我们在页面是这样使用的:

1
2
3
4
5
6
7
8
9
export default Vue.extend({
mounted() {
loadImages(...images).then(res => {
this.images = { ...res }
this.$refs.beginning.loadFinish()
}
)
}
})

loadImages()的回调函数里面也调用了 loadFinish() 函数,this.loadFinished 变量在第一次进入时,走的else分支,为 this.loadFinished 赋值为 true, 下一次再次进入 loadFinish() 时,就满足了if 分支的条件,所以就会进入主页面。

3.2 动画模块设计

1
2
3
4
5
6
7
8
9
10
11
12
activity   

└───annualHelpMap
│ │
│ └───engine
│ │ Sprite.js -- 动画基类
│ │ Avator.js -- 互助头像
│ │ Connect.js -- 头像运动轨迹,贝塞尔曲线
│ │ Landing.js -- 头像下落雨动画
│ │ Map.js -- 背景地图动画
│ config.js -- 工具类方法,图片预加载
│ const.js -- 存放头像尺寸、坐标等信息

Sprite.js 提供了动画类基础的方法:

Sprite

Avator,Connect,Landing, Map全部继承了Sprite,也就是继承了Sprite的方法。同时还分别有各自的动画实现逻辑。

3.3 requestAnimationFrame 实现动画

实现动画的原理利用 drawImage() 将图片画到 Canvas 指定的坐标画布上,实现动画就是利用window.requestAnimationFrame 实现动画循环:

1
2
3
4
5
6
7
8
9
10
connectAnimation() {
// 计算每一帧动画的间隔时间
this.getFrameTime()
// 每次进入清空画布
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
// 实现动画的秘密就在这里,循环调用
window.requestAnimationFrame(() => {
this.connectAnimation()
})
}

requestAnimationFrame 解决了浏览器不知道javascript不知道最佳循环间隔时间的问题。

编写动画循环的关键,是要知道延迟时间多长合适。

一方面,循环时间必须足够短,这样才能保证动画效果更平滑流畅;另一方面,循环还要足够长,这样才能保证浏览器有能力渲染产生的变化。

大多数显示器的刷新频率是60Hz,相当于每秒钟重绘60次。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率,因为即使超过了这个频率,用户体验也不会有提升。

因此最平滑动画的最佳循环间隔是1000ms/60,约等于17ms。以这个循环间隔重绘的动画是平滑的,因为这个速度最接近浏览器的最高限速。为了适应17ms的循环间隔,多重动画可能需要加以节制,以便不会完成得太快。

虽然与使用多组 setTimeout() 相比,使用 setInterval() 的动画循环效率更高。但是无论setTimeout() 还是 setInterval() 都不十分精确。为它们传入的第二个参数,实际上只是指定了把动画代码添加到浏览器UI线程队列以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务执行完成后再执行。如果UI线程繁忙,比如忙于处理用户操作,那么即使把代码加入队列也不会立即执行。

requestAnimationFrame的速度是由浏览器决定的,不同浏览器会自行决定最佳的帧效率。

四、总结