如何在NativeScript中嵌入V8?

作者 | Stanimira Vlaeva

译者 | 杨志昂

编辑 | Yonie

V8 是支持谷歌 Chrome、Node.js 和 NativeScript 的 JavaScript 引擎。NativeScript 嵌入 V8 来处理 JavaScript,并动态地调用 Android API。这让开发人员可以用 JavaScript 编写 Android APP,还可以直接访问底层的操作系统。通过本文,可以了解 NativeScript 团队在移动框架中嵌入 V8 所遇到的挑战,以及如何使用 V8 这款最复杂的 JavaScript 引擎之一来支持所有基于 C++ 的 APP。

本文将讨论现实世界中的 V8,或者更具体来说,是原生脚本框架中的 V8 引擎。我们先来谈谈NativeScript。它是一个使用 Web 技术为 Android 和 iOS 构建本地移动 APP 的技术框架。这些 Web 技术包括 Angular、Vue,还有就是普通的 JavaScript。

什么是 NativeScript

简而言之,NativeScript 是一种在移动端中执行 JavaScript 的方式。并且,你可以利用它来构建移动 APP。我们将简要概述这个框架的架构。在底层,我们有 Android 和 iOS,它们是移动设备的操作系统。在操作系统之上是为 Android 和 iOS 打造的 NativeScript 运行时,令你可 100% 访问本地 API。但是,如果你曾经为 Android 或 iOS 构建过本地 APP(Native APP),可能会注意到,APP 在两种操作系统下的实现方法大相径庭。不仅 API 不同,构建用户界面的方法也不同。一切都截然不同,因为这两种操作系统完全就是两个不同的世界。

这就是为什么 NativeScript 要为这些 API 提供了一个公共抽象。这是该框架的一部分使命,NativeScript 开发者使用这个用 JavaScript 写的抽象层,用它来打造页面布局,构建用户界面,甚至用 CSS 来风格化 APP,使用了这样的公共抽象后,你就可以只维护一套代码却能在 Android 和 iOS 平台上得到不同的 APP。

支持 Angular 和 Vue

NativeScript 还有一个非常轻量级的应用程序框架,它为我们提供了本地绑定、导航和其他一些很酷的东西。如果在构建 APP 时需要使用更复杂的东西,NativeScript 还支持 Angular 和 Vue JS。

NativeScript 拥有访问 API 的权限

Android 和 iOS 两个系统的运行时非常相似。它们之间最大的区别是 Android 运行时使用 V8 引擎,而 iOS 运行时使用另一个 JavaScript 引擎,即 JavaScript Core。

但它们工作原理非常相似。我们将首先解释本机 API 访问是如何工作的。可能通过“NativeScript”这个名称你就会猜到,我们拥有 100% 的 API 访问权限,所以,这是我们最引以为豪的部分。这就是为什么你应该使用 NativeScript 而不是选择其他别的什么。这是 NativeScript 的主要优势。我们后面会讲到它是如何工作的。

NativeScript 的优势

我们将从查看 NativeScript APP 的应用程序包开始。是这样的,我们有 Android 操作系统,它运行在一些手机或移动设备上。NativeScript APP 只是一个普通的 Android APP,当然其中加了一些 NativeScript 的优势:

第一部分是 NativeScript 开发人员在 APP 中编写并载入的 JavaScript 代码。这些 JavaScript 代码不是通过交叉编译或通过转换等方式得到的,而是在 APP 运行的整个生命周期中,一直保持 JavaScript 的形态。

第二部分是有两个版本的 NativeScript 运行时,它们都是用 Java 写的。我们这里想提的是,这两个版本已经一起装载到了 APP 内。

剩下的几乎全是 V8 引擎了。为什么在 Android APP 中装载 V8 引擎?是为了执行 JavaScript。V8 是一个 JavaScript 引擎,用于执行 JavaScript。V8 引擎被嵌入了 Chrome 浏览器,甚至现在还被嵌入了微软的 Edge,当然,还有我们的 NativeScript。V8 是由 Google 开发的。它的创建源自 Chrome 浏览器,是目前最快的 JavaScript 引擎之一。我们选择 V8 的另一个原因是,它有很棒的 API,我们将这个 API 插入到 runtime 中。

如果你想进一步了解 V8 及其工作原理,我强烈推荐以下两个参考资料:

第一个是非常棒的一系列即时编译器的速成课程,以弹出式互动呈现。

另一个是最近的一个“Life of a Script”的视频,这个视频讲解了它在执行 JavaScript 代码时所做的底层优化。该视频出自 V8 团队。

元数据生成器

NativeScript 魔法的下一部分是元数据生成器。这是 NativeScript 中,非常有效的 JavaScript 代码之一。但是我们有一些 JavaScript 语言中并不常见的东西,比如下图出现了 android 关键字,这是怎么实现的?

假设,在你的电脑上有一些本地的类库。例如,Android SDK。你可以在你的 NativeScript APP 中使用它。在构建 APP 时,NativeScript 会运行一个名为元数据生成器的特殊工具,该工具会遍历本地的类库,并获取关于 API 的信息。这个工具会获取所有的全局包、每个类、如何实例化这些类、这些类中的每个方法以及元签名的信息等。基本上,它能获取每个方法和 API 如何使用的信息。

它保存在一个紧凑的 runtime 二进制文件中,同样,该二进制文件也装载在 APP 中。

因此,我们也掌握了如何在元数据中使用 Java 创建的信息。元数据当然也装载在 APP 里。

APP 启动时会发生什么?

我们初始化 V8, 它可以执行 JavaScript 代码。我们从 APP 保存的文件中加载元数据,同时附上来源和回调函数。回调是实现嵌入 V8 最重要的部分。我们就是利用它们来插入 JavaScript 代码并完成各种操作。

回调

让我们首先解释一些关于这些回调的内容,以及它们是如何与元数据一起工作,从而提供本机 API 访问的。我们可以举一个例子,比如 Android media recorder(媒体录制器)。我们想要执行 JavaScript 代码,这时 NativeScript 运行时读取了元数据,发现有一个 Android 全局包。这就是为什么它在运行的 Android V8 实例中创建了一个全局对象,同时它还为该对象附加了一些回调,比如媒体包的 getter 回调,于是当我们查询 Android.media 时,NativeScript 运行时利用该回调进行插入。该回调会被执行,NativeScript 运行时会在该回调中试图找到元数据中的 Android.media。它会返回一些东西,例如 Android.media 有一些媒体录制器的信息。它还附加了一个包的 getter 回调。因此,当这个回调被调用时,我们就能在元数据中找到 Android 媒体包中的媒体录制器。

这次我们返回一个构造函数,因为这实际上是一个类。为什么这个构造函数如此重要?因为当它被 new 调用时,它实际上包含一个构造函数回调。同样,这个回调由 NativeScript 运行时附加。这才是真正发生魔法的地方。因为 NativeScript 运行时创建了一个本地 Java 对象。但这是怎么发生的呢?

我们使用 JNI(即 Java 原生接口),它是 V8 和运行中的 Android 运行时之间的桥梁。我们可以在这两者之间来来回回地保存函数。

这样,我们就创建了一个本地对象。接着我们创建 JavaScript 代理对象,稍后我们将讨论它,这个代理对象会被返回到 JavaScript 的世界。如果能访问代理中的内容的话,那么可以看到,实际上这个代理对象并不简单。它不是一个普通的对象,它会创建一些回调,同时还包含一些回调。

因此,当我们试图访问这个随机字段时,我们知道这个字段存在于 Java 世界中,因此我们附加了一个字段 getter 回调。这个字段 getter 回调会查询原始的 Java 对象。

但这里稍稍有点复杂。然后,我们可以从 Java 世界中得到结果。但是得到的数据类型与 JavaScript 数据类型不同,是吧?java.lang.String 可不能直接赋值给 JavaScript 变量。

封送服

这就是为什么要有封送服务(marshaling service)了。这个服务将数据类型从 Java 转换为 JavaScript,反之亦然。在这点上,你可能会说,如果要转换所有的东西是不是会特别慢?显然,如果要转换整个对象的话的确会很慢,这不是一个好主意。

这是代理非常有用的另一个原因。因此,对于对象,我们只是创建一个普通的 JavaScript 对象,它具有相同的方法,相同的签名,相同的成员。在对象里面包含有回调。这样,当你在 JavaScript 对象上调用具有相同名称的方法时,回调将被调用,NativeScript 运行时将调用 JNI 的原始 Java 方法。

这是一个代价很小的操作。它只是创建新的 JavaScript 对象,而不是转换对象数据。如果你调用一个方法,同样地,会触发一个回调方法。我们调用原始的 Java 方法。结果再次被封送并返回到 JavaScript 世界中。如果该方法带有参数,这些参数将转换为 Java 的数据格式。然后,Java 方法将带着这些转换过的参数被调用。

小结

如果你对所有这些回调感到困惑,那么让我们来快速浏览一下它们。我们尝试实例化新对象并将其赋予 JavaScript 变量。我们调用构造函数进行回调。通过 JNI 创建这个类的新实例,返回实例。因为它是一个对象,所以 NativeScript 运行时会为之创建一个 JavaScript 代理对象。然后我们尝试调用代理上的一些方法。我们实际上在无感知的情况下调用了回调。一切都是隐藏,这些事情发生在幕后。但是这些方法回调会随后调用原始 Java 方法。通过 JNI 返回的结果,经过封送并返回到 JavaScript 的世界。这就是所发生的所有通信魔法。

自动进行垃圾回收

此时,你可能想知道这些对象会发生什么。比如我们创建了 JavaScript 对象,还创建了 Java 对象。我们以某种方式把它们绑定在一起。所以,我们必须得留意它们的生命周期。在 JavaScript 中,我们不需要手动管理内存。因为 JavaScript 有一个自动运行的垃圾收集器,它总是收回未使用对象的内存。但这个垃圾收集器具有非确定性,即我们不能确定垃圾收集器何时会运行。另一种复杂情况是,Android 运行时也有垃圾收集器。

这很有趣吧。于是,我们就有两个垃圾收集器在运行。而我们在 Java 和 Javascript 两个世界里都有对象。这是 NativeScript 运行时面临的最大挑战之一。我们需要某种方式的同步,即我们必须确保,如果一个对象在另一个世界里的配对对象还在使用,这个对象就不能被回收。

例如,如果你通过 JavaScript 创建了一些 Java 对象,然后试图访问它,如果 Android 垃圾收集器回收了本地 Java 对象,这就大事不妙了,因为你会试图访问一些不存在的东西,而 APP 会崩溃。如果你正在运行一个移动 APP,而这个 APP 突然崩溃了,这种用户体验真的一点也不好。

强引用和弱引用

为了介入这些对象生命周期的管理,我们使用 finalizer 回调,当 V8 的垃圾收集器标记了一些要回收的东西,比如,某个对象在任何地方都没有活动实例,并且应该回收这个对象时,就会调用这个 finalizer 回调函数。这就是我们插入运行时脚本的地方。我们有强引用和弱引用。让我们看看它们具体长什么样子。

我们用之前一样的媒体录制器例子。首先,我们创建本地对象。然后创建 JavaScript 代理。然后 NativeScript 运行时有两种连接。一个是强引用,一个是弱引用。当两个对象首次创建时,我们在两个对象之间创建一个强引用,或者叫链接。如果觉得有些困惑,那我们就说得更加明确一些,代理在 V8 中,而原始对象位于 Android 运行时中,引用位于 NativeScript 运行时中。

好吧。垃圾回收的时间已到。此时某个垃圾收集器会自动运行。我们不能确定是 V8 垃圾收集器还是 Android 运行时垃圾收集器。但是在这个例子中,我们假设是 V8 先进行的内存回收。

由于 JavaScript 世界中没有人使用 JavaScript 代理的媒体录制器,所以它被打上了要做回收的标记。而此时,调用了 finalizer 回调。然后,NativeScript 运行时看到这里有一个依然活跃的强引用。这就是为什么强引用变成弱引用。这时我们命令 V8 不要回收那个对象。

下一次当 Android 垃圾收集器运行时,它决定将媒体录制器对象标记为要回收,因为 Java 世界中没有人使用这个对象。它看到这里有一个弱引用。因为它是弱引用,所以这个对象会被收集。

假设 V8 垃圾收集器在某个时候再次运行。现在只有一个弱引用,而且弱引用没有指向任何东西。因为我们在 Java 世界中已经没有任何配对对象了,所以此时这个对象也可以被回收。它被标记为已回收,现在我们可以收回内存了。

但是,如果在上述例子中,我们在 V8 中连续运行两次垃圾收集器,这个对象仍然有一个对活动对象的弱引用,而这个活动对象不是由 Java 垃圾收集器创建的,那么该 V8 对象也不会被回收。这是一个正常的周期。

可以想象,由于我们有两个正在运行的垃圾收集器,所以难免会出现一些挑战。可能会发生内存异常。通常在 Android 应用程序中创建的对象并不大。在 hello world 式的简单 APP 中不会发生这种情况。

但问题是,垃圾收集周期当然是必要的,应该运行这些周期来回收一些内存。如果我们创建一些大的对象,这可能会导致一些问题,因为内存没有按时回收。

例如,假设有大量图片。假设在 Java 世界中用 Java 数组表示图像。而这个 Java 数组相当庞大。而 JavaScript 代理对象并没有那么大。它实际上只是一个带有一些回调的普通对象,在内存中并不大。也就是说,我们在 Java 世界中占用了很多内存,却有一个非常简单的 JavaScript 代理。而 Java 的 Android 垃圾收集器实际上依赖于 V8 垃圾收集器来回收这一大块内存。

此时对于 V8 垃圾收集器而言,即使你有成千上万个这样的小的、普通的代理,它也没有内存压力而触发自动运行。因为在运行的 JavaScript 虚拟机中并不需要占用太多内存。所以 V8 没有理由触发这边的垃圾收集。而如果没有按时发生内存回收,我们可能会导致内存耗尽的异常,因为我们在 Java 虚拟机中占用了太多的空间。

由于问题的本质,因为没有直接的确定性解决方案,有些解决方案看上去像是可以克服这个问题的策略。第一个方案,V8 提供了一个 API,让我们可以指示 V8 内部的内存分配。在我们的例子中,我们可以对 V8 说,运行的 Android APP 实际上使用了这么多内存,而使用这个内存是因为创建了一些 JavaScript 对象。这些 JavaScript 对象仍然指向 Java 世界中活动的实例。以此提示 V8 应该更频繁地垃圾回收,因为它知道这意味着有更多的内存被释放。我的意思是,这个方案在实践中是起效的,但是我们仍然可能会碰到内存耗尽的异常。

另一件重要的事情是,我们在 NativeScript 运行时的内部实现这个,所以 NativeScript 开发人员并不需要使用它,这只是一种内部使用的技术。

另一个解决方案。当然,我们可以强制垃圾收集运行。我们可以对 V8 说,来吧,开始运行垃圾回收。将这些对象标记为可自由回收的对象。使这些强引用变为弱引用。然后我们就可以运行 Android 垃圾收集器了。然后再次运行 V8 垃圾收集器。

但这并不是最好的方法,因为它不能保证垃圾收集一定会运行。它有自己的时刻安排,或者可以通过提示可以促使它运行,但我们无法确保它一定会运行。而且你不能保证它也会按以上顺序运行。所以这并不是代价最小的选项。你检查对象,并查看它们是否还有仍然活动的引用,但这样可能会产生相反的效果。所以,这不是最好的解决方案。这是一种策略,虽然你能做到,但我们不建议使用它。

好吧。让我们再看一遍。我们有很强的引用。如果没有引用呢?我们是否能够控制这些不能被回收的 Java 对象,因为我不再使用它了,让它变得可以被回收。我没有在 JavaScript 世界中使用它。于是 NativeScript 运行时会发布一个 releaseNativeCounterpart 函数,我们需要运行这个对象,它基本上会销毁所有这些引用。我们调用它,指示不再使用这个本地对象,并且可以回收它的内存。

因此,当下一次 Android 垃圾收集器运行时,它不再依赖于 V8 的垃圾收集器。它可以标记这个对象并去回收它。

最后一部分看起来有点简单:NativeScript 中的 JavaScript 代码是在一个单独线程中运行和执行的,而它恰好是用户主界面线程。

如果你明白我的意思,会知道这可能将导致一些 log 和 jank 问题。因此,在使用移动 APP 时,你可能会看到一些小故障。对于本地移动 APP 来说,这也不是最好的用户体验。所以,你可能首先得明白什么是 jank。它是当你在做一些计算时,被丢弃的帧百分比。

但这并不是我们关注的问题。重要的是,在 NativeScript APP 中,如果你只是在构建用户界面,那么你会创建一些本地 Android 和 iOS 的小部件。例如,在滚动屏幕时,你不应该在本地列表视图中遇到 jank。如果你在创建动画,也是一样的。在 NativeScript 里你有很多方法可以创建动画,用 Angular、CSS 和 JavaScript。但从内部而言,它实际上是在创建本地 APP。所以,你在运行动画时不应该有任何问题。

另一个我们经常被问到的问题是,如果你创建一个 HTTP 请求,所使用的插件会在 Java 世界中创建一个后台线程,该线程不会冻结 UI 主线程。但在执行 CPU 密集型操作时,您可能会看到一些 jank。如果在 Android APP 中执行 CPU 密集型 Java 代码,也会发生同样的事情。

解决方案是什么?是 worker 线程。本质上,它们是解锁主线程的后台线程。我们没有 JavaScript 内存共享,但是我们有一种在 worker 线程和 UI 主线程之间通信的方法。

最后一个问题,什么是 NativeScript 中的 worker 线程?

先思考一下,Worker 线程是一个 isolate(隔离)吗?isolate 是 V8 引擎为正在执行的代码隔离一些内存的方法。不同的 isolate 可以并行运行,彼此之间没有内存共享。

我们再想想 context(上下文环境)。一个 isolate 里可以拥有多个 context。我们没有成员 isolate,也不能并行运行 context。此外,你还必须显式地指定正在执行某些代码的 context。

所以,最终答案是 isolate。

英文原文:https://2019.jsconf.eu/stanimira-vlaeva/embedding-v8-in-the-real-world.html

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20190831A04H9U00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励