这才是 WKWebview Cookie 管理的正确方式

说起 WKWebview 代替 UIWebview 带来的好处你可以举出一堆堆的例子,但说到 WKWebview 的问题,你绕不过的就是 WKWebview cookie 和 NSHTTPCookieStorage cookie 不共享的问题。你可以在网络上搜到如何将他们相互同步的帖子。

如何将 NSHTTPCookieStorage 同步给 WKWebview ,大概要处理很多种情况,包括但不限于以下;

  1. 初次加载页面时,同步 cookie 到 WKWebview
  2. 处理 ajax 请求时,需要的 cookie
  3. 如果 response 里有 set-cookie 还需要缓存这些 cookie
  4. 如果是 302 还需要处理 cookie 传递的问题

所以,如果你按照上面的要求编写了代码,你会发现总有漏网之鱼的情况没有处理,比方说请求 response 设置了 cookie,为了在后续跳转中带上这些 cookie,你需要暂存下来,这样可能会污染到 NSHTTPCookieStorage ;再举一个极端的真实的案例,如果有个网站的鉴权是通过 302 鉴权 和 response set-cookie 的,那么你会发现这个网站在鉴权那里陷入了死循环,因为 302 response set-cookie 后 302 的 location 地址加载时并没有携带上 302 时设置的 cookie,进而继续 302 set-cookie的跳转。

那如果解决 302 response set-cookie 的问题,我们不能在上述方案里修修补补,上述方案对正常的数据请求已经有很大的侵入性,对很多没有必要进行 cookie 设置的页面做了处理,一定程度上对性能也有影响。让我们跳脱原来的方案,重新审视下 WKWebview cookie 相关的资料。

WKWebview cookie 是怎么存储的

  1. session 级别的 cookie
    session 级别的 cookie 是保存在 WKProcessPool 里的,每个 WKWebview 都可以关联一个 WKProcessPool 的实例,如果需要在整个 App 生命周期里访问 h5 保留 h5 里的登录状态的,可以将使用 WKProcessPool 的单例来共享登录状态。
    > WKProcessPool 是个没有属性和方法的对象,唯一的作用就是标识是不是需要新的 session 级别的管理对象,一个实例代表一个对象。

    1. 未过期的 cookie
      有有效期的 cookie 被持久化存储在 NSLibraryDirectory 目录下的 Cookies/文件夹。
      image.png

注意,cookie 持久化文件地址在 iOS 9+ 上在 /Users/Mac/Library/Developer/CoreSimulator/Devices/D2F74420-D59B-4A15-A50B-774D3D01FADE/data/Containers/Data/Application/E8646AD5-1110-43F3-95D9-DE6A32E78DB7/Library/Cookies.
但是在 iOS 8 上 cookie 被保存在两部分,一部分如上所述,还有一部分保存在 App 无法获取的地方,/Users/Mac/Library/Developer/CoreSimulator/Devices/D2F74420-D59B-4A15-A50B-774D3D01FADE/data/Library/Cookies,大概就是后者的 Cookie 是 iOS 的 Safari 使用 。

在 Cookies 目录下两个文件比较重要;
* Cookie.binarycookies
* .binarycookies
两者的区别是 .binarycookies 是 NSHTTPCookieStorage 文件对象;Cookie.binarycookies 则是 WKWebview 的实例化对象。
这也是为什么 WKWebview 和 NSHTTPCookieStorage 的原因——因为被保存在不同的文件当中。

为了验证,你可以打开这两者文件进行查看,这里不再展开。

当然两个文件都是 binary file,直接用文本浏览器打开是看不到,有一个 python 写的脚本 BinaryCookieReaderhttps://gist.github.com/sh1n0b1/4bb8b737370bfe5f5ab8。可以读出来

WKWebview Cookie 是如何工作的?

  1. 当 webview loadRequest 或者 302 或者在 webview 加载完毕,触发了 ajax 请求时,WKWebview 所需的 Cookie 会去 Cookie.binarycookies 里读取本域名下的 Cookie ,加上
    WKProcessPool持有的 Cookie 一起作为 request 头里的 Cookie 数据。
  2. 但是如果仔细查看 NSURLRequest.h 源代码,而不是仅仅查看NSDictionary<NSString *, NSString *> *allHTTPHeaderFields; 的 quick help,你会发现这句话;
    @abstract Sets the HTTP header fields of the receiver to the given
    dictionary.
    @discussion This method replaces all header fields that may have
    existed before this method call.    

再查看下HTTPShouldHandleCookies 的 quick help,

@property BOOL HTTPShouldHandleCookies;
Description 
A boolean value that indicates whether the receiver should use the default cookie handling for the request.
YES if the receiver should use the default cookie handling for the request, NO otherwise. The default is YES.
If your app sets the Cookie header on an NSMutableURLRequest object, then this method has no effect, and the cookie data you set in the header overrides all cookies from the cookie store.
SDKs    iOS 8.0+, macOS 10.10+, tvOS 9.0+, watchOS 2.0+

结合两者,你也会发现一个核心的概念-如果设置了 allHTTPHeaderFields,则不用使用 the cookie manager by default

所以我们的方案是-在页面加载过程中不去设置 allHTTPHeaderFields,全部使用默认 Cookie mananger 管理,这样就不会有 Cookie 污染也不会有 302 Cookie 丢失的问题了,下面让我们验证一下。

唯一的问题——如何将 NSHTTPCookieStorage 的 Cookie 共享给 WKWebview。

解决方案

在首次加载 url 时,检查是否已经同步过 Cookie。如果没有同步过,则先加载 一个 cookieWebivew,它的主要目的就是将 Cookie 先使用 usercontroller 的方式写到 WKWebview 里,这样在处理正式的请求时,就会带上我们从 NSHTTPCookieStorage 获取到的 Cookie了。
核心代码如下,

if ([AppHostCookie loginCookieHasBeenSynced] == NO) {
        //
        NSURL *cookieURL = [NSURL URLWithString:kFakeCookieWebPageURLString];
        NSMutableURLRequest *mutableRequest = [NSMutableURLRequest requestWithURL:cookieURL cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:120];
        WKWebView *cookieWebview = [self getCookieWebview];
        [self.view addSubview:cookieWebview];
        [cookieWebview loadRequest:mutableRequest];
        DDLogInfo(@"[JSBridge] preload cookie for url = %@", self.loadUrl);
    } else {
        [self loadWebPage];
    }
//  注意,CookieWebview 和 正常的 webview 是不同的
- (WKWebView *)getCookieWebview
{
    // 设置加载页面完毕后,里面的后续请求,如 xhr 请求使用的cookie
    WKUserContentController *userContentController = [WKUserContentController new];

    WKWebViewConfiguration *webViewConfig = [[WKWebViewConfiguration alloc] init];
    webViewConfig.userContentController = userContentController;

    webViewConfig.processPool = [AppHostCookie sharedPoolManager];

    NSMutableArray<NSString *> *oldCookies = [AppHostCookie cookieJavaScriptArray];
    [oldCookies enumerateObjectsUsingBlock:^(NSString *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        NSString *setCookie = [NSString stringWithFormat:@"document.cookie='%@';", obj];
        WKUserScript *cookieScript = [[WKUserScript alloc] initWithSource:setCookie injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
        [userContentController addUserScript:cookieScript];
    }];

    WKWebView *webview = [[WKWebView alloc] initWithFrame:CGRectMake(0, -1, SCREEN_WIDTH,ONE_PIXEL) configuration:webViewConfig];

    webview.navigationDelegate = self;
    webview.UIDelegate = self;

    return webview;
}

这里需要处理的问题是,加载完毕或者失败后需要清理旧 webview 和设置标记位。

static NSString * _Nonnull kFakeCookieWebPageURLString = @"http://ai.api.com/xhr/user/getUid.do?26u-KQa-fKQ-3BD"
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation
{

    NSURL *targetURL = webView.URL;
    if ([AppHostCookie loginCookieHasBeenSynced] == NO && targetURL.query.length > 0 && [kFakeCookieWebPageURLString containsString:targetURL.query]) {
        [AppHostCookie setLoginCookieHasBeenSynced:YES];
        // 加载真正的页面;此时已经有 App 的 cookie 存在了。
        [webView removeFromSuperview];
        [self loadWebPage];
        return;
    }
}

同时记得删掉原来对 webview 的 Cookie 的所有处理的代码。

处理至此,大功告成,这样的后续请求, WKWebview 都用自身所有的 Cookie 和 NSHTTPCookieStorage 的 Cookie,这样既达到了 Cookie 共享的目的, WKWebview 和 NSHTTPCookieStorage 的 Cookie 也做了个隔离。

Leave a Reply