[iOS 开发] WebViewJavascriptBridge
前言:iOS 开发中,h5 和原生实现通信有多种方式, JSBridge 就是最常用的一种,各 JSBridge 类库的实现原理大同小异,这篇文章主要是针对当前使用最为广泛的 WebViewJavascriptBridge(v6.0.2),从功能 API、实现原理到源码解读、最佳实践,做一个简单介绍。
目录
- 一、简介
- 1.设计目的
- 2.特点
- 3.安装、导入
- 4.API
- 二、实现原理
- 1.目录结构
- 2.主要流程
- 2.1 初始化
- 2.2 JS 调用原生
- 2.3 原生调用 JS
- 2.4 小结
- 三、源码解读
- 四、最佳实践
- 1.JS 端的优化
- 2.Objective-C 端的优化
- 五、问题与讨论
- 六、延伸阅读
长文警告:由于文章篇幅较长,如果你不需要了解太多细节的话,可以忽略掉第三部分『源码解读』,通过阅读第二部分『实现原理』(含流程图)就基本可以了解到整个核心流程了(大图加载会比较慢,建议到电脑上阅读)。
一、简介
1. 设计目的
我们平时使用 UIWebView
时,原生和 JavaScript 的交互一般是通过以下两种方式实现的:
- Native to JavaScript:原生通过
-stringByEvaluatingJavaScriptFromString:
方法执行一段 JavaScript - JavaScript to Native:在网页中加载一个 Custom URL Scheme 的链接(直接设置 window.location 或者新建一个 iframe 去加载这个 URL),原生中拦截
UIWebView
的代理方法- webView:shouldStartLoadWithRequest:navigationType:
,然后根据约定好的协议做相应的处理
这两种方式的弊端在于代码过于松散,长而久之,- webView:shouldStartLoadWithRequest:navigationType:
方法变得越来越臃肿杂乱,就像下面这样:
- (BOOL)webView:(UIWebView*)webView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType {
NSString *urlString = request.URL.absoluteString;
NSDictionary *params = [[NSDictionary alloc] init];
if (request.URL.query.length > 0) {
params = [request.URL.query sc_URLParamKeyValues];
}
if ([urlString rangeOfString:@"app://share"].location != NSNotFound) {
// 处理分享逻辑的代码
return NO;
} else if ([urlString rangeOfString:@"app://getLocation"].location != NSNotFound) {
// 获取地理位置的代码...
// 回调 JS
[webView stringByEvaluatingJavaScriptFromString:[NSString stringWithFormat:"setLocation('%@')", locationString]];
return NO;
} else if ...
// 几十个 else if
else {
return YES;
}
return YES;
}
WebViewJavascriptBridge
框架提供了一种更优雅的方式,用来在 WKWebView
、UIWebView
(iOS) 以及 WebView
(OSX)中,建立一个 Objective-C 和 JavaScript 之间互相“发送消息”的机制,让我们可以在 JS 中像直接调 JS 方法那样发消息给 Objective-C,同时可以在 Objective-C 中像直接调 Objective-C 方法那样发消息给 JS。
2. 特点
- Objective-C 中发送消息给 web view 中的 JavaScript
- web view 中的 JavaScript 发送消息给 Objective-C
- 不论是原生还是 JavaScript,发送消息的过程就像平时调用同一语言/环境的方法一样简单
- 发送消息时不仅可以带参数,还可以传 callback 用于回调
3. 安装
3.1 使用 pod 安装
直接在 podfile 中加入下面这行代码,并执行 pod install
命令:
pod 'WebViewJavascriptBridge', '~> 6.0'
3.2 手动导入
在 WebViewJavascriptBridge 的 GitHub repository 上下载源码后,从下载好的文件中将 WebViewJavascriptBridge
文件夹直接拖入你的工程中。
4. API
4.1 Objective-C API
// 为指定的 web view (WKWebView/UIWebView/WebView)创建一个 JavaScript Bridge
+ (instancetype)bridgeForWebView:(id)webView;
// 注册一个名称为 handlerName 的 handler 给 JavaScript 调用
// 当在 JavaScript 中调用 WebViewJavascriptBridge.callHandler("handlerName") 时,该方法的 WVJBHandler 参数会收到回调
- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler;
// 调用 JavaScript 中注册过的 handler
// data 参数为调用 handler 时要传递给 JavaScript 的参数,responseCallback 传给 JavaScript 用来回调
- (void)callHandler:(NSString*)handlerName;
- (void)callHandler:(NSString*)handlerName data:(id)data;
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback;
// 如果你需要监听 web view 的代理方法的回调,可以通过该方法设置你的 delegate
- (void)setWebViewDelegate:(id)webViewDelegate;
4.2 JavaScript API
// 注册一个 handler 给 Objective-C 调用
registerHandler(handlerName: String, handler: function);
// 调用 Objective-C 中注册过的 handler
callHandler(handlerName: String);
callHandler(handlerName: String, data: undefined);
callHandler(handlerName: String, data: undefined, responseCallback: function);
5. 基本用法
5.1 导入头文件,声明一个 WebViewJavascriptBridge
属性:
#import "WebViewJavascriptBridge.h"
...
@property WebViewJavascriptBridge* bridge;
5.2 为你的 WKWebView
、UIWebView
(iOS)或者WebView
(OSX) 创建一个 WebViewJavascriptBridge
对象:
self.bridge = [WebViewJavascriptBridge bridgeForWebView:webView];
5.3 在 Objective-C 中注册 handler 和调用 JavaScript 中的 handler:
[self.bridge registerHandler:@"ObjC Echo" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"ObjC Echo called with: %@", data);
responseCallback(data);
}];
[self.bridge callHandler:@"JS Echo" data:nil responseCallback:^(id responseData) {
NSLog(@"ObjC received response: %@", responseData);
}];
5.4 复制下面的 setupWebViewJavascriptBridge
函数到你的 JavaScript 代码中:
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}
5.5 调用 setupWebViewJavascriptBridge
函数,使用 bridge
来注册 handler 和调用 Objective-C 中的 handler:
setupWebViewJavascriptBridge(function(bridge) {
/* 在这里做一些初始化操作 */
bridge.registerHandler('JS Echo', function(data, responseCallback) {
console.log("JS Echo called with:", data)
responseCallback(data)
})
bridge.callHandler('ObjC Echo', {'key':'value'}, function responseCallback(responseData) {
console.log("JS received response:", responseData)
})
})
二、实现原理
1. 目录结构
类名 | 功能 |
---|---|
WebViewJavascriptBridgeBase |
① 用来进行 bridge 初始化和消息处理的核心类; ② 这个类是在支持 WKWebView 后从 WebViewJavascriptBridge 中独立出来的逻辑,专门用来处理 bridge 相关的逻辑,不再与具体的 Web View 相关联了 |
WebViewJavascriptBridge |
① 桥接的入口,针对不同类型的 Web View (UIWebView 、WKWebView 、WebView )进行分发;② 针对 UIWebView 和 WebView 做的一层封装,主要用来执行 JS 代码,以及实现 UIWebView 和 WebView 的代理方法,并通过拦截 URL 来通知 WebViewJavascriptBridgeBase 做相应操作 |
WKWebViewJavascriptBridge |
针对 WKWebView 做的一层封装,主要用来执行 JS 代码,以及实现 WKWebView 的代理方法,并通过拦截 URL 来通知 WebViewJavascriptBridgeBase 做相应操作 |
WebViewJavascriptBridge_JS |
JS 端负责“收发消息”的代码 |
2. 主要流程
说明:
WebViewJavascriptBridge
中虽然对不同类型的 Web View 做了不同的处理,但是核心逻辑还是一样的,为了简单说明,这里只讨论UIWebView
情况下的逻辑。
WebViewJavascriptBridge
参与交互的流程包括三个部分:初始化、JS 调用原生、原生调用 JS。
2.1 初始化
WebViewJavascriptBridge 的初始化分为两部分,一部分是 Objective-C 中的 WebViewJavascriptBridge
对象的初始化,另一部分是 JavaScript 中的 window.WebViewJavascriptBridge
的初始化。
最终的目标是, Objective-C 和 JavaScript 两边各有一个 WebViewJavascriptBridge
对象,有了这两个对象,两边都可以收发“消息”,同时两边还各自维护一个管理响应事件的 messageHandlers 容器、一个管理回调的 callbackId 容器。
所以,我们这里讨论的初始化,不单单是一个对象的初始化,而是一个完整的准备过程,如下图所示。
Example_02
Example_03
首先我们创建一个管理公共 API 的 handler processor SCWebViewMessageHandler
:
@interface SCWebViewMessageHandler : NSObject
@property (weak, nonatomic) SCWebViewController *controller;
/// 注册 handler
- (void)registerHandlersForJSBridge:(WebViewJavascriptBridge *)bridge;
/// 要注册的特定 handler name,子类重写
- (NSArray *)specialHandlerNames;
@end
@implementation SCWebViewMessageHandler
- (void)registerHandlersForJSBridge:(WebViewJavascriptBridge *)bridge {
NSMutableArray *handlerNames = @[@"requestLocation", @"share"].mutableCopy;
[handlerNames addObjectsFromArray:[self specialHandlerNames]];
for (NSString *aHandlerName in handlerNames) {
[bridge registerHandler:aHandlerName handler:^(id data, WVJBResponseCallback responseCallback) {
NSMutableDictionary *args = [NSMutableDictionary dictionary];
if ([data isKindOfClass:[NSDictionary class]]) {
[args addEntriesFromDictionary:data];
}
if (responseCallback) {
[args setObject:responseCallback forKey:@"responseCallback"];
}
NSString *ObjCMethodName = [aHandlerName stringByAppendingString:@":"];
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self performSelector:NSSelectorFromString(ObjCMethodName) withObject:args];
#pragma clang diagnostic pop
}];
}
}
- (NSArray *)specialHandlerNames {
return @[];
}
#pragma mark - Handler Methods
// 获取地理位置信息
- (void)requestLocation:(NSDictionary *)args {
WVJBResponseCallback responseCallback = args[@"responseCallback"];
if (responseCallback) {
responseCallback(@"上海市浦东新区张江高科");
}
}
// 分享
- (void)share:(NSDictionary *)args {
NSString *shareContent = [NSString stringWithFormat:@"标题:%@\n 内容:%@ \n url:%@",
args[@"title"],
args[@"content"],
args[@"url"]];
[self.controller showAlertViewWithTitle:@"调用原生分享菜单" message:shareContent];
}
@end
这个类中主要干三件事,一是获取所有要注册的 handler name,并注册这些 handler;二是通过在 handler回调时,通过 runtime 调用与 handler 同名的 Objective-C 方法,参数只有一个 args,args 中包括两部分,一部分是 JS 传过来的 data,另一部分是回调 JS 的 responseCallback。
子类可以继承该类,通过重写 -specialHandlerNames
方法添加一些特定的 handler name,另外就是实现 handler 对应的 Objective-C 方法。
因此,第一个页面的 handler 可以交给 SCWebViewMessageHandler
处理,第二个页面和第三个页面就需要分别交给子类 SCWebViewSpecialMessageHandlerA
和 SCWebViewSpecialMessageHandlerB
来处理。
@interface SCWebViewSpecialMessageHandlerA : SCWebViewMessageHandler
@end
@implementation SCWebViewSpecialMessageHandlerA
- (NSArray *)specialHandlerNames {
return @[
@"makeACall"
];
}
- (void)makeACall:(NSDictionary *)args {
[self.controller showAlertViewWithTitle:@"拨打电话" message:args[@"number"]];
}
@end
@interface SCWebViewSpecialMessageHandlerB : SCWebViewMessageHandler
@end
@implementation SCWebViewSpecialMessageHandlerB
- (NSArray *)specialHandlerNames {
return @[@"pay"];
}
- (void)pay:(NSDictionary *)args {
NSString *paymentInfo = [NSString stringWithFormat:@"支付方式:%@\n价格:%@", args[@"type"], args[@"price"]];
[self.controller showAlertViewWithTitle:@"去支付" message:paymentInfo];
}
@end
定义好了这几个处理 handler 的类之后,我们就可以在 WebViewController
中进行相关的配置了:
- (void)viewDidLoad {
...
// 根据 pageId,获取对应的 MessageHandler
// 注册 handler
SCWebViewMessageHandler *handler = [[self.messageHandlerClass alloc] init];
handler.controller = self;
[handler registerHandlersForJSBridge:self.bridge];
...
}
到此为止,我们就解决了原生中 handler 管理的问题了。
完整示例代码见这里。
五、问题与讨论
-
已知 bug:在
WKWebView
中使用时,一旦- webView:decidePolicyForNavigationAction:decisionHandler:
方法被调用,就会出现连续回调两次decisionHandler
的问题。
首先,逻辑上讲,跟UIWebView
类似,- webView:decidePolicyForNavigationAction:decisionHandler:
方法中的拦截只应该回调一次decisionHandler
即可。
另外,这个问题还会导致应用在 iOS11 + XCode9 的环境下出现崩溃。解决办法见相关 Pull Request #296,期待 maintainer 能够早点 merge。 -
在加载
WebViewJavascriptBridge_JS
中的 JS 时,就会在创建messagingIframe
的同时,加载https://__wvjb_queue_message__
,
实际上这个时候sendMessageQueue
数组肯定是空的,也就是说完全不需要发消息,那为什么还要这么做呢?
就想问题中所说的,这个时候sendMessageQueue
数组肯定是空的,因为这个文件加载了,h5 中才会有WebViewJavascriptBridge
对象,所以,理论上来讲,根本就不存在在这个文件加载前就调用了WebViewJavascriptBridge.callHandler()
方法的情况。
因此,这里的原因肯定不是并不像有些朋友说的“跟WebViewJavascriptBridgeBase
中的startupMessageQueue
一样,就是在 JavaScript 环境初始化完成以后,把 JavaScript 要发送给 OC 的消息立即发送出去”。
通过查找原来版本的提交记录,终于找到了真正的原因,具体见相关 commit。 -
为什么
WebViewJavascriptBridge
中 JS 调用原生时,把要传给原生的数据放到 messageQueue 中,再让原生调 JS 去取,而不是直接拼在 URL 后面? -
WebViewJavascriptBridge
中加载 URL 调起原生时,为什么不是用window.location="https://xxx"
这种形式,而是新添加一个 iframe 来加载这个 URL?
因为如果当前页面正在加载时,就有用户操作导致window.location="https://xxx"
被触发,这样会使当前页面中还未加载完成的请求被取消掉。 -
回调的处理
其实在 JS 与 Objective-C 通信时,互相传参数并不难,比较难处理的就是回调的处理,WebViewJavascriptBridge
采用的策略是,call 的时候只传 id,callback 本身不传,它在 JS 和 Objective-C 两边,各自维护一个 callback 表,每个 callback 对应一个 id,回调的时候就根据这个 id 去取对应的 callback。
在这一点上,跟 React Native 的做法是一样的。 -
WebViewJavascriptBridge
中 web view 执行 JS 脚本时,为什么将其限制在主线程上? -
初始化的 JS 内容(也就是
setupWebViewJavascriptBridge
函数的定义和调用)是放在 APP bundle 中好呢,还是放到服务器上让 h5 自己去加载好呢? -
JS 中的闭包作用域问题
在一开始,为了能实现MyApp.share(data, callback)
的效果,我尝试了下面的这种做法:
var handlerNames = new Array("share", "requestLocation");
for (var i in handlerNames) {
var handlerName = handlerNames[i];
MyApp[handlerName] = Myfunction() {
if (typeof data == "function") { // 意味着没有参数 data,只有一个参数 callback
bridge.callHandler(handlerName, null, data);
} else if (callback == null) { // 第二个参数 callback 为 null 或者只有第一个参数 data
bridge.callHandler(handlerName, data);
} else { // 两个参数都有
bridge.callHandler(handlerName, data, callback);
}
}
};
但是,与 Objective-C 中的 block 不同,这里的闭包并没有将外面的 handlerName
copy 进去。