作者:闲鱼技术——羲凡
背景
不知道什么时候开始,一种tab自动吸顶下的多容器嵌套滚动浏览交互方式逐渐风靡在各大电商APP(美团、京东、拼多多等)。
这种相对复杂交互的滚动容器一般都在APP首页容易看到,实现的技术栈是客户端,h5下的实现案例比较少见,目前就只看到闲鱼跟拼多多有基于h5技术实现案例。个人猜测原因主要有两点:1. 对于多容器嵌套滚动缺乏原生能力支持,实现成本较大;2. 自行实现多容器嵌套滚动能力流畅性不达标,毕竟h5是基于webview来进行渲染,在滚动浏览阶段稍有计算必然会导致帧率下降。
效果与特征
但这样的滑动浏览交互在我们h5场景也同样存在强烈诉求,基于这篇文章,我给大家介绍下我们前端团队基于h5打造的多容器嵌套滚动浏览的实现方案。在介绍之前我们先来看下我们的体验效果:
从上面的效果我总结一下,一个既要满足视觉交互又要满足业务复杂诉求的多tab滚动容器需要具备以下特征:
- 外层滚动容器与tab容器滚动是一体化,滚动过渡要自然
- tabbar在滚动至顶时需自动吸顶
- 不同tab容器之间支持横滑切换浏览操作
- 不同tab容器的滚动浏览是隔离的,需要保持各自的浏览位置
- 不同tab容器可以承载着无限列表内容
常见方案
滚动偏移传递方式
从iOS客户端同学那学习到一种方案是通过最顶层的一个不可见滚动容器(S0)来传递滚动偏移量,而真正内容承载的容器使用的是传统的外滚动容器(S1)嵌套多个子滚动容器(S2-x)方式,大致原理可以参考下图:
大致思路如下:
- S0同步S1与S2-x的滚动偏移总和,S0统一接收来自用户的滚动行为
- 在tabbar触顶之前,S0的滚动偏移传递给S1
- 在tabbar触顶之后,S0的滚动偏移传递给S2-x
- 在tab子容器横滑之后,根据S2-x的滚动偏移来重置最顶层S0的滚动偏移计数
注:S2-x代表当前的子滚动容器
上面方案有以下优势:
- 多子滚动容器相互之间天然隔离,天然实现各tab容器的浏览记录隔离特性;
- 得益于最顶层的不可见滚动容器来接收统一的滚动交互,可以将滚动惯性传递下去,这点很重要,我们都知道单纯的两个嵌套滚动容器,滚动惯性是不能够从父容器传递到子容器的。
但上面的方案是否适合h5技术栈呢?顶层滚动行为需要监听,滚动时需要实时传递滚动偏移,真正承载内容的滚动容器不再是复用原生滚动能力,而且是由js计算驱动滚动。然而h5技术栈运行在webview中,属于APP系统下的一个子系统,性能相对敏感。从以往经验,容器滚动时的任何js执行都有可能导致掉帧,我们要打造滚动极致流畅只能是完全贴合h5原生的滚动,做到滚动阶段无监听、无计算。
注:Android端的NestedScrollingParent虽然是原生控件,但思路是与上面方案是大体一致的,都是父滚动事件分发的思路。
闲鱼h5方案
为了很好解决特征1,我们的方案是基于独一的滚动容器,并且直接复用滚动容器的默认滚动行为,不做滚动偏移传递或者滚动时的计算。
从下图结构看出,我们唯一的滚动容器是S0,它承载所有需要展示的内容,包括多tab模块与其他模块,多tab模块中由各自子容器(C0~3)承载对应的内容,子容器不再是滚动容器。初始化阶段记录各子容器对应的外层滚动偏移值(scroll_0~3 = 0),并根据当前tab子容器C1
初始状态
上下滚动状态
S0上下滚动时,为了实现特征2,我们的tabbar吸顶功能采用position:sticky来实现,使用sticky最大的收益就是吸顶衔接效果好,滚动过程无额外滚动监听计算。从sticky支持的程度来看(iOS9,Android 5.0)刚好能我们移动侧的最低兼容机型。
横滑开始
在解决特质4时,我们在横滑开始切换时tab子容器时需做两件事,
- 根据多tab模块当前的距离顶部偏移量offset_1来设置其余非可见区的子容器(C0/C2/C3)顶部偏移值,以保切换过程的独立浏览位置正确展示
- 记录此时外层滚动容器的滚动偏移值n1,并记录为scroll_1 = n1
以上操作会在横滑视觉效果之前完成,保证横滑过程中能正确看到下一子容器的正确浏览位置。
横滑结束
当横滑动作结束后,我们做了三件事
- 恢复当前子容器C2的顶部偏移
- 使用当前子容器记录的滚动偏移scroll_2来重新计算滚动容器S0的滚动偏移量
- 重置掉其他非可见区的子容器顶部偏移
横滑至有浏览记录的tab容器
在当前tab容器为C2子容器时继续进行上下滚动浏览,当外层容器S0滚动值为n2时(此时C2容器距离顶部的偏移为offset_2);再次进行右横滑交互,此时需要操作基本与第二步一致,但是由于C1已经存在滚动记录,所以需对C1进行另外的计算方式:外层滚动偏移n2 减去 C1记录的滚动偏移scroll_1 (n1)。
以上方案就能很好的完成开始总结的5个特征,并且在h5场景最大好处有两方面:
- 各个tab容器中的内容在滚动浏览过程中始终出自唯一的滚动容器,不会存在嵌套滚动中的滚动惯性无法传递或者传递过程的损耗;
- 主容器滚动过程完全依赖原生的滚动能力,没有任何的js计算,给16.6ms留足了时间。
至此,所有大致的思路已经完成,但还有些边界情况任需要我们打磨。
匠心打磨
iOS下的translate3d闪屏问题
在水平方向横滑浏览时,为了能达到最好的流畅度,我们采用的是translate3d,实现结构如下;
当translate3d作用于在一块较大图层时是会偶现闪屏问题,猜测与合成图层(Compositing Layer)的实现有关系,在合成渲染加速架构中使用了translate3d的渲染图层会被提升为合成图层,每个合成图层会拥有自己独立的纹理缓存与相应的管理机制;但即便是拥有独立的纹理缓存管理,也同样受限于底层OpenGL最大纹理尺寸大小(2000 * 2000像素点)。而网上常规的解法为每个滑块添加-webkit-translate3d: (0px, 0, 0)
其实就是为了将一张大的合成图层拆成各个小的合成图层。
中间父节点的overflow:hidden导致position:sticky吸附失效问题
随着业务的使用,交互场景越来越多样化,每个tab容器中开始出现子tabbar的交互,子tabbar也希望能sticky到主tabbar底部,这时候我们业务同学发现直接将子tabbar的吸附到主tabbar底部时发现不生效,定位后发现是由于tab容器设置了overflow:hidden属性,这个问题很久前就有过关于标准的争论,大概的意思是sticky节点是以最近一个拥有滚动机制的父节点来固定,怎么定义拥有滚动机制呢?overflow不等于visible时。但至于为什么overflow:hidden也算拥有滚动机制呢,猜测应该是只要是涉及到内容溢出就算吧,毕竟可以通过js来实现scroll。
This value always creates a new stacking context. Note that a sticky element "sticks" to its nearest ancestor that has a "scrolling mechanism" (created when overflow is hidden, scroll, auto, or overlay), even if that ancestor isn't the nearest actually scrolling ancestor. This effectively inhibits any "sticky" behavior (see the GitHub issue on W3C CSSWG).
我们之所以要给某些容器设置overflow:hidden主要原因是在不同tab容器内容高度是不一致的,为保证当前tab正确的滚动高度,我们需要借助overflow:hidden来裁剪掉相比当前tab容器长度要长的其余容器,大致如下图:
子tab吸顶问题与overflow:hidden之间没有什么好的解法,我们最终选择的是绕开它两同时的时机。
横滑开始时:所有tab容器取消overflow:hidden,这样可以保证横滑过程中看到的是正常吸顶的
横滑结束时:当前可见tab容器不需要设置,其他tab容器设置overflow:hidden
其他
除此之外,我们还解决了好多更细的问题,比如
- Android部分机型(基于Chrome 69及之前的内核)出现侧滑问题,父容器overflow:hidden无法防止孙子节点超宽内容左右拖拽
- Android中低端机型Intersection Observer捕获频率不足的问题
- 多tab之间跳跃切换时如何确保其他中间tab不被触发加载问题
未来规划
在方案分析过程中,我们也意识到了合成图层带来的GPU纹理缓存压力,前期考虑到webkit内核本身也会根据实际情况回收,并不会无边界开销;但对于我们来说在更长的列表中节点回收是不可逃避的话题,等待我们去解决的问题还很多,如何做到不定高回收重建?如何在不影响滚动性能的同时设计高性能回收与重建?低系统版本应该如何考虑?等等
相关阅读
https://trac.webkit.org/wiki/CoordinatedGraphicsSystem
https://github.com/w3c/csswg-drafts/issues/865