WKWebView:JavaScript与OC交互、Cookie管理、常见问题

3,526 阅读10分钟

WKWebView 简介

WKWebView 是Apple iOS8之后推出的webkit的核心控件,用来替换UIWebView。相比UIWebView,WKWebView优势在于:

  1. 更多的支持html5特性
  2. 高达60fps的滚动刷新率及内置手势
  3. 与safari相同的 Nitro javascript引擎
  4. 将UIWebViewDelegate与UIWebView拆分成了14类与3个协议
  5. 可以获取加载进度,不需要调用私有API

WKWebView 基本用法

创建

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;

WKWebView比UIWebView多了一个configuration,可以配置很多东西,比如js是否支持、画中画、是否内联视频播放、偏好设置等,具体可以在WKWebViewConfiguration里查看。

  • 重点要说的是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];

注入的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];

加载

加载页面的API

- (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));

加载本地的一个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,但还是有些问题的。先说一下常见用法

//下面这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;

新增属性

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的交互

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方法,缺点依然明显。
  • scriptMessageHandler,这个方法才是项目中实际使用的
/*! @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;

在oc中添加一个scriptMessageHandler,则会在all frames中添加一个js的function:window.webkit.messageHandlers..postMessage(messageBody),比如:

[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);
    }
}

在delloc里调用removeScriptMessageHandler

- (void)dealloc {
    //记得移除
    [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"currentCookies"];
}

Cookie管理

WKWebView的cookie问题真的很坑,前前后后折腾了大半年,总是遇到一些诡异的问题,下面会总结一下注意事项: 在使用 UIWebView 的时候, 我们并不存在使用 Cookie 的困扰, 我们只需要写一个方法, 往 HTTPCookieStorage里面注入一个我们用户的 HTTPCookie 就可以了。同一个应用,不同 UIWebView 之间的 Cookie 是自动同步的。并且可以被其他网络类访问比如NSURLConnection、AFNetworking。由于WKWebView会忽视默认的网络存储,如: NSURLCache、 NSHTTPCookieStorage、NSCredentialStorage。无法实现,不会主动把 Cookie 存储的NSHTTPCookieStorage,网络类无法访问 WKWebView 自己管理的 Cookie,也无法从 NSHTTPCookieStorage 中取到 WKWebView 请求时用到 Cookie。所以在WKWebView需要发起网路请求时,需要我们手动去注入 Cookie。大概思路:将cookie信息存入 NSHTTPCookieStorage,然后在适当的时机从NSHTTPCookieStorage 获取 Cookie 注入 WKWebView 。下面就带着这样几个问题具体说一下:我们的用户端app现在的注入 Cookie 方案是什么?解决了什么样的问题?既然不能共享在 NSHTTPCookieStorage 为什么还需要使用 NSHTTPCookieStorage 存取 Cookie?

用户端现在的注入 Cookie 方案是什么?解决了什么样的问题?

  • 方案一:在 WKWebView 发起网络请求向请求头中注入 Cookie ,解决了首次请求 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存在,首次访问一个页面,就不会有问题。

  • 方案二:在 WKWebView 的WKNavigationDelegate 代理方法 decidePolicyForNavigationAction 中 拦截网络请求向请求头中注入 Cookie 注入cookie ,解决了打开新的页面 Cookie 带不过去的问题
  • 方案三:在 WKWebView 的 WKNavigationDelegate 代理方法 decidePolicyForNavigationAction 中 拦截网络请求注入 Cookie,解决了打开新的页面 Cookie 内容发生改变,返回上级页面 Cookie 未更新的问题

经测试方案三可以同时解决方案二能解决的问题,因此用户端现在的 Cookie 注入方案是同时了方案一与方案三

既然不能共享在NSHTTPCookieStorage为什么还需要使用WKHTTPCookieStore存取cookie?

  • 苹果提供了NSHTTPCookieStorage、NSHTTPCookie这两个类是用来存储Cookie。
  • cookie信息不仅限于WKWebView的网络请求使用,某些native页面的网络请求也需要cookie信息,并且网络类NSURLConnection、AFNetworking 等是通过访问 NSHTTPCookieStorage 获取 Cookie 信息。
  • 如果 Cookie 信息,使用其他方式存储,我们不仅要管理 WKWebView 的 Cookie ,还要管理非 WKWebView 的网络请求能否获取到 Cookie。

登录后cookie携带不上问题

  • 现象:未登录访问h5页面,点击按钮弹出登录页,登录成功后再次点击按钮,又弹出登录页,抓包cookie为空
  • 原因:登录后登录状态写进cookie里了,并保存在NSHTTPCookieStorage里,但是没有同步到WKHTTPCookieStore里,所以h5获取不到cookie
  • 解决,登录后重新加载 loadRequest

Cookie异常丢失

由于WKWebView加载网页得到的Cookie会同步到NSHTTPCookieStorage中的特点,有时候你强行添加的Cookie会在同步过程中丢失。抓包你就会发现,点击一个链接时,Request的header中多了Set-Cookie字段,其实Cookie已经丢了

  • 解决:把自己需要的Cookie主动保存起来,每次调用[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies方法时,保证返回的数组中有自己需要的Cookie,用了runtime的Method Swizzling,代码如下:

适当的时候保存cookie

//比如登录成功,保存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;
    }
}

读取时如果cookie为空则添加

@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

这样就能保证sharedHTTPCookieStorage中Cookie存在。

白屏问题

当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];   //刷新就好了
}

自定义UserAgent

WKWebView提供Api,前文中也说明过,就是调用customUserAgent属性。

self.webView.customUserAgent = @"WebViewDemo/1.0.0";    //自定义UA,只支持WKWebView

Native与H5共享登录状态

access_token写入cookie,cookie管理问题上面已经介绍了

Native预览H5页面中的image

  1. 方案一:使用WKUserScript和scriptMessageHandler
  2. 方案二:scheme 交互

Native加载并缓存H5页面中的image

拦截请求,最核心的是在NSURLProtocol子类中,实现这个方法

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    //处理过不再处理
    if ([NSURLProtocol propertyForKey:DAURLProtocolHandledKey inRequest:request]) {
        return NO;
    }
    //根据request header中的 accept 来判断是否加载图片
    /*
    {
     "Accept" = "image/png,image/svg+xml,image/*;q=0.8,*\/*;q=0.5\";
     "User-Agent" = "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Mobile/14E269 WebViewDemo/1.0.0";
    }
     */
    NSDictionary *headers = request.allHTTPHeaderFields;
    NSString *accept = headers[@"Accept"];
    if (accept.length >= @"image".length && [accept rangeOfString:@"image"].location != NSNotFound) {
        return YES;
    }
    return NO;
}