版权申明:作者 Vong 已将本文在微信公众平台的发表权「独家代理」给 iOS 开发(iOSDevTip)微信公共账号。本文所有打赏将归原作者所有。
本文主要分析一些 iOS 中 WebView 与 JavaScript 交互的一些框架。
通过调用如下方法:
- (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
比如获取网页 title,也可以动态注入 JS,先写一个 JS 函数
function showAlert() {
alert('show alert');
}
然后保存为js 文件,最后读取这个文件并注入
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"test" ofType:@"js"]; NSString *jsString = [[NSString alloc] initWithContentsOfFile:filePath];
[webView stringByEvaluatingJavaScriptFromString:jsString];
直接调用无法做到,可以间接实现。
JS 中要从现在的网页跳到另外一个网页的时候,就会去修改 window.location.href
,而在 @protocol UIWebViewDelegate
中有一个回调方法
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
可以监听到网页的跳转,所以可以在此做文章。
通过指定window.location.href = schemename://nativemethodname:args
就可以去间接调用到原生函数。JS 一旦修改了window.location.href
,UIWebView
就会收到相应回调,也就是上面说的方法,这样我们可以通过判断request
的url
是否为自定义的 scheme
来决定是否调用原生函数。
创建iframe,设置src,并插入到body节点
function execute(url) { var iframe = document.createElement("IFRAME");
iframe.setAttribute("src", url); document.documentElement.appendChild(iframe);
iframe.parentNode.removeChild(iframe);
iframe = null;
}
execute("schemename://nativemethodname:args");
上述的这一串schemename://nativemethodname:args
由客户端和前端约定好即可。剩余的事就是截获这个request
,然后解析得到相应的参数,传入要调用的原生函数即可。同时在回调方法中要return NO
。
大致代码如下:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{ NSURL *requestUrl = request.URL; if ([requestUrl.scheme isEqualToString:@"schemename"]) { NSArray *components = [requestUrl.absoluteString componentsSeparatedByString:@":"]; NSString *resultJSONString = [components[2] stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
[self customMethod:resultJsonString]; return NO;
} return YES;
}
大致原理与上面说的一致。只不过WebViewJavaScriptBridge
进行了更完善的封装,使得 JS
与Native
之间的通信变得更为简便。
一开始注入WebViewJavaScriptBridge.js
,该文件中的JS
方法主要做了以下几件事
创建了一个用于发送消息的iFrame(通过创建一个隐藏的ifrmae
,并设置它的URL 来发出一个请求,从而触发UIWebView的shouldStartLoadWithRequest
回调协议)
创建了一个核心对象WebViewJavaScriptBridge
,并给它定义了几个方法,这些方法大部分是公开的API方法
创建了一个事件:WebViewJavaScriptBridgeReady
,并dispatch
。##
native
将方法名、参数、回调的id放到一个对象中传给js
。js
根据方法名字调用相应方法,之后将返回数据和responseId
拼装,最后通过src
重定向到UIWebview
的delegate
。native
得到数据后根据responseId
调用事先装入_responseCallbacks
的block
,动态读取调用,从而完成交互。
+ (instancetype)bridgeForWebView:(WVJB_WEBVIEW_TYPE*)webView handler:(WVJBHandler)handler;
+ (instancetype)bridgeForWebView:(WVJB_WEBVIEW_TYPE*)webView webViewDelegate:(WVJB_WEBVIEW_DELEGATE_TYPE*)webViewDelegate handler:(WVJBHandler)handler;
+ (instancetype)bridgeForWebView:(WVJB_WEBVIEW_TYPE*)webView webViewDelegate:(WVJB_WEBVIEW_DELEGATE_TYPE*)webViewDelegate handler:(WVJBHandler)handler resourceBundle:(NSBundle*)bundle;
+ (void)enableLogging;
- (void)send:(id)message;
- (void)send:(id)message responseCallback:(WVJBResponseCallback)responseCallback;
- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler;
- (void)callHandler:(NSString*)handlerName;
- (void)callHandler:(NSString*)handlerName data:(id)data;
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback;
初始化的工作主要如下:
设置默认的消息处理 block ———— messageHandler
初始化用来保存消息处理 block 的字典 ———— messageHandlers
初始化消息队列数组 ———— startupMessageQueue
初始化响应回调 ———— responseCallbacks
以及初始化全局唯一标识 ———— uniqueId
当在外部调用
- (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler;
方法时,会将 handler 保存到上面初始化好的 messageHandlers 当中,key为上述方法中的 handlerName,value 为上述方法的 handler。
- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName;
发送消息时,会将消息加入到消息队列数组,加到数组当中的object 为字典型,字典有三个 key,分别为 data,callbackId, handlerName,分别对应上述方法的三个参数。入队时,如果当前消息队列存在,则将该消息入队,否则立即分发该消息。
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
加载请求或 html 时,在 shouldStartLoadWithRequest 回调中会先判断请求是否带自定义协议。如果带有自定义协议,会调用注入的 js 中 WebViewJavaScriptBridge 的_fetchQueue 方法来获取当前消息,然后分发该消息。主要是在分发消息这一块,拿到消息 json 然后序列化,如果这个消息是队列(数组)才进行处理,消息队列中也是一系列字典型对象,这些对象可能有这么几个key:responseId,responseData,callbackId,handlerName,data。
然后对消息队列做一个遍历大致逻辑如下:
如果 responseId 对应的 value 存在,那么就到 responseCallbacks 字典中去寻找对应的 WVJBResponseCallback 型回调 block,然后执行,block 参数为 responseData 对应的 value。然后把这个 block 从 responseCallbacks 字典中移除。
如果 responseId 对应的 value 不存在,再看 callbackId 对应的 value 是否存在,存在则设置回调 responseCallback,这个 responseCallback 主要是创建一个消息,然后是消息入队,这个消息字典为 @{ @"responseId":callbackId, @"responseData":responseData }
;反之 responseCallback 中什么也不执行;最后判断 handlerName 对应的 value 是否存在,存在则取 messageHandlers 中对应的回调 handler,不存在就是用默认的 handler,最后执行这个 WVJBHandler 型的 handler,参数为 data 对应的 value 以及 responseCallback。
文字有点多,参考下下面这个流程图:
- (void)webViewDidFinishLoad:(UIWebView *)webView;
加载结束的回调中判断 js 是否初始化了 WebViewJavaScriptBridge实例,如果不存在,就注入本地的 js。然后检测到 bridge 调用自定义 scheme 后会分发整个消息队列即 startupMessageQueue,遍历消息队列然后取出每一个消息对象(NSDictionary) 然后将其序列化成 JSON,在主线程中调用 JS 的 WebViewJavaScriptBridge._handleMessageFromObjC 方法,参数就是序列化后的 JSON 数据。分发完成后,将 startupMessageQueue 队列置为 nil。
至此 OC 端的整个流程完毕。
JS 端流程和 OC 端流程大致是一样的
function init(messageHandler)function send(data, responseCallback)function registerHandler(handlerName, handler)function callHandler(handlerName, data, responseCallback)function _fetchQueue()function _handleMessageFromObjC(messageJSON)
类似 OC 中的初始化
注入一个默认的 messageHandler
初始化一个消息接收队列,然后调用内部方法 _dispatchMessageFromObjC 来分发消息,同时将消息接收队列置空
调用内部 function _doSend(message, responseCallback)
函数,该函数通过判断 responseCallback 回调是否存在,存在则将这个回调存入到 responseCallbacks 字典中,其 key 是全局唯一的,同时将这个 key 存入到 message 这个字典参数中,其 key 和 value 一致,及 message[‘key’] = key。接着把这个 message 参数入队即加到 sendMessageQueue 数组中。然后重定向 frame 的 src,这样 OC 端就可以在代理回调方法中去拦截这个 src 对应的 request。
如果消息接受队列存在则将消息 JSON 入队即添加到 receiveMessageQueue 数组中。反之,调用内部消息分发方法
function _dispatchMessageFromObjC(messageJSON)
分发机制类似 OC 端的那张流程图,在此不做详述。
注册:在 messageHandlers 这个字典添加对应的 key 和 value。key 为 name,value 为 handler
调用:类似发送消息,message 参数为 { handlerName:handlerName, data:data }
这里会将消息发送数组进行 JSON 转化,转换后清空消息队列,然后返回给 OC 端。
上面说的所有都在内部注入的 JS(WebViewJavaScriptBridge.js.txt) 中完成,该 JS 做的事情在上述的实现原理中也有提到,这里不再展开。
可参考下例的写法:
document.addEventListener('WebViewJavascriptBridgeReady', function(event) { var bridge = event.bridge // Start using the bridge}, false)
可将上述代码封装到一个 JS 函数中,然后在函数中进行其它一系列操作,如init,send 等具体参见 Demo。
JSVirtualMachine:非常轻量,可初始化多个 VM 来支持 JS 中的多线程
JSContext:给 JS 提供运行上下文环境以及一系列值操作(通过下标来获取,类似 NSdictionary,即context[@”objectKey”]),一个 VM 中可有多个 context
JSValue:数据桥梁
JSManagedValue:用于解决 retain cycle
JSContext 可调用 evaluateScript:
方法来执行某个脚本如下:
[context evaluateScript:@”var square = function(x) {return x*x;}”];
JSValue *squareFunction = context[@”square”]; NSLog(@”%@”, squareFunction); // function (x) {return x*x;}JSValue *aSquared = [squareFunction callWithArguments:@[context[@”a”]]]; NSLog(@”a^2: %@”, aSquared); //a^2: 25JSValue *nineSquared = [squareFunction callWithArguments:@[@9]];
NSLog(@”9^2: %@”, nineSquared); //81
两种方式:
Block
JSExport 协议
context[@"factorial"] = ^(int x) { int factorial = 1; for (; x > 1; x--) {
factorial *= x;
} return factorial;
};
[context evaluateScript:@"var fiveFactorial = factorial(5);"];
JSValue *fiveFactorial = context[@"fiveFactorial"];// 5! = 120NSLog(@"5! = %@", fiveFactorial);
值得注意的是:
不要在 block 中持有 JSValue,而是应该将JSValue 作为参数来传递
不要在 block 中持有 JSContext,可通过 [JSContext currentContext]
来获取当前 context
JSContext *context = [[JSContext alloc] init];
context[@"callback"] = ^{ //错误示例
JSValue *object = [JSValue valueWithNewObjectInContext:context]; //正确的姿势
JSValue *object = [JSValue valueWithNewObjectInContext:
[JSContext currentContext]];
object[@"x"] = 2;
object[@"y"] = 3; return object;
};
如果没有这个协议,OC 端的修改会同步到 JS 端,但是 JS 端的修改对 JS 和 OC 均无影响。见下例
//TestModel.m- (NSString *)description
{ NSString *str = [@"TestModel With testString:" stringByAppendingString:self.testString]; return [str stringByAppendingString:[NSString stringWithFormat:@" and numberStr:%@",self.numberStr]];
}// viewDidLoadTestModel *model = [[TestModel alloc] init];
model.testString = @"test string";
model.numberStr = @"123";
JSContext *context = [[JSContext alloc] initWithVirtualMachine:[[JSVirtualMachine alloc] init]];
context[@"model"] = model;
JSValue *modelValue = context[@"model"];// model: TestModel With testString:test string and numberStr:123NSLog(@"model: %@",model);// model JSValue: TestModel With testString:test string and numberStr:123NSLog(@"model JSValue: %@",modelValue);
model.numberStr = @"456";// model: TestModel With testString:test string and numberStr:456NSLog(@"model: %@",model);// model JSValue: TestModel With testString:test string and numberStr:456NSLog(@"model JSValue: %@",modelValue);
[context evaluateScript:@"model.testString = \"anotoher test\";model.numberStr = \"789\""];// model: TestModel With testString:test string and numberStr:456NSLog(@"model: %@",model);// model JSValue: TestModel With testString:test string and numberStr:456NSLog(@"model JSValue: %@",modelValue);
如果想要上述 JS 修改起作用,则需要实现 JSExport 协议。
通过实现该协议来暴露自定义类给 JS,这样 JS 会为这个类创建一个 wrapper object,这样看起来就像 OC 和 JS 在互相传值一样。这样,一个对象可以在 JS 和 OC 间共享,任何一端的更改都将同步到另外一端。需要注意的是,JS 只能修改暴露在协议中的属性或调用协议中的方法。
// .h@protocol TestModelDelegate <JSExport>@property (nonatomic, copy) NSString *testString;
- (void)modelTest;@end@interface TestModel : NSObject <TestModelDelegate>@property (nonatomic, copy) NSString *testString;@property (nonatomic, copy) NSString *numberStr;@end// .m- (NSString *)description
{ NSString *str = [@"TestModel With testString:" stringByAppendingString:self.testString]; return [str stringByAppendingString:[NSString stringWithFormat:@" and numberStr:%@",self.numberStr]];
}
- (void)modelTest
{ NSLog(@"modelTest!!!");
}
- (void)test
{ NSLog(@"Test!!!");
}// viewDidLoad[context evaluateScript:@"model.testString = \"anotoher test\";model.numberStr = \"567\""];// model: TestModel With testString:anotoher test and numberStr:123NSLog(@"model: %@",model);// model JSValue: TestModel With testString:anotoher test and numberStr:123NSLog(@"model JSValue: %@",modelValue);// modelTest!!![context evaluateScript:@"model.modelTest()"];
JSValue *unknowValue = [context evaluateScript:@"model.test()"];// unknowValue :undefinedNSLog(@"unknowValue :%@",unknowValue);
上例中的 numberStr 之所以还是保持为 123 是因为,这个属性不在协议中,JS 对其修改不起作用,同样如果 JS 中调用 model 不在协议中的方法,也不起作用,如果用 JSValue 去接收这个值,其值为 undefined。
没有任何响应,如果用一个 JSValue 去接收上面代码的值,得到的是 undifine
Objective-C type | JavaScript type |
---|---|
nil | undefined |
NSNull | null |
NSString | string |
NSNumber | number, boolean |
NSDictionary | Object object |
NSArray | Array object |
NSDate | Date object |
NSBlock | Function object |
id | Wrapper object |
Class | Constructor object |
UIWebview 也有一个 JSContext 实例,但是没有暴露在 API 中,但是我们可以通过 KVC 或者在 NSObject 分类去拿到这个实例,然后来进行自定义的一些操作。关于 NSObject 分类实现可以参考这里。但是这两种方法都有可能被拒。
其实WebView 与 JS 的交互和上述的 TestModel 与 JS 交互区别不大。只不过上例都是自己创建的 context,而在webView 中则是我们通过 KVC 来拿到这个 context 而不是自己创建。来看一个例子:
<!DOCTYPE html><html>
<head>
<script>
function test()
{
objcObject.testDemo();
alert(objcObject);
} </script>
</head>
<body>
<h1>JavaScriptCore Demo</h1>
<button type="button" onclick="test()">测试</button>
</body></html>
上述 html 简单创建了一个 button,然后绑定一个事件。
接下来看看 ViewController 里面做了什么。
// .h@protocol TestJSDelegate <JSExport>- (void)testDemo;@end@interface WebViewController : UIViewController <TestJSDelegate,UIWebViewDelegate>@end// .m- (void)viewDidLoad
{
[super viewDidLoad]; NSURL *path = [[NSBundle mainBundle] URLForResource:@"test" withExtension:@"html"]; NSString *html = [NSString stringWithContentsOfURL:path encoding:NSUTF8StringEncoding error:nil];
[self.webView loadHTMLString:html baseURL:nil];
}
- (void)webViewDidFinishLoad:(UIWebView *)webView
{
JSContext *context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
context[@"objcObject"] = self;
}
- (void)testDemo
{ NSLog(@"test!!!");
}
运行效果:
如果协议方法中有多个参数该怎么调用呢?举个例子
// ObjC 中的某个协议方法- (void)testWithName:(NSString *)name age:(NSNumber *)age
{ NSLog(@"name:%@,age:%@",name,age);
}
// 在上述 html 的 js 中添加一行代码
objcObject.testWithNameAge("Tracy",20);
那么在按钮点击后,协议方法将会被执行,然后打印出 name:Tracy,age:20
。
OC 中是用的是 ARC,JavaScriptCore 中用的是垃圾回收机制(garbage collection),JavaScriptCore 中所有引用都为强引用。在大部分情况下,JavaScriptCore 能做到在这两种内存管理机制之间无缝切换,但是在以下两种情况下需要特别注意:
在 OC 对象中存储 JavaScript 值
在 OC 对象中添加 JavaScript 域
如下例就会造成循环引用:
// ClickHandler 构造器,button 为 OC 对象,callback 是按钮点击事件回调function ClickHandler(button, callback) { this.button = button; this.button.onClickHandler = this; this.handleEvent = callback;
};
@implementation MyButton- (void)setOnClickHandler:(JSValue *)handler
{
_onClickHandler = handler; // Retain cycle}@end
上例中 ClickHandler 对 button 进行了强引用,而 MyButton 中又对 _onClickHandler 这个 JSValue 进行了强引用,最终导致循环引用,如下图所示:
举个栗子,在某个方法中有一个临时的 OC 对象,然后通过 JSContext 被 JS 中的变量引用,但是该 OC 方法调用结束后,这个临时对象将被释放,因此 JS 会造成错误访问。
同样的,如果用 JSContext 创建了对象,然后在 OC 中用 JSValue 去接收,即使把 JSValue 变量在 OC 中被 retain,但可能因为 JS 中因为变量没有了引用而被释放内存,那么对应的JSValue也没有用了。
所以苹果引入了另一个类来解决这种循环引用的问题。
@implementation MyButton- (void)setOnClickHandler:(JSValue *)handler
{ //正确的姿势
_onClickHandler = [JSManagedValue managedValueWithValue:handler];
[_context.virtualMachine addManagedReference:_onClickHandler
withOwner:self]
}
@end
addManagedReference
做的事情主要如下:它创建了一个 garbage collected reference,这种引用既不是强引用也不是弱引用。
JSManagedValue 本身是一个对 JavaScript Value 的弱引用,而 JSValue 是强引用。addManagedReference 将 JSManagedValue 转换为 garbage collected reference。如果 JS 在垃圾回收过程中能够找到 managed reference 的所有者,那么这个引用将不会被释放,否则将被释放。JSManagedValue 需要调用其addManagedReference:withOwner: 方法把它添加到JSVirtualMachine 中,确保使用过程中 JSValue 不会被释放。
如前面所说,每一个 JSVirtualMachine 都可以有多个 JScontext,在每个进程中又可以有多个 JSVirtualMachine。JSValue 可以在同一个 JSVirtualMachine 中的不同 JSContext 之间传递,但是不能跨 JSVirtualMachine 来传递。因为每个 JSVirtualMachine 都有自己的内存堆以及垃圾回收器,如果 JSValue 跨 JSVirtualMachine 传递,那么垃圾回收器将不知如何处理来自不同内存堆的 JSvalue。
JavaScriptCore 的 API 是线程安全的
同步锁粒度:JSVirtualMachine,即我们可以在 JSVirtualMachine 不同线程中调用 JS,但是如果有线程正在执行 JS,那么其它线程将不能执行 JS 操作。所以要想进行并发操作,那么需要为每个操作创建一个单独的 JSVirtualMachine 来实现并发。
在性能、稳定性、功能方面有很大提升(最直观的体现就是加载网页是占用的内存);
允许JavaScript的Nitro库加载并使用(UIWebView中限制);
支持了更多的HTML5特性以及 native 和 web 的高效交互;
高达60fps的滚动刷新率以及内置手势;
将UIWebViewDelegate与UIWebView重构成了14类与3个协议
WebKit 为非线程安全的,所以要确保该 framework 的所有方法在主线程上调用。
更多内容请参考Nshipster:http://nshipster.cn/wkwebkit/
总得来说两种方式都可以实现二者的交互,JavaScriptBridge 相对而言复杂一些,但是安全且不需要做版本适配,APP 上架不会被拒,但是 JavaScriptCore 更加简洁,不需要写繁琐的代码,但是有被拒的风险,同时这个框架是在 iOS7 之后才有,所以如果要适配 iOS6的话还是选择 JavaScriptBridge。
https://github.com/wang9262/WebViewJSDemo
联系客服