20

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

 2 years ago
source link: https://easeapi.com/blog/blog/152-ios-wkwebview.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK