JS in Android (Google V8)

4 6月

简介

由于项目动态化的需要,希望在 Android 应用中嵌入脚本语言。常见有 LuaJs。Lua 的集成在之前的文章中已经介绍过。lua 是一个非常轻量的语言,专为嵌入而生,这是他的优势。但因为太轻了,可以说几乎没有标准库,这也使得用起来不是很方便。但目前最大的问题是 lua 脚本无法完美地运行在 web 与微信小程序中,而 js 在这方面有先天的优势。

为了更加紧密地结合原生应用,要求是不依赖 webview,而是程序本身与脚本语言进行交互。鉴于 js 不像 lua 那样原生提供了交互 api,所以必须考虑嵌入一个脚本引擎来实现。

目前常见的 js 引擎有下面几个

  • V8:是 Google 开发的一个开源 js 引擎,使用 c++ 作为开发语言,性能极高。j2v8 项目通过 JNI 将其移植到了 java。
  • Rhino: 是一个 Mozilla 开发的开源 js 引擎。它的特点是完全使用 java 实现,在和 java 的交互上有先天优势。并已经被作为 JDK6/7 标准库默认的脚本引擎。(在 Android 中被移除了,需要手动导入 jar)在 JDK8 中被 Nashorn 取代。
  • JsCore: 是 WebKit 内核默认的开源 js 引擎,由苹果使用 C 开发。AndroidJSCore 通过 JNI 移植到了 Android,但是此项目已停止维护并合并到 LiquidCore

关于上面三个引擎在 Android 上的效率表现,可以参照网易杭州前端技术部的 测试文章。总体来看 Rhino 表现极差,毕竟是纯 java 实现也可以理解了。V8 表现优异,而且 j2v8 封装的很好,最终决定使用这个方案了。

J2V8

HelloWorld

V8 引擎为了实现极致的性能,其内部是非常复杂的。幸运的的是,j2v8 做了非常好的封装,并且 java 层非常简洁,上手可以说是非常快了。如果有之前 lua 的整合经验,那么几乎是一气呵成。已经发布到了 Maven 中央仓库,Android Studio 可以直接添加 implementation 'com.eclipsesource.j2v8:j2v8:5.0.103@aar' 来自动化集成。

要运行 js 首先需要创建一个运行环境,下面是一个 HelloWorld,主要翻译自官方文档

下面的例子创建了一个 js 运行时,并执行一段 js 代码获得了结果:

public static void main(String[] args) {
 V8 runtime = V8.createV8Runtime(); // 创建 js 运行时
 int result = runtime.executeIntegerScript("" // 执行一段 js 代码
  + "var hello = 'hello, ';\n"
  + "var world = 'world!';\n"
  + "hello.concat(world).length;\n");
 System.out.println(result);
 runtime.release(true); // 为 true 则会检查并抛出内存泄露错误(如果存在的话)便于及时发现
}

一旦创建了运行时,就可以在上面执行脚本。根据返回类型不同有好几种脚本执行方法。在本例中,我们使用 executeIntegerScript,因为结果是一个 int。当应用程序终止时,必须释放运行时。

内存管理

需要说明的,这里的内存管理指的是 java 管理 c 的内存。至于 js 对象的生命周期,会由 v8 引擎自动管理。

手动管理

显然 c 需要手动管理内存。而 java 层拿到是其实直接一个 c 的引用,因此必须手动释放内存。否则会导致 c 层内存泄露。注意:finalize() 方法中释放是不可取的,因为此函数调用的实际不确定,甚至不保证会调用。

对于 j2v8 来说,以下对象必须手动释放:

  • 自行创建的对象。例如 new V8Object() 创建的。
  • 从 js 中主动获取的对象。例如 v8.getObject().
  • 从 js 数组中提取的。例如 v8Array.getObject(0).

注意:

  • c++ 层作为参数传入到 java 的对象无需释放。因为它不是 java 自己创建的。
  • 但是若传入的是数组,那么从数组中获取的对象必须释放,因为它是 java 主动获取的。
  • 创建出的用作传给(或返回给) js 的对象必须释放,因为它是 java 创建的。

自动管理

从 j2v8 V4 开始支持了 自动内存管理。但这也不是全自动的,需要手动创建一个内存管理器。

为了避免忘记释放对象,MemoryManager 一旦被实例化,就会自动跟踪所有创建的 v8 对象,并在自身被释放时释放它们。作为开发者,可以在一个代码块的开始实例化 MemoryManager,然后调用一些 v8 api,最后在代码块结束时释放它。这样就可以保证这个代码块不会造成任何的 v8 内存泄露。显然自动管理要求实例化内存管理器,因此在频繁调用的函数上面并不是一个最佳的选择,因为这会不断地创建对象造成额外开销。

下面是没有使用 MemoryManager 时的一个例子:

loDash = nodeJS.require(new File("/Users/irbull/node_modules/lodash"));

V8Object o1 = o("a", 1);
V8Object o2 = o("b", 2);
V8Object o3 = o("c", 3);
V8Object objects = (V8Object) loDash.executeJSFunction("assign", o1, o2, o3);
LoDashObject e1 = loDash(objects);
LoDashObject e2 = e1.e("values");
V8Function f = f((V8Object receiver, V8Array parameters) -> parameters.getInteger(0) * 3);
LoDashObject result = e2.e("map",f);
System.out.println(result);

loDash.release();
e1.release();
e2.release();
f.release();
o1.release();
o2.release();
o3.release();
result.release();
objects.release();

在用了自动内存管理后,代码会变得非常简洁:

MemoryManager scope = new MemoryManager(v8); // 实例化 MemoryManager
loDash = nodeJS.require(new File("/Users/irbull/node_modules/lodash"));

V8Object objects = (V8Object) loDash.executeJSFunction("assign", o("a", 1), o("b", 2), o("c", 3));
LoDashObject result = loDash(objects).e("values").e("map",
  f((V8Object receiver, V8Array parameters) -> parameters.getInteger(0) * 3));
System.out.println(result);

scope.release(); // 释放

如果希望保留一些对象供将来使用,MemoryManager 提供了 persist API。例如,我们可以在 result 对象上使用它。

内存管理器也可以嵌套。在进入新范围时,可以创建一个新的 MemoryManager 来管理该范围内的对象。所有以前的 MemoryManager 将收到所有新对象创建和删除的通知。

多线程

一般来说认为 js 是单线程的。j2v8 所创建的 runtime 也只能同时在一个线程上使用。不过我们可以在不同线程上创建多个 runtime 来同时运行,但是这样不同线程的 runtime 是相互隔离的,无法访问其他线程下的变量与函数。

幸运的是,j2v8 提供了线程切换。尽管同时只有1个线程可以访问,但是我们可以在不同的线程间切换使用。j2v8 通过 V8Locker 来锁定线程。在 runtime 创建时,会自动实例化一个 V8Locker,在实例化时会自动锁定到当前线程,只有获得锁的线程才允许访问 runtime。要想其他线程获得锁,只需先释放当前线程的锁即可。

下面是一个简单的例子:(使用 kotlin 语言)此例子中定义了一个多线程的 AsyncTask,并在准备工作中释放了主线程的锁,在子线程中取得了锁并执行了一些操作,在子线程退出前释放锁。最后主线程重新获得锁,完成了一个完整的线程切换。

private class LoadTask() : AsyncTask<int, int, int>() {
    override fun onPreExecute() {
        super.onPreExecute()
        if (v8.locker.hasLock()){
            v8.locker.release() // 释放主线程的锁
        }
    }
    override fun doInBackground(vararg params: int?): int {
        v8.locker.acquire() // 子线程获得锁
        // 执行一些 v8 操作
        // ...
        v8.locker.release() // 释放子线程的锁
        return 1
    }
    override fun onPostExecute(result: int?) {
        super.onPostExecute(result)
        v8.locker.acquire() // 主线程重新获得锁
    }
}

Java 调用 Js

Java 调用 js 非常简单,总共有三种方式。

1. 执行 js 脚本

这就是 HelloWorld 中展示的方式。v8 可以直接执行 js 脚本,并取得返回值。

2. 通过函数名调用

方式一虽然简单,但若需要传递复杂的参数(例如数组)就很麻烦了。通过函数名可以定位到 js 函数,并调用它,支持传递复杂的参数。下面是一个简单的例子:

首先我们定义个 js 全局函数:

function add(a, b){
    return a + b
}

然后在 java(kotlin) 中执行它:

val arg = V8Array(v8).push(12).push(21) // 创建参数数组
val r = v8.executeIntegerFunction("add", arg) // 调用函数
arg.close() //别忘记释放对象
// r = 33

这样,通过创建一个数组,我们可以传各种各样的参数了。

3. 通过 Function 对象调用

在 js 中万物皆对象,函数也不例外。在 j2v8 中,一切 js 对象都用 V8Object 表示,我们可以直接将其强制转换为 V8FunctionV8Function 表示的就是一个 js 函数对象,它拥有 call() 方法可以直接被调用。

if (v8.getType("add") == V8.V8_FUNCTION){ // 先判断 add 是不是一个函数
    val arg = V8Array(v8).push(12).push(21)
    val call = v8.getObject("add") as V8Function // 取得函数对象
    val r = call.call(null, arg) // 调用它
    arg.close()
    call.close()
}

乍一看此方法似乎比方法二要麻烦,确实一般情况下我们都采用方法二。但试想这样一个需求:js 先调用 java 函数,并传入一个类型为 js 函数的参数作为回调。那么此时方法三就可以大显身手了。


上面调用的都是 js 全局函数,所以调用者都是 v8,也就是运行时本身。如果要调用对象的成员函数呢?也很简单,只要先通过 val obj = v8.getObject() 获取到对象,并使用 obj.executeIntegerFunction() 就可以啦。同理,如果要调用对象的对象的成员函数,只要再获取一层对象就好。

其实 V8 本身就继承自 V8Object,这更体现了 js 万物皆对象的特性。运行时自身可以被理解为是一个根对象。

Js 调用 Java (注册 Java 回调)

Js 不可以直接调用 Java 的函数,一般来说得先把 Java 函数注册到 js 才可以。本节部分内容来自官方文档

接口方式

要注册函数到 js,首先要创建一个类,并实现 JavaCallback 接口(如果 java 函数没有返回值,则实现 JavaVoidCallback 接口即可)。这两个接口均有一个 invoke(V8Object receiver, V8Array parameters) 函数,当被 js 调用时就会触发,这样就可执行 java 代码啦。

  • parameters 是传入的参数列表,表现为一个 V8数组,可以从中提取 js 传入的各个参数。
  • receiver: 是此函数被调用时所基于的对象。看下面一个例子:

js:

var array1 = [{first:'Ian'}, {first:'Jordi'}, {first:'Holger'}];
for ( var i = 0; i < array1.length; i++ ) {
  print.call(array1[i], " says Hi."); // print 是 java 注册到 js 的一个函数
}

java:

JavaVoidCallback callback = new JavaVoidCallback() {
    @Override
    public void invoke(final V8Object receiver, final V8Array parameters) {
        System.out.println(receiver.getString("first") + parameters.get(0));
    }
}

v8.registerJavaMethod(callback, "print") // 注册到 js 全局函数,函数名为 `print`

在这个例子中,print 不再基于 Global,而是基于 array1[i] 被调用的,因此 array1[i] 将被传入 java 作为 receiver. 最终 java 将依次输出 Ian says Hi., Jordi says Hi., Holger says Hi..

反射方式

接口方式有些麻烦,必须将使用一个类包装一下,并实例化再注册到 js(或创建匿名类)。

下面是一个简单的例子:

class Console {
     public void log(final String message) {
          System.out.println("[INFO] " + message);
     }
     public void error(final String message) {
          System.out.println("[ERROR] " + message);
     }
}

public void start() {
     Console console = new Console();
     V8Object v8Console = new V8Object(v8);
     v8.add("console", v8Console);
     v8Console.registerJavaMethod(console, "log", "jlog", new Class<?>[] { String.class });
     v8Console.registerJavaMethod(console, "err", "jerr", new Class<?>[] { String.class });
     v8Console.release();
     v8.executeScript("console.jlog('hello, world');");
}

// 然后可以直接在 js 中调用 `console.jlog('hello, world')` 与 `console.jerr('hello, world')` 了。

在本例中,通过反射注册了现有 java 对象的方法。必须指定Java对象、方法的名称和参数列表。并且此处不是直接注册为全局函数,而是先创建了一个名为 console 的 js 对象,把函数注册到了此对象上,

不难发现,通过接口方式注册,参数可以是动态的;而通过反射注册,参数必须明确指定并且与 java 参数严格匹配,若参数不匹配则会异常。

无需注册

在 j2v8 V4 中,增加了新的方式,可以无需注册将 js 执行 java 函数。

在之前的例子中,java 函数必须先注册到 js 才可以被调用。试想这样一个需求:js 有一个函数,需要传入一个回调函数。此时希望 java 来执行此函数,那么参数应该怎么传递呢?之前,是没有办法,但是现在有了。

js:

function add(a, b, callback){
    callback(a + b)
}
// 我承认这个 callback 有点画蛇添足,这只是为了演示。
// 现在我们要从 java 中调用这个 add()

j2v8 V4 中新增了 V8Function 类。其实这个类我们之前在 java 调用 js 的时候已经用过了。只不过那时候是从 js 获得的,现在我们要自己创建它。

java(kotlin):

val callback = V8Function(v8,
        { receiver: V8Object, parameters: V8Array -> System.out.println(parameters.getInteger(0)) })
val arg = V8Array(v8).push(1).push(2).push(callback)
v8.executeVoidFunction("add", arg)

在这个例子中,我们直接使用 lambda 表达式创建了一个 V8Function,作为参数传给了 js,然后 js 在执行 callback 的时候也就执行了 java 代码。最终将输出3。当然你也可以使用 v8.add("print", callback) 直接注册为 js 全局函数,相比之前的方案要简洁不少。


好啦,基本上就说到这了。通过这些例子,相信对于更加基本的 js 对象操作、数组操作也已经轻车熟路了。用到什么新的再补充吧。

发表评论

电子邮件地址不会被公开。 必填项已用*标注