最近在个人项目中频繁的使用 JavaScript,于是趁着这个机会介绍一些 iOS 开发中关于 JavaScript 的内容。

JavaScriptCore 是一个存在于 OS X 与 iOS 平台中很长时间的框架,从 iOS 7 开始移动端的开发者已经可以使用这套框架,虽然他并不完美,但依赖于内置的 JavaScript 解释器,还是可以为我们做出大量的贡献,而这篇博客主要介绍的,就是这种 JavaScript Binding 技术。

什么是 JavaScript Binding?

那什么是 JavaScript Binding 呢?有的朋友可能接触过类似的东西,比如 Lua Binding 什么的。简单来说,就是绑定,是一种将 JavaScript 与 Native 进行绑定为两者之间建起桥梁的技术。

这种技术用在什么场合?

每种语言都有自己无法替代的特性,但也都有自己的硬伤。将这二者绑定,最主要的目的就是为了互相弥补。

举个栗子,我们知道 JavaScript 经常与 HTML 用在一起,而 HTML 5 中的 Canvas 又是很多小游戏开发者的主战场,但是在移动端 Canvas 的性能还有待提高,那么要更好地在移动端展现这些游戏的魅力,难道只能用 Native 的方式全部重新开发?这时候这种 Binding 技术就派上了用场。提一个大概的思路,通过 OpenGL 实现一套与 Canvas 风格类似的 API 并利用 Binding 技术让 JavaScript 使用这些 API。这样做的好处很显然,第一,几乎甚至可能完全不需要改动原有的代码,第二,这套 API 还可以适用于其他项目。

如何使用?

说了这么多,是时候来看一下如何使用了。

首先我已经提到,JavaScriptCore 是一套框架,那么我们在 Xcode 中的 Build Phases 标签下将其引入项目。

#import <JavaScriptCore/JavaScriptCore.h>

接下来,我们从简单的部分说起。和绘图类似,我们也需要一个上下文实例。

JSContext *context = [JSContext new];

然后,创建一些变量:


JSValue *someInt = [JSValue valueWithInt32:12345 inContext:context];
JSValue *someArray = [JSValue valueWithNewArrayInContext:context];
NSLog(@"someInt = %zd", [someInt toInt32]);
NSLog(@"someArray's count = %zd", [someArray toArray].count);

类似的方法还有很多,就不一一列举了。除了上面这种方法,我们还可以在上下文中为其命名:


JSValue *myVar = [JSValue valueWithDouble:3.1415926 inContext:context];
context[@"myVar"] = myVar;
NSLog(@"myVar = %@", context[@"myVar"]);

这样做,相当于 JavaScript 中的 var myVar = 3.1415926; 语句。

那么我们是否可以直接使用这样的语句吗?答案是可以。


[context evaluateScript:@"var anotherVar = 100;"];
NSLog(@"anotherVar = %@", context[@"anotherVar"]);

是的,这就是 eval。但问题又来了,如果 eval 语句有错呢?


[context setExceptionHandler:^(JSContext *ctx, JSValue *exception) {
    NSLog(@"%@", exception);
}];

接着,我们来写一些错误的语句产生异常:


JSValue *err = [context evaluateScript:@"a + b"];
NSLog(@"err = %@", err);

看看输入结果,成功捕获了异常。


ReferenceError: Can't find variable: a
err = undefined

现在,让我们来试试 JavaScript 中定义和调用函数吧:


[context evaluateScript:@"function add(a,b){ return a + b; }"];
JSValue *addFunc = context[@"add"];
JSValue *res = [addFunc callWithArguments:@[@(1), @(2)]];
NSLog(@"res = %@", res);

首先通过 eval 定义了函数 add(a, b),然后通过 callWithArguments 方法调用并传入参数,成功得到返回值。

看起来似乎不错,但我不喜欢这种用字符串的方式定义,这让我觉得十分的不可控。


context[@"printline"] = ^(id arg) {
    JSContext *ctx = [JSContext currentContext];
    JSValue *str = [JSValue valueWithObject:@"tmp string" inContext:ctx];
    ctx[@"tmp"] = str;
    NSLog(@"%@ -> tmp = %@", arg, ctx[@"tmp"]);
};

现在我们改用 block 的方式定义,much better。调用一下试试看:


[context evaluateScript:@"printline(\"test printline()\");"];
NSLog(@"tmp = %@", context[@"tmp"]);

但是处女座的我还是觉得不够完美:


context[@"variousAdd"] = ^() {
    NSArray *args = [JSContext currentArguments];
    CGFloat sum = 0;
    for (JSValue *arg in args) {
        sum += [arg toDouble];
    }
    return sum;
};
JSValue *sum = [context evaluateScript:@"variousAdd(1, 2, 3, -0.5);"];
NSLog(@"sum = %@", sum);

现在我们可以通过 [JSContext currentArguments] 语句得到传入的参数列表后再进行操作,这样子好多了。

MOCK

现在,我们来做一些更高级的事情。

首先新建一个 test.js 文件并导入项目(记得要 Copy Bundle Resource 哦),打开这个文件输入一些简单的 JavaScript 代码。


console.log("Hello, world!");

现在我们回到之前的代码,将该文件读取为 NSString 对象,并通过 eval 方法执行。


NSString *path = [[NSBundle mainBundle] pathForResource:@"test" ofType:@"js"];
NSError *error;
NSString *js = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error];
[context evaluateScript:js];

残忍的事情发生了,并没有像我们预期的那样输出 Hello, world!

为什么呢?因为我们现在所处的环境并不是 Web 页面,因此我们无法调用 console.log 这样的方法。

那么怎样解决这个问题呢?来认识一下 JSExport

首先我们在 Xcode 中新建一个 Console 类:


#import <Foundation/Foundation.h>

@interface Console : NSObject <ConsoleExport>

@end

接着,同样导入 JavaScriptCore,更重要的是,我们需要定义一个基于 JSExport 的协议,并在协议中声明一个 log 方法:

#import <JavaScriptCore/JavaScriptCore.h>

@protocol ConsoleExport <JSExport>

- (void)log;

@end

别忘了让 Console 类遵守这个 ConsoleExport 协议:


@interface Console : NSObject <ConsoleExport>

@end

然后来实现 log 方法,打印所有传入的参数:


#import "Console.h"

@implementation Console
- (void)log {
    NSLog(@"%@", [[JSContext currentArguments] componentsJoinedByString:@", "]);
}
@end

有了这些,我们还需要将它放到 JavaScript 中,回到之前的代码:


JSValue *console = [JSValue valueWithObject:[Console new] inContext:context];
context[@"console"] = console;

再次尝试 [context evaluateScript:js];,成功打印。

但你必须 注意 的是,即便我们置入了一个 Console 对象,却并不能调用其构造方法,苹果在其文档中也对此作出了说明。也就是说,在这个 JavaScript 环境中并不存在一个可以使用构造方法的 Console 类。

此外,我们知道 console 并非只有 log 这一个方法,类似的如果我们需要模拟其它的特性,也会有很多方法,我的建议是直接在 JavaScript 中进行模拟。

内存管理 与 线程安全

最后,还有两个很重要的东西:

  • 内存管理

  • 线程安全

内存管理

内存管理在这里主要指的是,当 Native 和 JavaScript 中的对象相互引用时,出现的循环引用问题。

为了避免这种情况,当 Native 引用 JavaScript 对象时,我们需要将 JSValue 对象包装为 JSManagedValue 对象:


// ....
JSValue *noneManaged = context[@"some_js_object"];
JSManagedValue *managed = [JSManagedValue managedValueWithValue:noneManaged];
NSLog(@"noneManaged = %@ -> managed = %@", noneManaged, managed);

线程安全

线程安全是开发中非常重要的一部分,在使用本文介绍的内容时,也要注意这些问题。

这里我们还需要介绍另外一个类,叫做 JSVirtualMachine。前面我们创建上下文(JSContext)时直接使用了 new 方法,也就相当于 [[JSContext alloc] init],现在,我们还可以通过 initWithVirtualMachine: 方法指定其所在的虚拟机:


JSVirtualMachine *vmA = [JSVirtualMachine new];
JSContext *ctxA1 = [[JSContext alloc] initWithVirtualMachine:vmA];
JSContext *ctxA2 = [[JSContext alloc] initWithVirtualMachine:vmA];

JSVirtualMachine *vmB = [JSVirtualMachine new];
JSContext *ctxB = [[JSContext alloc] initWithVirtualMachine:vmB];

这段代码中先后创建了两个虚拟机对象,并分别为不同的上下文指定了虚拟机。

这里我要说明的是,同一个虚拟机中的多个上下文处于同一线程,所以理所当然的,不同虚拟机中的上下文就处于不同的线程。同一虚拟机中的上下文并不能直接互相访问。在开发过程中如果涉及线程问题,要多加注意。

结语

除了本文介绍的内容,事实上这套框架中还支持许多特性,例如 isEqualToObjectisEqualWithTypeCoercionToObjectinvokeMethod 等等,有兴趣或者有需要的朋友可以自己查阅。