cover_image

移动端动态化开发框架Thresh的JSI优化实践

万红波 满帮技术团队
2021年12月09日 07:59


图片

背景

图片



Thresh是满帮开源的一套基于Flutter的面向前端开发者的跨平台动态化方案,它提供了一个简单、高效的应用开发框架和丰富的组件及API,帮助开发者在前端开发中具有原生 APP 体验的服务,目前在满帮的APP中有大规模的业务应用,它引入独立JS运行时,使用前端开发者熟悉JavaScript开发业务。
最初JS引擎用在浏览器中作为JS脚本的执行引擎,为页面提供动态化和交互能力。随着跨平台技术的发展,现在越来越多的场景把JS引擎脱离浏览器,作为独立的运行时来使用,比如RN/Weex/NodeJS。由于大多数JS引擎都是使用C++来实现,可以很方便地通过C++扩展JS的能力,同时又可以满足性能和跨平台需求,这就需要使用到JS引擎的API来作扩展。JSI(JavaScript Engine Interface)是一套经过封装的JS引擎API,屏蔽各JS引擎的差异,可以通过它很方便地扩展JS引擎的能力,提升JS与平台侧其他容器或中间件的通信性能,目前RN/Weex/NodeJS中广泛地使用了JSI或者JS Binding来扩展能力和提高性能。本文会先介绍一下JS引擎以及其常规优化思路和JSI的基本概念,后半部分会介绍一下JSI在满帮Thresh框架中的一些探索和实践,希望给大家带来一些思考和启发。


图片

JS引擎基础

图片



通常来讲,一个跨端引擎,主要由脚本执行引擎和渲染引擎组成,对应我们通常说的逻辑和渲染两部分。它运行的基本流程大概是这样,脚本引擎解析网络或者本地加载的脚本文件并执行相关业务逻辑,通过相关平台API或者Bridge来调用平台相关能力,生成结构化的UI显示数据并传递给渲染引擎处理,渲染引擎根据样式等信息生成渲染树,并根据样式指定的排版约束进行排版来确定每个元素的大小(width,height)和位置(x,y),并记录在渲染树中,最后把渲染树通过平台UI框架或者自绘的方式完成光栅化,最后输出到屏幕。当用户在屏幕上执行点击或者拖动等交互操作时,平台侧把点击消息传递给渲染引擎,渲染引擎在渲染树上结合排版信息执行Hittest操作来决定哪个元素处理该处理这个消息,之后再通过JS引擎的接口把这个消息传递到该元素绑定的消息处理函数进行处理,消息处理可能会引起UI的变化,再去更新渲染树,再重复之前的步骤驱动渲染引擎更新UI。


图片


比较常见的脚本引擎如JS引擎,Python,Lua等等,常见的渲染引擎如webcore,blink,flutter等,以及各种平台Native的UI渲染引擎.
本文主要聚焦到JS引擎,目前主流的JS引擎如下,V8由于Chrome,NodeJS,Electron的强劲生态,占据比较大的份额,JavaScriptCore主要由于IOS平台的原因,也在占据了一定的份额,其他像Hermes,SpiderMonkey等都局限于某一个场景。
图片

JS引擎根据不同维度有不同的分类,比如


图片

衡量JS引擎的技术指标如下:

图片

不同的场景对技术指标的要求也不太一样,比如在PC端,对包体积的要求比较小,而移动端比较敏感。再比如内存占用,由于PC上可以通过磁盘换入换出解决内存不够的问题,而在移动端上只能OOM,所以移动端对内存更加敏感。V8/JSC诞生在PC时代,开始主要应用于浏览器,目前逐步发展到移动端以及服务端。它们诞生的时代也一定意义上影响了他们的特点,像V8为了提高性能,它的架构在2016年之前的版本大量使用空间换时间的优化策略,甚至把所有的JS代码AOT编译为机器码来提高执行性能,这也导致了内存占用大,启动速度较等问题。这些问题在移动端上尤为突出,所以V8在v5.3之后改变了架构,引入了Ignition解释器,重新使用生成bytecode方案来平衡性能和资源的消耗。在2019年,RN推出hermes,同期Fabrice Bellard推出了quickjs,他们都充分考虑了移动端应用的特点来针对性地设计JS引擎,比如他们都采用了基于栈的字节码设计方案,字节码密度更大,生成的产物更小,都取消了JIT能力,缩短TTI时间,都可以分发bytecode,提高执行速度。它们都都是针对移动端特点来进行差异化的策略优化,后续针对移动端的JS引擎领域应该会更加繁荣。
下面我们简单介绍一下JS引擎的工作原理,以方便后续介绍JSI以及通用的优化,如果已经了解,可以直接跳过。


图片

JS引擎工作原理

图片



我们以V8为例说明JS引擎的核心工作原理。
图片

JS引擎加载JS文件后,通过Parser进行词法分析和语法分析生成AST语法树,解释器Ignition根据语法树生成bytecode并解释执行,在解释执行的过程中,解释器Ignition会收集热点函数的调用情况称之为反馈Feedback,一般频繁调用的函数可能会被判定为热点函数,Ignition会把热点函数通过JIT编译器TurboFan即时编译为机器码直接执行,提高执行效率。但是机器码一般比bytecode大很多,所以也不太适合把很多函数都编译为机器码,在v8内部也会根据一定的策略去进去权衡来提高综合性能。


图片


图片

JS引擎常规优化手段

图片


JS引擎是一个非常基础的垂直领域,涉及虚拟机,编译器等计算机基础领域,目前只有很少的厂商能有能力对引擎内部进行研发和定制,国外主要是Google,Apple,微软等巨头在主导,国内华为应该有一个上百人规模的编译器团队,负责华为方舟编译器,自研编程语言等相关的工作,对JS引擎也有涉及,阿里的UC团队以及之前的AliOS团队也有对V8以及编译器的相关研究和定制,剩下应该就是一些大学等科研单位有所涉及,但比较成功的商业化方案还没有看到过。所以它的技术门槛非常高,对于大部分的App和框架来说,投入JS引擎的研发ROI非常低,但也有一些常用的策略性优化,可以给App带来巨大的提升。
  • Code Cache
Code Cache的主要思想还是把之前已经编译过的JS Bytecode缓存起来,下次再执行这段JS时,就可以直接解释执行,不需要再进行编译了,减少JS编译的时间,一般会选择一些不经常变的JS代码生成Code Cache,比如一些JS框架代码,比如现在很多小程序容器的JS 框架代码就可以预先生成Code Cache,提高启动速度。
图片

  • JS AOT(Ahead of Time)
即把JS代码在研发阶段就提前先编译成Bytecode甚至机器码内置在端里,或者上传到CDN动态下发到端上,提高JS的执行效率。Hermes和Quickjs都提供了生成字节码的接口,可以很方便的生成字节码。早期的V8是支持全量编译成机器码的,但是由于占用内存太大而放弃,但是不可否认,在特定场景,性能提升非常明显。
  • JIT / JIT less
图片
JIT(Just In Time)的作用是在运行时把Bytecode编译成机器码,提高执行性能,尤其是在一些计算密集型的场景,如使用JS处理2D/3D图形,web游戏引擎等领域,都可以带来明显的性能提升。但是JIT功能也带来了一些问题,比如JS引擎代码量剧增,内存占用变大,由于引擎启动时会初始化JIT编译器环境,从而导致JS引擎的冷启动时间变长,另外由于JS语言不是强类型,如果变量类型后期发生变化,被JIT优化后的机器码,也会失效,从而带来负反馈,并引起性能衰减,所以像Hermes以及Quickjs都没有引入JIT的能力,主要就是为了提高JS引擎的冷启动时间以及降低内存占用。
  • Code Snapshot
V8提供一个Code Snapshot功能,可以提高V8创建Isolate和Context的时间,主要的原理是通过V8提供的接口CreateSnapshotDataBlob把JS代码生成Snapshot文件,并序列化到磁盘,JS引擎运行时,反序列化Snapshot文件,这样原先JS Context中build-in的JS全局函数以及变量等存放在heap上对象就直接创建完成了,不用再耗费时间重新创建,这可以使得在移动端Context创建的时间从270ms减低到10msElectronNodeJS都有利用Snapshot提升启动速度的案例。
除了上面这些提升JS引擎的常用策略外,目前使用比较广泛的,就是通过JS引擎提供的接口(JSI)来扩展JS语言的能力,提高相关接口调用的性能。


图片

JSI基础

图片


在介绍JSI之前,要先讲一下JS Binding,JS Binding的作用就是通过某个JS引擎API接口实现或者桥接Platform的能力,并向JavaScript暴露这些接口,供上层应用使用。它在浏览器中广泛应用,W3C标准API在浏览器内都都是通过JS binding来实现,比如我们常见的的DOM API,Web Canvas API,WebGL等等。
当一个Web标准提案通过后,浏览器厂商要支持这个功能的话,就需要在浏览器内基于JS引擎的API来实现这个标准,比如Chrome要支持WebGL这个W3C标准,就需要在内部使用V8暴露的接口来把平台提供的OpenGL API能力桥接和封装成WebGL的API,暴露到JavaScript中, 这样Web页面就可以通过JavaScript来开发3D效果的页面了。
另外NodeJS支持用C++ AddOn来扩展Node的能力,而Addon底层实现基础就是V8的JS binding技术。
与传统的JS Bridge相比,JS Binding的优势:
  • 性能更高,无跨语言转换消耗
  • C/C++实现,具备跨平台特性
  • 扩展性强
    但也存在问题,如果JS引擎升级,API有变化,这样就使得所以依赖JS引擎API的binding代码或者AddOn都需要重新编译,这对浏览器自身可能影响不大,但对于有很多三方生态的,影响就很大,像Node的C++ Addon都要一起升级,这显然不能接受,由此出现了Node的N-API,它跟JSI的概念是一致的,都是对JS引擎API的抽象层,屏蔽JS引擎不同版本以及不同引擎的差异,对外提供统一的ABI接口,这样NodeJS底层的JS引擎,可以是V8,也可以是JSCChakracore,只要他们都实现统一的N-API即可,基于N-API实现的Add-On Module不感知底层的JS引擎。
    图片
    JSI目前在NodeJS,RN以及很多独立JS worker的场景中都大量应用,它具有下面的优点:
    • ABI接口统一,引擎的升级和切换,对JS Binding代码, Addon等上层代码无感知,不需要重新编译和发布
    • 不同JS引擎可以切换,为不同场景提供更灵活的支持方式
    缺点是,跟原始的JS引擎API相比,由于多了一层数据结构封装于调用,性能会略有降低。
    下面我们着重介绍一下在满帮通过JSI相关技术改造Thresh容器的通信链路,来提升业务体验的探索和实践。


    图片

    Thresh框架

    图片


    Thresh是满帮集团开源的一套稳定、高性能基于JS的跨端动态化方案,它提供了一个简单、高效的应用开发框架和丰富的组件及API,帮助开发者在前端开发中具有原生 APP 体验的服务,目前在满帮的APP中有大规模的业务应用,它的总体架构如下:
    图片
    关于Thresh的详细介绍,可以参考以下文章

    文章地址

    徐维顺、章伟,公众号:满帮技术团队满帮动态化Flutter框架“Thresh”,现在开源了!!



    图片

    Thresh JSI技术改造

    图片



    目前Thresh 1.0的整体架构如下图所示,JS线程执行JS Bundle并驱动构建UI组件树,生成VNode tree并把它数据序列化为json数据,之后通过js bridge把json数据传到native侧,native利用之前创建好的Flutter channel把json再传到dart侧,dart侧解析json数据,构建Widget Tree,之后再走Flutter原生的排版和渲染流程,最后把页面显示出来。
    图片
    整个1.0的通信链路比较长,数据需要跨三种语言转换(C++/Java/Dart)和并完成序列化和反序列化,跨三个线程(JS Thread->UI Thread->Dart Thread),通信的效率比较低,引入JSI后的新架构如下:
    图片

    通过实现基于JSI的C++ Binding代码,我们在C++侧直接可以获取到JS thread的json数据,存储在共享内存中,Dart线程刷新时获取共享内存数据,并解析生成Widget等操作,省去了多次的跨语言转换以及序列化和反序列化,并且数据不经过Flutter Platform Thread,直接从JS Thread到Dart Thread,减少了两次线程调度,同时把通信操作从主线程移除,也提高了App的交互流畅度,降低主线程的繁忙程度。


    图片

    技术数据

    图片


    完成技术改造后,使用Android低端(Google Pixel3)和中高端(华为Mate 40)机器,在线下页面和线上页面分别进行了测试。
    线下测试Thresh静态列表页面,渲染性能减少接近~60%。
                                         
    图片  图片


    灰度上线后,在货车帮司机端短途订单页面的首帧渲染时间在低端机上减少~25%,高端机首帧渲染时间减少接近10%。
    图片图片


    可以看到在使用JSI改造后,容器的启动性能和渲染性能大幅提升,再总结一下相关的优化点:
    • JS和Dart之间的通信链路缩短,不需要跨三个线程(JS<->Platform<->Dart)通信,JS和Dart直接通信。
    • 数据通信使用内存数据,去除序列化和反序列化开销。
    通过这次技术改造,提升了通信效率,也使得容器各个阶段的串行流程执行效率得到提高,带动容器整体执行效率,整个过程中,也进一步暴露了一些存在问题,距离一个对开发者友好且具备技术先进性的跨平台框架,还有不少细节问题需要完善和打磨并完成相关的技术积累,但我们已经在正确的方向上,保持耐心,持续投入,静待质变。
    图片


    图片

    后期规划

    图片


    • 脚本引擎建设
    收口App内的脚本引擎,通过MB JS Runtime,RN/Thresh/Davinci容器可以毫无感知的切换底层JS引擎,比如内存紧张的低端机型,可以切换到内存占用较小的hermes或者qjs上,减少内存占用,减低OOM,提升业务和app稳定性。同时也可以支持Thresh/Davinci切换到执行JS Bytecode产物,提升脚本执行效率。
    图片
    • 渲染引擎建设
      • 探索Thresh JS-Dart线程合并
    目前Thresh JS线程负责生成渲染指令,通过JS Binding传递到共享内存中供Dart线程去消费,页面渲染完成后,处理用户消息,再把消息传递到JS线程执行,虽然JS的逻辑和Dart的渲染分别运行在2个线程,但是基本上无法并行执行:Dart线程处理渲染需要依赖JS线程发送过来的渲染指令,JS线程处理消息时,需要依赖Dart线程接受到的平台消息,互相依赖,这样反而增加了大量跨线程数据传输,因此后面计划尝试把两个线程合并,进一步减低通信成本,也可以减低设计复杂度,便于维护。
    图片
      • 渲染指令直接生成RenerObject Tree
      由于前端框架已经具备dom diff能力,这与Flutter侧的Element Tree能力是重复的,所以可以通过把渲染指令直接对接到生成Flutter RenderObject上,缩短渲染链路,提升性能,降低内存占用
    • 跨平台生态建设
      • Thresh对接RN生态,降低开发者学习成本
    目前初步的想法是在RN框架的ShadowThread中,新增加一条链路,把UIViewOperation发送到Flutter的UI Thread,驱动生成Flutter Widget或者RenderObject来渲染,这样从Thresh角度,可以无缝接入的RN和React生态,从RN角度,可以保证双端一致性,在此基础上,逐渐生长出集团业务相关基础组件和业务组件来支撑上层业务迭代。
    图片
      • 对接多种DSL
    参考Weex JS Framework,在js runtime中,抽象出基础的DOM API,完成真实节点的增删改查,适配Vue和React等常用前端框架。


    图片

    参考文档

    图片



    图片


    作者简介:
    万红波:满帮移动端技术专家,主要从事满帮移动端跨平台相关基础设施建设。曾就职于手淘客户端团队,从0到1参与淘宝Weex架构升级,Flutter入淘,阿里小程序架构升级等客户端的开发和持续优化,对于移动端开发有深刻理解和丰富经验。

    继续滑动看下一个
    满帮技术团队
    向上滑动看下一个