本文来自抖音开放平台-抖音小程序前端技术团队-欧阳同学
同层渲染到底是什么
同层渲染是个相对陌生但并不难理解的概念,用一句话来归纳就是:让原生组件和前端元素渲染在同一个层级上。
原生组件指的是 iOS、Android 上的 native 控件,而前端元素指的是 ELEMENT_NODE,如熟知的 HTMLElement 和 SVGElement 等
为什么要同层渲染
事实上各种前端、客户端资料里都很少提及 “同层” 的概念,也没有太多关于 “同层” 的定义。关于同层的介绍大部分出现在国内环境下各平台小程序的分析文章里。
那么为什么小程序生态下就需要所谓 “同层” 呢?其实说到底,最初我们想要的仅仅是原生控件的能力,这时候还没涉及到同层。比如:
- 我们想用一些第三方能力,比如某个高性能的 video 容器,比如某个地图 SDK 的能力。这些能力往往是 native 提供的
- 我们想更灵活的控制一些组件特性,比如 input 的光标位置与换行特性,但 web 元素这些特性在不同 webview 版本上兼容性不一致,甚至不可用
- 有些特殊能力并不想暴露在 web 层,比如广告组件
所以我们希望部分组件用原生实现,但随之而来又有一些新的问题:
- 原生控件因为脱离整个 webview,组件间的层级关系并不受 webview 内核控制,这会导致一些层级错乱的问题,z-index 不生效了,原生组件的层级永远是最高的
- 位置关系也变得很诡异,必须想办法告诉同层组件 “你应该出现在哪”,需要通信耗时不说,还容易因为各种问题导致计算出现偏差
综上,关于 “为什么要同层渲染” 这个问题的答案就比较明显了:小程序场景需要用原生组件,并且需要让原生组件和前端元素一样受 webview 控制
除了同层渲染之外,还有没有其他优化方式呢?本文就不再展开,推荐崔红保老师在 GMTC 深圳 2019 大会上分享的小程序的未来方向
同层方案上的探索
其实实现同层的思路很简单,核心总结起来就是:在 webview 上提供一个容器,让 native 把组件绘制在容器里。但理想很丰满,现实往往是骨感的 😅
iOS
在 iOS 上的 webview 容器是 WKWebview,它有这么个特性:如果 WKWebview 中的某个元素支持滚动,那么 iOS 会开启一个 UIScrollView(或是一个继承于 UIScrollVIew 的 WKChildScrollView)
WKWebview 这么做是因为让 webview 容器中的滚动行为更贴切原生滚动,这也是为什么大家会感觉 iPhone 上的 web 滚动很丝滑,因为 iOS 上的 webview 的滚动实际上是真正的原生控件滚动
既然 UIScrollView 是一个端能感知的原生视图控件,恰好 WKWebview 又能启用 UIScrollVIew,那么我是不是可以有一个很大胆的想法:
- 前端通过一些手段让某个元素可滚动,并给这个容器一个唯一 id
- 通过 jsbridge 告诉端容器 id,同时告诉端我们需要什么类型的原生控件
- 端找到这个容器,并往里面的 UIScrollView 绘制原生控件
事实证明这么干是行得通的!但问题来了,前端需要给容器一个唯一 id,但这个标识端上要如何识别到呢?经过一番探索,发现 HTML 元素上的 background-color 样式兼容性较好,在 iOS 10 以上的系统中,端上都是可以拿到的。所以 iOS 上的同层 1.0 方案应运而生。
1.0 时代
前端通过给 HTML 节点设置 background-color,端上根据这个 id 获取到前端提供的同层容器,然后实现同层渲染。但实际开发中还是遇到了不少坑
首先 webkit 源码里有这么一段逻辑:
大概意思是,如果这个颜色是不可见的(比如 alpha 值透明)那么 WKWebview 就不会生成颜色的合成层。反之,就会生成一个名为 contents color 的 WKCompositingView
端上可以通过这个 WKCompositingView 来获取到前端设置的 background-color,但也正因为强依赖于这个背景色的 WKCompositingView,所以埋下了一系列的坑 🌚
- 一号坑
因为 background-color 必须 “可见” 的缘故,所以所有的同层组件都会有一个背景色,即使设置成最浅的 alpha,但,总归是有底色的。在一些对 UI 要求较高的场景下就会很尴尬
- 二号坑
如果说不太明显的样式问题勉强能接受,那么接下来的两个坑就比较致命了。因为 background-color 是 6 位 16 进制的 string,所以当超出这个范围的时候就直接识别不到 WKCompositingView 了,会导致同层渲染失败
- 三号坑
如果前端节点隐藏,比如弹窗中有一个同层组件,在未弹窗时这个同层节点是会渲染失败的,因为此时的 DOM 虽然在,但 background-color “不可见”
诸如此类 “节点不见了” 的边缘 case 有很多,再加之我们面向的对象是开发者,我们是不可能也不能限制开发者的使用场景的,所以线上的问题会变得尤为复杂。
1.1 时代
之所以称之为 1.1 时代而非 2.0,是因为大体上的同层思路是不变的,只是做了一些优化。
事实上,我们在选用 1.0 的 background-color 方案时也知道会有一系列潜在的问题,但在当时的设备环境来说是无奈之举:我们需要兼容 iOS 10,只有 background-color 这个属性在各个版本的 iOS 设备中都能被端获取到
但随着时间推移,iOS 10 的设备已经逐步淡出大众视野,市场占有率也变得极低,我们开始考虑不再适配 iOS 10 设备,转用更优雅的同层方案:
- 优化一
不再采用 background-color 作为标识符,改用不侵入样式的 className 属性。这样就解决掉 1.0 方案的一号坑和二号坑问题,但也意味着这套 1.1 方案是不支持 iOS 10 设备的
- 优化二
优化查找容器的过程。在 1.0 时,依赖前端主动通知端查找容器。但这样有个问题,前端需要感知同层 dom 节点的所有变更,及时同步端。这样无疑是耗费大量性能的:前端需要有观察者、每次通信都需要耗时、端上每次收到消息还要深度遍历找出容器
在 1.1 中的做法是:前端只需要告知容器插入与移除,后续感知容器的变化过程下放到端上处理:
- 端上 hook UIScrollView 的 init 阶段,每次生成 UIScrollView 就缓存进一个 view 集合
- 每次收到前端的容器插入通知,会去 view 集合中找出对应的 UIScrollView 进行插入操作,而不是每次收到通知就去深度遍历整颗 view 树
- 当前端节点变更时,连带会销毁 / 重建 UIScrollView,此时 1 阶段的钩子感知到后就能在 view 集合中找到容器,根据需要判断是否做下一步的插入动作
- 收到前端移除节点的通知后,在 view 集合中移除该 UIScrollView 缓存
这样的好处是前端不再需要关心容器和同层控件间的关系,也不再需要冗余的通信
Android
与 iOS 有明确改造方向不同,安卓 webview 的自定义性更强,方案选择也更自由,我们和内核团队的同学在安卓上探索了多套方案,逐步摸索出更适用于小程序场景的安卓同层渲染方案
1.0 时代
其实最开始解决问题的思路并不是同层。从「为什么要同层渲染」一节可以看出,最初遇到的问题是原生组件的层级永远是最高的,我们想解决的层级问题。
所以最开始大家正向的去思考面临的问题的解决方案,想到的是:既然要解决原生层级最高的问题,那把 webview 整体盖在原生控件上不就得了,那么我是不是可以有一个很大胆的想法:
- 直接让 webview 控件上升至最高层级
- 将原生控件绘制在 webview 容器的下方
- 在 webview 上需要原生控件的地方渲染成透明,可以理解成打了个“洞”
所以严格意义上这个方案并不能称之为“同层”,元素并不是同一层级的,webview 对原生控件是 0 感知的(虽然真正的同层渲染 webview 对原生控件也没什么感知,但却是可以控制容器的)
但这样的方案有两个致命的问题:
一是对端来说,客观上修改了 webview 在整体布局上的位置关系,对应用的侵入性较大。二是这种方案是需要前端不断告诉端上去更新位置的。虽然这个操作可以下放到 webview 内核去做处理,业务无感知,但仍然是会有很多时候位置同步不准确,导致渲染位置异常的
这也是为什么早期小程序原生组件需要有 fixed 这个属性,就是用来同步 fixed 元素位置用的
1.1 时代
这个阶段算是一个不断探索的阶段。
刚开始我们仍旧在 1.0 方案的思路上探索,希望在“挖洞”上找到最优解:
- 为了不改变整体布局关系,不再于 webview 下方新建 native view
- 在 webview 初始化时添加一个单独的子 view(DisplayView)覆盖在 webview 之上,用于显示 web 的渲染内容
- native view 添加至 webview 和 DisplayView 之间,以达到“同层”显示的效果
但归根结底这并不是严格意义上的同层方案,内核与上层应用需要做很多位置信息同步的事情,更头疼的是,得把 css 样式表上的内容想办法转化成原生控件和内核需要的信息。换言之,这种挖洞方案最大的问题是 web 元素和原生控件间的关联关系是割裂的
除此之外,内核团队的同学也尝试过利用 Flutter 将组件绘制在 VirtualDisplay 上,再想办法合成到自身的 layer tree,但最终都因性能问题而告终
2.0 时代
归根结底挖洞不是同层,大家开始重新思考同层的方向。除了层级关系之外,同层还需要有一个很重要的原则:同层元素应遵守 web 表现,通俗点说就是 webview 能控制容器的表现,而容器中的原生控件需要自适应跟随容器的表现(包括布局和事件派发)
受到 iOS 的启发,大家开始尝试寻找安卓上有没有类似 iOS ScrollView 特性的能力:可以由前端控制生成,同时端上也能对其进行操作,并且还符合 web 标准表现。我们发现 <embed> 标签可以满足这个能力
Chromium 中有插件系统(Extension),除了这个广为人知的浏览器插件体系之外,还有另一个由 C/C++ 为主导的插件体系:Plugin,它更底层,能做的事情更多也更自由,于是大家又有了一个很大胆的想法:
- 利用 Blink 特性,用 embed 标签去加载一个自定义的 plugin
- 这个自定义的 plugin 会创建一个 layer 用来绘制。因为一些原因,采用了 VideoLayer 作为载体,它内部实现是将内容写入 SurafceTexture 中,由 SurfaceTexture 接管 native view 绘制,再通过内部的 compositor 把 native view 绘制在 webview 上
- 为了接收手势,在同层元素上方盖了个透明的 view,为了方便理解,暂且称之为 gestures view。这个 view 会将所有即将抵达 webview 的手势缓存进队列里,在原生控件被激活时回放手势
这套方案的优点是显而易见的:
- 对最上层的前端来说,整个同层容器就是一个切切实实的 <embed> 标签,这意味着 W3C 标准下的各种特性,同层容器都能享用到。我们不用纠结 css 的问题,位置的问题,fixed 的问题
- 还记得 iOS 令人头疼的同步容器 id 么,在安卓这套方案上同步 id 是非常方便的,embed 标签上的所有 html 特征(attributes)内核都是可以轻易拿到的,因为它就是一个标准的 ELEMENT_NODE
但它也不是银弹,它也是有缺点的:
- 我们的业务组件需要做大量的改造,因为和 1.0 方案不同,它不仅仅是 “挖了个洞” 而已
- 理论上性能上会有损耗,因为中间需要创建 SurfaceTexture 以及将 native 控件转成纹理再上屏。但总体上损耗在可接受范围内,与 web 组件没有明显差异。相比于 1.1 方案中 Flutter 的 VirtualDisplay 好太多
手势
同层组件还有另外一个比较麻烦的问题是手势。因为无论同层再怎么同都好,它终归是一个原生控件,原生控件的手势与事件消费系统与 web 模型是不一致的,会导致一些手势冲突:
- webview 与原生控件抢夺手势控制权
- 前端的合成手势与端手势有差异
- 前端事件流与端手势消费逻辑冲突
篇幅有限,这里不再继续展开细节,总结一下几个原则:
- 所有事件必须在 webview 走一圈。不能在进入容器前就把手势给吃掉,也不能因为某些特殊原因导致手势没有进入到 webview
- 当手势进入 webview 时,控制权需要交给 webkit / blink。走正常的前端处理流程,保证在 webview 容器中整套事件模型是和前端标准对齐的
- 当同层元素激活时,需要继续走 web 的分发模型。不能因为原生控件被激活,而中断前端事件
- preventDefault 会终止原生控件响应手势
提一个比较有意思的发现,preventDefault 会终止原生控件响应手势。以安卓为例,一开始比较疑惑为什么 webview 里 v8 的执行结果能影响原生控件的行为?
后来发现,是因为合成线程除了做绘制之外,也是会响应部分手势的。当 UI 线程产生一个手势时,会通过 IPC 给合成线程发个消息,最终的手势处理是合成线程执行的
无论 iOS 还是安卓,同层组件都是在合成线程上完成渲染的,当 preventDefault 时 UI 线程就不会生成手势,合成线程收不到事件自然就没事发生
手势的处理过程分支情况比较多,感兴趣可以参考 How cc works,及合成线程关于事件处理的源码
总结
因为上文提到的各种各样的原因,在小程序场景下我们是需要使用一些原生控件的能力的。而恰又因为原生控件的一些约束,我们不得不采用所谓 “同层渲染” 的方式来解决各种问题
事实上同层的本质就是利用合成层的能力,尽可能抹平原生控件在 web 环境下的差异。与开头呼应一下,同层渲染到底是什么呢?
解答是:更倾向于将 “同层渲染” 理解成 “合成层渲染”
最后罗列一个汇总表:
系统 | 时代 | 说明 | 优点 | 缺点 |
iOS | 1.0 | backgroundColor 方案 | 兼容 iOS 10 | 有底色,且端侧容易识别失败(或丢失)同层容器节点 |
1.1 | className 方案 | 性能更好,容器鲁棒性也更强 | 不兼容 iOS 10 | |
Android | 1.0 | 挖洞方案 | 性能开销小,且业务组件改造成本低 | 并非真正同层,对前端样式标准支持欠缺,前端对同层节点无感知 |
1.1 | 挖洞增强与 VirtualDisplay 探索阶段 | 安卓同层上的探索 | 挖洞增强不如人意,VirtualDisplay 方案性能太差 | |
2.0 | surface 方案 | 真正的同层,内核侵入小,对前端样式兼容性强 | 业务组件改造成本大,以及理论上会有部分性能损耗 |