iOS开发iOS技术资料iOS

[iOS 开发] WebViewJavascriptBridge

2017-08-13  本文已影响1607人  ShannonChenCHN

前言:iOS 开发中,h5 和原生实现通信有多种方式, JSBridge 就是最常用的一种,各 JSBridge 类库的实现原理大同小异,这篇文章主要是针对当前使用最为广泛的 WebViewJavascriptBridge(v6.0.2),从功能 API、实现原理到源码解读、最佳实践,做一个简单介绍。

目录

长文警告:由于文章篇幅较长,如果你不需要了解太多细节的话,可以忽略掉第三部分『源码解读』,通过阅读第二部分『实现原理』(含流程图)就基本可以了解到整个核心流程了(大图加载会比较慢,建议到电脑上阅读)。

一、简介

1. 设计目的

我们平时使用 UIWebView 时,原生和 JavaScript 的交互一般是通过以下两种方式实现的:

这两种方式的弊端在于代码过于松散,长而久之,- 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 框架提供了一种更优雅的方式,用来在 WKWebViewUIWebView(iOS) 以及 WebView(OSX)中,建立一个 Objective-C 和 JavaScript 之间互相“发送消息”的机制,让我们可以在 JS 中像直接调 JS 方法那样发消息给 Objective-C,同时可以在 Objective-C 中像直接调 Objective-C 方法那样发消息给 JS。

2. 特点

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 为你的 WKWebViewUIWebView (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 (UIWebViewWKWebViewWebView)进行分发;
② 针对 UIWebViewWebView 做的一层封装,主要用来执行 JS 代码,以及实现 UIWebViewWebView的代理方法,并通过拦截 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_01
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 处理,第二个页面和第三个页面就需要分别交给子类 SCWebViewSpecialMessageHandlerASCWebViewSpecialMessageHandlerB 来处理。

@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 管理的问题了。
完整示例代码见这里

五、问题与讨论

  1. 已知 bug:在 WKWebView 中使用时,一旦 - webView:decidePolicyForNavigationAction:decisionHandler: 方法被调用,就会出现连续回调两次 decisionHandler 的问题。
    首先,逻辑上讲,跟 UIWebView 类似,- webView:decidePolicyForNavigationAction:decisionHandler: 方法中的拦截只应该回调一次 decisionHandler 即可。
    另外,这个问题还会导致应用在 iOS11 + XCode9 的环境下出现崩溃。解决办法见相关 Pull Request #296,期待 maintainer 能够早点 merge。

  2. 在加载 WebViewJavascriptBridge_JS 中的 JS 时,就会在创建 messagingIframe 的同时,加载 https://__wvjb_queue_message__
    实际上这个时候 sendMessageQueue 数组肯定是空的,也就是说完全不需要发消息,那为什么还要这么做呢?
    就想问题中所说的,这个时候 sendMessageQueue 数组肯定是空的,因为这个文件加载了,h5 中才会有 WebViewJavascriptBridge 对象,所以,理论上来讲,根本就不存在在这个文件加载前就调用了 WebViewJavascriptBridge.callHandler() 方法的情况。
    因此,这里的原因肯定不是并不像有些朋友说的“跟 WebViewJavascriptBridgeBase 中的 startupMessageQueue一样,就是在 JavaScript 环境初始化完成以后,把 JavaScript 要发送给 OC 的消息立即发送出去”。
    通过查找原来版本的提交记录,终于找到了真正的原因,具体见相关 commit

  3. 为什么 WebViewJavascriptBridge 中 JS 调用原生时,把要传给原生的数据放到 messageQueue 中,再让原生调 JS 去取,而不是直接拼在 URL 后面?

  4. WebViewJavascriptBridge 中加载 URL 调起原生时,为什么不是用 window.location="https://xxx" 这种形式,而是新添加一个 iframe 来加载这个 URL?
    因为如果当前页面正在加载时,就有用户操作导致 window.location="https://xxx" 被触发,这样会使当前页面中还未加载完成的请求被取消掉。

  5. 回调的处理
    其实在 JS 与 Objective-C 通信时,互相传参数并不难,比较难处理的就是回调的处理,WebViewJavascriptBridge 采用的策略是,call 的时候只传 id,callback 本身不传,它在 JS 和 Objective-C 两边,各自维护一个 callback 表,每个 callback 对应一个 id,回调的时候就根据这个 id 去取对应的 callback。
    在这一点上,跟 React Native 的做法是一样的。

  6. WebViewJavascriptBridge 中 web view 执行 JS 脚本时,为什么将其限制在主线程上?

  7. 初始化的 JS 内容(也就是 setupWebViewJavascriptBridge函数的定义和调用)是放在 APP bundle 中好呢,还是放到服务器上让 h5 自己去加载好呢?

  8. 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 进去。

六、延伸阅读

上一篇下一篇

猜你喜欢

热点阅读