揭秘:支付宝小程序 V8 Worker 技术演进

2020-07-10 12:49:50 浏览数 (1)

阿里妹导读:本文分享支付宝小程序 V8 Worker 相关工作沉淀和总结,包括技术演进、基础架构、基础功能、以及 JS 引擎能力输出,以及一些优化方案等。欢迎同学们共同探讨,指正。

文末福利:《小程序开发不求人》电子书下载。

从 Service Worker 到 V8 Worker

本节简要介绍支付宝小程序从 Service Worker 到 V8 Worker 的技术演进过程。

众所周知,支付宝小程序源码打包完成之后主要分为两部分:

  • 第一部分负责小程序的视图展示,打包产物为 index.js,我们称为 Render 部分
  • 第二部分负责小程序的业务逻辑、视图更新等,打包产物为 index.worker.js,我们称为 Worker 部分

同时,前端框架 APPX 也分为 Render 部分(af-appx.min.js)和 Worker 部分(af-appx.worker.min.js):

  • Render部分(index.js 和 af-appx.min.js)运行在 UCWebView 或 SystemWebView 上
  • Worker 部分(index.worker.js 和 af-appx.worker.min.js)运行于 Service Worker[1] 上

Service Worker

Service Worker 由浏览器内核提供,设计目的是用于充当 Web 应用程序与浏览器之间的代理服务器;Service Worker 运行在独立的 worker 上下文,因此它不能访问 DOM。相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,所以不会造成阻塞。

但是有一个问题是 Service Worker 的启动和 Render 部分的启动是串行的,必须是在 WebView 启动之后,由 Render 部分的 JS 发起。这对小程序来说就是较大的性能瓶颈。

WebView Worker

为了解决 Worker 和 Render 串行初始化和执行带来的性能问题,小程序团队尝试过使用 WebView 来执行 Worker。也就是在启动小程序的时候同时 new 出两个 WebView,一个 WebView 用来渲染 Render 部分,另一个 WebView 专门用来执行 Worker 部分的 JS 脚本。但是专门使用一个 WebView 来执行 Worker 部分的 JS 脚本,无疑是”大材小用“,使用一个 WebView 的资源消耗必然是较高的。

V8 Worker

Service Worker 的串行初始化会影响小程序启动性能,WebView Worker 来运行小程序 Worker 代码又不够轻量,使用专有 JS 引擎来做 Worker 部分的工作乃是最优选择,因此 V8 Worker 应运而生。

下图是小程序 V8 Worker 的基本结构,本文后面继续详细描述。

利用 V8 引擎运行 Worker 主要有以下一些优势:

  • 能够解决 Render 和 Worker 串行初始化和运行的问题,WebView 和 V8 引擎可并行初始化、可并行执行 Render 和 Worker 部分的 JS 脚本
  • 能够提供 JS 安全运行环境,隔离框架 JS 和业务 JS
  • 易于给小程序注入 JS 对象,绑定 JSAPI
  • 能够支持更丰富的数据类型,如 ArrayBuffer 等
  • 能够扩展 Worker 能力,提供小程序插件、多线程 Worker 等功能
  • 能够充分利用 V8 引擎的能力做性能优化,如 V8 CodeCache 等
  • 能够给小程序以外的业务提供 JS 引擎能力,如 V8 Native 插件
  • 能够自定义 JS 引擎运行参数

V8 Worker 基础架构

本节主要介绍了支付宝小程序的 V8 Worker 工程结构、基于 V8 Worker 的小程序架构;同时如果对 V8 引擎不是很熟悉,这里给出了 V8 的简要介绍和学习资料链接。

V8 简介与入门

在介绍 V8 Worker 之前,先简要了解下 V8 引擎[2]本身。如果对 V8 很熟的大牛请自行跳过。

V8 是 Google 的开源项目,是一个高性能 JavaScript 和 WebAssembly 引擎,应用于用于 Chrome 浏览器、Node.js 等项目。学习 V8 的门槛还是比较高,这里只给出了阅读本文所需要知道的 V8 基本概念,以及官方的嵌入式 V8 的 HelloWorld 代码,同时给出一些学习链接。

嵌入式 V8 基本概念

1 Isolate (隔离)

Isolate 和操作系统中进程的概念有些类似。进程是完全相互隔离的,一个进程里有多个线程,同时各个进程之间并不相互共享资源。Isolate 也是一样,Isolate1 和 Isolate2 两个拥有各自堆栈的虚拟机实例,且相互完全隔离。

2 Contexts (上下文)

在 V8 中,一个 context 就是一个执行环境, 它使得可以在一个 V8 实例中运行相互隔离且无关的 JavaScript 代码。你必须为你将要执行的 JavaScript 代码显式的指定一个 context。

之所以这样是因为 JavaScript 提供了一些内建的工具函数和对象,他们可以被 JS 代码所修改。比如,如果两个完全无关的 JS 函数都在用同样的方式修改一个 global 对象,很可能就会出现一个意外的结果。

3 Handle(句柄)与 垃圾回收

Handle 提供了一个 JS 对象在堆内存中的地址的引用。V8 垃圾回收器将回收一个已无法被访问到的对象占用的堆内存空间。垃圾回收过程中,回收器通常会将对象在堆内存中进行移动. 当回收器移动对象的同时,也会将所有相应的 Handle 更新为新的地址。

当一个对象在 JavaScript 中无法被访问到,并且也没有任何 Handle 引用它,则这个对象将被当作 "垃圾" 对待。回收器将不断将所有判定为 "垃圾" 的对象从堆内存中移除。V8 的垃圾回收机制是其性能的关键所在。

Local Handles 保存在一个栈结构中,当栈的析构函数(destructor)被调用时将同时被销毁。这些 handle 的生命周期取决于 handle scope(当一个函数被调用的时候,对应的 handle scope 将被创建)。当一个 handle scope 被销毁时,如果在它当中的 handle 所引用的对象已无法再被 JavaScript 访问,或者没有其他的 handle 指向它,那么这些对象都将在 scope 的销毁过程中被垃圾回收器回收。入门指南中的例子使用的就是这种 Handle。

Persistent handle 是一个堆内存上分配的 JavaScript 对象的引用,这点和 local handle 一样。但它有两个自己的特点,是对于它们所关联的引用的生命周期管理方面。当你希望持有一个对象的引用,并且超出该函数调用的时期或范围时,或者是该引用的生命周期与 C 的作用域不一致时,就需要使用 persistent handle 了。例如 Google Chrome 就是使用 persistent handle 引用 DOM 节点。Persistent handle 支持弱引用,即 PersistentBase::SetWeak,它可以在其引用的对象只剩下弱引用的时候,由垃圾回收器出发一个回调。

4 Templates(模板)

在一个 context 中,template 是 JavaScript 函数和对象的一个模型。你可以使用 template 来将 C 函数和数据结构封装在一个 JavaScript 对象中,这样它就可以被 JS 代码操作。例如,Chrome 使用 template 将 C DOM 节点封装成 JS 对象,并且将函数安装在 global 命名空间中。你可以创建一个 template 集合, 在每个创建的 context 中你都可以重复使用它们。你可以按照你的需求,创建任意多的 template。然而在任意一个 context 中,任意 template 都只能拥有一个实例。

在 JS 中,函数和对象之间有很强的二元性。在 C 或 Java 中创建一种新的对象类型通常要定义一个类。而在 JS 中你却要创建一个函数, 并以函数为构造器生成对象实例。JS 对象的内部结构和功能很大程度上是由构造它的函数决定的。这些也反映在 V8 的 template 的设计中, 因此 V8 有两种类型的 template:

1)FunctionTemplate

一个 Function Template 就是一个 JS 函数的模型. 我们可以在我们指定的 context 下通过调用 template 的 GetFunction 方法来创建一个 JS 函数的实例. 你也可以将一个 C 回调与一个当 JS 函数实例执行时被调用的 function template 关联起来。

2)ObjectTemplate

每一个 Function Template 都与一个 Object Template 相关联。它用来配置以该函数作为构造器而创建的对象。

5 Accessors (存取器)

存取器是一个当对象属性被 JS 代码访问的时候计算并返回一个值的 C 回调。存取器是通过 Object Template 的 SetAccessor 方法进行配置的。该方法接收属性的名称和与其相关联的回调函数,分别在 JS 读取和写入该属性时触发。

存取器的复杂性源于你所操作的数据的访问方式:

  • 访问静态全局变量
  • 访问动态变量

6 Interceptors(拦截器)

我们可以设置一个回调,让它在对应对象的任意属性被访问时都会被调用。这就是 Interceptor。考虑到效率,分为两种不同的 interceptor:

  • 属性名拦截器:当通过字符串形式的属性名访问时调用。比如在浏览器中使用 document.theFormName.elementName 进行访问。
  • 属性索引拦截器:当通过属性的下标/索引访问时调用。比如在浏览器中使用 document.forms.elements[0] 进行访问。

7 Security Model(安全模型)

在 V8 中,同源被定义为相同的 context。默认情况下,是无法访问别的 context 的。如果一定要这样做,需要使用安全令牌或安全回调。安全令牌可以是任意值,但通常来说是个唯一的规范字符串。当建立一个 context 时,我们可以通过 SetSecurityToken 来指定一个安全令牌, 否则 V8 将自动为该 context 生成一个。

学习资料

  • 官方文档 [4]
  • Ignition: An Interpreter for V8 [5]
  • Ignition: Jump-starting an Interpreter for V8 [6]
  • V8: Hooking up the Ignition to the Turbofan [7]
  • V8 Code Caching [8]

基于 V8 Worker 的小程序架构

本小节详细讲述 V8 Worker 的小程序架构,分别描述了 Render 部分和 V8 Worker 的 JSAPI 流程细节,以及 Render 和 Worker 直接如何通信。

单 V8 Context 结构

如上图所示,在 V8 Worker 的初期,一个小程序占用一个 V8 Isolate,一个 V8 Isolate 只创建一个 V8 Context。也就是小程序的前端框架 APPX 的代码 appx.worker.min.js 和小程序的业务代码 index.worker.js 运行于同一个 V8 Isolate 上的同一个 V8 Context 上。这样的设计就会存在 JS 安全性问题,业务 JS 代码可以通过拼接冒名的形式访问到为 APPX 注入的内部 JS 对象和内部 JSAPI,在同一个 V8 Context 中,是无法隔离开业务 JS 代码和 APPX 框架 JS 代码的运行环境的。后面我们会介绍如何解决这个安全问题。

Render 部分 JSAPI 流程

如上图所示,Render 和 Nebula 直接的双向通行是分别通过 Console.log 和 WebView 的 loadUrl[9] 接口进行的。

容器到 Render

容器要加载运行 Render 部分的 JS 脚本,都是通过 WebView 的 loadUrl 进行;WebView 在运行 Render 部分的 JS 脚本(af-appx.min.js 和 index.js)之前,需要提前注入 APPX 框架需要用到的全局 JS 对象,如 window.AlipayJSBridge[10] 等,供 JSAPI 调用使用。

Render 到容器

Render 侧到容器的 JSAPI 的调用,本质上是通过 Console.log[11] Web API 实现。

Worker 部分 JSAPI 流程

Worker 到容器

类似于 Render 部分,在初始化 V8 Worker 时,也需要在 V8 Worker 环境中注入 AlipayJSBridge 这个全局 JS 对象,AlipayJSBridge 的定义在 workerjs_v8_origin.js [12]中,workerjs_v8_origin.js[13] 已提前在 V8 Worker 中加载。

代码语言:javascript复制
AlipayJSBridge = {  //xxxxx  call: function (func, param, callback) {     nativeFlushQueue(func, viewId, JSON.stringify(msg), extraData);  }  //xxxxx}

同时,我们已经在 V8 Worker 环境中提前注入了 nativeFlushQueue API,同时绑定了这个 API 的 JAVA 侧回调:

代码语言:javascript复制
mV8Runtime.registerJavaMethod(new AsyncJsapiCallback(this), "__nativeFlushQueue__");

这样 Worker 部分 JSAPI 通过 AlipayJSBridge.call() 调用,最终会回调到容器侧的AsyncJsapiCallback() 。

容器到 Worker

JSAPI 在容器侧处理完成之后,如果有返回结果,将会返回到 Worker。

Render 和 Worker 通信

基于容器总线的消息通道

以 Render 到 Worker 发送消息为例,流程大致为:

  • Render 侧发送 postMessage 消息,此时消息需要经过一次序列化转成字符串。
  • WebChromeClient onConsoleMessage 拦截到消息,反序列化成 JSONObject 并发送到容器总线 bridge.sendToNative(event) 。
  • 容器总线进行事件分发。
  • worker 插件拦截到 postMessage 事件,并发送到 worker。
  • V8Worker 将消息反序列化成 string,并转成 JS 数据类型,传到 Worker 所在的 V8Context。
  • workerjs_v8_origin.js 中_invokeJS 函数被调用,至此,Worker 已收到来自 Render 的消息。
基于 MessageChannel 的消息通道

可以看出,基于容器总线的消息通道,一个消息从 Render 到 Worker 中间需要经过多次的序列化和反序列化,这是非常耗时的操作;不仅在小程序启动过程中影响小程序启动速度,小程序的滑动等交互事件都会有大量的 Worker 和 Render 之间的消息传递,所以也会影响帧率。

于是,基于 MessageChannel 的消息通道应运而生。

MessageChannel 允许我们创建一个新的消息通道,并通过它的两个 messagePort 属性发送数据。如下图所示,MessageChannel 会创建一个管道,管道的两端分别代表一个 messagePort,都能够通过 portMessage 向对方发送数据,通过 onmessage 来接受对方发送过来的数据。利用 MessageChannel 的特性,render 和 worker 之间的通信可以不通过 Nebula 总线,这样减少了消息的序列化和反序列化。

V8 Worker 接入 JSI

背景

随着支付宝端以及整个集团使用V8引擎的业务越来越多,对 V8 引擎的升级维护工作就越来越复杂和重要。每个业务可能使用不同的接口,升级 V8 引擎时都需要重新适配。同时,刚才前文也提到了,目前 V8 引擎由 UCWebView 内核提供,使用 V8 需要重新进行拷贝。

如何解决这些问题呢?"计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决",于是就诞生了 JSI(JavaScript Interface)。

JSI 简介

JSI(JavaScript Interface)是对 JavaScript 引擎(V8、JSC 等)进行封装,给业务方提供基础的、完整的、稳定的、与具体 JS引擎无关的、向后兼容的 Java API 和 Native API。

JSI 带来的优势有:

  • 与具体 JavaScript 引擎无关的、各平台通用 JSI API
  • 实现了 Inspector、Code Cache、JS Timer 等通用的功能,能让业务方更加集中精力地关注于业务方面的开发,缩短开发周期
  • JSI 负责处理 JS 引擎版本兼容性问题,接入业务无感知
  • JSI 直接使用 UC 内核中的 libwebviewuc.so,不需要进行 V8 拷贝
  • JSI 采用支付宝和 UC 共建的形式,JSI 接入层下沉到 Ariver 工程,通过 Ariver 输出到集团各业务

基于 JSI 的 V8 Worker

下图是基于 JSI 的 V8 Worker 工程结构。对比基于 J2V8[14] 的 V8 Worker 发现,小程序、小游戏、Cube 等业务只需要通过 JSI 的 Java 接口去加载 V8 引擎即可,JSI 中使用 U4 Linker 加载 libwebviewuc.so,可复用 UC WebView SDK 中的 libwebviewuc.so,且无需拷贝,解决了与 UC WebView 在同一个进程中共存时 libwebviewuc.so 全局变量冲突的问题。JSI 同时提供了 Java 和 C 两种封装 API,方便业务方接入。

JSI 接入文档详细介绍了如何快速通过 JSI 来使用 JS 引擎:

  • Java 和 Native 侧的初始化
  • 创建 JSEngine(对应于 v8::isolate)
  • 创建 JSContext(对应于 v8::Context)
  • 如何通过 Java/C 接口注入 JS 对象(全局常量、全局函数、全局访问器)
  • 如何执行 JS 脚本
  • Trace 分析、Timer 等

V8 Worker 如何解决 JS 安全问题

前文已经介绍,采用单 V8 Isolate 单 V8 Context 结构的 V8 Worker 会存在 JS 安全问题,无法隔离业务 JS 和前端框架 JS 的运行环境。下面就介绍多 Context 隔离的 V8 Worker 和多 Isolate 隔离多线程 Worker。

多 Context 隔离

下图描述了多 V8 Context 隔离架构的 V8 Worker。对于同一个小程序,在同一个 V8 Isolate 下,分别为小程序前端框架脚本(af-appx.worker.minjs)、小程序业务脚本(index.worker.js)和小程序插件[15]脚本(plugin/index.worker.js)创建单独 APPX Context、Biz Context、Plugin Context(jsi::JSContext 就对应于 v8::Context)。同一个小程序可能会存在多个小程序插件,对于每一个插件都会分配一个单独 V8 Context 运行环境。

如 V8 Context 安全模型[16]所描述,同源即被定义为 Context,默认情况下不同的 Context 是不能相互访问的,除非通过 SetSecurityToken 设定安全令牌。正式利用了这一特性,我们将前端框架、小程序业务和小程序插件的 JS 运行环境进行了安全隔离。

多 Isolate 隔离的多线程 Worker

在小程序中,对于一些异步处理的任务,可以放置于后台 Worker 线程去运行,待运行结束后,再把结果返回到小程序主线程,这就是多线程 Worker。

上图描述了多线程 Worker 的设计框架。小程序 Worker 主线程运行于单独的 V8 Isolate 上,同时,业务 JS、APPX 框架 JS、插件 JS 会运行属于各自的 V8 Context 上。同时对于每一个 Worker 任务,都会单独起一个 Worker 线程,创建单独的 V8 Isolate 和 V8 Context 实例。每一个 Worker 任务和小程序主线程中的任务都是相互线程隔离的、Isolate 隔离的。

Isolate 隔离意味着 V8 堆的隔离,因此 Worker 主线程和后台 Worker 线程,是无法直接传递数据的。Worker 主线程和后台 Worker 线程要想实现数据传递,则需要进行序列化和反序列化(Serialize 和 Deserialize)。序列化即将数据从源 V8 堆上拷贝至 C 堆上,反序列化即将数据从 C 堆上拷贝至目标 V8 堆上。Worker 主线程和后台 Worker 线程通过序列化和反序列化的接口 postMessage 和 onMessage 来进行数据传递。

JS 引擎能力输出

支付宝中一些其他业务如(Native GCanas)想要在 C 层获得 JS 引擎能力,同时不想自己费力去重新接入 JS 引擎。这时需要 V8 Worker 具备将小程序的 JS 运行环境对外输出的能力。V8 Native 插件是其中一个方案。

V8 Native 插件

下图描述了 V8 Native 插件的框架。设计思路如下:

  • 在 V8 Worker 中增加一层 C 插件代码,定义 Native 插件的接口,加载业务的动态链接库并管理插件。
  • 将小程序 JS 运行环境(基于 JSI 的 C 接口,jsi::JSEngine、jsi::JSContext)通过插件接口暴露给插件业务方,业务方即可获得小程序JS运行环境,方便添加自定义的 JS 对象,绑定自定义 JSAPI。
  • V8 Worker 将小程序生命周期事件,通过插件接口通知给业务方。
  • 同时给插件业务暴露 PostTask 接口,允许插件业务将任务放到小程序的 JS 线程去执行。

插件业务通过接入 V8 Native 插件将获得如下能力:

  • 获得小程序生命周期事件
  • 获得小程序 JS 执行环境
  • 在小程序 JS 线程执行任务
  • 访问小程序的 JS 对象,JSAPI
  • 注入自定义 JS 对象,绑定自定义的 C 实现 JSAPI

由于插件业务能够直接获得小程序 JS 的执行环境,因此插件业务必须可信的,否则会带来安全问题;所以在 V8 Worker java 层需要对插件进行白名单管理和开关控制。

V8 Worker 性能优化

并行初始化

V8 Worker 最初引入的原因就是为了解决小程序 Render 和 Worker 串行初始化和执行的问题。前文已经介绍,这里不再赘述。

Code Caching

上图是 V8 code caching 的原理。因为 JS 是 JIT 语言,所以 V8 运行 JS 时需要先解析和编译,因此 JS 的执行效率一直都是个问题。V8 code caching 的原理是,第一次运行 JS 脚本的时候同时会生成该 JS 脚本的字节码缓存,并保存在本地磁盘,第二次再运行同一个脚本的时候,V8 可以利用第一次保存的字节码缓存重建 Compile 结果,这样就不需要重新 CompileCode。这样第二次利用 Code Cache 之后,执行这个脚本将会更快。

V8 Code caching 分为两种:

  • Lazy Code caching:只将跑过的热函数生成 code caching
  • Eager Code caching:将整个JS脚本都生成 code caching

Eager Code caching 生成的缓存将会更全,热点函数命中率也会更高。同时体积将会更大,因此第二次从磁盘加载缓存时耗时也会更多。V8 官方宣称 Eager Code caching会比 Lazy Code caching 减少 20%-40% 的 parse 和 compile 的时间。实际上我们通过实验发现 Eager Code caching 并不比 UC 目前的 Lazy Code caching 有更好的效果。原因是缓存的体积对性能影响巨大。但是通过 Trace 分析,使用 Eager Code caching 和没有使用 cache 相比,JS 执行时间还是有较大的提升。

相关链接 [1]https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API [2]https://v8.dev/docs [3]https://chromium.googlesource.com/v8/v8/ /branch-heads/6.8/samples/hello-world.cc [4]https://v8.dev/docs [5]https://docs.google.com/presentation/d/1OqjVqRhtwlKeKfvMdX6HaCIu9wpZsrzqpIVIwQSuiXQ/edit#slide=id.ge4ef702cb_2_67 [6]https://docs.google.com/presentation/d/1HgDDXBYqCJNasBKBDf9szap1j4q4wnSHhOYpaNy5mHU/edit#slide=id.g1357e6d1a4_0_58 [7]https://docs.google.com/presentation/d/1HgDDXBYqCJNasBKBDf9szap1j4q4wnSHhOYpaNy5mHU/edit#slide=id.g1357e6d1a4_0_58 [8]https://v8.dev/blog/code-caching [9]https://developer.android.com/reference/android/webkit/WebView#loadUrl(java.lang.String) [10]https://codesearch.alipay.com/source/xref/Android_wallet_master/android-phone-nebula-git/nebula/js/h5_bridge.js?r=78c30345 [11]https://developer.mozilla.org/en-US/docs/Web/API/Console/log [12]https://codesearch.alipay.com/source/xref/Android_wallet_master/android-ariver/js/workerjs_v8_origin.js?r=b59d7f92 [13]https://codesearch.alipay.com/source/xref/Android_wallet_master/android-ariver/js/workerjs_v8_origin.js?r=b59d7f92 [14]https://github.com/eclipsesource/J2V8 [15]https://opendocs.alipay.com/mini/plugin/plugin-introduction [16]https://v8.dev/docs/embed#security-model

0 人点赞