文章目录
  1. 1. 前言
  2. 2. JS引擎介绍
    1. 1. JavaScriptCore
    2. 2. V8
    3. 3. SpiderMonkey
    4. 4. Rhino
    5. 5. Nashorn
  3. 3. JS引擎性能比较
    1. 3.1 创建JS环境
      1. 3.1.1 JSCore
      2. 3.1.2 J2V8
      3. 3.1.3 Rhino
    2. 3.2 实验〇:包的依赖
    3. 3.3 实验一:空循环
    4. 3.4 实验二:JS调用Java方法
    5. 3.5 实验三:JS递归计算斐波那契
    6. 3.6 实验总结
  4. 4. J2V8的Android开发实例

JavaScript引擎能够解析和执行JS脚本,帮助移动端进行跨平台开发。本文主要介绍和讨论JS引擎在Android系统上的使用和性能比较,并针对JS绘制本地化问题给出了基于V8的开发实例。

1. 前言

近期在产品中遇到了这样的一个问题:Web端已经存在一个功能完善的JavaScript库,如果将相关Web嵌入到App中,则WebView将带来一定的性能影响;如果Android端本地开发一个相同的库,则需要消耗大量的资源。考虑到代码复用和跨平台开发,团队调研了JS引擎在Android上的使用,希望解决”不经过WebView,而将JS库拿到Android端运行,并将运行结果交给本地输出”这一需求。

JS引擎能够解析JS脚本,通常被依附于浏览器中,例如Safari所使用的JavaScriptCore、Google Chrome所使用的V8和FireFox所使用的SpiderMonkey等等。也被用作移动端的跨平台开发,例如NativeScript在iOS端和Android分别使用了JavaScriptCore和V8,ReactNative则都使用了JavaScriptCore。

2. JS引擎介绍

在Android系统中能运行的JS引擎有如下5种:

1. JavaScriptCore

JavaScriptCore是一个在WebKit中提供JS引擎的开源框架,最近更新日期是2016年9月26日。目前该引擎由Apple维护,使用于Safari浏览器,iOS7后也集成到了iPhone平台。

由于其使用C语言编写,因此在Android开发中并不能直接使用。Github上的开源项目AndroidJSCore能够帮助开发者经过调用Java接口而使用JavaScriptCore。该项目由ericwlange维护,最新版本3.0.1发布于2016年7月31日,有API文档和示例代码,但缺少相关技术文章,并仍存在一些性能问题。

2. V8

V8是由Google开发并维护的高性能开源JS引擎,采用C++编写,使用于Google Chrome浏览器。最新版本为V8 5.5 beta,更新于2016年10月24日。

同JavaScriptCore一样,在Android开发中,相关接口需要通过一层包装进行调用。Github上的开源项目J2V8,相关文章可在此处http://eclipsesource.com/blogs/tag/j2v8/查阅,最新版本4.6.0发布于2016年9月22日。

J2V8是一套针对V8的Java绑定。J2V8的开发为Android平台带来了高效的Javascript的执行环境,其以性能与内存消耗为设计目标。它采用了“基本类型优先”原则,意味着一个执行结果是基本类型,那么所返回的值也就是该基本类型。它还采用了“懒加载”技术,只有当JS执行结果被访问时,才会通过JNI复制到Java中。

此外J2V8提供了release()方法,开发者在某一对象不再需要时主动调用该方法去释放本地对象句柄,释放规则如下:

  • 如果是由代码创建的对象,那么必须释放它;如果一个对象是通过返回语句传回来的话,系统会替你释放它;
  • 如果是由系统创建的对象,则无需担心释放,而如果对象是JS方法的返回值,就必须手动的释放它。

3. SpiderMonkey

开源JS引擎SpiderMonkey最初由Netscape开发,如今由Mozilla开发并维护,且被广泛用于Mozilla产品(如FireFox)。最新版本为SpiderMonkey 45,更新于2016年4月14日。

SpiderMonkey提供了一些核心的JavaScript数据类型,如数字,字符串,数组,对象等等,以及一些方法如Array.push。它还使得每个应用程序都容易将其自己的对象和方法暴露给JavaScript代码。应用开发者可以决定应用如何将与所写脚本相关的对象暴露出来。

4. Rhino

Rhino是由Mozilla开发的开源JS引擎。采用Java编写,因此可以直接调用,在JDK 6、JDK 7中更是捆绑了该引擎。最新版本为Rhino 1.7.7.1,更新于2016年2月1日。

其提供的特性包括:

  • JavaScript 1.7的全部特性
  • 可以用脚本方式调用Java
  • 用一个JavaScript Shell来执行JavaScript脚本
  • 用一个JavaScript编译器来将JavaScript脚本文件转换成Java类文件
  • 用一个JavaScript调试器来调试Rhino执行的脚本

5. Nashorn

Nashorn由Oracle开发并维护,从JDK 8开始,Rhino被Nashorn代替,成为JDK默认JS引擎。Nashorn同JDK 8一同发布和开源,较Rhino而言性能更好,但不支持Android Dalvik虚拟机。

3. JS引擎性能比较

从可用性方面考虑,选取了JavaScriptCore+AndroidJSCore(下文由“JSCore”指代)、V8+J2V8(下文由“J2V8”指代)和Rhino三种方案进行实验。

3.1 创建JS环境

部分引擎并不支持直接运行文件,因此将JS代码作为字符串交给JS引擎执行,执行过程中可以调用已注册到JS上下文的Java方法。

3.1.1 JSCore
//创建JS上下文
JSContext context = new JSContext();
//创建Java方法
JSFunction func = new JSFunction(context, "func") {
    public void func(String msg) {
        //相关处理
    }
};
//将Java方法注册到JS上下文
context.property("func", func);
//执行JS
context.evaluateScript(/*JS代码*/);
3.1.2 J2V8
//创建JS上下文
V8 context = V8.createV8Runtime();
//创建Java方法,并注册到JS上下文
context.registerJavaMethod(new JavaVoidCallback() {
    public void invoke(final V8Object receiver, final V8Array parameters) {
        //相关处理
    }
}, "func");
//执行JS
context.executeScript(js);
//释放资源
context.release();
3.1.3 Rhino
class Func {
    void func(String msg) {
        //相关处理
    }
}

//创建JS上下文
Context context = Context.enter();
context.setOptimizationLevel(-1);
Scriptable scope = context.initStandardObjects();
try {
    //执行JS
    context.evaluateString(scope, js, "RhinoUtil", 1, null);

    //将Java方法封装到类中,并作为参数传递给JS方法
    Function function = (Function) scope.get("rhinofunc", scope);
    function.call(context, scope, scope, new Object[]{new Func()});
} finally {
    Context.exit();
}

3.2 实验〇:包的依赖

比较各方案依赖包引入后,apk的体积增量:

依赖包占用(MB) 依赖方式
J2V8 8.2 compile ‘com.eclipsesource.j2v8:j2v8:4.6.0@aar’
JSCore 44.9😳 compile ‘com.github.ericwlange:AndroidJSCore:3.0.1’
Rhino 0.6 将jar包放入libs文件夹下
  • J2V8引入的是全平台包,实际上它有针对Linux、Windows、MacOS、Android各平台进行打包,而Android平台最新版本为3.0.5(约4MB),并不是最新的4.6.0版本
  • 具AndroidJsCore作者回答,由于必须针对各CPU架构进行打包,而有7种CPU架构,每种架构包约6MB,因此整个依赖包体积相比之下显得庞大,但也可以通过ABIs Splits进行拆分。
  • Rhino的jar包仅1.2M,因此apk增量并不显著。

3.3 实验一:空循环

JSCore和J2V8执行JS代码如下:

var i = 0;
for (;i < /*次数*/; i++) {
}

Rhino执行JS代码如下:

function rhinofunc(f) {
    var i = 0;
    for (;i < /*次数*/; i++) {
    }
}

循环次数分别设置为100万、1000万、1亿次,在Java代码中执行JS前输出开始时间,在JS执行后输出结束时间,相减得出执行时间记录如下:

执行时间(ms) 100万次 1000万次 1亿次
J2V8 17.6 122.8 1116.6
JSCore 22 173.2 1551
Rhino 2078.4 22358.4 198600😳

由实验结果可以看到:J2V8和JSCore相比,J2V8执行时间更短,但两者时间都在同一数量级上。而Rhino执行时间远大于前两者,根本原因在于Java程序运行较C/C++慢。

3.4 实验二:JS调用Java方法

JSCore和J2V8执行JS代码如下:

var i = 0;
for (;i < /*次数*/; i++) {
    func();
}

Rhino执行JS代码如下:

function rhinofunc(f) {
    var i = 0;
    for (;i < /*次数*/; i++) {
        f.func();
    }
}

调用次数分别设置为1万、10万、100万次,记录执行时间如下:

执行时间(ms) 1万次 10万次 100万次
J2V8 490.2 4855.8 47527
JSCore 1287.6 13448.2 121586.8😳
Rhino 408.2 4007.2 36752.4

启动App时记录占用内存,JS执行过程中记录最高占用内存,相减得出占用内存。

占用内存(KB) 1万次 10万次 100万次
J2V8 7264 14004 17328
JSCore 15368 15140 16720
Rhino 2776 9084 9116

同样也是由于开发语言的差异,在JS调用Java上Rhino性能更优。执行时间方面,J2V8接近Rhino,而JSCore则远落后于前两种方案,这可能与其Java绑定层有很大关系。对于J2V8和JSCore则需要先经过JNI包装层才能调用到Java方法,而大量的JNI调用是非常耗时、耗内存的。

3.5 实验三:JS递归计算斐波那契

JS代码斐波那契函数如下:

function fibo(n) {
    if (n == 1 || n == 2) {
        return 1;
    }
    else {
        return fibo(n - 1) + fibo(n - 2);
    }
}
执行时间(ms) fibo(20) fibo(30) fibo(40)
J2V8 4 67.6 8158.6
JSCore 6.6 76.2 7884.4
Rhino 248.4 26898 ???😳
占用内存(KB) fibo(20) fibo(30) fibo(40)
J2V8 6148 6120 17328
JSCore 6204 6200 16720
Rhino 5284 9052 9116

Rhino运行JS时频繁GC,虽然使其所占内存较少,但执行速度非常缓慢,相比之下J2V8和JSCore方案效率更高。

3.6 实验总结

单从JS引擎来说:Rhino执行不需要通过JNI且占用更少的内存,但执行效率很低;V8和JavaScriptCore等C语言开发的引擎远胜于Rhino等Java开发的引擎,但需要一层Java包装层,并存在JNI调用性能问题。就J2V8和AndroidJSCore两个包装层而言:J2V8的可用性、可靠性、健壮性更优;AndroidJSCore还存在着不少的性能问题,在上述实验中出现较少,但实际开发中还存在很多坑。

以上三种实现方案中更推荐J2V8方案。对于内存问题,J2V8的内存释放机制较为完善,在实际开发中可以通过主动release来释放内存;对于JNI调用性能问题,J2V8团队也在尝试通过批处理回调来进行优化,在将来的版本中会得到改善。

4. J2V8的Android开发实例

有以下一段JS代码,其中Canvas绘制了一些简单的图形:

var c=document.getElementById("myCanvas");
var ctx=c.getContext("2d");

ctx.beginPath();
ctx.arc(100, 100, 25, 0, 360, false);
ctx.moveTo(20,20);
ctx.lineTo(20,100);
ctx.lineTo(70,100);
ctx.moveTo(100, 100);
ctx.setLineWidth(15);
ctx.setStrokeColor("#188ffc");
ctx.stroke();

ctx.setFillColor("#188ffc");
ctx.fillText("Hello World!",150,50);

其在Web端绘制效果如下:

logo

实例中,借助J2V8在Android端运行同样一段脚本,使图形绘制到Android Canvas中。下面给出简单实现,主要思路为自定义View,JS中将要绘制的图形先存储到在List中,待整个JS脚本执行完毕,再将所有图形在View.onDraw(Canvas canvas)中通过Android Canvas绘制。

1) 将ctx封装为V8Object,并在其中对应实现JS Canvas的方法

public class JustContext {
    protected V8 mRuntime;
    protected V8Object mCtx;
    private ArrayList<AbstractDraw> mShapeList = new ArrayList<>();

    public JustContext(V8 v8Runtime) {
        mRuntime = v8Runtime;
        initCtx();
    }

    public V8Object getCtx() {
        return mCtx;
    }

    public ArrayList<AbstractDraw> getShapeList() {
        return mShapeList;
    }

    protected void initCtx() {
        mCtx = new V8Object(mRuntime);

        //将Java方法注册到该V8Object中
        mCtx.registerJavaMethod(this, "beginPath", "beginPath", null);
        mCtx.registerJavaMethod(this, "closePath", "closePath", null);

        ...
    }

    public void clean() {
        mCtx.release();
    }

    public void beginPath() {...}

    public void closePath() {...}

    ...
}

2) 自定义View,在View的onDraw(Canvas canvas)方法中将传入的图形List绘制出来

public class JustView extends View implements Serializable {
    ...

    private ArrayList<AbstractDraw> mShapeList = new ArrayList<>();

    public void setShapeList(ArrayList<AbstractDraw> shapeList) {
        this.mShapeList = shapeList;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        for (AbstractDraw abstractDraw : mShapeList) {
            abstractDraw.draw(canvas);
        }
    }

    ...
}

3) 将ctx注册到JS上下文,并执行JS代码,执行完毕后将绘制图形List传递给View

JustView mJustView;
...

public void runJs(String js) {
    V8 context = V8.createV8Runtime();
    JustContext ctx = new JustContext(context);
    context.add("ctx", ctx.getCtx());
    context.executeScript(js);

    //释放内存
    ctx.clean();
    context.release();

    mJustView.setShapeList(ctx.getShapeList());
}

...

4) 因为已将ctx注册到JS上下文,因此可以直接使用ctx变量,并运行如下JS代码

    ctx.beginPath();
    ctx.arc(100, 100, 25, 0, 360, false);
    ctx.moveTo(20,20);
    ctx.lineTo(20,100);
    ctx.lineTo(70,100);
    ctx.moveTo(100, 100);
    ctx.setLineWidth(15);
    ctx.setStrokeColor("#188ffc");
    ctx.stroke();        
    ctx.setFillColor("#188ffc");
    ctx.fillText("Hello World!",150,50);

绘制结果如下:

android_shape

以上仅是该实例的简单实现,详细的代码和设计可以根据需求的不同而进行细化,此处https://github.com/LiuHongtao/JustDraw提供一份具体实现的参考。由于每执行一个Canvas方法都将调用一次Java方法,该方案当绘制量过大的时候会出现JNI调用的性能问题,在性能方面也存在优化的空间,后续工作中将进行调用的优化,减少JNI调用次数。

J2V8的团队开发的一套移动端跨平台框架Tabris.js,支持Android和iOS平台,又于近期宣布支持Windows 10。该框架旨在编写一份JavaScript代码,而能生成Android和iOS两个平台的本地应用。它在Android上使用了J2V8作为JS引擎方案,在iOS上使用系统自带的JSCore。

不同于ReactNative、Weex之类的框架,它的亮点在于不通过WebView,但却能使用Web APIs,如Canvas 2d context(据了解,Weex也正在测试Canvas组件并即将开源)、XMLHttpRequest、localStorage。该框架通过批处理回调减少了JNI的调用,在一定程度上解决了JNI性能问题,例如其Canvas相关代码tabris.CanvasContext中,先将每次调用的Canvas方法和参数存储起来,并在适当的时候通过一次JNI调用来传递要执行的Canvas方法,相关代码如下:

defineMethod("fillText", 3, function(text, x, y /* , maxWidth */) {
    this._strings.push(text);
    this._booleans.push(false, false, false);
    this._doubles.push(x, y);
});

function defineMethod(name, reqArgCount, fn) {
    tabris.CanvasContext.prototype[name] = function() {
        if (reqArgCount && arguments.length < reqArgCount) {
            throw new Error("Not enough arguments to CanvasContext." + name);
        }
        this._pushOperation(name);
        if (fn) {
            fn.apply(this, arguments);
        }
    };
}

_pushOperation: function(operation) {
    if (this._opCodes.indexOf(operation) < 0) {
        this._newOpCodes.push(operation);
        this._opCodes.push(operation);
    }
    this._operations.push(this._opCodes.indexOf(operation));
}

_flush: function() {
    if (this._operations.length > 0) {
        this._gc._nativeCall("draw", {packedOperations: [
          this._newOpCodes,
          this._operations,
          this._doubles,
          this._booleans,
          this._strings,
          this._ints]});
        this._newOpCodes = [];
      this._operations = [];
      this._doubles = [];
      this._booleans = [];
      this._strings = [];
      this._ints = [];
    }
}

也就是说Tabris.js更完整的的实现了本节中的方案,这也使得“将Web端JS库直接拿到移动端执行,并由本地输出结果”变得更加容易。

相关文章