WKWebView使用记录

现在在做的 App 中有很多页面都需要动态的需求,项目中大量使用了 WKWebView 老加载 h5 页面,这篇文章记录下 WKWebView 的使用。

WKWebView 可将网页处理限制在App的网页视图中,从而确保不安全的网站内容不会影响到 App 的其他部分,并且苹果表示2020年12月起将不再接受使用 UIWebView 的 App 更新。

苹果爸爸的推动加上 WKWebView 本身相较于 UIWebView 有许多优点:内存开销比 UIWebView 小很多,支持了更多的 HTML5 特性,流程粒度上更加细致,可以在请求时候询问是否请求数据还可以在返回数据后询问是否加载数据,在返回错误时候也更加细致。

所以,好好了解一下 WKWebView 是很有必要的。

WKWebView 创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
- (WKWebView *)webView {
if (!_webView) {
WKWebViewConfiguration* webViewConfig = [[WKWebViewConfiguration alloc] init];
webViewConfig.allowsInlineMediaPlayback = YES;

_webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
_webView.autoresizingMask = UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;
if (@available(iOS 11.0, *)) {
_webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
_webView.backgroundColor = [UIColor whiteColor];
_webView.allowsBackForwardNavigationGestures = YES;
_webView.scrollView.contentOffset = CGPointZero;
_webView.UIDelegate = self;
_webView.navigationDelegate = self;

[_webView addObserver:self forKeyPath:@"canGoBack" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
[_webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
[_webView addObserver:self forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL];
}
return _webView;
}

- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)context {
if ([keyPath isEqualToString:@"canGoBack"]) {
/// 导航栏返回和关闭按钮
} else if ([keyPath isEqualToString:@"estimatedProgress"]) {
/// 加载进度条
} else if ([keyPath isEqualToString:@"title"]) {
/// 导航栏标题
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}

  • webViewConfig.allowsInlineMediaPlayback = YES; 这个属性是支持视频页面内播放,不设置这个属性会导致页面内的视频都是打开视频播放器全屏播放的,使用 UIWebview 时候没有这个问题。
  • KVO 添加了对 WKWebView 几个属性的观察:
    • title:网页标题,设置到原生的导航栏上。
    • estimatedProgress:网页的加载进度,可以自制加载进度条。
    • canGoBack:网页是否可以返回,用于处理原生导航栏返回按钮和关闭按钮的显示。

WKWebView 代理方法介绍

创建 WKWebView 时,遵守了两个代理,这里分别介绍一下代理的常用方法。

WKNavigationDelegate 主要是处理一些跳转、加载处理操作,WKUIDelegate 主要处理 JS 脚本,确认框,警告框等。

WKNavigationDelegate

常用代理方法介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** 开始请求服务器并加载页面 */
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation;

/** 开始渲染页面时调用,响应的内容到达主页面的时候响应,刚准备开始渲染页面*/
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation;

/** 页面渲染完成后调用 */
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation;

/** 页面加载出错调用 */
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error;

/** 请求服务器发生错误 (如果是goBack时,当前页面也会回调这个方法,原因是NSURLErrorCancelled取消加载) */
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error;

/** 接收到服务器跳转请求即服务重定向时之后调用 */
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(WKNavigation *)navigation;

/** 是否允许页面加载 */
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

/** 收到服务器响应后,决定是否跳转。 当实现了这个方法,上一个方法将不会回调 */
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;

其中 decidePolicyForNavigationAction 相当于 uiwebview的shouldStartLoadWithRequest 方法,在这个方法里可以对页面跳转进行拦截处理,decisionHandler(WKNavigationActionPolicyAllow) 是允许跳转,decisionHandler(WKNavigationActionPolicyCancel) 是取消跳转,注意当处理情况比较多时候执行完 decisionHandler() 这个回调后要加上 return,否则会引起崩溃。

WKUIDelegate

常用代理方法介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 /   *  web界面中有弹出警告框时调用
*
* @param webView 实现该代理的webview
* @param message 警告框中的内容
* @param completionHandler 警告框消失调用
*/
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler;

/** 输入框 */
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler;

/** 确认框 */
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL result))completionHandler;

/** 创建一个新的webView,可以解决点击内部链接没有反应问题 */
- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures;

这里注意下,在使用 WKWebview 时发现有些三方播放页面点击链接不跳转的问题,用户点击网页上的链接,需要打开新页面时,将先调用 decidePolicyForNavigationAction 方法,其中的 WKNavigationAction 有两个属性 sourceFrame 和 targetFrame,类型是 WKFrameInfo,WKFrameInfo 的 mainFrame 属性标记着这个 frame 是在主 frame 里还是新开一个 frame。
如果 targetFrame 的 mainFrame 属性为 NO,将会新开一个页面,WKWebView 遇到这种情况,将会调用它的 WKUIDelegate 代理中的 createWebViewWithConfiguration 方法,所以如果我们不实现这个协议就会出现点击无反应的情况,因此对于这种情况需要特殊处理,可以采取下边的方法:

1
2
3
4
5
6
-(WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
if (!navigationAction.targetFrame.isMainFrame) {
[webView loadRequest:navigationAction.request];
}
return nil;
}

相当于放弃原来页面直接打开新的页面。

WKWebView 与 H5 交互

js 与 OC 交互

js 向 OC 传值的方式有两种:

拦截 url

可以通过拦截 url 中的字段来进行处理,有些情况下通过 url 传参数比较方便,可以及时处理参数减少打开 h5 页面的延迟,但是需要视具体业务逻辑来处理。

1
2
3
4
5
6
7
8
9
10
11
12
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
NSURL *url = navigationAction.request.URL;
if ([url.scheme isEqualToString:@"should_get_param"]) {
// 做截取参数的操作
decisionHandler(WKNavigationActionPolicyAllow);
return;
} else {
//do other things。。。
decisionHandler(WKNavigationActionPolicyAllow);
return;
}
}

WKScriptMessageHandler

使用 WKScriptMessageHandler 代理方法,然后和前端同学约定好调用方法的 name,在创建 webView 时候添加监听。例如:webCallNative。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webView 设置时,添加监听
WKUserContentController *userContent = self.webView.configuration.userContentController;

[userContent addScriptMessageHandler:self.messageHandler name:@"webCallNative"];

// 在 WKScriptMessageHandler 的代理方法中实现逻辑
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
NSLog(@"[WebView] : H5对客户端发起调用 name : %@, body : %@", message.name, message.body);

if ([message.name isEqualToString:@"jsCallOC"] && [message.body isKindOfClass:[NSString class]]) {
NSDictionary *result = [message.body JSONDictionary];
NSString *value = [result stringOrEmptyStringForKey:@"value"]; //value为h5传过来的值
// 得到 js 传来的数据
}
}

这里添加监听时,我们传入的 handler 是一个我们自己定义的一个 handler,这里的操作其实是为了避免 WKWebView 的内存泄漏问题。可以参考 这篇文章

这里的 handler 是我们自定义的一个 WeakScriptMessageHandler,它实现了 WKScriptMessageHandler 协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// MARK : 防止 WKUserContentController 注入 JS 产生内存泄漏
@interface WeakScriptMessageHandler : NSObject <WKScriptMessageHandler>

@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;

@end

// MARK : 实现
@implementation WeakScriptMessageHandler

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate {
if (self = [super init]) {
_scriptDelegate = scriptDelegate;
}
return self;
}

#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
[self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}
@end

需要注意的是,利用 WeakScriptMessageHandler 我们可以正常释放承载 webView 的控制器了,在控制器的 dealloc 方法中,我们需要对 WeakScriptMessageHandler 实例进行释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
- (void)dealloc {
[self.webView removeObserver:self forKeyPath:@"title"];
[self.webView removeObserver:self forKeyPath:@"canGoBack"];
[self.webView removeObserver:self forKeyPath:@"estimatedProgress"];
[self.webView.configuration.userContentController removeAllUserScripts];
[self.webView setNavigationDelegate:nil];
[self.webView setUIDelegate:nil];
NSLog(@"<%@>被释放",NSStringFromClass(self.class));

[self clearWebCache];
}

// MARK : 清理 webView 的缓存。可以按需清理
- (void)clearWebCache {
/*
在磁盘缓存上。
WKWebsiteDataTypeDiskCache,

html离线Web应用程序缓存。
WKWebsiteDataTypeOfflineWebApplicationCache,

内存缓存。
WKWebsiteDataTypeMemoryCache,

本地存储。
WKWebsiteDataTypeLocalStorage,

Cookies
WKWebsiteDataTypeCookies,

会话存储
WKWebsiteDataTypeSessionStorage,

IndexedDB数据库。
WKWebsiteDataTypeIndexedDBDatabases,

查询数据库。
WKWebsiteDataTypeWebSQLDatabases
*/

//allWebsiteDataTypes清除所有缓存
NSSet *websiteDataTypes = [WKWebsiteDataStore allWebsiteDataTypes];
NSDate *dateFrom = [NSDate dateWithTimeIntervalSince1970:0];
[[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteDataTypes modifiedSince:dateFrom completionHandler:^{

}];
}

js 端调用方法的方式:注意传值时候的JSON格式处理,处理不正确会出现传值失败无法调用成功的问题

1
window.webkit&&window.webkit.messageHandlers.webCallNative.postMessage(JSON.stringify(data));

OC 与 js 交互

1
2
3
4
NSString * NativeCallJs = [NSString stringWithFormat:@"NativeCallJs('%@')", value]; //注意调用js方法传参数要加上单引号!!!
[self.webView evaluateJavaScript:OCCallJs completionHandler:^(id result, NSError *error) {

}];

WKWebView 补充设置

设置 UserAgent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (@available(iOS 9.0, *)) {
[webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id obj, NSError *error) {
if ([obj isKindOfClass:[NSString class]]) {
NSString * userAgent = obj;
if (![userAgent containsString:@"customUA"]) {
userAgent = [userAgent stringByAppendingString:@"customUA"];
}
[[NSUserDefaults standardUserDefaults] registerDefaults:@{ @"UserAgent": userAgent OR @""}];
[[NSUserDefaults standardUserDefaults] setObject:userAgent forKey:uaKey];
self.webView.customUserAgent = userAgent;
}
}];
} else {
[self.webView setValue: userAgent forKey:@"applicationNameForUserAgent"];
}

因为 WKWebview 不会像 UIWebview 那样每次在请求之前会将 NSHTTPCookieStorage 里面的 cookie 自动添加到请求中,所以应采用将 cookie 通过 js 注入到 WKWebview 中的方法,参考这篇文章的思路,使用同一个 processPool 的方法,创建了两个 WKWebView,其中的一个用来加载 h5,另一个专门用来加载 cookie,解决了 WKWebview 种 cookie 的问题。

由于 UIWebview 的 Cookie 是由 NSHTTPCookieStorage 管理的,NSHTTPCookieStorage 是一个单例可以管理整个项目的 Cookie,在请求时候会自动带上上次保存的 Cookie,但是 WKWebview 的 Cookie 信息并不存储在 NSHTTPCookieStorage 中,是由 WKProcessPool 管理的,所以对于多个 WKWebview 之间可以通过将 WKProcessPool 单例化来解决 Cookie 共享的问题。

设置 cookie 可在第一次请求 host 时使用一个 cookieWebview 来加载并设置好 cookie,然后再使用 self.webview 来继续加载 url,self.webview 与 cookieWebview 共用单例 sharedProcessPool,因此可以解决 WKWebview 种 cookie 的问题。