本文整理自美团技术沙龙第83期《前端新动向》()。长久以来,容器要实现动态化和双端复用,难免要牺牲掉一些性能。有没有办法让动态化容器的性能尽可能接近原生?美团金服大前端团队给出了一种解决方案,尝试突破动态化容器的天花板。
1 动态化容器的天花板
2 容器分类及前期思考
3 Recce的选型与搭建
3.1 解释器&编程语言的选择
3.2 UI 框架
3.3 渲染层
3.4 整体架构
4 Recce的一些细节问题
5 总结和展望
6 Q & A
注释
1 动态化容器的天花板
自2015年推出至今9年时间,各类容器(动态化容器简称,下同)方案已经成为业界前端的普遍选择。业界有微信(小程序)、抖音(Lynx)、拼多多(Lego)、支付宝(Nebula/BirdNest)、京东(Taro-Native)等。美团也有MRN、MMP/MSC等容器。可以说容器是前端工程的关键基石,也是绕不开的话题。
过去我们做动态化改造主要为了解决以下问题:
降研发成本:通过容器将多端合一,避免一个需求在每个端重复开发,以改善研发成本结构。随着HarmonyOS NEXT的推广,这个优势将变得更大。 增部署效率:通过动态发布避开App集中集成,使得业务在移动端上可以独立部署和发布、实现211迭代,提升业务迭代面客效率。
然而凡事有利必有弊,有用必有费。动态化容器在解决上述问题的同时也带来以下问题:
降低页面成功:动态化容器引入了动态部署、解释器等更多的环节。在增加整体复杂度的同时,更多的环节也带来了更多的错误和计算开销,具体体现在页面白屏和页面加载耗时增加上。 牺牲用户体验:动态化容器需要更多的硬件算力,相同的业务复杂度下,容器化页面相较原生页面更慢、更卡、不流畅,这在下沉市场设备上更为突出,卡顿甚至成为卡死。
动态化容器的绝对天花板是原生应用,目前事实天花板是React Native/WebView。
定性地看前端容器天花板的问题,这里引述我们美团容器界的一位前辈的理论:性能、效能、动态化是动态化容器的不可能三角(下图左)。现有的通用的容器方案都是在这三个维度做“三选二”。
定量地看动态化容器,下图(右)展示了一个3000个相同视图节点的简单Benchmark页面。没有额外逻辑,也没有网络请求,以React Native为例,在同一台设备上,在React Native上做一个这个页面,动态化页面加载耗时大约是Native原生页面的5倍。
先说一下我们目前取得的成果:美团金服前端团队做了一个新的容器Recce,然后在同样的测试页中,我们将执行业务逻辑部分的速度提升了8倍,整体的页面加载速度提升了一倍。而在实际的业务中,页面加载速度也实现了3倍的提升,在兼顾动态化和效能的前提下,实现了性能的大跨步提升,性能表现接近Native原生。下文将重点介绍Recce具体是怎么实现的,希望能够给大家提供一些帮助或借鉴。
2 容器分类及前期思考
首先,当我们计划做一个容器之前,需要先对现有容器建立基本的认识。
前端容器最重要部分之一在于绘制图形界面以完成人机交互,现有主要容器方案按照绘制方式可归纳为下列三类:
第一类可以称为基于Web的方案,这类的共同特点就是调用WebView,通过JavaScript和CSS去绘制页面,然后通过Web提供的接口去和宿主通信。 第二类称之为“自己绘制”,它会调用更底层的OpenGL等图形的绘制框架,同时也会有自己的一套方案和语言去标记和编写自己的这些页面。 第三类则是去调用系统的UI框架,就是基于平台提供的UI框架去进行绘制,和第二种的区别是,会有一层抹平平台差异的平台抽象层。
第一层,也就是最上层的UI框架,跟我们直接平时写的代码相关,它会直接决定业务代码的样子。 第二层,其作用就像它的名字一样,也就是运行时支持,为运行UI框架提供支持,在这里会有解释器或者是标准库之类的东西。 第三层,会对不同平台去做一层不同的抽象,比如像RN会对视图操作之类的统一为相同概念和统一接口。 第四层,渲染层,对应着不同分类选择的各自的渲染的方式。
那么下面,我们就会基于上图对现有容器这个分类和结构为认知基础,结合之前影响容器性能表现最大的因素在于逻辑解释器执行效率和逻辑解释器通信效率这个认知,再去考虑实现一个满足性能、安全、动态化的容器方案该怎么做。
3 Recce的选型与搭建
| 3.1 解释器&编程语言的选择
解释器以Wasm为主, JavaScript为辅
前文也提到,我们期望能获得一个既能满足性能,又安全,同时还可以动态化这样的一个方案。既然必须动态化,就必须有逻辑解释器[1],问题就变成了怎样选一个性能好且安全的解释器,并且成本在可接受范围。现成的解释器还是不少的,前端范畴有V8、JavaScriptCore、QuickJS等JavaScript解释器,有符合WebAssembly(后简称Wasm) 规范的、WasmEdge[2];大家日常工作中会接触到的Ruby、Python;还有游戏行业用的比较多的Lua、C#[3]。
首先可以排除掉Ruby和Python这两个语言和解释器,无论是性能还是生态都不如JavaScript。Lua和C#也可以排除,主要是游戏生态和前端差距太远,晚饭想吃炸鸡,中午才开始孵蛋显然就来不及了。在JavaScript解释器和Wasm解释器两个大范围里,Wasm解释器在性能和安全上较JavaScript解释器有决定性优势,生态上较JavaScript略差,但都在W3C标准范围内,一样可以运行在H5和小程序里,“晚饭想吃炸鸡,中午开始杀鸡还是来得及的”。
所以在解释器的选型上,就确定了Wasm解释器为主,JavaScript为辅的基本策略。而Wasm解释器具体选择是Wasm3,原因有两方面:第一,这是在不支持JIT[4]下最快的Wasm解释器;第二,对包大小占用非常少。
编程语言选择
在确定了Wasm解释器之后,编程语言的选择就变成了,在支持Wasm的语言里选择性能、安全和成本最优的。理论上可以编译成WebAssembly执行的语言非常之多,但真正成熟到可以上生产环境的只有C、C++、Rust、Go这四种[5]。首先可以先排除掉C,虽然性能好但是不支持高级抽象只适合用在嵌入式等极端场景,不适合用来前端写业务。然后可以排除C++,性能表现上C++和Rust不相上下都好过Go,但是错误管理和内存安全上输Rust一筹,并且C++在前端业务层也是生态基本为零,不像Rust在前端生态发展迅猛。最后可以排除Go,在性能表现上、类型系统设计、错误管理、还有前端生态上都输Rust一筹。
综上所述,在运行时我们就选择了Rust和Wasm3,JavaScript和QuickJS后面再进行介绍。
| 3.2 UI 框架
用JavaScript/QuickJS的部分可以复用Vue或者React,这些先不提。用Rust则必须要为运行时的上层设计一套UI框架,确定应该怎么样编写页面。这项工作的挑战在于,它并不像JavaScript生态,有多年的积累可供参考或复用,比如经典的React和Vue。当然好处在于也没有历史包袱,所以必须要结合Rust语言的特点才能更好地完成这个任务。
UI框架大抵需要做到三点:(1)提供声明式DSL方便前端研发描述界面;(2)提供组件封装和状态管理能力完成业务逻辑和用户交互的衔接;(3)性能卓越。其中(1)和(2)在JavaScript生态中已经都有实现,(3)则未必,否则也不会有几十种Web UI框架并存这种局面。这个问题难在如何用Rust这门强类型纯静态语言去实现JavaScript弱类型动态语言实现的功能,并且要维持Rust零开销抽象的优势。
为了解决这个问题,我们参考了GitHub上开源的各种框架。一方面参考了Dioxus的DSL设计和UI封装,另一方面也保持了 Rust-Dominator观察者模式订阅变更的更新效率,我们将这两个优点合并到一起,就得到了Recce UI,就其特点,没有 Diff,也没有VDOM,跟SolidJS一样实现真正的订阅,我们也尽可能地去保证可以高效地构建UI。下图中的表格对应的就是一些Web项目下的性能对比,这个也并没有直接对应到具体实践的容器上,因为我们也做了一个类似Native渲染的东西,所以这个表格对我们来说具备很好的参考价值,至少可以看到Dominator的更新效率还是很不错的。
| 3.3 渲染层
复用WebView:如果追求高性能,这条路就不通,复用WebView意味着渲染指令/视图树 要用低效的方式以WebView 的JsCore为跳板再去驱动WebCore做渲染。这和高性能就南辕北辙,还不如纯H5性能表现好。 自建渲染绘制器:这条路技术上是行得通的,但目前走不得:第一,从代码量上看Chromium内核有数百万行C++,考虑到跨平台兼容则过千万行代码,这个规模和复杂度是超过美团App本身,即使照着写一遍,没有数十名C++专家投入三五年之功是看不到成效的。第二,当世只有Google和Apple有这个能力做成WebView。FireFox的新浏览器计划半途而废,微软则直接放弃转投Chromium。 调用系统UI框架:类似RN的方式,研发成本上是我们能接受的;渲染样式虽然没有WebView的CSS全,但是Flex足够支撑业务需求了,且保持是W3C的严格子集;RN在性能上的问题主要出在通信层上,这个我们可以解决掉;最后也是最重要的是这个选择不是一个单向门,假如我们获得了驾驭blink[6]能力,那么就可以很低的成本平滑切换到blink上。
所以渲染层就是复用了React Native的Native部分,我们决定要站在这个“巨人”的肩膀上开始行动。毕竟React Native 已经提供了非常优秀的组件封装,同时它也解决了Android和iOS在渲染层面的差异,因为这些接口基本上都是在系统UI下进行封装的,所以我们有理由相信这些接口本身的性能是良好的。
| 3.4 整体架构
最终,Recce的概览如下图所示。这里重点讲下先前没有提及的Recce Host平台抽象层,这一层我们主要做了两件事:第一件,属性设置优化(或者叫渲染通信优化);第二件,平台抽象。
属性设置优化后文会详细介绍,这里只说平台抽象。我们结合WebIDL的设计和LLVM的架构理解,在平台抽象层上下都实现了标准接口。类似llvm的MIR使得编译器前端和编译器后端可以独立迭代和接入,平台抽象层的标准接口设计使得只要遵循渲染指令标准的解释器或者渲染器都可以很容易接入。这就是我们很容易把QuickJS + Vue/React支持了的原因。Recce-js可以使线上的大部分以JavaScript为主的前端页面获得更好的性能表现。
同样,Recce的鸿蒙适配的成本也非常低,不需要上千或者几千pd那么多。最重要的是未来替换性能更好的解释器或者语言或者UI框架都是简单可行的。这一层是Rust实现的高效、安全且使得容器整体架构易扩展和易维护。
综上选型和搭建工作基本上已经完成了。接下来,我们再对Recce上的一些细节问题进行补充。
4 Recce的一些细节问题
首先,就是上一节讨提到的,为什么没有使用React Native的属性转换?因为,我们发现属性转换是React Native一个性能瓶颈。其实为了评估这个问题,我们做了一个3000个节点的页面。当然,这个页面可能跟我们平时常见的页面长得不一样,但是它的渲染和布局成本和业务实际是比较接近的。
页面的逻辑我们写得尽可能简单,同时去掉了React Native的启动时间,然后我们启用了原生代码,但是调用了Yoga的布局计算,就这样写了一个页面去做对比。最终发现,二者的耗时差距非常大,因为我们的布局方式是一样的,调用的UI也完全是相同的,基于此,基本上就可以认定剩下的93%的时间都是为了将设置页面的各种各样的数据从JavaScript传递到具体的平台,也就是说属性转换会耗费大量的时间。
这里可以再深挖一下,为什么属性转换会耗费这么多的时间?我们稍微研究了一下这个问题后发现,主要还是因为React Native会有多次序列化和反序列化,这是一个类似于字典的东西,而且除了序列化的时间,还需要考虑构建字典本身需要的时间,还有执行字典的一些查找、设置等操作,最终我们还需要频繁地按照字典里面的Key值查找,查找到之后,再设置到具体的属性设置,而以上这些操作都会消耗掉不少的时间及内存。
事实上,React Native官方也正在尝试解决这个问题,在官方公布的的一个实验性的新的框架中,React Native直接将一个JSObject转换成了一个C++的静态类型(*Props),然后在对应的各个平台中,直接使用了一个转换后的静态类型。如此以来,实际上就只存一次JSObject这样字典就可以,从创建开始到最终设置,始终使用这个静态类型,那么剩下的操作都会变得非常高效了。
同时,我们也会去思考应该怎么去解决属性传递以及查找的问题。这里可以简单看一下,我们常用的几种数据结构,都是基于数组、链表、哈希表、字典等等之类。但实际上,我们在这种场景下可以选择的可能就只有字典和数组,而React Native最常用的方式就是基于字典去构建各种各样的属性。但是基于字典这种方式并没有非常好的性能,如果传递的载体还是JSON字符串的时候,还需要承担JSON本身解析的任务。经过考量之后,我们最终决定采用基于索引的数组来构建一个个的属性值。
类似的,我们也可以把组件注册的标识从字符串修改为索引,由于这个属性和组件不太一样,就无法知道客户端会提供哪些原生的组件,所以至少要在运行时去使用字符串获取一次组件信息。在获取之后,就可以使用获取的这个索引,从而保持一个比较快的匹配效率。
最近,我们还做了一个富文本的标签,这个标签跟前两者相比就变得更不一样了。这里实际上输入的是一段HTML的字符串,所以输入的内容非常自由,我们没有办法像前两者一样使用一些静态的计算方式。但即便是富文本,仍然可以有一些已知的内容,比如像文本的样式、字符串等等,这些内容是可以提前知道的。而在这个层面,我们是可以做一些事情的,实际上可以基于所有输入的计算,得到一个完美的哈希函数,然后确保所有的输入不会发生碰撞。进而,在查找Key的时候就会变得快很多。
以上,就是我们在遇到属性传递时解决的各种各样的问题。而接下来,我们还需要解决一个问题——跨语言的调用问题。当然,在讨论这个问题之前,我们先简单地将这些调用划分成了四类:
第一类是C与C之间的调用,其实C语言本身并没有什么转换,这里只是把它作为一个最特殊的场景,进行归类处理。 第二类是Rust去调用C,这两种语言虽然也是直接在原生上去运行的,但是它们之间会增加了一些调用约定之类的转换。 第三类是Java和JavaScript去调用C,这就需要借助解释器提供的接口去进行调用,其中也会涉及更多的转换的工作,比如说两个内存空间之间的拷贝。 第四类,我们认为它们其实更接近这个IPC的调用,比如像通过WebView提供的基于字符串传递的接口。
在这种场景下,我们会有更多的转换工作以及一些编码约定的设置,所以我们必须要找到一种基于字符串编码方式去传递各种复杂的数据。而具体应用到Recce内部的时候,其实并不存在类似于IPC场景的调用,所以只需要解决每一层之间语言的调用。但实际上,我们仍然面对着非常复杂的调用这个事实。
参考下图,可以看到每一层实际上都涉及到一个具体的跨语言调用。同时,还有刚刚提到的性属性设置,它则会跨过中间的所有层级,直接设置到具体的平台。最后要需要强调一下,涉及到具体的属性以及类似的一些方法调用,比如说打开相机,也会跨过中间层直接去做调用,而这个调用跟属性设置又存在很多不一样的地方。
针对不同的调用场景,我们也采取了两种不同的解决方案:
手写+辅助函数:通常用在不会频繁增改接口、手写本身已经比较好维护。 接口定义+代码生成:可能频繁增改接口、手写维护会非常困难(还需要频繁维护文档)。
具体来讲,我们分成了以下4个场景:
属性设置:把定义的属性props_gen生成Recce Ul+ Vue + React + Android + iOS+HarmonyOs等各种属性的操作代码。 Rust(Wasm3):扩展支持Wasm3。 QuickJS:借助Rust宏封装一些本地函数(UI操作接口)+ 自定义的二进制读写实现。 业务方法调用&平台抽象层与Platform交互:使用完成接口调用和文档生成。
5 总结和展望
最终我们获得了一个如上图的高性能、安全的动态化容器,可以以Wasm的方式支持原生级别的性能,也可以将JavaScript的前端工程的性能提升一截。
从某个角度看,像是我们把RN用Rust重写了,添加了Wasm解释器的支持。但用熟悉WebView架构的视角看,也可以看作是一个WebEngine Lite,只是试图绘制暂时用的系统UI。
文章最后做一下回望和展望。
回望:我们所做的所有架构和优化工作都可以概括为,区分本质复杂度和偶然复杂度,恰当的回应本质复杂度,降低偶然复杂度。
动态化容器的本质复杂度是什么?最主要的一条脉络是,渲染管线,以前端研发的编码逻辑和数据为输入,在管线中变为组件树-> 虚拟文档树 -> 文档树 -> 视图树/样式树 -> 图层树 -> 呈现树,最终绘制到屏幕上为用户所眼见视图为输出。至于用什么DSL、编程语言、解释器、编码方式等等其实是偶然复杂度。
Recce的选型和搭建过程,实际上是围绕渲染管线进行优化,比如精简流程去掉了VDOM等环节,比如简化运行时、选用执行效率更高的解释器和编码方式等,再比如削峰填谷、消除瓶颈以提升渲染管线整体效率等。而这些工作都是为了降低容器本身的计算开销,因为动态化容器相对业务而言也是偶然复杂度。将终端有限的软硬件计算资源更多的留给业务本质复杂度是容器迭代的正确方向。
展望:目前Recce还在逐步完善和落地推广阶段,可以做的事情有很多:
改善开发体验:比如引入LLM来降低Rust的学习门槛和开发成本,比如完善调试&脚手架工具链等等。 进一步优化性能:比如以Rust 原生方式运行以获得超过Android原生的性能表现;比如利用Wasm解释器线性内存特性,可以在CI上完成大部分的预计算,进一步提升加载性能表现; 自研渲染层:这样一是能进一步提高性能,二也能降低多端维护成本,三是把样式能力集对齐到WebView可以实现和H5、小程序的同构。当然这样其实就是 一个完整的WebEngine Lite了。
6 Q & A
Q:Recce的性能如何?可以把这个问题更具象化一些吗?
A:目前从我们已经实践的范围中,在我们业务场景中能够找到最低端的POS设备上面,Recce UI是可以和Flutter的性能表现媲美,而且我们是在动态解释运行,而Flutter是原生运行。也就是实现了原生级别的性能表现。
Q:以后JS没用了吗?
A:JS有用的,但这个问题分两个层面回答。第一层,Recce是支持这个JS运行时和相应生态的。比较极端的场景,比如说POS机,下沉市场的低端机,或者说对于性能要求很高的,比如说像App的首页冷启动,或者说像这个支付收银台等等这种场景下,优化性能的收益很大,那么就可以考虑用Recce-rs这个方案。如果不是这么极端的一般场景,我们用这个Recce-js的方案也能获得一个低成本,获得一个性能优化。性能优化是没有止境的,根据业务场景的需要去选择,Recce提供了更多且更好的选择。
第二层,移动端开发从一开始就有原生开发和H5/JS开发(PhoneGap发布于2009年),但JS成为主流,始于2015年,Google把V8适配到了Android上。这里就不是JS行不行,而是V8很行。终端硬件的计算资源始终是有限的,V8通过解释器层面JIT/AOT编译技术 大幅提升了JS的运行效率,使得JS铺开成为可能。但近几年JS引擎不再有大幅度迭代,手机硬件算力的升级速度也明显放缓,这就导致了终端上 软硬件计算容量的提升速度跟不上业务复杂度的提升速度。这才是当下JS技术栈面临的问题,也是为什么有如此多JS框架在卷性能的原因。
当然大家不用特别悲观,JS和Web始终是互联网最重要的基础设施,事物是呈现螺旋发展趋势的(当然有人负责发展,有人负责螺旋),随着周期演变,新的软硬件技术升级又会推动JavaScript往前发展。比如如果一个使用TypeScript/JavaScript的高性能解释器出来,那么能够使现在的JavaScript工程性能大幅度提升,毕竟前端线上代码资产90%是以JavaScript形式存在的。
Q:为什么叫 Recce ?
A:Recce/ˈrɛkiː/名字取自海豹部队的一把枪。选择这个名字的初衷是因为在Recce-rs选型的时候,目标是接近原生的性能表现、还要动态化,那么肯定要舍弃一部分开发体验的,切换Rust语言上会对研发同学增加学习成本、抬高门槛。因为预判到这一点,所以我们希望使用Recce的同学始终记得是经过了更多的训练的精英,要克服各种困难去完成高价值的任务。凡事有利必有弊,有用比有费,对于需要使用Recce-rs优化的场景 学习Rust就不是最难的技术问题。我们在为高价值场景提供更好的选择的同时,也将部分优化能力通过Recce-js反哺到一般业务场景上。不同的场景不同的成本结构,没有最好,只有合适的方案。
Q:Recce为什么要追求原生级别的性能?
A:主要有三点原因。
一、前端提升性能体验可以提升用户体验和业务获客效率
前端部署和面客是重叠的过程,前端部署成功率影响业务面客。部署成功率低意味着,客户遇到白屏或不愿意等待最终放弃。提升性能可以提升前端部署成功率,进而可以提升用户体验和业务获客效率。
部署:代码离开开发环境,到用户终端设备运行前的这段环节为部署。
二、追求原生级别性能的动态化容器是公司业务的发展需要
美团业务的首要特点是低毛利,低毛利意味着业务发展需要做大规模,提升UE,提升复购。
做大规模,意味着要争取广大下沉市场。那么在性能体验上就需要至少Meet友商,在下沉市场表现最好的应用(微信、拼多多)的主要功能都是原生实现的。争取下沉市场扩大规模在前端的命题意味着提供原生级别的性能体验,在下沉市场这可能不再是体验好坏而是能否使用的问题。 提升UE,意味着必须要考虑研发人效,前端提研发人效就必须考虑跨平台同构,因此需要容器化,将多端开发降为一端开发。 提升复购,复购意味着多业态混合经营,那么在包大小约束下就必须要让大部分业务动态化。
综上,公司业务发展的需要决定了美团需要原生级别性能的动态化容器。
三、部署是前端工程领域的核心问题,容器是部署问题的主要答案
把时间拨回到2010年,彼时移动端开发方兴未艾,主要的技术栈只有Native和H5(PhoneGap为代表),其中Native性能表现好但不跨端、H5迭代效率高但性能体验差。随着时间推移,Native方向为了解决跨端研发成本和部署效率,发展出了 ReactNative、Flutter等等方案,H5方向为了改善性能,发展出了离线化、SSR/ESR、解释器优化等等。
可以说两个方向是相向而行,其实这是一个问题的两个面。究其根本在于前端代码运行在不受我们控制且计算资源有限的用户设备上,不同于后端的主要问题是高并发,前端的主要问题是怎样把代码低成本跨过物权边界送到大规模且不同的用户的设备上并高效运行起来。
回顾过去,性能表现好原生要做动态化降低部署成本,部署成本低的H5要优化性能提升用户体验。换句话说,前端工程领域的核心问题是部署成本和用户体验的平衡。比如包大小就是原生开发部署成本高而非常困扰客户端开发的一个典型例子。
|| 注释 ||
---------- END ----------
|
|
|
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...