如何让 js 调用一个 iOS native 未注册 handler 的方法

前几天面试阿里某核心业务群的 iOS 开发,没错,就是在这个如北极寒冷的冬天面试的。
各种面试中的刀光剑影、见招拆招之后,博主的命运,你猜猜看。


不过我被问到一个有意思的问题:” 如何让 js 调用一个 iOS native 未注册 handler 的方法 “ 或者说 “ 如何让 js 调用一个 iOS native 并不存在的方法 ”
这个问题的背景是讲怎么设计一个简单 web container 并实现 js 和 native 的相互调用。博主水平有限,简单回答了一下。其实,博主就会使用 WebViewJavascriptBridge , 其他方案缺乏实践经验,包括原生的 JavaScriptCore, 如果要深入了解 JavaScriptCore,可以看看 JavaScriptCore by Example , 或者中文的 ios7 JavaScriptCore.framework 或者直接官网 走起。

关于 iOS 如何设计一个 Hybrid 框架,可能讨论的已经很多了。
主要是如何选择的问题,要怎么做组合:

iOS web view native <=> js bridge
UIWeb​View WebviewJavascriptBridge
WKWeb​View Java​Script​Core

关于 WebviewJavascriptBridge, 这里有 WebViewJavascriptBridge 简单使用及原理分析,个人觉得写得很好,于是不再赘述。


本来我想回答一句正确的废话,“使用 JSPatch 可以动态给类添加新的方法,然后 js 就可以调用这个不存在的方法了。” 但是阿里的面试嘛,这样回答的话,面试官肯定让讲讲 JSPatch 的原理。最近因为不务正业,没有认真“背诵”各种功课,让讲 JSPatch 的原理的话,跪的速度会更快。关于阿里核心业务群的面试是什么样子的,可以看看《阿里面试回来,和Java程序员谈一谈》。
再说,我又“高瞻远瞩”地考虑到苹果的政策,JSPatch 还是算了吧。失策呀,能不能上线是一回事,做出做不出是另一回事呀。


那么该怎么去实现“让 js 调用一个 iOS native 未注册 handler 的方法”呢?

不如先看看 WebViewJavascriptBridge 自带的 demo 是如何实现的吧。

**题外话:如果 demo 编译出现错误**
1, 把工程目录下的 Pod 文件夹删了  
2, 在 Build Phases 里把 [CP] 开头的三项全删掉
3, 在工程目录里把 Frameworks 目录下的 Pods_ExampleApp_iOS.framework 删掉

在 ExampleUIWebViewController.m 中定义

@interface ExampleUIWebViewController ()
@property WebViewJavascriptBridge* bridge;
@end

并且在 viewWillAppear 里面为 _bridge register handler @"testObjcCallback"

- (void)viewWillAppear:(BOOL)animated {
    if (_bridge) { return; }
    
    UIWebView* webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:webView];
    
    [WebViewJavascriptBridge enableLogging];
    
    _bridge = [WebViewJavascriptBridge bridgeForWebView:webView];
    [_bridge setWebViewDelegate:self];
    
    [_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
        NSLog(@"testObjcCallback called: %@", data);
        responseCallback(@"Response from testObjcCallback");
    }];
    
    [_bridge callHandler:@"testJavascriptHandler" data:@{ @"foo":@"before ready" }];
    
    [self renderButtons:webView];
    [self loadExamplePage:webView];
}

此时,如果点击网页上的 Fire testObjcCallback button, 那么 console 会打印:

2017-03-15 23:06:45.750 ExampleApp-iOS[58653:1106005] WVJB RCVD: {
  "callbackId" : "cb_1_1489590405743",
  "handlerName" : "testObjcCallback",
  "data" : {
    "foo" : "bar"
  }
}
2017-03-15 23:06:45.751 ExampleApp-iOS[58653:1106005] testObjcCallback called: {
    foo = bar;
}
2017-03-15 23:06:45.751 ExampleApp-iOS[58653:1106005] WVJB SEND: {"responseId":"cb_1_1489590405743","responseData":"Response from testObjcCallback"}

同时,页面会显示


如何让 js 调用一个 iOS native 未注册 handler 的方法_第1张图片
正常情况下的显示.png

通常来说,国人会犯单复数不分的错误。

ExampleApp.html line48-56 如下

var callbackButton = document.getElementById('buttons').appendChild(document.createElement('button'))
callbackButton.innerHTML = 'Fire testObjcCallback'
callbackButton.onclick = function(e) {
    e.preventDefault()
    log('JS calling handler "testObjcCallback"')
    bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
        log('JS got response', response)
    })
}

新入行的 Tony 老师经常在英文单词要不要写复数形式中迷茫。这不他又凌乱了

bridge.callHandler('testObjcCallbacks', {'foo': 'bar'}, function(response) {
   log('JS got response', response)
})

原来的 testObjcCallback 变成了 testObjcCallbacks, 团队里也没有人做测试,就这样上线了。新入行的 Tony 老师面临着被炒鱿鱼。他只能寄希望于一些热修复技术救急。当然,还敢线上热修,呵呵。
好吧, Tony老师这个梗一点不好笑。


稍微正经一点讲,在 ExampleApp.html 里面 testObjcCallback 变成了 testObjcCallbacks, 那么 console 将会打印:

2017-03-15 23:57:14.462 ExampleApp-iOS[59574:1137770] WVJBNoHandlerException, No handler for message from JS: {
    callbackId = "cb_1_1489593434458";
    data =     {
        foo = bar;
    };
    handlerName = testObjcCallbacks;
}

同时,页面也不会显示第二条数据了。

此时如果要实现 js 调用一个 iOS native 端没有注册过的 testObjcCallbacks, 那么可能还是需要 JSPatch, 同时还要对 WebViewJavascriptBridge 做一点改造。

首先,根据上面 console 信息,找到 log 打印所在的位置 WebViewJavascriptBridgeBase.m line104, 改造从这里开始。
为原来的 WebViewJavascriptBridgeBaseDelegate 增加一个新的 delegate.

@protocol WebViewJavascriptBridgeBaseDelegate 
- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand;

/* 处理那些 WVJBNoHandlerException, No handler for message from JS */

- (void) _noHandlerForMessage:(NSString *)handlerName withData:(NSDictionary *)data callbackId:(NSString *)id;
//
@end

将 WebViewJavascriptBridgeBase.m line103-106 改造如下:

if (!handler) {
    if (self.delegate && [self.delegate respondsToSelector:@selector(_noHandlerForMessage:withData:callbackId:)]) {
        [self.delegate _noHandlerForMessage:message[@"handlerName"] withData:message[@"data"] callbackId:message[@"callbackId"]];
        NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@, \n But WebViewJavascriptBridgeBaseDelegate can deal with it", message);
        continue;
    } else {
        NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
        continue;
    }
}

因为 WebViewJavascriptBridge 的结构是 WebViewJavascriptBridge(使用 UIWebView) 和 WKWebViewJavascriptBridge(使用 WKWebView) 公用一个 WKWebViewJavascriptBridgeBase, 所以需要将 base 里面的 _noHandlerForMessage delegate 分别交给上层来处理,以 WebViewJavascriptBridge 为例:

WebViewJavascriptBridge.h 里面新增加一个 delegate 类型

@protocol WebViewJavascriptBridgeExceptionDelegate 

/* 处理那些 WVJBNoHandlerException, No handler for message from JS  -- 传递给应用层去处理了*/

- (void) _noHandlerForMessage:(NSString *)handlerName withData:(NSDictionary *)data callbackId:(NSString *)id;
//
@end

并且为 WebViewJavascriptBridge 增加一个新的方法:

- (void)setWebViewExceptionDelegate:(id)webViewExceptionDelegate {
    _webViewExceptionDelegate = webViewExceptionDelegate;
}

那么对应 WebViewJavascriptBridgeBaseDelegate 传过来的 _noHandlerForMessage:withData:callbackId 直接传递到应用层去

- (void)_noHandlerForMessage:(NSString *)handlerName withData:(NSDictionary *)data callbackId:(NSString *)id {
    if (_webViewExceptionDelegate && [_webViewExceptionDelegate respondsToSelector:@selector(_noHandlerForMessage:withData:callbackId:)]) {
        [_webViewExceptionDelegate _noHandlerForMessage:handlerName withData:data callbackId:id];
    }
}

对于 WebViewJavascriptBridge 库的改造基本就是这样了。


那么在应用层如何处理呢。这时候就需要借助 JSPatch 了,我本来想自己写一个函数封装,但是看过 JSPatch 写的优美且完备的代码,干嘛不用现成的呢。
在 ExampleUIWebViewController.m viewWillAppear里添加

[_bridge setWebViewExceptionDelegate:self];

然后去实现 _noHandlerForMessage:withData:callbackId 方法

- (void) _noHandlerForMessage:(NSString *)handlerName withData:(NSDictionary *)data callbackId:(NSString *)id {
    NSString *sourcePath = [[NSBundle mainBundle] pathForResource:@"ExampleApp" ofType:@"js"];
    NSString *script = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
    
    [JPEngine evaluateScript:script];
    
    [self performSelector:NSSelectorFromString(handlerName)];
}

这里新加了一个 ExampleApp.js, 内容很简单,就是定义一个 testObjcCallbacks 对象,主要是弹一个 AlertView.

defineClass('ExampleUIWebViewController', {
    testObjcCallbacks: function() {
        var alertView = require('UIAlertView').alloc().initWithTitle_message_delegate_cancelButtonTitle_otherButtonTitles("Alert","原生并不存在的方法", self, "OK",  null);
        alertView.show();
    }
})

运行一下,看看效果,果然有了:

如何让 js 调用一个 iOS native 未注册 handler 的方法_第2张图片
js 调用了一个原生不存在的方法.png

_noHandlerForMessage:withData:callbackId 方法里,直接使用了[self performSelector:NSSelectorFromString(handlerName)]; 这样是不能处理多个参数的情况的。解决办法是使用 NSInvocation, 下面的例子来着这里,与本文无太大的关系。

- (void)viewDidLoad {
  [super viewDidLoad];
  //NSInvocation;用来包装方法和对应的对象,它可以存储方法的名称,对应的对象,对应的参数,
  /*
   NSMethodSignature:签名:再创建NSMethodSignature的时候,必须传递一个签名对象,签名对象的作用:用于获取参数的个数和方法的返回值
   */
  //创建签名对象的时候不是使用NSMethodSignature这个类创建,而是方法属于谁就用谁来创建
  NSMethodSignature*signature = [ViewController instanceMethodSignatureForSelector:@selector(sendMessageWithNumber:WithContent:)];
  //1、创建NSInvocation对象
  NSInvocation*invocation = [NSInvocation invocationWithMethodSignature:signature];
  invocation.target = self;
  //invocation中的方法必须和签名中的方法一致。
  invocation.selector = @selector(sendMessageWithNumber:WithContent:);
  /*第一个参数:需要给指定方法传递的值
         第一个参数需要接收一个指针,也就是传递值的时候需要传递地址*/
  //第二个参数:需要给指定方法的第几个参数传值
  NSString*number = @"1111";
  //注意:设置参数的索引时不能从0开始,因为0已经被self占用,1已经被_cmd占用
  [invocation setArgument:&number atIndex:2];
  NSString*number2 = @"啊啊啊";
  [invocation setArgument:&number2 atIndex:3];
  //2、调用NSInvocation对象的invoke方法
  //只要调用invocation的invoke方法,就代表需要执行NSInvocation对象中制定对象的指定方法,并且传递指定的参数
  [invocation invoke];
}
- (void)sendMessageWithNumber:(NSString*)number WithContent:(NSString*)content{
  NSLog(@"电话号%@,内容%@",number,content);
}

这里可以对 performSelector 和使用 NSInvocation 做进一步的封装,使之更灵活。

完整代码在GItHub; 感觉目前的实现完全比较初级,实用价值不高。原价都是十万八万的源代码,现在统统不要钱了。


以上方案仅在 UIWebView 上实现了,并未测试 WKWebView 的情况,关于如何使用 WKWebView 做一个混合开发框架,可以参考一下 WebViewJavascriptBridge 在 WKWebView 上的实现。也可以参考自己动手打造基于 WKWebView 的混合开发框架系列(至于此文中的“BlackHawk,纯 Swift 开发的基于 WKWebView 的高性能 Cordova 替代”,使用不使用就要看个人的实际情况了)。如果对于 WKWebView 认识不够充分,参考《WKWebView 那些坑》.


虽然借助 JSPatch 和 WebViewJavascriptBridge, 实现了“js 调用一个 iOS native 未注册 handler 的方法”,但是由于 JSPatch 最近受到了苹果的“重视”,所以这种方案上线的可能性是不大的。关于 JSPatch 的最近的风波,可以看看苹果“热修复门”事件回顾和分析,苹果妥妥的双标。
苹果这么做,有利有弊吧。看着会增强 iOS app 安全性,并且自己依旧牢牢控制 iOS 平台。然而,原生应用的命运的主动权已经不在苹果手里了。正如 JSPatch 作者说的“JS 没事,iOS 该失业还是会失业。” 何况,搞安全的,还有更高超的做法让原生应用动态化。

如何让 js 调用一个 iOS native 未注册 handler 的方法_第3张图片
安全圈看苹果“热修复门”.png

下方有个打赏按钮,博主最近穷。

你可能感兴趣的:(如何让 js 调用一个 iOS native 未注册 handler 的方法)