前几天面试阿里某核心业务群的 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 |
---|---|
UIWebView | WebviewJavascriptBridge |
WKWebView | JavaScriptCore |
关于 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"}
同时,页面会显示
通常来说,国人会犯单复数不分的错误。
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();
}
})
运行一下,看看效果,果然有了:
在 _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 该失业还是会失业。” 何况,搞安全的,还有更高超的做法让原生应用动态化。
下方有个打赏按钮,博主最近穷。