剖析OC与JS交互

96
Chris_js
2017.09.22 16:51* 字数 3796

随着H5技术的兴起,在iOS开发过程中,难免会遇到原生应用需要和H5页面交互的问题。其中会涉及方法调用及参数传值等场景。

iOS原生应用和web页面的交互大致上有这几种方法:
1)URL拦截协议;(兼容iOS6及以下时可考虑,本文介绍)
2)第三方框架WebViewJavaScriptBridge;(本文不介绍)
3)iOS7之后的JavaScriptCore;(推荐、本文重点介绍)
4)iOS8之后的WKWebView;(强烈推荐、本文重点介绍)

从使用场景上可分为OC调用JS、JS调用OC两种形式,下面分别讨论之:

一、OC调用JS

JS 调用OC 方法后,有的操作可能需要将结果返回给JS。这时候就是OC 调用JS 方法的场景。

方式一:使用UIWebView的方法

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;

NSString *jsStr = [NSString stringWithFormat:@"showAlert('%@')",@"这是JS中alert弹出的message"];
[_webView stringByEvaluatingJavaScriptFromString:jsStr];
注意:这是一同步方法,可能会阻塞UI,如果要执行的js方法比较耗时,会造成界面卡顿,比如,js弹出alert后会阻塞UI界面,等待用户操作响应,而同时stringByEvaluatingJavaScriptFromString方法也会等待js执行完毕后返回。这就造成了死锁。官方推荐使用WKWebView的evaluateJavaScript:completionHandler:代替这个方法。

还比如UIWebView的一些常用操作:

// 获取当前页面的title
NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
// 获取当前页面的url
NSString *url = [webview stringByEvaluatingJavaScriptFromString:@"document.location.href"];

方式二、使用JavaScriptCore的方法(iOS7.0之后)

1、使用JSContent的方法:- (JSValue *)evaluateScript:(NSString *)script;

JSContext *context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
NSString *textJS = @"showAlert('这是JS中alert弹出的message')";
[context evaluateScript:textJS];
//带参数
NSString *jsStr = [NSString stringWithFormat:@"payResult('%@')",@"支付成功"];[[JSContext currentContext] evaluateScript:jsStr];

2、使用JSValue的方法:- (JSValue *)callWithArguments:(NSArray *)arguments;

JSContext *context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];[context[@"payResult"] callWithArguments:@[@"支付成功"]];


方式三、使用WKWebView(iOS8之后)

使用WKWebView的evaluateJavaScript:completionHandler:方法,解决了使用UIWebView的stringByEvaluatingJavaScriptFromString方法易造成死锁的问题。(官方推荐)

// 将分享结果返回给js
NSString *jsStr = [NSString stringWithFormat:@"shareResult('%@','%@','%@')",title,content,url];
[self.webView evaluateJavaScript:jsStr completionHandler:^(id _Nullable result, NSError * _Nullable error) {
     NSLog(@"%@----%@",result, error);
}];

说明:evaluateJavaScript:completionHandler:没有返回值,JS 执行成功还是失败都会在completionHandler 中返回。所以使用这个API 就可以避免执行耗时的JS,或者alert 导致界面卡住的问题。

二、JS调用OC

方式一、拦截URL

使用拦截URL方式又可细分为使用UIWebView拦截URL方式和使用WKWebView拦截URL方式。

1)使用UIWebView拦截URL:

UIWebView的代理方法shouldStartLoadWithRequest会拦截到每一个链接的Request。当return YES时webView 就会加载这个链接;return NO时webView 就不会加载这个链接,我们可以在该方法中根据scheme做不同处理。

相关测试js代码如下:(仅截取部分核心代码)

function loadURL(url) {
var iFrame;
iFrame = document.createElement("iframe");
iFrame.setAttribute("src", url);
iFrame.setAttribute("style", "display:none;");
iFrame.setAttribute("height", "0px");
iFrame.setAttribute("width", "0px");
iFrame.setAttribute("frameborder", "0");
document.body.appendChild(iFrame);
// 发起请求后这个iFrame就没用了,所以把它从dom上移除掉
iFrame.parentNode.removeChild(iFrame);
iFrame = null;
}
function payClick() {
loadURL("haleyAction://payAction?order_no=112233445566&channel=chris&amount=99&subject=测试支付");
}
function payResult(pay_no,pay_channel,pay_amount,pay_subject) {
var content = pay_no + ", " + pay_channel + ", " + pay_amount + ", " + pay_subject ;
asyncAlert(content);
document.getElementById("returnValue").value = content;
}
//设置一延迟方法,解决stringByEvaluatingJavaScriptFromString同步死锁
function asyncAlert(content) {
setTimeout(function(){
alert(content);
},1);
}
<input type="button" value = "支付" onclick = "payClick()" />

在UIWebView的代理方法中,拦截URL通过scheme做不同处理:

#pragma mark - UIWebViewDelegate
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *URL = request.URL;
NSString *scheme = [URL scheme];
if ([scheme isEqualToString:@"haleyaction"]) {
[self handleCustomAction:URL];
return NO;
}
return YES;
}

在此处,根据URL的不同host做响应不同处理。

- (void)handleCustomAction:(NSURL *)URL
{
NSString *host = [URL host];
if ([host isEqualToString:@"scanClick"]) {
     NSLog(@"扫一扫");
}  else if ([host isEqualToString:@"payAction"]) {
    [self payAction:URL];

}

OC中的处理,并将处理结果回传至JS中(OC执行js方法,以参数形式回传结果)。

- (void)payAction:(NSURL *)URL
{
NSArray *params =[URL.query componentsSeparatedByString:@"&"];
NSMutableDictionary *tempDic = [NSMutableDictionary dictionary];
for (NSString *paramStr in params) {
NSArray *dicArray = [paramStr componentsSeparatedByString:@"="];
if (dicArray.count > 1) {
NSString *decodeValue = [dicArray[1] stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
[tempDic setObject:decodeValue forKey:dicArray[0]];
}
}
NSString *orderNo = [tempDic objectForKey:@"order_no"];
long long amount = [[tempDic objectForKey:@"amount"] longLongValue];
NSString *subject = [tempDic objectForKey:@"subject"];
NSString *channel = [tempDic objectForKey:@"channel"];
// 支付操作
// 将支付结果返回给js(参数拼接,当参数不是字符串时,不要加单引号'',如amount)
NSString *jsStr = [NSString stringWithFormat:@"payResult('%@', '%@', %lu, '%@')", orderNo, channel, (unsigned long)amount, subject];}
[self.webView stringByEvaluatingJavaScriptFromString:jsStr];
}

附注:js调用OC方法需要传参数时,可使用如下方法:

js中代码:
function shareClick() {
loadURL("haleyAction://shareClick?title=测试分享的标题&content=测试分享的内容&url=http://www.baidu.com");
}

在OC方法中获取参数,因所有的参数都在URL的query中,可先通过&将字符串拆分,再通过=把参数拆分成key和value。

- (void)share:(NSURL *)URL
{
NSArray *params =[URL.query componentsSeparatedByString:@"&"];
NSMutableDictionary *tempDic = [NSMutableDictionary dictionary];
for (NSString *paramStr in params) {
       NSArray *dicArray = [paramStr componentsSeparatedByString:@"="];
       if (dicArray.count > 1) {
            NSString *decodeValue = [dicArray[1]
               stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
           [tempDic setObject:decodeValue forKey:dicArray[0]];
       }
}
NSString *title = [tempDic objectForKey:@"title"];
NSString *content = [tempDic objectForKey:@"content"];
NSString *url = [tempDic objectForKey:@"url"];
// 在这里执行分享的操作
// 将分享结果返回给js
NSString *jsStr = [NSString stringWithFormat:@"shareResult('%@','%@','%@')",title,content,url];
[self.webView stringByEvaluatingJavaScriptFromString:jsStr];
}

附注:以下代码可以往HMTL的JS环境中插入全局变量、JS方法等
[webView stringByEvaluatingJavaScriptFromString:@"var arr = [3, 4, 'abc'];"];

2)使用WKWebView拦截URL(iOS8)(但更推荐使用下面介绍的方式三)

通过WKWebView的代理(WKNavigationDelegate)方法实现拦截URL(需要实现WKNavigationDelegate代理协议):

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
NSURL *URL = navigationAction.request.URL;
NSString *scheme = [URL scheme];
if ([scheme isEqualToString:@"haleyaction"]) {
     [self handleCustomAction:URL];
     decisionHandler(WKNavigationActionPolicyCancel);
     return;
}
decisionHandler(WKNavigationActionPolicyAllow);
}

说明:WKNavigationActionPolicyCancel代表取消加载,相当于UIWebView的代理方法return NO的情况;WKNavigationActionPolicyAllow代表允许加载,相当于UIWebView的代理方法中 return YES的情况。
关于[self handleCustomAction:URL];的具体实现同上面使用UIWebView拦截URL的情形,此不赘述。

注意:在WKWebView中要使用alert等弹窗操作,需要实现代理WKUIDelegate中相应方法,自己定义弹窗,否则alert弹不出。

#pragma mark - WKUIDelegate
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提醒" message:message preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"知道了" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
completionHandler();  //此block一定得调用,否则alert弹不出,调用位置随意
}]];
[self presentViewController:alert animated:YES completion:nil];
}

方式二、使用JavaScriptCore

JavaScriptCore是iOS7开始引入的js框架。JavaScriptCore中主要的类有5个,下面分别介绍:1、JSContext:
JSContext是为JavaScript的执行提供运行环境,所有的JavaScript的执行都必须在JSContext环境中。常见的方法有:

//创建JSContext
JSContext *context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 运行一段js代码,输出结果为JSValue类型
- (JSValue *)evaluateScript:(NSString *)script;
// 获取当前正在运行的JavaScript上下文环境
+ (JSContext *)currentContext;
// 获取当前被调用方法的参数
+ (NSArray *)currentArguments;

2、JSValue:
JavaScript中的变量和方法。JSValue都是通过JSContext返回或者创建的,并没有构造方法。JSValue包含了每一个JavaScript类型的值,通过JSValue可以将Objective-C中的类型转换为JavaScript中的类型,也可以将JavaScript中的类型转换为Objective-C中的类型。JSValue可以说是JavaScript和Object-C之间互换的桥梁。每个JSValue都和JSContext相关联并且强引用context,当与某JSContext对象关联的所有JSValue释放后,JSContext也会被释放。

//示例:在OC中往JS环境中添加一个变量(如一数组arr)
JSContext *context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
[context evaluateScript:@"var arr = [3, 4, 'abc'];"];
//取出arr变量
JSValue *jsArr = context[@"arr"];
//转换成NSArray
NSArray *nsArr=[jsArr toArray];
NSLog(@"NSArray: %@", nsArr);
//常见其他转换方法
- (int32_t)toInt32;
- (NSArray *)toArray;
- (NSString *)toString;

3、JSVirtualMachine:
JS运行的虚拟机,代表一个独立的JavaScript对象空间,并为其执行提供资源。

4、JSManagedValue:
JS和OC对象的内存管理辅助对象,主要用来保存JSValue对象,解决OC对象中存储js的值导致的循环引用问题。

5、JSExport:
JS调用OC中的方法和属性都写在继承自JSExport的协议当中,如此一来,这些方法和属性会自动提供给JavaScript。

让JSContext访问我们的本地OC代码的方式主要有两种:block 和JSExport协议,以下主要介绍block方式。当一个 Objective-C block 被赋给JSContext里的一个标识符(如下面的@"share"),JavaScriptCore 会自动的把 block 封装在 JavaScript 函数里。这使得在 JavaScript 中可以简单的使用 Foundation 和 Cocoa 类,所有的桥接都为你做好了。

JS调用OC示例代码:

//html中代码变得更简洁
function shareClick() {
       share('测试分享的标题','测试分享的内容','url=http://www.baidu.com');
}

在UIWebView代理方法中- (void)webViewDidFinishLoad:(UIWebView *)webView添加JS要调用的OC方法:

#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView
{
NSLog(@"webViewDidFinishLoad");
[self addCustomActions];   //js要调用的oc方法
}

oc方法代码:

- (void)addCustomActions
{
JSContext *context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
[self addShareWithContext:context];
[self addPayActionWithContext:context];
//其他js要调用的方法...
}

具体实现:(模块独立开,方便清晰定位)

- (void)addShareWithContext:(JSContext *)context {
__weak typeof(self) weakSelf = self;
context[@"share"] = ^() {
    NSArray *args = [JSContext currentArguments];
    if (args.count < 3) {
    return ;
    }
    NSString *title = [args[0] toString];
    NSString *content = [args[1] toString];
    NSString *url = [args[2] toString];
    // 在这里执行分享的操作...
    // 将分享结果返回给js
    NSString *jsStr = [NSString      stringWithFormat:@"shareResult('%@','%@','%@')",title,content,url];
    [[JSContext currentContext] evaluateScript:jsStr];
  };
}

注意:
1)获取js运行环境JSContext的时机:一般在webViewDidFinishLoad中获取

self.context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

2)线程问题:js调用OC代码,是在子线程。而更新OC中的UI(如弹出OC原生Alert),需要回到主线程。
3)循环引用问题:无论是把Block传给JSContext对象让其变成JavaScript方法,还是把它赋给exceptionHandler属性,在Block内都不要直接使用其外部定义的JSContext对象或者JSValue,应该将其当做参数传入到Block中,或者通过JSContext的类方法+ (JSContext *)currentContext;来获得。否则会造成循环引用使得内存无法被正确释放。

附加:
1)通过JSValue获取JavaScript对象上的属性。

JSValue会自动延展数组大小。并且通过JSValue还可以获取JavaScript对象上的属性,比如例子中通过"length"就获取到了JavaScript数组arr的长度(元素个数)。

JSContext*context=[[JSContext alloc]init];
[context evaluateScript:@"var arr = [21, 7 , 'iderzheng.com'];"];
JSValue*jsArr=context[@"arr"];  // Get array from JSContext
NSLog(@"JS Array: %@;    Length: %@", jsArr, jsArr[@"length"]);
jsArr[1]=@"blog";  // Use JSValue as array
jsArr[7]=@7;
NSLog(@"JS Array: %@;    Length: %d", jsArr,[jsArr[@"length"]toInt32]);
NSArray *nsArr=[jsArr toArray];
NSLog(@"NSArray: %@", nsArr);
//Output:
//  JS Array: 21,7,iderzheng.com    Length: 3
//  JS Array: 21,blog,iderzheng.com,,,,,7    Length: 8
//  NSArray: (
//  21,
//  blog,
//  "iderzheng.com",
//  "<null>",
//  "<null>",
//  "<null>",
//  "<null>",
//  7
//  )

2)异常处理

在JSContext中执行的JavaScript如果出现异常,只会被JSContext捕获并存储在exception属性上,而不会向外抛出。正确做法是:给JSContext对象设置exceptionHandler属性,它接受的是^(JSContext *context, JSValue *exceptionValue)形式的Block。其默认值就是将传入的exceptionValue赋给传入的context的exception属性。

JSContext *jsContext = [[JSContext alloc] init];
//捕获运行js脚本的错误信息
jsContext.exceptionHandler = ^(JSContext *context, JSValue *exceptionValue) {
/*此处exceptionValue赋值给传入的context的exception属性,而不是赋给外部创建的  jsContext对象,这样做是为了避免循环引用*/
      context.exception = exceptionValue; 
      NSLog(@"异常信息:%@", exceptionValue);
};


方式三、使用WKWebView的addScriptMessageHandler

WKWebView是iOS8开始引入的UIWebView的增强版,无论是内存、性能还是使用灵活性上都要优越于UIWebView,是官方推出的UIWebView的替代品。

WKWebView->configuration(WKWebViewConfiguration)->userContentController(WKUserContentController)->addScriptMessageHandler:name:

WKWebView正是使用addScriptMessageHandler实现js调用原生OC方法。而要使用此功能,必须实现WKScriptMessageHandler协议。该协议仅有一个@required方法:
-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;
其中,message.name即为消息发送者,以指示发送的是哪个消息,可理解为js要调的oc方法名称。
1)创建WKWebView:

WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
WKPreferences *preferences = [WKPreferences new];
preferences.javaScriptCanOpenWindowsAutomatically = YES;
preferences.minimumFontSize = 40.0;
configuration.preferences = preferences;
self.webView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];

2)并配置js调用的oc方法(addScriptMessageHandler:name:)

- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"Share"];
}

为避免循环引用导致的控制器不能释放问题,一般的,在viewWillDisappear方法中需要移除相应的scriptMessageHandler:

- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
// 移除scriptMessageHandler
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"Share"];
}

3)实现WKScriptMessageHandler协议方法

#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
if ([message.name isEqualToString:@"Share"]) {
     [self shareWithParams:message.body]; //js需要传参数给oc
} else if ([message.name isEqualToString:@"Location"]) {
     [self getLocation];  //无参
}
}

说明:可根据message.name 来区分js要执行的不同oc方法,而message.body 中存放着JS 要给OC 传的参数。注意message.body允许的参数类型为:
Allowed types are  NSNumber,NSString,NSDate,NSArray,NSDictionary, and NSNull.

4)解析参数,oc方法的具体实现

- (void)shareWithParams:(NSDictionary *)tempDic
{
if (![tempDic isKindOfClass:[NSDictionary class]]) {
     return;
}
NSString *title = [tempDic objectForKey:@"title"];
NSString *content = [tempDic objectForKey:@"content"];
NSString *url = [tempDic objectForKey:@"url"];
// 在这里执行分享的操作
// 将分享结果返回给js
NSString *jsStr = [NSString stringWithFormat:@"shareResult('%@','%@','%@')",title,content,url];
[self.webView evaluateJavaScript:jsStr completionHandler:^(id _Nullable result, NSError * _Nullable error) {
      NSLog(@"%@----%@",result, error);
}];
}

5)js中的代码

<input type = "button" value = "分享" onclick = "shareClick()" />
function shareClick() {
//此处传递的参数是一字典
window.webkit.messageHandlers.Share.postMessage({title:'测试分享的标题',content:'测试分享的内容',url:'http://m.rblcmall.com/share/openShare.htm?share_uuid=shdfxdfdsfsdfs&share_url=http://m.rblcmall.com/store_index_32787.htm&imagePath=http://c.hiphotos.baidu.com/image/pic/item/f3d3572c11dfa9ec78e256df60d0f703908fc12e.jpg'});
}

附加:WKWebView提供了estimatedProgress属性代表当前网页加载进度,可通过KVO监听此属性,实现进度计算。示例代码如下:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (object == self.webView && [keyPath isEqualToString:@"estimatedProgress"]) {
CGFloat newprogress = [[change objectForKey:NSKeyValueChangeNewKey] doubleValue];
if (newprogress == 1) {
    [self.progressView setProgress:1.0 animated:YES];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.7 *        NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    self.progressView.hidden = YES;
    [self.progressView setProgress:0 animated:NO];
});
}else {
    self.progressView.hidden = NO;
    [self.progressView setProgress:newprogress animated:YES];
}
}
}

日记本