打造丝般顺滑的 H5 翻页库

17,805 阅读11分钟
原文链接: fex.baidu.com

背景

随着近几年移动营销页的火爆,催生了一个中国式的名词「H5」。而 H5 最常见的形态就是类似幻灯片翻页效果。

我们需要制作 H5 的时候,最快的办法就是使用一些滑动插件库,如 iDangero.us 出品的 Swiper,百度 BE-FE 出品的 iSlider。通过这些翻页库提供的强大的配置功能,我们就能实现很酷炫的翻页效果。当然,这些库还支持自动播放,点击切换和当前页面指示等配置,所以还能用在网页上,实现一些 web carousel 的效果。

百度 H5 也先后使用了 Swiper 和 iSlider 作为 H5 运行时的翻页框架,随着用户越来越多,也遇到了一些问题:
1. H5 平台和这些库不能很好贴合,有些配置项用不上,而有些必要的功能需要「Hack」的方式实现。
2. 一些 H5 的元素多、动画多,在低端机型上翻页时,翻页时会有「卡顿感」和「粘滞感」,用户体验不好。

而我们希望的 H5 翻页库能和平台本身的功能完美贴合,在保持体积小的同时,在翻页的时候能做到「丝般顺滑」。于是我们就开始了研(zao)究(lun)之(zi)旅。

开始

H5 滑屏框架的开发,第一个问题就是:页面是否跟随手指滑动?这也是腾讯 ISUX 团队的《滑屏 H5 开发实践九问》的第一问(这篇文章原文出处现在是 404 ,大家可以在其他的转载网站看到),这里用这篇文章的图片说明一下这个问题。

不跟随手指滑动 跟随手指滑动

左图:不跟随手指滑动,右图:跟随手指滑动。

左边的是不跟随手指滑动,只需要关注手指触碰开始和离开两个时间点,中间过程不用考虑。所以实现起来比较简单。但是用户的操作没有实时的反馈,体验不够好。因此,尽管实现起来更复杂,我们仍然决定实现前一种「跟随手指滑动」的效果。

起步

下图是跟随手指滑动的 H5 最直观的版本,所有的「页面」依次从上到下,首尾相接。需要说明一下,这里的「页面」打引号,是因为实际上他们都是 div,后文说的页面都指这些 div。同时,我们这里以最常见的竖直方向滑动为例,水平方向同理。

swiper-basic.png

基本原理图

这些 div 的宽度和高度都是 100% 的容器高度,可视区域是中间的部分,我们监听 touchstart, touchmove, touchend 事件,跟鼠标拖拽的原理类似:
1. touchstart 时,记下起点位置;
2. touchmove 实时计算滑动的距离,让所有页面一起沿着 Y 轴 translate 这段距离。
3. touchend 时,能得到最终的滑动距离,跟设定的阈值比较。进入到页面自动控制阶段:大于阈值则让页面滑动到下一页,小于阈值则恢复到起始位置。

深入探究

简单的版本在上一部分很容易就实现了,如果其他需求不多,页面上元素和动画比较少,基本上就够用了。但是本文要探究的是如何能做到「丝般顺滑」,其实就是两个字:性能。

性能的瓶颈是什么呢?

我们的目标是:在「三多一低」(页面多、元素多、动画多,配置低)的情况下,滑动翻页时,尽可能不产生卡顿。

我们分成两部分来看这问题:手指离开屏幕前和手指离开屏幕后。

手指离开屏幕前

此时比较耗费性能的操作是:当 touchmove 触发时,计算出了要移动的距离,所有的页面都需要沿着 Y 轴移动相同的距离。此时必然免不了进行 DOM 操作,而 DOM 操作是非常「昂贵」的,再加上 touchmove 事件的频繁触发,性能处理不够好的话,很容易出现卡顿。

为了优化性能,我们很自然的想到一个策略:减少 DOM 操作

这里面包含两部分:减少 DOM 操作的元素和减少 DOM 操作的属性。前者比如,看不到的页面不参与动画。后者比如,只改变元素的 css 属性的一个或几个。

减少 DOM 操作的元素

最开始简易的版本的例子中,touchmove 触发时,所有的页面都沿着 Y 轴移动。其实没有必要,因为相当一部分页面是看不见的。那一般情况下,我们最少需要操作几个页面呢?答案是两个。可以回想一下,我们滑动的时候,最多能同时看到两个页面。这个方法相对于所有的页面一起移动,成倍地提升性能。

减少 DOM 操作的属性

这个方法的主要意思是,只需要操作一次 DOM 能达到的效果,绝不用两次。实际上,对于 slide 动画,我们只需要改变页面的 transform 的值,其他的 DOM 操作(增加 class,修改元素的 innerHTML)等能不做就不做。

我们得到了一个初步的方案:初始化时,所有的页面一次性全部置入 container,除了我们用到的两页,display 属性都设置为 nonetouchmove 的时候,只有这两页的 transform 属性有变化。

touchmove 的过程,我们可以写成数学表达式:

s=f(x),x∈[0,sideLength]

x 表示手指滑动的距离,s 表示页面滑动距离,sideLength 是当前滑动边的长度,如果是沿 y 轴滑动,则是页面高度。写到这里,就跟时下很流行的「数据驱动」的概念很类似了。我们要实现的就只有一个 render 函数,输入是用户的交互数据,输出是页面表现。

手指离开屏幕后

当手指离开屏幕时,我们就已经知道了这次滑动的结果(向上还是向下?翻页还是回弹?),要实现的只是动画效果,我们有两个选择:
方案一:复用 touchmove 的 render 逻辑,按照手指滑动的速度,使用 requestAnimationFrame 控制动画;
方案二:使用 css3 transition 动画;

方案一的优点在于:可以在手指滑动和动画过程使用同样的 render 函数,最大限度复用了代码,逻辑统一;同时可以精确控制动画的每一帧,动画曲线会比较流畅。 缺点就是可能存在的性能问题。方案二跟方案一刚好相反。其实说到底还是 js 动画 vs css 动画的问题。

动画性能实验

为了比较两个方案在 H5 翻页动画上的性能优劣,我们取一个稍微复杂点的例子:

H5:百度无人车招聘的 H5
动画:从第 1 页翻到第 2 页
CPU: 6 * slowdown
浏览器:Chrome 61.0.3163.100(64 位)

js 动画方案:点击这里
css 动画方案:点击这里

js 动画

js 翻页动画方案,Profile 结果

css 动画

css 翻页动画方案,Profile 结果

通过实验我们可以看到,js 的动画过程中,帧率大多维持在 30 fps 上下。而 css 动画,基本都在 60 fps 上下。而且在动画过程中,明显感觉 js 动画有卡顿。这种情况在一些 CPU 和显卡配置相对低的 Android 机型上尤为明显。对于这个问题有兴趣的同学,可以看一下 swiper 库的 raf 分支,这是本次对比测试所用到的 js 。

所以,尽管 js 的动画方案看起来比较「优雅」,能用「数据驱动」的理念,统一解决滑动过程和动画过程的问题。实际上性能有瓶颈,我们只能在手指离开屏幕后,采用 css 的动画方案以保证性能。正应了一句话「能用 css 做的,绝对不要用 js 解决」。

实施方案

下图形象地展示了我们实施的基本思路,只有两页:
currentPage :当前页面
activePage:即将要翻到的下一页

其余的页面都是初始化的时候加载进 DOM 结构,但是 displaynone 并且 z-index 都是 0。这里展示「层叠」的状态是为了更形象的展示。

swiper-basic.png

swiper 原理图

为了方便获取页面,我们采用双向链表保存页面结构。每个 page 具有 prevnext 分别指向上一个和下一个 page
我们重点要关注的是,怎么样确定 activePage ?即下一个要去到的页面。答案很简单,其实,当用户开始触碰屏幕,并且滑动的时候,就能确定了:
1. 滑动距离 x < 0,表示页面向上滑动,此时 activepage = currentPage.next
2. 滑动距离 x > 0,表示页面向下滑动,此时 activepage = currentPage.prev

扩展

翻页效果

我们举的例子中的翻页效果是最普通的滑动效果。怎么样扩展支持立方体、翻转等效果呢?可以回头看看「手指离开屏幕前」部分,我们提出了 s=f(x),x 是用户滑动距离,s 是页面滑动距离。我们把 s 扩展一下,变成「页面翻转角度」或「页面缩放比率」,就可以支持其他的效果了。

事实上,我们在滑动的时候,本身就是使用 css3 的 transform 属性,将其中的 translate, rotate, scale 适当的组合就能做出千变万化的翻页效果了。

更令人愉悦的动画

这里指的是 animation-timing-function,拿最简单的滑动效果举例。如果是线性的函数,用户滑动的速度始终等于页面滑动速度。而「感觉上」更流畅、更灵敏的应该是:刚开始页面滑动速度大于用户滑动速度,随着翻页的进行,两者趋于相同,过了某个点后,单位时间内,页面滑动速度开始逐渐小于用户滑动速度,将速度表示为距离,就可以得到 x 和 s 之间的关系如下图:

swiper-basic.png
x 和 s 的关系图(横轴为 x,纵轴为 s)

在这里,不得不再提起两种动画方案: js 动画和 css 动画。

js 动画方案的一个优点是,可以精确控制动画的进程,而 css 无法做到。比如用户在 x = 0.8 的时候手指离开屏幕,因为采用的同一个 render,js 可以知道手指离开屏幕的瞬间 x 处于 0.8 的位置,接下来的动画由 requestAnimationFrame 完成,整个过程流畅且完整。

而 css 动画则不同,css 动画只有在动画开始之前设定 animation-timing-function,当用户在 x = 0.8 手指离开屏幕时,原本的 js 控制滑动过程中断,由 css 来完成剩余的动画,css 无法根据手指离开屏幕的瞬间动态计算 animation-timing-function ,所以在衔接的那个点,两者速度不匹配,会影响整体动画效果。

但遗憾的是,js 的动画方案有性能问题,我们在用户手指离开屏幕后的那一部分只能采取 css 动画方案。这个「更令人愉悦的动画」也只能用在手指滑动期间。

总结和展望

本文讲述了一个「丝般顺滑」的 H5 翻页库的开发过程中遇到的一些问题和对应的解决方法。基本的滑动翻页模型建立之后,重点关注了性能的问题,分为手指离开屏幕前手指离开屏幕后两个阶段。前一阶段主要聚焦于减少 DOM 操作。后一阶段聚焦于动画的性能,并且对比了 js 动画和 css 动画的性能数据,最后得出了在手指离开屏幕后使用 css 动画的结论。此外,我们还基于「数据驱动」的思想,在翻页效果动画函数两部分进行了扩展,增强了翻页库的功能,也丰富了 H5 的展现效果。

本文中尝试用「数据驱动」的思想去解释整个过程,但是因为性能问题只能暂时放弃,希望在未来能找到更好的方案。由于水平所限,文中难免会出现纰漏,欢迎大家批评指正,共同学习进步。感谢 Swiperislider 翻页库的启发,特别感谢和 @Ronny 的热烈讨论。

本文所述的 swiper 库地址:github.com/fex-team/sw…。master 分支所用的代码是目前百度 H5 线上使用的。raf 分支是文中提到的使用 js 动画方案。