前言
关于UIWebView
的介绍,相信看过上文的小伙伴们,已经大概清楚了吧,如果有问题,欢迎提问。
本文是本系列文章的第二篇,主要为小伙伴们分享下WKWebView
相关的内容:
- iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(上)
- iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(中)
- iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(下)(已发布😜)
关于文中提到的一些内容,这里我准备了个Demo,有需要的小伙伴可以下载。
本文目录
- 前言
- WKWebView
- 简介
- 基本用法
- 创建
- 动态注入js
- 加载
- 代理
- 新属性
- JavaScript与Objective-C的交互
- OC -> JS
- JS -> OC
- URL拦截
- scriptMessageHandler
- 实际运用
- Cookie管理
- 解决首次加载Cookie带不上问题
- 解决后续Ajax请求Cookie丢失问题
- 解决跳转新页面时Cookie带不过去问题
- 解决上面3步都做了Cookie依然丢失
- 性能对比
- 各种坑
- js alert方法不弹窗
- 白屏问题
- Cookie丢失
- evaluateJavaScript:completionHandler:异步
- 自定义contentInset刷新时页面跳动的bug
- 加载POST请求丢失RequestBody
- NSURLProtocol问题
- 未完待续
WKWebView
简介
WKWebView
是Apple于iOS 8.0推出的WebKit
中的核心控件,用来替代UIWebView
。WKWebView
比UIWebView
的优势在于:
- 更多的支持HTML5的特性
- 高达60fps的滚动刷新率以及内置手势
- 与Safari相同的JavaScript引擎
- 将UIWebViewDelegate与UIWebView拆分成了14类与3个协议(官方文档说明)
- 可以获取加载进度:
estimatedProgress
(UIWebView需要调用私有Api)
作者本人在项目中使用WKWebView
也1年多了,确确实实感受到了它的优势,但是同样也感受到了它带来的一些坑。下面来具体的介绍下WKWebView
。其实Apple开源了WebKit,有兴趣的小伙伴可以研究下它的实现。
基本用法
创建
WKWebView
的创建方法有这两种
/*-initWithFrame: to initialize an instance with the default configuration. 如果使用initWithFrame方法将使用默认的configuration
The initializer copies the specified configuration, so mutating the configuration after invoking the initializer has no effect on the web view. 我们需要先设置configuration,再调用init,在init之后修改configuration则无效
*/
- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration *)configuration NS_DESIGNATED_INITIALIZER;
- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER;
仔细看第一个方法,比UIWebView
多了个configuration
,这个配置可以设置很多东西。具体查看WKWebViewConfiguration.h
,可以配置js是否支持,画中画是否开启等,这里主要讲两个比较常用的属性。
第一个属性是websiteDataStore
。
/*! @abstract The website data store to be used by the web view.
*/
@property (nonatomic, strong) WKWebsiteDataStore *websiteDataStore API_AVAILABLE(macosx(10.11), ios(9.0));
业界普遍认为 WKWebView
拥有自己的私有存储,它的一些缓存等数据都存在websiteDataStore
中,具体增删改查就可以通过WKWebsiteDataStore.h
中提供的方法,这里不多说,一般用的时候比较少,真的要清除缓存,简单粗暴的方法是删除沙盒目录中的Cache文件夹。
第二个属性是userContentController
。
/*! @abstract The user content controller to associate with the web view.
*/
@property (nonatomic, strong) WKUserContentController *userContentController;
这个属性很重要,后面讲的js->oc的交互,以及注入js代码都会用到它。查看WKUserContentController
的头文件,你会发现它有如下几个方法:
@interface WKUserContentController : NSObject <NSCoding>
//读取添加过的脚本
@property (nonatomic, readonly, copy) NSArray<WKUserScript *> *userScripts;
//添加脚本
- (void)addUserScript:(WKUserScript *)userScript;
//删除所有添加的脚本
- (void)removeAllUserScripts;
//通过window.webkit.messageHandlers.<name>.postMessage(<messageBody>) 来实现js->oc传递消息,并添加handler
- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;
//删除handler
- (void)removeScriptMessageHandlerForName:(NSString *)name;
@end
那么整体我创建一个WKWebView
的代码如下:
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
WKUserContentController *controller = [[WKUserContentController alloc] init];
configuration.userContentController = controller;
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
self.webView.allowsBackForwardNavigationGestures = YES; //允许右滑返回上个链接,左滑前进
self.webView.allowsLinkPreview = YES; //允许链接3D Touch
self.webView.customUserAgent = @"WebViewDemo/1.0.0"; //自定义UA,UIWebView就没有此功能,后面会讲到通过其他方式实现
self.webView.UIDelegate = self;
self.webView.navigationDelegate = self;
[self.view addSubview:self.webView];
动态注入js
通过给userContentController
添加WKUserScript
,可以实现动态注入js。比如我先注入一个脚本,给每个页面添加一个Cookie
//注入一个Cookie
WKUserScript *newCookieScript = [[WKUserScript alloc] initWithSource:@"document.cookie = 'DarkAngelCookie=DarkAngel;'" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[controller addUserScript:newCookieScript];
然后再注入一个脚本,每当页面加载,就会alert当前页面cookie,在OC中的实现
//创建脚本
WKUserScript *cookieScript = [[WKUserScript alloc] initWithSource:@"alert(document.cookie);" injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:NO];
//添加脚本
[controller addUserScript:script];
这样每当页面出现的时候,会alet弹出当前页面所有的cookie字符串。注入的js source可以是任何js字符串,也可以js文件。比如你有很多提供给h5使用的js方法,那么你本地可能就会有一个native_functions.js
,你可以通过以下的方式添加
//防止频繁IO操作,造成性能影响
static NSString *jsSource;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
jsSource = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"native_functions" ofType:@"js"] encoding:NSUTF8StringEncoding error:nil];
});
//添加自定义的脚本
WKUserScript *js = [[WKUserScript alloc] initWithSource:jsSource injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:NO];
[self.configuration.userContentController addUserScript:js];
加载
加载一个请求或者页面也很简单
- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;
- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL API_AVAILABLE(macosx(10.11), ios(9.0));
- (nullable WKNavigation *)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;
- (nullable WKNavigation *)loadData:(NSData *)data MIMEType:(NSString *)MIMEType characterEncodingName:(NSString *)characterEncodingName baseURL:(NSURL *)baseURL API_AVAILABLE(macosx(10.11), ios(9.0));
基本与UIWebView
的很相似,但是需要说明的是,加载本地的一个html需要使用loadRequest:
方法,使用loadHTMLString:baseURL:
方法会有问题。
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:@"test" ofType:@"html"]]]];
代理
在WKWebView
的头文件,你会发现
@protocol WKNavigationDelegate; //类似于UIWebView的加载成功、失败、是否允许跳转等
@protocol WKUIDelegate; //主要是一些alert、打开新窗口之类的
有两个协议,它将UIWebView
的代理协议拆成了一个跳转的协议和一个关于UI的协议。虽说这两个协议中的所有方法都是Optional,但是关于WKUIDelegate
协议是有坑的,后面的各种坑中会提到。简单说下WKNavigationDelegate
中比较常用的方法
//下面这2个方法共同对应了UIWebView的 - (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
//先:针对一次action来决定是否允许跳转,action中可以获取request,允许与否都需要调用decisionHandler,比如decisionHandler(WKNavigationActionPolicyCancel);
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
//后:根据response来决定,是否允许跳转,允许与否都需要调用decisionHandler,如decisionHandler(WKNavigationResponsePolicyAllow);
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;
//开始加载,对应UIWebView的- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation;
//加载成功,对应UIWebView的- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation;
//加载失败,对应UIWebView的- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error;
WKUIDelegate
这里先不提了,小伙伴们可以参考我Demo中的实现。
新属性
WKWebView.h
定义了如下几个常用的readonly
属性:
@property (nullable, nonatomic, readonly, copy) NSString *title; //页面的title,终于可以直接获取了
@property (nullable, nonatomic, readonly, copy) NSURL *URL; //当前webView的URL
@property (nonatomic, readonly, getter=isLoading) BOOL loading; //是否正在加载
@property (nonatomic, readonly) double estimatedProgress; //加载的进度
@property (nonatomic, readonly) BOOL canGoBack; //是否可以后退,跟UIWebView相同
@property (nonatomic, readonly) BOOL canGoForward; //是否可以前进,跟UIWebView相同
这些属性都很有用,而且支持KVO,所以我们可以通过KVO观察这些值的变化,以便于我们做出最友好的交互。
JavaScript与Objective-C的交互
介绍完WKWebView
的基本用法,让我们来研究下基于它的js与oc的交互。
OC -> JS
这个比较简单,WKWebView
提供了一个类似JavaScriptCore
的方法
//执行一段js,并将结果返回,如果出错,error则不为空
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id result, NSError * _Nullable error))completionHandler;
该方法很好的解决了之前文章中提到的UIWebView
使用stringByEvaluatingJavaScriptFromString:
方法的两个缺点(1. 返回值只能是NSString。2. 报错无法捕获)。比如我想获取页面中的title
,除了直接self.webView.title
外,还可以通过这个方法:
[self.webView evaluateJavaScript:@"document.title" completionHandler:^(id _Nullable title, NSError * _Nullable error) {
NSLog(@"调用evaluateJavaScript异步获取title:%@", title);
}];
JS -> OC
URL拦截
此方法与上篇文章中UIWebView
介绍到的URL拦截方法一致,都是通过自定义Scheme,在链接激活时,拦截该URL,拿到参数,调用OC方法,缺点依然明显。WKWebView
实现起来如下:
比如我的链接依然是
<a href="darkangel://smsLogin?username=12323123&code=892845">短信验证登录</a>
当用户点击这个a标签时,会被拦截
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
//可以通过navigationAction.navigationType获取跳转类型,如新链接、后退等
NSURL *URL = navigationAction.request.URL;
//判断URL是否符合自定义的URL Scheme
if ([URL.scheme isEqualToString:@"darkangel"]) {
//根据不同的业务,来执行对应的操作,且获取参数
if ([URL.host isEqualToString:@"smsLogin"]) {
NSString *param = URL.query;
NSLog(@"短信验证码登录, 参数为%@", param);
decisionHandler(WKNavigationActionPolicyCancel);
return;
}
}
decisionHandler(WKNavigationActionPolicyAllow);
NSLog(@"%@", NSStringFromSelector(_cmd));
}
整体实现是与UIWebView
十分相似的,这里就不多说了。
这里再次提一下WebViewJavascriptBridge,它在最近的新版本中支持了WKWebView
。使用的方案同样是拦截URL,具体原理在之前的文章中简单描述过,这里不再赘述。下面说下Apple的新方法。
scriptMessageHandler
这是Apple在WebKit
里新增加的方法,位于WKUserContentController.h
。
/*! @abstract Adds a script message handler.
@param scriptMessageHandler The message handler to add.
@param name The name of the message handler.
@discussion Adding a scriptMessageHandler adds a function
window.webkit.messageHandlers.<name>.postMessage(<messageBody>) for all
frames.
*/
- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;
/*! @abstract Removes a script message handler.
@param name The name of the message handler to remove.
*/
- (void)removeScriptMessageHandlerForName:(NSString *)name;
其实Apple的注释已经很清楚了,在OC中添加一个scriptMessageHandler,则会在all frames
中添加一个js的function: window.webkit.messageHandlers.<name>.postMessage(<messageBody>)
。那么当我在OC中通过如下的方法添加了一个handler,如
[controller addScriptMessageHandler:self name:@"currentCookies"]; //这里self要遵循协 WKScriptMessageHandler
则当我在js中调用下面的方法时
window.webkit.messageHandlers.currentCookies.postMessage(document.cookie);
我在OC中将会收到WKScriptMessageHandler
的回调
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"currentCookies"]) {
NSString *cookiesStr = message.body; //message.body返回的是一个id类型的对象,所以可以支持很多种js的参数类型(js的function除外)
NSLog(@"当前的cookie为: %@", cookiesStr);
}
}
当然,记得在适当的地方调用removeScriptMessageHandler
- (void)dealloc {
//记得移除
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"currentCookies"];
}
这样就完成了一次完整的JS -> OC的交互。
问题:
- 该方法还是没有办法直接获取返回值。
- 通过
window.webkit.messageHandlers.<name>.postMessage(<messageBody>)
传递的messageBody中不能包含js的function,如果包含了function,那么 OC端将不会收到回调。
对于问题1,我们可以采用异步回调的方式,将返回值返回给js。对于问题2,一般js的参数中包含function是为了异步回调,这里我们可以把js的function转换为字符串,再传递给OC。
实际运用
关于上述问题1和问题2的结合利用,实现JS -> OC的调用,并且OC -> JS 异步回调结果,这里还是拿分享来举个例子。
比如js端实现了如下的方法(这段js的封装前面的文章里也有提及,小伙伴有问题可以看下之前的):
/**
* 分享方法,并且会异步回调分享结果
* @param {对象类型} shareData 一个分享数据的对象,包含title,imgUrl,link以及一个回调function
* @return {void} 无同步返回值
*/
function shareNew(shareData) {
//这是该方法的默认实现,上篇文章中有所提及
var title = shareData.title;
var imgUrl = shareData.imgUrl;
var link = shareData.link;
var result = shareData.result;
//do something
//这里模拟异步操作
setTimeout(function() {
//2s之后,回调true分享成功
result(true);
}, 2000);
//用于WKWebView,因为WKWebView并没有办法把js function传递过去,因此需要特殊处理一下
//把js function转换为字符串,oc端调用时 (<js function string>)(true); 即可
shareData.result = result.toString();
window.webkit.messageHandlers.shareNew.postMessage(shareData);
}
function test() {
//清空分享结果
shareResult.innerHTML = "";
//调用时,应该
shareNew({
title: "title",
imgUrl: "http://img.dd.com/xxx.png",
link: location.href,
result: function(res) {
//这里shareResult 等同于 document.getElementById("shareResult")
shareResult.innerHTML = res ? "success" : "failure";
}
});
}
在html页面中我定义了一个a标签来触发test()函数
<a href="javascript:void(0);" onclick="test()">测试新分享</a>
在OC端,实现如下
//首先别忘了,在configuration中的userContentController中添加scriptMessageHandler
[controller addScriptMessageHandler:self name:@"shareNew"]; //记得适当时候remove哦
//点击a标签时,则会调用下面的方法
#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"shareNew"]) {
NSDictionary *shareData = message.body;
NSLog(@"shareNew分享的数据为: %@", shareData);
//模拟异步回调
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
//读取js function的字符串
NSString *jsFunctionString = shareData[@"result"];
//拼接调用该方法的js字符串
NSString *callbackJs = [NSString stringWithFormat:@"(%@)(%d);", jsFunctionString, NO]; //后面的参数NO为模拟分享失败
//执行回调
[self.webView evaluateJavaScript:callbackJs completionHandler:^(id _Nullable result, NSError * _Nullable error) {
if (!error) {
NSLog(@"模拟回调,分享失败");
}
}];
});
}
}
那么当我点击a标签时,html页面上过2s,会显示success,然后再过2s,会显示failure。
我们来简单分析一下,点击之后,触发了test()
函数,test()
中封装了对share()
函数的调用,且传了一个对象作为参数,对象中result
字段对应的是个匿名函数,紧接着share()
函数调用,其中的实现是2s过后,result(true);
模拟js异步实现异步回调结果,分享成功。同时share()
函数中,因为通过scriptMessageHandler
无法传递function
,所以先把shareData
对象中的result这个匿名function
转成String
,然后替换shareData
对象的result
属性为这个String
,并回传给OC,OC这边对应JS对象的数据类型是NSDictionary
,我们打印并得到了所有参数,同时,把result
字段对应的js function String
取出来。这里我们延迟4s回调,模拟Native分享的异步过程,在4s后,也就是js中显示success的2s过后,调用js的匿名function
,并传递参数(分享结果)。调用一个js function的方法是 functionName(argument);
,这里由于这个js的function已经是一个String了,所以我们调用时,需要加上()
,如 (functionString)(argument);
因此,最终我们通过OC -> JS 的evaluateJavaScript:completionHandler:
方法,成功完成了异步回调,并传递给js一个分享失败的结果。
上面的描述看起来很复杂,其实就是先执行了JS的默认实现,后执行了OC的实现。上面的代码展示了如何解决scriptMessageHandler
的两个问题,并且实现了一个 JS -> OC、OC -> JS 完整的交互流程。
Cookie管理
比起UIWebView
的自动管理,WKWebView
坑爹的Cookie
管理,相信阻止了很多的尝试者。许多小伙伴也许曾经都想从UIWebView
转到WKWebView
,但估计因为Cookie
的问题,最终都放弃了,笔者折腾WKWebView
的Cookie
长达多半年之久,也曾想放弃,但最终还是坚持下来了,虽说现在不敢说完全掌握,至少也不影响正常使用了。
下面来说几点注意事项:
-
WKWebView
加载网页得到的Cookie
会同步到NSHTTPCookieStorage
中(也许你看过一些文章说不能同步,但笔者这里说下,它真的会,大家可以尝试下,实践出真知)。 -
WKWebView
加载请求时,不会同步NSHTTPCookieStorage
中已有的Cookie
(是的,最坑的地方)。 - 通过共用一个
WKProcessPool
并不能解决2中Cookie
同步问题,且可能会造成Cookie
丢失。
结合自己的实践和参考一些资料,笔者得到上面的结论。
关于如何操作NSHTTPCookieStorage
,前面的文章中提到过了,本文不再赘述。对于问题2,StackOverFlow上有些解答,但经过实际尝试,发现还是或多或少有一些问题。
为了解决这个最为致命的Cookie问题,需要的做的有以下几点:
解决首次加载Cookie带不上问题
在request的requestHeader中添加Cookie:
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
NSArray *cookies = [NSHTTPCookieStorage sharedHTTPCookieStorage].cookies;
//Cookies数组转换为requestHeaderFields
NSDictionary *requestHeaderFields = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
//设置请求头
request.allHTTPHeaderFields = requestHeaderFields;
[self.webView loadRequest:request];
这样,只要你保证sharedHTTPCookieStorage
中你的Cookie存在,首次访问一个页面,就不会有问题。
解决后续Ajax请求Cookie丢失问题
解决此问题,也比较简单,添加WKUserScript
。
/*!
* 更新webView的cookie
*/
- (void)updateWebViewCookie
{
WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource:[self cookieString] injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
//添加Cookie
[self.configuration.userContentController addUserScript:cookieScript];
}
- (NSString *)cookieString
{
NSMutableString *script = [NSMutableString string];
[script appendString:@"var cookieNames = document.cookie.split('; ').map(function(cookie) { return cookie.split('=')[0] } );\n"];
for (NSHTTPCookie *cookie in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]) {
// Skip cookies that will break our script
if ([cookie.value rangeOfString:@"'"].location != NSNotFound) {
continue;
}
// Create a line that appends this cookie to the web view's document's cookies
[script appendFormat:@"if (cookieNames.indexOf('%@') == -1) { document.cookie='%@'; };\n", cookie.name, cookie.da_javascriptString];
}
return script;
}
@interface NSHTTPCookie (Utils)
- (NSString *)da_javascriptString;
@end
@implementation NSHTTPCookie (Utils)
- (NSString *)da_javascriptString
{
NSString *string = [NSString stringWithFormat:@"%@=%@;domain=%@;path=%@",
self.name,
self.value,
self.domain,
self.path ?: @"/"];
if (self.secure) {
string = [string stringByAppendingString:@";secure=true"];
}
return string;
}
@end
同样只要你保证sharedHTTPCookieStorage
中你的Cookie存在,后续Ajax请求就不会有问题。
解决跳转新页面时Cookie带不过去问题
即便你做到了上面两点,你会发现,当你点击页面上的某个链接,跳转到新的页面,Cookie
又丢了,此时你是想狗带的~怎么解决呢?
//核心方法:
/**
修复打开链接Cookie丢失问题
@param request 请求
@return 一个fixedRequest
*/
- (NSURLRequest *)fixRequest:(NSURLRequest *)request
{
NSMutableURLRequest *fixedRequest;
if ([request isKindOfClass:[NSMutableURLRequest class]]) {
fixedRequest = (NSMutableURLRequest *)request;
} else {
fixedRequest = request.mutableCopy;
}
//防止Cookie丢失
NSDictionary *dict = [NSHTTPCookie requestHeaderFieldsWithCookies:[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies];
if (dict.count) {
NSMutableDictionary *mDict = request.allHTTPHeaderFields.mutableCopy;
[mDict setValuesForKeysWithDictionary:dict];
fixedRequest.allHTTPHeaderFields = mDict;
}
return fixedRequest;
}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
#warning important 这里很重要
//解决Cookie丢失问题
NSURLRequest *originalRequest = navigationAction.request;
[self fixRequest:originalRequest];
//如果originalRequest就是NSMutableURLRequest, originalRequest中已添加必要的Cookie,可以跳转
//允许跳转
decisionHandler(WKNavigationActionPolicyAllow);
//可能有小伙伴,会说如果originalRequest是NSURLRequest,不可变,那不就添加不了Cookie了,是的,我们不能因为这个问题,不允许跳转,也不能在不允许跳转之后用loadRequest加载fixedRequest,否则会出现死循环,具体的,小伙伴们可以用本地的html测试下。
NSLog(@"%@", NSStringFromSelector(_cmd));
}
#pragma mark - WKUIDelegate
- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
#warning important 这里也很重要
//这里不打开新窗口
[self.webView loadRequest:[self fixRequest:navigationAction.request]];
return nil;
}
最终的方法,已经附上。小伙伴们自行参考。同样需要你保证sharedHTTPCookieStorage
中你的Cookie存在。
解决上面3步都做了Cookie依然丢失
看过上面的方法过后,小伙伴们应该记得最清楚的是保证sharedHTTPCookieStorage
中你的Cookie存在。怎么保证呢?由于WKWebView
加载网页得到的Cookie
会同步到NSHTTPCookieStorage
中的特点,有时候你强行添加的Cookie
会在同步过程中丢失。抓包(Mac推荐Charles)你就会发现,点击一个链接时,Request
的header
中多了Set-Cookie
字段,其实Cookie已经丢了。下面推荐笔者的解决方案,那就是把自己需要的Cookie
主动保存起来,每次调用[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies
方法时,保证返回的数组中有自己需要的Cookie
。下面上代码,用了runtime
的Method Swizzling
,详细代码,请参考Demo。
首先是在适当的时候,保存
//比如登录成功,保存Cookie
NSArray *allCookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
for (NSHTTPCookie *cookie in allCookies) {
if ([cookie.name isEqualToString:DAServerSessionCookieName]) {
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:DAUserDefaultsCookieStorageKey];
if (dict) {
NSHTTPCookie *localCookie = [NSHTTPCookie cookieWithProperties:dict];
if (![cookie.value isEqual:localCookie.value]) {
NSLog(@"本地Cookie有更新");
}
}
[[NSUserDefaults standardUserDefaults] setObject:cookie.properties forKey:DAUserDefaultsCookieStorageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
break;
}
}
在读取时,如果没有则添加
@implementation NSHTTPCookieStorage (Utils)
+ (void)load
{
class_methodSwizzling(self, @selector(cookies), @selector(da_cookies));
}
- (NSArray<NSHTTPCookie *> *)da_cookies
{
NSArray *cookies = [self da_cookies];
BOOL isExist = NO;
for (NSHTTPCookie *cookie in cookies) {
if ([cookie.name isEqualToString:DAServerSessionCookieName]) {
isExist = YES;
break;
}
}
if (!isExist) {
//CookieStroage中添加
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:DAUserDefaultsCookieStorageKey];
if (dict) {
NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:dict];
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
NSMutableArray *mCookies = cookies.mutableCopy;
[mCookies addObject:cookie];
cookies = mCookies.copy;
}
}
return cookies;
}
@end
当打开手机百度首页后,我们查看页面中的Cookie其中第一个,是之前测试添加的,用来动态注入js。
WKUserScript *newCookieScript = [[WKUserScript alloc] initWithSource:@"document.cookie = 'DarkAngelCookie=DarkAngel;'" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
[controller addUserScript:newCookieScript];
第二个,就是真正有用的Cookie啦,这幅图用到了Safari
调试,后面会讲到。通过上面的折腾,一般,就能够有效减少Cookie的丢失了。
性能对比
加载一般的页面,对比不出什么,这里我就测试下内存占用吧,同样一个html,分布看下内存占用。
UIWebView从页面UI元素上看,WKWebView
还多个barButtonItem
呢,这么简单个页,内存占用小了3M,复杂的页面可想而知。
各种坑
虽然WKWebView
真的很不错,但是它的坑,还是有很多的,下面简单说下。
js alert方法不弹窗
之前提过WKUIDelegate
所有的方法都是Optional
,但如果你不实现,它就会
If you do not implement this method, the web view will behave as if the user selected the OK button.
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler;
OK,意思就是说,如果不实现,就什么都不发生,好吧,乖乖实现吧,实现了就能弹窗了。
白屏问题
当WKWebView加载的网页占用内存过大时,会出现白屏现象。解决方案是
/*! @abstract Invoked when the web view's web content process is terminated.
@param webView The web view whose underlying web content process was terminated.
*/
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView {
[webView reload]; //刷新就好了
}
有时白屏,不会调用该方法,具体的解决方案是
比如,最近遇到在一个高内存消耗的H5页面上 present 系统相机,拍照完毕后返回原来页面的时候出现白屏现象(拍照过程消耗了大量内存,导致内存紧张,WebContent Process 被系统挂起),但上面的回调函数并没有被调用。在WKWebView白屏的时候,另一种现象是 webView.titile 会被置空, 因此,可以在 viewWillAppear 的时候检测 webView.title 是否为空来 reload 页面。(出自WKWebView 那些坑)
Cookie丢失
从一个登录状态的页面跳转到另一个页面,WTF,登录状态丢失了?什么鬼?其实上文中的Cookie管理一节,已经介绍过解决方案了,原因也就是WKWebView
加载请求时,不会同步NSHTTPCookieStorage
中已有的Cookie
。如果偶尔还是会出现丢失登录状态的情况,那笔者只能说,再检查下自己的代码,找找原因,有好的解决方案,欢迎告知笔者。
evaluateJavaScript:completionHandler:异步
该方法是异步回调,这个一看方法的声明便知。可能有小伙伴就是需要同步获取返回值,有没有办法呢?答案是没有。
可能你会说用信号量dispatch_semaphore_t
。好吧,可能你会这么写~
__block id cookies;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
[self.webView evaluateJavaScript:@"document.cookie" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
cookies = result;
dispatch_semaphore_signal(semaphore);
}];
//等待三秒,接收参数
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
//打印cookie,肯定为空,因为足足等了3s,dispatch_semaphore_signal都没有起作用
NSLog(@"cookie的值为:%@", cookies);
笔者故意只等待了3s,如果你等待DISPATCH_TIME_FOREVER
,恭喜你,程序不会Crash,但界面卡死了。笔者测试的结果是,NSLog
的触发时间要早于completionHandler
回调,不论你等多久,它都会打印null。所以当你永久等待时,就卡死了。这里的缘由,笔者不太清楚,有搞清楚的小伙伴可以帮忙指点一下,谢谢~
所以还是老实的接受异步回调吧,不要用信号来搞成同步,会卡死的。
自定义contentInset刷新时页面跳动的bug
PM说毛玻璃好看,,so easy,于是我们在代码中轻轻敲下
self.webView.scrollView.contentInset = UIEdgeInsetsMake(64, 0, 49, 0);
然后默默的微笑着点击cmd + R,太简单了。然后看到了这样的画面
是的,上面的方法在UIWebView
中没毛病,可是在WKWebView
中,就产生了刷新时页面跳动的bug。
这个坑,坑了我大半年之久,Apple的Document中没有记录,最终笔者在Apple开源的WebKit2的ChangeLog中找到了答案。下面是官方人员的回答:
厉害了,word哥,我选择狗带,居然还是私有Api。怎么整呢?
self.webView.scrollView.contentInset = UIEdgeInsetsMake(64, 0, 49, 0);
//史诗级神坑,为何如此写呢?参考https://opensource.apple.com/source/WebKit2/WebKit2-7600.1.4.11.10/ChangeLog
[self.webView setValue:[NSValue valueWithUIEdgeInsets:self.webView.scrollView.contentInset] forKey:@"_obscuredInsets"]; //kvc给WKWebView的私有变量_obscuredInsets设置值
这么写就OK了,通过KVC设置私有变量的值,笔者用了半年了,过Apple审核没问题,不用担心。如果这个能帮助到大家,不用感谢我~
加载POST请求丢失RequestBody
这个问题,没有直接的解决办法。问题的根源在于:
在 webkit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC 发送给 App Process。出于性能的原因,encode 的时候 HTTPBody 和 HTTPBodyStream 这两个字段被丢弃掉了。
因此,如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发起的所有 http(s)请求都会通过 IPC 传给主进程 NSURLProtocol 处理,导致 post 请求 body 被清空。
(出自WKWebView 那些坑)
参考 Apple源码 及 bug report 。
具体的解决办法,就是另辟蹊径,WKWebView 那些坑中有介绍,这里笔者不再展开。
因为WKWebView
被设计的使用场景,是用来当做浏览器,解决Native可以直接在App内浏览网页的问题。而浏览器浏览一个网站,怎么可能是POST请求呢?所以这个问题,笔者目前感受较小,有需要的小伙伴可以自行解决。
NSURLProtocol问题
WKWebView
不同于UIWebView
,其实并不支持NSURLProtocol
。如果想拦截,可以通过调用私有Api。
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]
此方法缺点也很多,笔者这里不推荐小伙伴使用,毕竟调用私有Api是Apple禁止的。况且,真的必须使用NSURLProtocol
的话,还是用UIWebView
吧。
未完待续
本文主要讲述了WKWebView
的一些基础用法、OC与JS的交互,Cookie的管理,以及一些使用过程中的坑,旨在为没用过的小伙伴们详细介绍下。虽然它的坑很多,但是它的优点也有很多,我们应该敢于拥抱新事物,拥抱新知识。还在等什么?WKWebView
赶快用起来吧~
更新
文章中内容较久远,iOS 11以上可以直接用以下的方式,双向同步cookie。
.h
//
// UWWkWebViewCookieManager.h
//
// Created by DarkAngel on 2018/4/12.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/**
WKWebView的Cookie管理,只用于iOS 11以上
*/
@interface UWWkWebViewCookieManager : NSObject
/**
从NSHTTPCookieStorage同步cookie
*/
+ (void)synchronizeCookiesFromNSHTTPCookieStorage NS_AVAILABLE_IOS(11_0);
@end
NS_ASSUME_NONNULL_END
.m
//
// UWWkWebViewCookieManager.m
//
// Created by DarkAngel on 2018/4/12.
//
#import "UWWkWebViewCookieManager.h"
#import <WebKit/WebKit.h>
#import "GCDMethods.h"
@interface UWWkWebViewCookieManager () <WKHTTPCookieStoreObserver>
@end
@implementation UWWkWebViewCookieManager
+ (void)load
{
if (@available(iOS 11.0, *)) {
[[[WKWebsiteDataStore defaultDataStore] httpCookieStore] addObserver:(id<WKHTTPCookieStoreObserver>)self];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(cookiesDidChangeInHTTPCookieStorage:) name:NSHTTPCookieManagerCookiesChangedNotification object:nil];
}
}
/**
从[NSHTTPCookieStorage sharedHTTPCookieStorage]同步Cookie到WKHTTPCookieStore
*/
+ (void)synchronizeCookiesFromNSHTTPCookieStorage NS_AVAILABLE_IOS(11_0)
{
if (@available(iOS 11.0, *)) {
GCD_MAIN_SYNC(^{
[[[WKWebsiteDataStore defaultDataStore] httpCookieStore] getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull wkCookies) {
NSMutableSet *before = [NSMutableSet setWithArray:wkCookies];
NSSet *after = [NSSet setWithArray:[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies];
//需要保留的
NSMutableSet *toKeep = [NSMutableSet setWithSet:before];
[toKeep intersectSet:after];
//需要添加的
NSMutableSet *toAdd = [NSMutableSet setWithSet:after];
[toAdd minusSet:toKeep];
//需要删除的
NSMutableSet *toRemove = [NSMutableSet setWithSet:before];
[toRemove minusSet:after];
for (NSHTTPCookie *cookie in toRemove.allObjects) {
[[[WKWebsiteDataStore defaultDataStore] httpCookieStore] deleteCookie:cookie completionHandler:nil];
}
for (NSHTTPCookie *cookie in toAdd.allObjects) {
[[[WKWebsiteDataStore defaultDataStore] httpCookieStore] setCookie:cookie completionHandler:nil];
}
}];
});
} else {
}
}
/**
从WKHTTPCookieStore同步Cookie到[NSHTTPCookieStorage sharedHTTPCookieStorage]
*/
+ (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore NS_AVAILABLE_IOS(11_0)
{
GCD_MAIN(^{
[cookieStore getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
NSSet *before = [NSSet setWithArray:[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies];
NSMutableSet *after = [NSMutableSet setWithArray:cookies];
//需要保留的
NSMutableSet *toKeep = [NSMutableSet setWithSet:before];
[toKeep intersectSet:after];
//需要添加的
NSMutableSet *toAdd = [NSMutableSet setWithSet:after];
[toAdd minusSet:toKeep];
for (NSHTTPCookie *cookie in toAdd.allObjects) {
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
}
}];
});
}
/**
从[NSHTTPCookieStorage sharedHTTPCookieStorage]同步Cookie到WKHTTPCookieStore
*/
+ (void)cookiesDidChangeInHTTPCookieStorage:(NSNotification *)notification
{
if (@available(iOS 11.0, *)) {
[self synchronizeCookiesFromNSHTTPCookieStorage];
}
}
@end
下篇文章,将主要为小伙伴们介绍下如何用Safari调试,实际应用中一些需求如何实现,如何更好的与前端h5开发同学配合以及如何找出问题所在等。下篇文章见~
下篇文章已发布:
iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(下)