在Android上使用JS引擎是一种什么样的体验?
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端绘制效果如下:
实例中,借助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);
绘制结果如下:
以上仅是该实例的简单实现,详细的代码和设计可以根据需求的不同而进行细化,此处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库直接拿到移动端执行,并由本地输出结果”变得更加容易。
相关文章