iOS WKWebView详解及JS Bridge同步调用问题

WKWebView是 iOS 8.0以后用于替代UIWebView的浏览器组件。和UIWebView相比,WKWebView性能更高,支持更多的HTML5特性,控制更加细致。本文简要介绍了UIWebView的使用以及JS和native APP同步交互的问题。

WKWebView

@interface WKWebView : UIView

//重要属性
@property (nonatomic, readonly, copy) WKWebViewConfiguration *configuration;
@property (nullable, nonatomic, weak) id <WKNavigationDelegate> navigationDelegate;
@property (nullable, nonatomic, weak) id <WKUIDelegate> UIDelegate;
@property (nonatomic, readonly, strong) WKBackForwardList *backForwardList;

@property (nonatomic, readonly, nullable) SecTrustRef serverTrust;

@property (nullable, nonatomic, copy) NSString *customUserAgent;
@property (nonatomic) BOOL allowsLinkPreview;
@property (nonatomic, readonly, strong) UIScrollView *scrollView;
...

//加载方法
- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;
- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL;
...

- (nullable WKNavigation *)goBack;
- (nullable WKNavigation *)goForward;

- (nullable WKNavigation *)reload;
- (nullable WKNavigation *)reloadFromOrigin;

//类方法
+ (BOOL)handlesURLScheme:(NSString *)urlScheme;

//与JS交互接口
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;
//下面两个是iOS 14新引入API
- (void)evaluateJavaScript:(NSString *)javaScriptString inFrame:(nullable WKFrameInfo *)frame inContentWorld:(WKContentWorld *)contentWorld completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;
- (void)callAsyncJavaScript:(NSString *)functionBody arguments:(nullable NSDictionary<NSString *, id> *)arguments inFrame:(nullable WKFrameInfo *)frame inContentWorld:(WKContentWorld *)contentWorld completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;

@end

WKBackForwardList

访问过的web页面历史记录。

WKNavigation

WKNavigation对象可以用来了解网页的加载进度。通过loadRequest、goBack等方法加载页面时,将返回一个WKNavigation对象。通过WKNavigationDelegate代理的以下几个方法,可知页面的加载情况。

//开始加载
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation;
//加载完成
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation;
//加载失败
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error;

WKNavigationDelegate

WKNavigationDelegate除了上述方法,还有一些重要的接口:

//在尝试加载内容之前调用,确定是否加载请求
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

//在请求响应后调用,决定是否加载内容,在这里可以针对特定HTTP状态码的处理
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler {

    if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
        NSHTTPURLResponse *response = (NSHTTPURLResponse *)navigationResponse.response;
        if (response.statusCode != 200) {
           //非200状态码不加载
            decisionHandler(WKNavigationResponsePolicyCancel);
            return;
        }
    }
    decisionHandler(WKNavigationResponsePolicyAllow);
}

//参考:Authentication Challenge的内容:/blog/blog/137-ssl-pinning.html
- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler;

WKNavigationAction

包含网页导航信息,需要据此显示对应的操作界面。

WKFrameInfo

标识当前网页内容信息的对象。

@interface WKFrameInfo : NSObject <NSCopying>

/*! @abstract A Boolean value indicating whether the frame is the main frame
 or a subframe.
 */
@property (nonatomic, readonly, getter=isMainFrame) BOOL mainFrame;

/*! @abstract The frame's current request.
 */
@property (nonatomic, readonly, copy) NSURLRequest *request;

/*! @abstract The frame's current security origin.
 */
@property (nonatomic, readonly) WKSecurityOrigin *securityOrigin API_AVAILABLE(macos(10.11), ios(9.0));

/*! @abstract The web view of the webpage that contains this frame.
 */
@property (nonatomic, readonly, weak) WKWebView *webView API_AVAILABLE(macos(10.13), ios(11.0));

@end

WKWebViewConfiguration

@interface WKWebViewConfiguration : NSObject <NSSecureCoding, NSCopying>

@property (nonatomic, strong) WKProcessPool *processPool;

/*! @abstract The preference settings to be used by the web view.
*/
@property (nonatomic, strong) WKPreferences *preferences;

/*! @abstract The user content controller to associate with the web view.
*/
@property (nonatomic, strong) WKUserContentController *userContentController;

/*! @abstract The website data store to be used by the web view.
 */
@property (nonatomic, strong) WKWebsiteDataStore *websiteDataStore API_AVAILABLE(macos(10.11), ios(9.0));

/*! @abstract The name of the application as used in the user agent string.
*/
@property (nullable, nonatomic, copy) NSString *applicationNameForUserAgent API_AVAILABLE(macos(10.11), ios(9.0));
...

@end

WKWebViewConfiguration表示初始化WKWebVie的配置信息。

WKProcessPool

@interface WKProcessPool : NSObject <NSSecureCoding>
@end

WKProcessPool表示用于管理web内容的独立进程。WKWebView为了安全和稳定性考虑,会为每一个WKWebView实例分配独立的进程(而不是直接使用APP的进程空间),系统会有一个设定的进程个数上线。相同WKProcessPool对象的WKWebView共享相同的进程空间。这点也是WKWebView区别UIWebView的一个很大不同点。

可以看到WKProcessPool类没有暴漏任何接口,这意味着我们只能创建和读取该对象,通过对象地址判断是否在相同进程。

WKUserContentController

管理JavaScript 和 Web 视图的交互。WKUserScript代表一个需要注入到网页中的JavaScript脚本。

WKPreferences

偏好设置。

WKUIDelegate

处理和用户交互的代理。有三个方法需要重点说下:

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler;
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL result))completionHandler;
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler;

以上三个方法会分别在web页面执行JavaScript的alert、confirm、prompt方法时被调用。

WKScriptMessageHandler和WKScriptMessageHandlerWithReply

@protocol WKScriptMessageHandler <NSObject>
@required
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;
@end

//iOS 14
@protocol WKScriptMessageHandlerWithReply <NSObject>
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message replyHandler:(void (^)(id _Nullable reply, NSString *_Nullable errorMessage))replyHandler;
@end

WKScriptMessageHandler和WKScriptMessageHandlerWithReply是WKUserContentController暴露的代理协议,包含一个必须实现的方法,用于响应web的JavaScript代码发送的消息。

WKContentWorld

WKContentWorld是iOS 14的新增内容,可以理解为不同的命名空间不同的运行环境。显而易见的,在逻辑上,native APP的JS环境和web JS运行环境存在名称冲突的可能。WKContentWorld有两个类属性defaultClientWorld 、pageWorld,分别代表native APP和web容器的JS运行空间。开发者也可以通过:

+ (WKContentWorld *)worldWithName:(NSString *)name

工厂方法创建一个独立的JS运行环境。

WKWebView的基本使用

WKUserContentController *userContentController = [[WKUserContentController alloc] init];
//注册处理器的名称
[userContentController addScriptMessageHandler:self name:@"easeapiHandler"];

WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = userContentController;

self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
self.webView.UIDelegate = self;
[self.view addSubview:self.webView];

NSURLRequest *request = [[NSURLRequest alloc] initWithURL:[NSURL URLWithString:@"https://easeapi.com/blog"]];
[self.webView loadRequest:request];

native APP和JS交互

JS向native APP传递数据

通过addScriptMessageHandler注册唯一的name之后,在js代码中可以通过以下方式发送数据:

//js侧发送消息
let params = { "success": false }      
window.webkit.messageHandlers.easeapiHandler.postMessage(params)

//native APP接收消息
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    if ([message.name isEqualToString:@"easeapiHandler"]) {
        NSDictionary *body = message.body;
    }
}

native APP执行js代码

//在js侧定义方法
jsFunc = function(msg) {
 console.log(msg)
 return "ok"
};

//native APP执行js方法并获得返回结果
[self.webView evaluateJavaScript:@"jsFunc('hello world!')" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
 NSLog(@"result = %@", result);
}];

WKWebView和JS交互同步问题

可以看到,使用window.webkit.messageHandlers.[name].postMessage的交互方式有时候并不好用,当需要在JS侧同步获取native APP的数据,然后才能继续执行JS代码时,就不能很好的实现需求。因为postMessag没有回调接口,无法将native APP的执行结果带回来。与此不同的,native APP执行js代码的evaluateJavaScript接口,则有completionHandler的回调,可以在native APP侧获取js的执行结果。

那么问题就来了:在JS侧执行postMessage时,如果拿到native APP的执行结果?

这个问题我记得在UIWebView时代并不存在,在WKWebView上却是个需要考虑的问题。目前,笔者没有找到一种优雅的实现方案,提出的两种方案可供参考。

方案1:借助runJavaScriptTextInputPanelWithPrompt方法

上面介绍WKUIDelegate时提到的runJavaScriptTextInputPanelWithPrompt方法,这个方法本意是js在执行prompt方法时,给native APP一个自己实现prompt弹窗的时机,注意到这个方法有个completionHandler,即native APP处理完之后将数据返回给JS侧。

js prompt()方法用于显示用户进行输入的对话框。定义如下:

let msg = prompt(text, defaultText)
//text:标题文案
//defaultText:输入框默认文案
//返回用户输入的文案

当在WKWebView环境执行prompt方法时,会调用:

- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler {
 //针对特定prompt单独处理
 if ([prompt isEqualToString:@"cmd"]) {
  //将处理结果返回给JS。
  completionHandler("result");
 }
 ...
}

由于输入和返回都是字符串,可以通过JSON包装的形式扩展,这样就可以在js层调用特定名称的prompt同步拿到native APP的响应。

方案2:使用iOS 14新增的API

大概苹果也发现了这个问题,所以在iOS 14的系统中,针对WKWebView新增了很多优化的API,其中就包括针对addScriptMessageHandler的优化。新增了一个有replyHandler的didReceiveScriptMessage API。

[self.webView.configuration.userContentController addScriptMessageHandlerWithReply:self contentWorld:WKContentWorld.pageWorld name:@"easeapiHandler"];

//WKScriptMessageHandlerWithReply
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message replyHandler:(void (^)(id _Nullable reply, NSString *_Nullable errorMessage))replyHandler {
    replyHandler(@"success", nil);
}

在JS侧使用promise异步回调获取结果。

authSuccess = function() {
  let params = { "result": true }      
  let promise = window.webkit.messageHandlers.easeapiHandler.postMessage(params)
  promise.then(
   function(result) {
    prompt('result', result)
   },
   function(err) {
           console.log(err)
         }
  )
 };

WKWebView addScriptMessageHandler循环引用

addScriptMessageHandler/addScriptMessageHandlerWithReply会强持有对象,需要在合适的时候进行removeScriptMessageHandlerForName操作,否则会造成循环引用。

Discover WKWebView enhancements

其他文章

iOS CLLocationManager的弹窗问题
iOS NSURLProtocol详解及使用陷阱
iOS URLSession Authentication Challenge及SSL Pinning
iOS Method Swizzling使用陷阱
CocoaPods Podfile and podspec configurations