移动端动画性能优化——requestAnimationFrame

    移动端上消耗内存最高的两个事件分别是touchmovescroll事件,浏览器要时刻监听这两个事件。这两个事件的刷新频率可能会比屏幕的刷新频率还要高,这就意味着有一部分事件的执行和重绘是无效的。通常的屏幕刷新频率大概在50~60HZ左右,所以屏幕会在1000/60 = 16.7ms左右刷新一次。在该周期中事件执行多次发生的渲染和重绘是无效的。
    虽然目前新出来的手机大部分性能都很好,但是为了优化用户体验,还是要尽量减小手机客户端的负荷。为了优化移动端的动画,采用requestAnimationFrame API来对动画进行优化。

    如上图所示,拖拽是h5开发中常见的功能,在该功能中需要用到touch event的相关事件。其中,比较消耗移动端性能是touchmove事件。(此处需要用chrome的性能截图来展示)。接下来先简要介绍一下requestAnimationFrame,然后在给出此处是如何优化的。

背景

    传统的 javascript 动画是通过定时器 setTimeout 或者 setInterval 实现的。但是定时器动画一直存在两个问题,第一个就是动画的循时间环间隔不好确定,设置长了动画显得不够平滑流畅,设置短了浏览器的重绘频率会达到瓶颈,推荐的最佳循环间隔是17ms(大多数电脑的显示器刷新频率是60Hz,1000ms/60);第二个问题是定时器第二个时间参数只是指定了多久后将动画任务添加到浏览器的UI线程队列中,如果UI线程处于忙碌状态,那么动画不会立刻执行。为了解决这些问题,H5 中加入了 requestAnimationFrame;

requestAnimationFrame的优点

1、requestAnimationFrame会将每一帧的dom操作集中起来,并在一次回流或者重绘中执行完成,这个执行的频率和屏幕刷新的频率是一致的。
2、该api由浏览器提供,浏览器对该api的调用有相应的优化处理,并且在处理隐藏和不可见元素的时候,是不会发生重绘和回流的操作的,节省了cpu和gpu的开销。

如何去使用requestAnimationFrame

    查询了一下网上给出的相关demo,如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 在浏览器开发者工具的Console页执行下面代码。
// 当开始输出count后,切换浏览器tab页,再切换回来,可以发现打印的值从离开前的值继续输出
let count = 0;
function requestAnimation() {
if (count < 500) {
count++;
console.log(count);
requestAnimationFrame(requestAnimation);
}
}
requestAnimationFrame(requestAnimation);

    这种调用方法适合知道最终状态,然后设置一个递归结束的条件,比如常见的进度条动画就比较适合这种递归调用方法。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function loadingBar(ele) {
// 使用闭包保存定时器的编号
let handle;
return () => {
// 每次触发将进度清空
ele.style.width = "0";
// 开始动画前清除上一次的动画定时器
// 否则会开启多个定时器
cancelAnimationFrame(handle);
// 回调函数
let _progress = () => {
let eleWidth = parseInt(ele.style.width);
if (eleWidth < 200) {
ele.style.width = `${eleWidth + 5}px`;
handle = requestAnimationFrame(_progress);
} else {
cancelAnimationFrame(handle);
}
};
handle = requestAnimationFrame(_progress);
};
}

    而在拖拽功能中,监听的是touchmove事件,被拖拽组件的最终状态是未知的,目前无法给出(或者我没想到- -!)一个明确的递归中止条件。所以采取了类似函数节流的思想来使用requestAnimationFrame

1
2
3
4
5
6
7
8
9
10
11
12
13
let padding = false;
function onTouchmove (evt) {
if (padding){
return;
}
padding = true;
requestAnimationFrame(function(){
// ...

padding = false;
});
}
document.body.addEventListener('touchmove', onTouchmove);

    这样做的好处一是由touchmove来触发动画的调用,用来替代了递归的作用。二是防止在一帧内发生多次requestAnimationFrame的调用。所以,在将touchmove的监听函数改成以下结构后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
constructor(props) {
super(props);
// 初始化一些参数
this.padding = false;
}
touchMove(e) {
if (this.padding) return;
e.preventDefault();
e.stopPropagation();
// 计算出x方向移动多少
const moveX = this.startFingerX - e.touches[0].clientX;
// 在touchend函数中用到 touchend event传参取不到clientX
this.lastFingerX = e.touches[0].clientX;
// 计算出y方向移动多少
const moveY = this.startFingerY - e.touches[0].clientY;
this.padding = true;
window.requestAnimationFrame(() => {
this.renderMove(moveX, moveY);
this.padding = false;
});
}

    此时的拖拽动画出现了一件很神奇的现象,如下所示:

    如图,松开鼠标即触发了touchend事件后,dom没有运行贴近屏幕两边的动画。debug的时候在相应的地方打印出调试信息

然后发现了以下结果:

图片中的true和false指的是什么呢,我们回到之前设置的一个flag this.padding, 该变量是用来控制节流的,当该值为true的时候,dom没有动画展示。这表示了touchend事件发生后,在touchmove处注册的requestAnimationFrame还没有触发。这从侧面说明了requestAnimationFrame注册的回调函数是在每次屏幕刷新时才会执行,而不是一触发touchmove事件就会执行。在不影响动画的体验的同时,降低了event执行的次数。那么此时该如何解决这种情况呢,其实很简单,只需要在touchend事件处也使用requestAnimationFrame来注册回调即可

1
2
3
4
5
6
7
8
touchEnd(e) {
e.preventDefault();
e.stopPropagation();
if (this.sticky) {
window.requestAnimationFrame(() => this.handleSticky.apply(this));
}
// this.recoverLeftBack();
}

以上即是对requestAnimationFrame的简要介绍。(还需要从chrome devtool 工具从性能上直观的分析,未完待续。。。)

文章作者: Junjie
文章链接: http://yoursite.com/2019/07/29/requestAnimationFrame/
版权声明: 本博客所有文章除特别声明外,均采用 undefined 许可协议。转载请注明来自 Junjie's blog