CodeV

Objective-C与JavaScript的交互之JavaScriptCore

本文不再详述Objective-C与JavaScript之间交互的UIWebView和WKWebView常用实现,网上有太多此类文章可供参考,本文着重介绍JavaScriptCore以及一个使用JavaScriptCore黑魔法的UIWebView分类。JavaScriptCore框架提供了在Swift,Objective-C和基于C的应用程序中运行JavaScript程序的能力。您也可以使用JavaScriptCore将自定义对象插入JavaScript环境。JavaScriptCore是从iOS7开始引入的可实现Objective-C与JavaScript之间完美交互的框架,

JavaScriptCore框架主要包括四个类:JSContext,JSManagedValue,JSValue,JSVirtualMachine和一个协议:JSExport。

JSContext

JSContext对象表示一个JavaScript执行环境。您可以创建和使用JavaScript上下文来运行Objective-C或Swift代码中的JavaScript脚本,访问在JavaScript中定义或计算的值,并使JavaScript可访问Objective-C本地对象,方法或函数。Objective-C与JavaScript交互的类,对象,函数等都将保存在JSContext对象中。

JavaManagedValue

JSManagedValue对象包装JSValue对象,并添加“conditional retain”行为以提供值的自动内存管理,它是JavaScript和Objective-C对象的内存管理辅助对象。JavaManagedValue主要用来将JavaScript值存储在本身导出为JavaScript的Objective-C或Swift对象中。

要点

由于JavaScript内存管理机制和Objective-C内存管理机制并不相同,JavaScript是垃圾回收机制,Objective-C是引用计数机制,不要将非托管的JSValue对象存储在导出为JavaScript的原生对象中。由于JSValue对象强引用其封闭的JSContext对象,因此此操作会创建一个引用循环,而无法释放上下文,造成内存泄漏,JavaManagedValue就是保存JSValue并避免循环引用的扩充类。

托管值的“conditional retain”行为可确保只要满足以下任一条件,就会保留其基础JavaScript值:

  • JavaScript值可通过JavaScript对象图访问(即,不受JavaScript垃圾回收管理)
  • JSManagedValue对象可通过Objective-C/Swift对象图访问,如使用addManagedReference:withOwner:方法报告给JavaScriptCore虚拟机

然而,如果这两个条件都不成立,托管值将其value属性设置为nil,并释放基础JSValue对象。

注意

JSManagedValue对象本身的行为类似于对其底层JSValue对象的ARC弱引用 - 也就是说,如果不使用addManagedReference:withOwner:方法添加“conditional retain”行为,当JavaScript垃圾收集器销毁基础JavaScript值时,则受托管值的value属性会自动变为nil。

JSValue

JSValue实例是对JavaScript值的引用。您可以使用JSValue类在JavaScript和Objective-C/Swift表示之间转换基本值(例如数字和字符串),以便在原生代码和JavaScript代码之间传递数据。您还可以使用此类创建JavaScript对象,该对象包装原生的自定义类的对象或由原生方法或块(blocks)提供的JavaScript函数实现。此对象会对创建自身的JSContext产生强引用。

每个JSValue实例都来自一个JSContext对象,该对象表示包含该值的JavaScript执行环境。该值持有对其上下文对象的强引用 - 只要保留与特定JSContext实例关联的任何值,该上下文就保持活动状态。当你调用实例方法时,就是在JSValue对象上调用,如果该方法返回另一个JSValue对象,则返回的值与原始值属于相同的上下文。

每个JavaScript值还(间接通过上下文属性)与特定的JSVirtualMachine对象关联,该对象表示上下文的底层执行资源集合。您可以将JSValue实例仅传递给由同一虚拟机托管的JSValue和JSContext实例方法 - 尝试将值传递给其他虚拟机会引发Objective-C异常。

在JavaScript和原生类型之间转换

JavaScriptCore会使用下面总结的规则自动将原生值转换为JavaScript值,反之亦然。

  • NSDictionary对象或Swift字典及其包含的键成为具有相匹配命名属性的JavaScript对象,反之亦然。 键的值被递归复制和转换。
  • NSArray对象或Swift数组成为JavaScript数组,反之亦然,并且元素被递归地复制和转换。
  • Objective-C块(或带有@convention(block)属性的Swift闭包)成为JavaScript Function对象,参数和返回类型使用与值相同的规则进行转换。原生块或方法转换为支持的JavaScript函数块或方法;所有其他都会转换为空字典。
  • 对于所有其他原生对象类型(以及类类型或元类型),JavaScriptCore使用反映原生类层次结构的构造函数原型链创建JavaScript包装器对象。默认情况下,原生对象的JavaScript包装器并不能使该对象的属性和方法在JavaScript中可用。要选择导出到JavaScript的属性和方法,请参阅JSExport。

在转换对象,方法或块时,JavaScriptCore使用表1中汇总的规则隐式转换对象属性和方法参数的类型和值。

Objective-C (and Swift) Types JavaScript Types Notes
nil undefined
NSNull null
NSString (Swift String) String
NSNumber and primitive numeric types Number, Boolean Conversion is consistent with the following methods:
valueWithBool:inContext:,
valueWithDouble:inContext:,
valueWithInt32:inContext:,
valueWithUInt32:inContext:
NSDictionary (Swift Dictionary) Object Recursive conversion.
NSArray (Swift Array) Array Recursive conversion.
NSDate Date
Objective-C or Swift object (id or AnyObject)
Objective-C or Swift class (Class or AnyClass)
Object Converts with
valueWithObject:inContext:/toObject.
Structure types: Object Other structure types are not supported.
Objective-C block (Swift closure) Function Convert explicitly with
valueWithObject:inContext:/toObject.

JavaScript functions do not convert to native blocks/closures unless already backed by a native block/closure.

JSVirtualMachine

JavaScript虚拟机,JSVirtualMachine实例为JavaScript执行提供了一个自包含的环境,有独立的堆空间和内存回收机制(垃圾回收机制),因此不同虚拟机之间不能交换数据。这个类主要有两个作用:支持JavaScript并行执行(同一个虚拟机里不能执行多线程),以及管理在JavaScript和Objective-C/Swift之间桥接的对象的内存。

线程和并行JavaScript执行

每个JavaScript上下文(一个JSContext对象)都属于一个虚拟机。每个虚拟机都可以包含多个上下文,允许在上下文之间传递值(JSValue对象)。但是,每个虚拟机都是不同的 - 您不能将在一个虚拟机中创建的值传递到另一个虚拟机中的上下文。

JavaScriptCore API是线程安全的 - 例如,您可以创建JSValue对象或从任何线程运行脚本 - 但是,尝试使用同一虚拟机的所有其他线程都将等待。要在多个线程上同时运行JavaScript,请为每个线程使用单独的JSVirtualMachine实例。

管理导出对象的内存

当您将Objective-C或Swift对象导出到JavaScript时,不得在该对象中存储JavaScript值。此操作会创建一个引用循环-JSValue对象拥有对其封闭JavaScript上下文的强引用,并且JSContext对象也拥有对导出到JavaScript的原生对象的强引用。相反,请使用JSManagedValue类有条件地保留JavaScript值,并将该受管值的本地所有权链(native ownership chain)报告给JavaScriptCore虚拟机。使用addManagedReference:withOwner:removeManagedReference:withOwner:方法将原生对象图描述给JavaScriptCore。在删除对象的最后一个托管引用后,JavaScript垃圾收集器可以安全地销毁该对象。

JSExport

实现这个协议,用于将Objective-C类及其实例方法,类方法和属性导出为JavaScript代码。

使用JavaScriptCore时,OC与JS的交互

Objective-C调用JavaScript
1
2
3
4
5
JSContext *ctx = [[JSContext alloc] init];
NSString *js = @"function add(a,b) {return a+b}";
[ctx evaluateScript:js];
JSValue *n = [ctx[@"add"] callWithArguments:@[@2, @3]];
NSLog(@"---%@", @([n toInt32]));//---5

[ctx evaluateScript:js];此处代码执行完,实际在JSContext里注册了JS的add函数(生成引用add函数的 JSValue)。上述代码创建了JSContext对象,执行了一段JS代码,代码声明了一个函数,后面调用此函数,返回值为 JSValue。实际上JSContext是实现OC与JS交互的核心。

JavaScript调用Objective-C
使用block
1
2
3
4
5
6
JSContext *ctx = [[JSContext alloc] init];
ctx[@"minus"] = ^int(int a, int b) {
return a - b;
};
JSValue *value = [ctx evaluateScript:@"minus(4, 5)"];
NSLog(@"4 minus 5 = %d", [value toInt32]);

OC的block导入到JS中,实际上是转换成了JS的function(函数)。

使用JSExport

使用JSExport可以实现将OC中更复杂的对象导入到JS中使用。下面是苹果官方文档提供的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@protocol MyPointExports <JSExport>
@property double x;
@property double y;
- (NSString *)description;
- (instancetype)initWithX:(double)x y:(double)y;
+ (MyPoint *)makePointWithX:(double)x y:(double)y;
@end

@interface MyPoint : NSObject <MyPointExports>
- (void)myPrivateMethod;// Not in the MyPointExports protocol, so not visible to JavaScript code.
@end

@implementation MyPoint

// ...

@end

将JS代码保存在script.js的文件中,文件直接添加到工程里:

1
2
3
4
5
6
7
8
9
// Objective-C properties become fields.
point.x;
point.x = 10;
// Objective-C instance methods become functions.
point.description();
// Objective-C initializers can be called with constructor syntax.
var p = MyPoint(1, 2);
// Objective-C class methods become functions on the constructor object.
var q = MyPoint.makePointWithXY(0, 0);

创建JSContext,执行上述JS代码:

1
2
3
4
5
6
7
8
9
10

JSContext *ctx = [[JSContext alloc] init];
ctx.exceptionHandler = ^(JSContext *context, JSValue *exception){
[JSContext currentContext].exception = exception;
NSLog(@"exception:%@",exception);
};
ctx[@"p"] = [MyPoint makePointWithX:10 y:10];
ctx[@"MyPoint"] = MyPoint.class;
NSURL *fileURL = [NSURL fileURLWithPath:[[NSBundle bundleForClass:self.class]pathForResource:@"script.js" ofType:nil]];
[ctx evaluateScript:[[NSString alloc] initWithContentsOfURL:fileURL encoding:(NSUTF8StringEncoding) error:nil]];

上述代码同时还使用了JS执行异常的处理exceptionHandler。注意这里没有直接引用ctx而是使用 [JSContext currentContext]查找到当前的JSContext,是为了避免在自身引用的block中引用自身导致循环引用。同时从上述代码中可以看到,OC导出到JS的函数名转换规则,不过我们也可以通过 JSExportAs宏定义函数名的转换,以下是转换的代码:

1
2
3
4
5
6
7
@protocol MyPointExports <JSExport>
JSExportAs(make, +(MyPoint *)makePointWithX:(double)x y:(double)y );
@property double x;
@property double y;
- (NSString *)description;
- (instancetype)initWithX:(double)x y:(double)y;
@end

则相应的js代码则修改为

1
var q = MyPoint.make(0, 0);
UIWebView黑魔法

UIWebView在加载页面时,其实会创建JS的运行环境JSContext,因此可以利用此JSContext,完成OC与JS的交互。

获取UIWebView加载页面时的JSContext的方法,通过KVO访问:

1
2
3
4
- (void)webViewDidFinishLoad:(UIWebView *)webView {
NSLog(@"webview's context : %@", [webView
valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]);
}

注意因为webViewDidFinishLoad:的调用时间晚,会导致上述方法加载JSContext较晚。另外还要注意在 UIWebView的生命周期内,可能会进行多次加载,多次调用webViewDidFinishLoad:。还因为是私有属性,存在着因为版本更新而变动的可能,因此不建议使用。

WebFrameLoadDelegate

此协议定义在WebKit框架下,但并没有公开头文件,因此无法直接使用(虽然无法看到头文件,但奇怪的是在官方文档中却可以查找到该协议的API文档),但我们仍然可以直接重载此代理方法,示例代码可在这里找到:https://github.com/TomSwift/UIWebView-TS_JavaScriptContext:

1
2
3
4
5
6
7
8
9
10
11
12
@protocol TSWebFrame <NSObject>
- (id) parentFrame;
@end

@implementation NSObject (TS_JavaScriptContext)

- (void) webView: (id) unused didCreateJavaScriptContext: (JSContext*) ctx forFrame: (id<TSWebFrame>) frame
{
NSLog(@"didCreateJavaScriptContext");
}

@end

在上述的 webView:didCreateJavaScriptContext:forFrame: 代理方法中可以获取到context,注意这里回调 的webView类型为 WebView,不是我们熟悉的UIWebView类型,所以不建议使用该对象。因此如果需要查找到此 context关联的是哪个webView,可以通过全局的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static NSHashTable* g_webViews = nil;
- (void) webView: (id) unused didCreateJavaScriptContext: (JSContext*) ctx forFrame: (id<TSWebFrame>) frame
{
NSParameterAssert( [frame respondsToSelector: @selector( parentFrame )] );

// only interested in root-level frames

if ( [frame respondsToSelector: @selector( parentFrame) ] && [frame parentFrame] != nil )
return;

void (^notifyDidCreateJavaScriptContext)() = ^{

for ( UIWebView* webView in g_webViews )
{
NSString* cookie = [NSString stringWithFormat: @"ts_jscWebView_%lud", (unsigned long)webView.hash ];

[webView stringByEvaluatingJavaScriptFromString: [NSString stringWithFormat: @"var %@ = '%@'", cookie, cookie ] ];

if ( [ctx[cookie].toString isEqualToString: cookie] )
{
[webView ts_didCreateJavaScriptContext: ctx];
return;
}
}
};

if ( [NSThread isMainThread] )
{
notifyDidCreateJavaScriptContext();
}
else
{
dispatch_async( dispatch_get_main_queue(), notifyDidCreateJavaScriptContext );
}
}

为了将所有UIWebView对象都添加到NSHashTable中,可以重写UIWebView的allocWithZone:方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ (id) allocWithZone:(struct _NSZone *)zone
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

g_webViews = [NSHashTable weakObjectsHashTable];
});

NSAssert( [NSThread isMainThread], @"uh oh - why aren't we on the main thread?");

id webView = [super allocWithZone: zone];

[g_webViews addObject: webView];

return webView;
}

到这里,所有的都一目了然了,我们获取了UIWebView的JSContext的,然后导入OC中的对象到JSContext中,剩下的就是在JS中调用此对象了。

内存管理
block中捕获JSContext

JSValue会对其所在的JSContext维持一个强引用,因此在JSContext中注入的block时,不要直接捕获JSContext或 JSValue,可以使用[JSContext currentContext];获取JSContext以及对JSValue进行弱引用处理。

1
2
3
4
5
6
7
JSContext *ctx = [[JSContext alloc] init];
JSValue *jsvalue = [JSValue valueWithBool:NO inContext:ctx];
__weak typeof(JSValue) *wjsvalue = jsvalue;
ctx[@"oclog"] = ^void(void) {
NSLog(@"context:%p, jsvalue:%p", [JSContext currentContext], wjsvalue);
};
NSLog(@"jsvalue:%p", jsvalue);
Objective-C与JavaScript循环引用

OC中有对象持有JSValue,并且该对象被传递到JS中使用的时候,会产生循环引用,这时候需要用 JSManagedValue保存JSValue,避免循环引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
//定义一个JSExport protocol
@protocol JSExportTest <JSExport>
//用来保存JS的对象
@property (nonatomic, strong) JSValue *jsValue;
@end
@interface JSExportObj : NSObject<JSExportTest>
@end
@implementation JSExportObj
@synthesize jsValue = _jsValue;
- (void)dealloc {
NSLog(@"%s", __FUNCTION__);
}
@end
1
2
3
4
5
6
7
8
9
10
11
12
self.obj = [[JSExportObj alloc] init];
//创建context
JSContext *ctx = [[JSContext alloc] init];
//设置异常处理
ctx.exceptionHandler = ^(JSContext *context, JSValue *exception) {
context.exception = exception;
NSLog(@"exception:%@",exception);
};
//加载JS代码到context中
[ctx evaluateScript:@"function callback (){};function setObj(obj){this.obj=obj;obj.jsValue=callback;}"];
//调用JS方法
[ctx[@"setObj"] callWithArguments:@[self.obj]];

上述代码中,JS对象保留了传进来的obj,而obj.jsValue对callback也产生强引用,导致JS对象与OC对象相互引 用,产生了循环引用。鉴于JS与OC内存管理方法的不同(JS为垃圾回收,OC为引用计数),系统提供了 JSManagedValue解决这个问题。将代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface JSExportObj ()
//添加一个JSManagedValue用来保存JSValue
@property (nonatomic, strong) JSManagedValue *managedValue;
@end
@implementation JSExportObj
//重写setter方法
- (void)setJsValue:(JSValue *)jsValue {
JSContext *ctx = [JSContext currentContext];
if (_managedValue) {
[ctx.virtualMachine removeManagedReference:_managedValue withOwner:self];
_managedValue = nil;
}
if (jsValue) {
_managedValue = [JSManagedValue managedValueWithValue:jsValue];
[ctx.virtualMachine addManagedReference:_managedValue withOwner:self];
}
}

- (JSValue *)jsValue {
return _managedValue.value;
}