美文网首页iOS技术点iOSUIWebView和JS交互
iOS中UIWebView与WKWebView、JavaScri

iOS中UIWebView与WKWebView、JavaScri

作者: Dark_Angel | 来源:发表于2017-05-10 20:28 被阅读9046次

前言

关于UIWebView的介绍,相信看过上文的小伙伴们,已经大概清楚了吧,如果有问题,欢迎提问。

本文是本系列文章的第二篇,主要为小伙伴们分享下WKWebView相关的内容:

关于文中提到的一些内容,这里我准备了个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中的核心控件,用来替代UIWebViewWKWebViewUIWebView的优势在于:

  • 更多的支持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的交互。

问题

  1. 该方法还是没有办法直接获取返回值
  2. 通过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

new

我们来简单分析一下,点击之后,触发了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的问题,最终都放弃了,笔者折腾WKWebViewCookie长达多半年之久,也曾想放弃,但最终还是坚持下来了,虽说现在不敢说完全掌握,至少也不影响正常使用了。

下面来说几点注意事项:

  1. WKWebView加载网页得到的Cookie会同步到NSHTTPCookieStorage中(也许你看过一些文章说不能同步,但笔者这里说下,它真的会,大家可以尝试下,实践出真知)。
  2. WKWebView加载请求时,不会同步NSHTTPCookieStorage中已有的Cookie(是的,最坑的地方)。
  3. 通过共用一个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)你就会发现,点击一个链接时,Requestheader中多了Set-Cookie字段,其实Cookie已经丢了。下面推荐笔者的解决方案,那就是把自己需要的Cookie主动保存起来,每次调用[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies方法时,保证返回的数组中有自己需要的Cookie。下面上代码,用了runtimeMethod 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 WKWebView

从页面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开源的WebKit2ChangeLog中找到了答案。下面是官方人员的回答:

厉害了,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赶快用起来吧~

下篇文章,将主要为小伙伴们介绍下如何用Safari调试,实际应用中一些需求如何实现,如何更好的与前端h5开发同学配合以及如何找出问题所在等。下篇文章见~

下篇文章已发布:
iOS中UIWebView与WKWebView、JavaScript与OC交互、Cookie管理看我就够(下)

相关文章

网友评论

  • 丸子_f396:您好,我使用wkwebview想通过拦截url的方式实现对html按键事件的响应,我使用decidePolicyForNavigationAction的方法只能拦截到当前页面的url(https://。。。。。.html),而无法获取按键的url(native://。。。。)
    使用wkwebview需要对原始的html进行修改吗
  • cc23d027e2eb:帮大忙了 非常感谢
  • 低调的腹:楼主,为啥我登录成功后,然后我删除应用,重新加载会是白屏,重新加载会自动去登录,是不是没传cookie
    Dark_Angel:@低调的腹 没做cookie处理的话,是肯定不会有cookie的,文中我写的很详细了
    低调的腹:就是退出这个应用
    Dark_Angel:@低调的腹 删除应用什么鬼,不是很懂你的操作流程
  • 聪zero:白屏除了刷新有没有办法解决,因为一刷新页面上原来的数据都没了
  • LD_左岸:self.webView.allowsBackForwardNavigationGestures = YES; //允许右滑返回上个链接,左滑前进
    这个属性设置上之后 是能想全屏pop手势那样 往左一划就能回退吗
    但是我加上了 划不动呢...
    Dark_Angel:@左岸__ 边缘右滑倒回,非全屏。
  • 超_iOS:(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
    这个不触发,是因为WKWebView并没有办法把js function传递过去,因此需要特殊处理一下.吗?

    要改JS代码么
    Dark_Angel:这个很早就写了Demo,没有时间发布新文章,这里放出链接,抛砖引玉吧
    https://github.com/DarkAngel7/DANativeApi
    超_iOS:@Dark_Angel 不懂JS,实在懵逼:cry:
    Dark_Angel:@_超 对,无法直接传递function,需要修改js,在js层封装一个bridge,用一个对象存储所有function,每个function对应一个callbackId,js传递callbackId和其他参数到native,而native需要回调js时,回调时回传callbackId和参数,bridge根据callbackId找到其对应的function,结合参数实现完整交互。
  • 请叫我小左:博主请问wkwebview怎么解决跨域cookie的问题吗?比如开始加加载的是www.a.com,然后点击登录按钮跳转到的是www.b.com,登录完成后返回a,然后关闭窗口,重新加载a的后发现依然是未登录的状态。
    Dark_Angel:@请叫我小左 a到b登录再返回a时,a肯定不能自动刷新,主动执行reload虽然刷新了,但刷新的还是之前的request,之前的request header中没有登录状态的cookie,则刷新了之后页面呈现的依然是未登录状态。
    如果流程是x到a到b,b登录,返回a,再返回x,再进入a,此时a应该是登录状态。
    请叫我小左:@Dark_Angel 我有用你文章中的dome载入我的页面,发现依然是没法记住登录状态。
    Dark_Angel:@请叫我小左 文中介绍过了,首次请求在请求头里添加cookieStorage中的cookie,后续通过userScript修复wkwebview不会自动从cookieStorage同步cookie的问题。而wkwebview会自动将cookie同步到storage中,所以只要storage中cookie存在,就可以维持登录状态。
  • Xavier_Lost:使用了YWebView https://github.com/haifengkao/YWebView
    4G切换WIFi直接加载网页直接是白屏:confounded:
  • 4f38d8742e72:你好,博主,你上面的 updateWebViewCookie 的方法里面重新添加脚本,等于是重新配置 WKWebViewConfiguration,按照头文件说明,初始化webview指定的配置,在调用初始化器之后改变配置将没有任何效果。 可以解惑下这么做的原因吗?
    Dark_Angel:@Arvin_sir 用了这么久了,怎么可能没用。
    最简单的证明方法,先添加若干script,然后创建webView,然后[self.webView.configuration.userContentController removeAllUserScripts];,再找个地方打印 self.webView.configuration.userContentController.userScripts,看看是否为空吧
    4f38d8742e72:@Dark_Angel wkwebview 里的 configuration 属性是只读的,不能修改,但可以修改configuration.userContentController 里的属性,这个能理解!不过我想说的是其实那个方法确实没作用,脚本没有添加成功,你可以在 loadRequest: 之前输出 self.wkWebView.configuration.userContentController.userScripts[0].source 看看,输出的还是在 wkwebview 初始化时添加的脚本配置。所以,updateWebViewCookie 方法里的脚本配置可以直接在初始化 wkwebview 时做就好了。
    Dark_Angel:纠正下刚刚的说法:
    Apple的意思应该是不能修改WKWebView的configuration的userController、suppressesIncrementalRendering、allowsInlineMediaPlayback等属性,就是说在WKWebView创建之后不能修改这些直属与configuration的属性的值或者指针。
    updateWebViewCookie方法其实没有修改configuration中的任何属性,而修改的是configuration.userContentController的一些属性。注意修改的是WKUserContentController的属性,而不是configuration的userContentController。

    我想这样能够正确解释你的问题。
  • paintingStyle:受益匪浅,这才是干货。
  • 请叫我喵_喵:各楼层注意:关于bitcode问题尽管来找我!!
  • 请叫我喵_喵:亲,你没有说shareResult是一个标签的id,我当时在想:你咋能直接这么使用一个(undefined)对象了,我马上clone你的Demo来看test.html;果然,老夫差点觉得怎么不认识js了呢...
    最后,我觉得你的这几篇技术贴非常棒! 加油!
    Dark_Angel:@请叫我喵_喵 谢谢
  • Minger:你好,请问如果要设置httponly 该怎么办?
    Dark_Angel:创建cookie时,指定该属性为true
  • DroisAndEthan:赞赞赞
    Dark_Angel:@阿飞的隐喻 谢谢
  • Franky木下:感谢你的文章,帮助很大。打赏一杯非星巴克的咖啡,聊表谢意。
    Dark_Angel:@Franky木下 :blush:非常感谢
  • CholMay:很牛,正在看三篇文章,持续更新啊,:+1:
    Dark_Angel:@夜晚看日出 谢谢,最近比较忙,有时间更新
  • ZFJ_张福杰:写的很详细,学习了,可以转载吗???
    Dark_Angel:@岁月轻狂_杰 可以~
  • xiAo__Ju:毛玻璃那个我们是这样解决的
    webView.frame.size.height = webView.frame.height - 64
    webView.scrollView.clipsToBounds = false // 上滑时导航栏保持半透明效果
    xiAo__Ju:@Dark_Angel 非常感谢你的文章
    Dark_Angel:@xiAo__Ju 毕竟操作contentInset本来就不被推荐
    Dark_Angel:@xiAo__Ju 嗯,这个方法挺好的,前不久我们也改成这样了。
  • XMFraker:感谢分享,受益匪浅
    请教个问题
    Cookie管理中第一条, 第一次请求时,如果设置Cookie
    之后有重定向请求的话,后续重定向请求都会重置使用第一次设置的Cookie
    这个该怎么解决
    Dark_Angel:@XMFraker 你的我看过了,你得结合着上面4种管理cookie的方法使用,我在项目中,4种都会处理,才不会有问题
    XMFraker:@Dark_Angel
    感谢回复, 我现在的面临的需求是这样一个逻辑
    没法放图片, 我上传到七牛服务器上了(http://7xlt1j.com1.z0.glb.clouddn.com/F61E3F49-E139-474A-8179-2804A49A0878.png)
    麻烦您看下
    Dark_Angel:一般cookie就是为了维持一种状态,重定向又不想保留cookie,这样的需求很奇怪。
    如果是这种情况:A是h5、B是原生,初始加载时,A是未登录状态,点击B登录,A如果不主动刷新,是不会变成登录状态的,从A重定向到C,C会因为A初始未登录,而维持未登录。
    上面说的这种情况的处理方案是,A在B登录时接收到通知,然后刷新location.replace("xxx")。这样A重定向到C也会维持登录状态。

    如果不是上面讲的那种情况,还想实现你的需求:访问A时添加一个临时Cookie,重定向到任何页面,都不维持这个Cookie,那么分析如下:
    首先重定向请求而维持了Cookie,那么肯定Cookie的domain是一样的,domain不一样,是不可能维持的;那么domain一样,而又不想维持cookie,那只能通过拦截请求,在代理方法里:
    - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler 中执行 decisionHandler(WKNavigationActionPolicyCancel); 并且手动loadRequest;或者 allow,同时处理request的header,把指定name的value置为空(如Cookie管理3,fixRequest是增加cookie,这里你也可以主动删除cookie,如yourcookiename=; expires=-1; path=xx; domain=xxx;)。
  • 东瓜:请问下 WKWebview能通过JavaScriptCore 获取JSContext 然后在做交互吗
    Dark_Angel:@东瓜 嗯,稍微繁琐一点,但如果封装好,都一样。
    东瓜:@Dark_Angel 谢谢 不能用JavaScriptCore的话 感觉WKWebView传值给JS有点坑
    Dark_Angel:@东瓜 很遗憾,不能。请参考文中webkit的源码https://opensource.apple.com/source/WebKit2/。WKWebView与UIWebView设计并不相同。
  • Winsanity_倒带人生: 好好研究一下 真的获益匪浅
  • 上善若水jf:非常不错的文章
  • f7139db11dcd:感谢分享

本文标题:iOS中UIWebView与WKWebView、JavaScri

本文链接:https://www.haomeiwen.com/subject/akagtxtx.html