前言
客户端开发和前端开发,不可避免的会经常遇到需要相互通信的场景。正巧前段时间工作上遇到了一个交互的场景,趁机研究整理一下,以下是自己的理解。
一、客户端如何调用Web
端?
在UIWebView
时代,有两种:
- 方法一:UIWebView实例直接执行
- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script
方法
[webView stringByEvaluatingJavaScriptFromString:@"js command"];
- 方法二:获取到UIWebView的
JSContext
,利用JSContext实例去执行- (JSValue *)evaluateScript:(NSString *)script
方法
- (void)webViewDidFinishLoad:(UIWebView *)webView {
JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
[jsContext evaluateScript:@"js command"];
}
最开始怀疑UIWebView
最终是通过JSContext
来执行的Script
,但是通过验证发现并不是。
验证方式:
1.通过Aspect
框架,hook到JSContext的- (JSValue *)evaluateScript:(NSString *)script
方法;
2.然后利用UIWebView实例去调用- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script
3.发现hook的方法并不会调用。
在WKWebView
时代,因为获取不到JSContext
,只有一种方式:
由WKWebView的实例执行- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)( _Nullable id, NSError * _Nullable error))completionHandler
[webView evaluateJavaScript:@"js command" completionHandler:^(NSString* result, NSError* error) {}];
二、Web端如何调用客户端?
在UIWebView
时代,有两种:
-
方法一:Web端通过创建
iframe
等方式,通过加载新的“文档”,这样会触发原生UIWebView的相关代理,利用传递过来的信息进行原生方法调用JS端创建iframe进行调用
var iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = 'http://loadurl'; document.documentElement.appendChild(iframe); window.MSF_Iframe = iframe;
原生在webView代理里拦截进行处理
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType { NSURL *url = request.URL; // 发现是自定义的url,就拦截掉开始处理 }
-
方法二:获取到UIWebView的JSContext后,给JSContext添加方法/对象等,Web端即可以调用原生。
- (void)webViewDidFinishLoad:(UIWebView *)webView { JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; // 给JS端添加了一个JSInVokeOC方法,JS端调用该方法,就会触发下面的block context[@"JSInVokeOC"] = ^id(JSValue *obj) { return @{}; }; }
这种方式是利用JavaScriptCore框架的能力,在Web端给global对象创建了对应的方法,对象。
在WKWebView
时代,有两种:
-
方法一:和上面的UIWebView一样,Web端可以通过加载新的“文档”,触发原生相关代理方法,在代理方法里可以根据信息来进行方法调用
-
方法二:利用系统已经提供好的
bridge
原生通过相关api注册好方法
[webView.configuration.userContentController addScriptMessageHandler:self name:@"jsInvokeNative"];
Web端通过以下方式调用原生
window.webkit.messageHandlers.jsInvokeNative.postMessage()
通信问题是解决了,但是在实际使用中就会发现,缺少一个非常关键的事情:调用对方后,一方执行完成了,如何把对应的信息回复给对方?
三、如何处理回调?
原理是:各端在调用的时候记录好调用方法的回调和回调标识
,另一端处理完消息后,根据回调标识,再次调用对方。
实际案例:
之前对接广告,广告侧要求业务方给Webview注入一个JSBridge
,JSBridge需要提供invoke
方法,以供Web
端调用原生的方法,来获取是否登陆等信息。
下面我们一步一步来实现:
-
原生利用Webview,调用
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)( _Nullable id, NSError * _Nullable error))completionHandler
方法,给Web
环境中的window
声明一个JSBridge对象
。JSBridge对象包含以下三个内容:
- invoke方法,用来调用原生
- 有个Native回调JS的方法
- 存在一个集合,用来存放等待原生回调的函数
-
invoke方法里,是创建一个
iframe
,将Web端需要传递的参数信息拼接到iframe的url上
去调用原生 -
原生
在WebView相关代理方法里拦截到这个url的信息
后,开始调用原生的方法进行处理 (信息来到原生,原生侧开始处理消息) -
原生处理完成后,要根据之前url里给到的回调函数信息,
再次调用Web端的方法
,将相关信息给到web端
JS代码示例如下:
NSString * MSF_inject_jsBridge_js() {
#define msf_js_func__(x) #x
// BEGIN preprocessorJSCode
static NSString * preprocessorJSCode = @msf_js_func__(
;
function setupWebViewJsBridge() {
if (window.MSF_JsBridge) { return; }
// 创建JSBridge对象
window.MSF_JsBridge = {};
window.MSF_JsBridge.callBacks = {};// 用来存放回调函数
window.MSF_JsBridge.invoke = invokeNativeMethod; // 调用原生的方法
window.MSF_JsBridge.nativeCallBackToJs = nativeCallBackToJs; // 留给原生回调的方法
};
// 原生回调的方法
function nativeCallBackToJs(callBackId, params) {
if (callBackId) {
var callBack = window.MSF_JsBridge.callBacks[callBackId];
if (callBack) {
callBack(params);
}
// 处理完后,要移除回调,否则会越来越多
delete window.MSF_JsBridge.callBacks.callBackId;
console.log(params);
}
};
function invokeNativeMethod(methodName, params) {
// 如果有回调,处理一下params:将回调函数转移到集合中,把回调id传递给原生
if (params.callBack != undefined) {
var callBackId = 'callBackId'+new Date().getTime();
window.MSF_JsBridge.callBacks[callBackId] = params.callBack;
params.callBackId = callBackId;
delete params.callBack;
}
// 如果已经创建过iframe,直接利用
if (window.MSF_Iframe) {
window.MSF_Iframe.src = 'mushao://' + methodName + '://' + JSON.stringify(params);
return;
}
// 创建iframe
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'mushao://' + methodName + '://' + JSON.stringify(params);
document.documentElement.appendChild(iframe);
window.MSF_Iframe = iframe;
};
setupWebViewJsBridge();
);
// END preprocessorJSCode
#undef msf_js_func__
return preprocessorJSCode;
};
在WebView加载完成后
开始注入
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
NSString *injectJs = MSF_inject_jsBridge_js();
[webView evaluateJavaScript:injectJs completionHandler:^(id _Nullable data, NSError * _Nullable error) {
if (error) {
NSLog(@"注入失败:%@",error);
return;
}
// 模拟代码:模拟JS调用原生
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.wkWebView evaluateJavaScript:@"window.MSF_JsBridge.invoke('receiveNativeInfo',{'device':'iphone X','callBack':(function(params) { console.log('call back success')})})" completionHandler:^(id _Nullable data, NSError * _Nullable error) { }];
});
}];
}
从Safari的调试器
里可以看到注入JSBridge成功:
在webView相关代理方法里拦截Web端的调用消息
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(**void** (^)(WKNavigationActionPolicy))decisionHandler {
NSString *urlStr = [navigationAction.request.URL.absoluteString stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
if ([urlStr hasPrefix:@"mushao://"]) {
NSArray *infoArray = [urlStr componentsSeparatedByString:@"//"];
if (infoArray.count >= 3) {
NSString *methodName = [infoArray objectAtIndex:1];
if (![methodName hasSuffix:@":"]) {
methodName = [methodName stringByAppendingString:@":"];
}
NSString *jsonParamsStr = [infoArray objectAtIndex:2];
NSLog(@"收到了js调用的原生方法\n方法名:%@\n参数:%@",methodName,jsonParamsStr);
[self performSelector:NSSelectorFromString(methodName) withObject:jsonParamsStr];
}
}
decisionHandler(WKNavigationActionPolicyAllow);
}
拦截到后开始处理消息,处理完成后开始回调:
- (void)receiveNativeInfo:(NSString *)jsonParams {
//解析数据
NSData *jsonData = [jsonParams dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
NSDictionary *params = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];
if (error) {
return;
}
// 开始做原生的相关处理
// ……
// 原生处理结束
// 如果有回调,拿到信息后,再次发消息给JS
id callBackId = params[@"callBackId"];
if (callBackId) {
[self.wkWebView evaluateJavaScript:[NSString stringWithFormat:@"window.MSF_JsBridge.nativeCallBackToJs('%@',{'msg':'oc to js info'})",callBackId] completionHandler:^(id _Nullable data, NSError * _Nullable error) {
}];
}
}
从调试面板上可以看到JS收到了原生的回调:
到现在,除了Web端回调给原生的实现没有处理外,其他的基本上通了。而Web端回调给原生,和上面的原理是一样的,就是记录回调,然后web端处理消息后,再次调用原生。
四、WebViewJavascriptBridge
上面是自己的简单实现,在github上已经有一个非常好的框架WebViewJavascriptBridge
1.框架如何使用?
- 客户端提供给Web端的方法
[_bridge registerHandler:@"testObjcCallback" handler:^(**id** data, WVJBResponseCallback responseCallback) {
NSLog(@"testObjcCallback called: %@", data);
responseCallback(@"Response from testObjcCallback");
}];
- web端提供给原生的调用方法
window.WebViewJavascriptBridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
var responseData = { 'Javascript Says':'Right back atcha!' }
log('JS responding with', responseData)
responseCallback(responseData)
})
- 客户端调用Web端的方法
[_bridge callHandler:@"testJavascriptHandler" data:@{ @"foo":@"before ready" }];
2.原理简介
- web端响应原生的调用
// 如果有回调id,记录对应的回调id,封装到responseCallback中
if (message.callbackId) {
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};
}
// 取出对应的函数,开始调用
var handler = messageHandlers[message.handlerName];
if (!handler) {
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
handler(message.data, responseCallback);
}
// 将回调message存到队列中,开始去触发原生
function _doSend(message, responseCallback) {
if (responseCallback) {
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}
- 原生调用Web端
- (**void**)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
[**self** _log:@"SEND" json:messageJSON];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
if ([[NSThread currentThread] isMainThread]) {
[self _evaluateJavascript:javascriptCommand];
} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self _evaluateJavascript:javascriptCommand];
});
}
}
- Web端调用原生的简化过程:
原生调用Web的简化过程就不画了,和上面的差不多。
五、JavaScriptCore
最后这里想再聊一下JavaScriptCore
这个框架
JavaScriptCore框架有以下主要类:
-
JSVirtualMachine JS虚拟机,一个虚拟机实例代表单独一个Javascript的“对象空间”或者执行资源。
看了内部实现,就是对象持有三个
NSMapTable
,用来存放一些对象(个人觉得是为了处理相关对象的生命周期,比如JSContext对象就是绑定到JSVirtualMachine对象的一个NSMapTable中的) -
JSContext 代表一个
JavaScript
的执行环境。所有的Javascript values
都会绑定到对应的执行环境中。 -
JSValue 是对JSContext中的Javascript值的引用(即Js值在OC中的映射)
通过JSValue的API,可以给JSContext里创建对应的Javascript对象。
几乎支持所有的类型:对象,布尔,整型,id类型,NSRange,CGRect,函数等等,甚至可以支持创建promise
-
JSExport 提供了一种声明方式来导出OC对象和类——包括属性、实例方法、类方法和初始化方法等给到 JavaScript。(即可以把原生的对象通过这个提供给JS端)
这个框架异常的强大。但是WKWebView没办法使用让人很头疼。目前看用的比较多的就是热修复。
著名的JSPatch
就用了对应的能力。
JSPatch引擎在初始化的时候,利用JSContext给JS端声明了很多的原生方法以供调用。
想实现动态化,利用JavaScriptCore这个框架是一个不错的选择。未来有时间可以再深入研究一下。