不同WKWebView之间实现浏览状态隔离

项目中用到了WKWebView来渲染网页,有一个需求是在不同的账号下希望实现浏览状态隔离,如何实现呢?

Sample app

下图是我们用来测试的sample app, 两个tab分别有两个不同的WKWebView实例,每次用户切换tab的时候,对应的tab会刷新对https://login.live.com这个地址的访问。

问题

  1. 默认状态下,不同的WKWebView实例是不会共享浏览状态的,因此我们可以在两个tab登录不同的账号,切换tab的时候对应的登录状态不会乱掉
  2. 但是如果我们在其中一个tab网页登录的时候选择Keep me signed in,奇怪的事情发生了,当切换到另一个tab的时候,发现另一个tab竟然也处于登录的状态

原因

  1. 勾选Keep me signed in的时候发生了什么?
    登录状态的保持需要客户端保存cookie,而我们知道cookie是分session cookiepersistent cookie的,两者的区别在WKWebView的体现就是,前者会在WKWebView实例消失的时候随之消失,而后者会保存在文件系统中。当用户勾选Keep me signed in的时候,网页会写入某些persistent cookie,这样当app下次启动初始化WKWebView的时候,网页就能读取到这些内容保持用户的登录状态。
    下图是app的文件系统,我们发现当勾选了Keep me signed in,但是人为删除Cookies文件时,网页的登录状态会丢失,这样也进一步验证了我们的想法(也可以打开Cookies文件查看内容)
  1. 为什么默认状态下,WKWebView不会共享浏览状态?
    默认状态下,不同的WKWebView拥有不同的processPool, 因此浏览状态相互之间是不会共享的。

  2. 为什么选择Keep me signed in的时候浏览状态会共享?

  • 首先要介绍WKWebsiteDataStore的概念

    A WKWebsiteDataStore object represents various types of data used by a chosen website. Data types include cookies, disk and memory caches, and persistent data such as WebSQL, IndexedDB databases, and local storage.

    iOS SDK提供了两种WKWebsiteDataStore, WKWebsiteDataStore.default()会返回默认的, persistent dataStore,而WKWebsiteDataStore.nonPersistent()会创建一个non-persistent dataStore并返回(苹果的文档也介绍了,这种dataStore经常被用来实现无痕浏览)。

    If a web view is associated with a nonpersistent data store, no data is written to the file system. This property implements private browsing in a web view.

  • 默认状态下,WKWebViewwebsiteDataStore是default版本的,也就是支持persistent cookie的dataStore

  • 因此单纯的使用不同的WKProcessPool(默认行为),并不能保证浏览状态的隔离;由于默认状态下使用了相同的支持persistent cookie的WKWebsiteDataStore.default(),网页需要persistent的cookie会共享。

解决方法

从上面的分析来看,解决方法看起来很直接,我们可以让不同的WKWebView持有不同的WKWebsiteDataStore.nonPersistent()实例

1
2
3
4
5
6
private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
let view = WKWebView(frame: .zero, configuration: config)
return view
}()

我们发现无论是否勾选Keep me signed in,登录状态都不会共享,太好了!

但是

当用户勾选了Keep me signed in,杀掉app之后,我们发现登录状态”竟然”丢失了!这样虽然我们实现了登录状态隔离,但是Keep me signed in这个选项变得无用,这可不是一个专业的工程师希望看到的。而我们也知道,这也是我们采用了上述方案之后expected结果,因为WKWebsiteDataStore.nonPersistent()本身就是不支持persistent cookie的。

我们看起来进退维谷(这是一个视频链接,请不要在公众场合打开 ;))

  • 要想实现登录状态隔离,必须使用不同的WKWebsiteDataStore
  • 只有WKWebsiteDataStore.default()才能保存persistent cookie
  • 但是WKWebsiteDataStore.default()是一个类似单例的存在,我们无法创建不同的persistent WKWebsiteDataStore

解决方法+1

于是我们开始尝试:

然后在面向StackOverflow编程无果之后,你终于意识到自己已经是有经验的工程师了,应该可以独立解决问题了,在苦思冥想之后,终于想到了下面的方法

  • 去看Webkit的源码,想办法使用私有的api

    在浏览_WKWebsiteDataStoreConfiguration.h这个文件时,我们发现如下的定义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #import <WebKit/WKFoundation.h>

    #if WK_API_ENABLED

    #import <Foundation/Foundation.h>

    NS_ASSUME_NONNULL_BEGIN

    WK_CLASS_AVAILABLE(macosx(WK_MAC_TBA), ios(WK_IOS_TBA))
    @interface _WKWebsiteDataStoreConfiguration : NSObject

    @property (nonatomic, copy, setter=_setWebStorageDirectory:) NSURL *_webStorageDirectory;
    @property (nonatomic, copy, setter=_setIndexedDBDatabaseDirectory:) NSURL *_indexedDBDatabaseDirectory;
    @property (nonatomic, copy, setter=_setWebSQLDatabaseDirectory:) NSURL *_webSQLDatabaseDirectory;
    @property (nonatomic, copy, setter=_setCookieStorageFile:) NSURL *_cookieStorageFile;

    @end

    NS_ASSUME_NONNULL_END

    #endif

    于是理论上我们可以创建WKWebsiteDataStoreConfiguration之后,配置不同的cookieStorageFile路径,然后利用下面的private API,来创建不同的支持的persistent cookie的dataStore

    1
    2
    3
    4
    5
    @interface WKWebsiteDataStore (WKPrivate)

    + (NSSet<NSString *> *)_allWebsiteDataTypesIncludingPrivate;

    - (instancetype)_initWithConfiguration:(_WKWebsiteDataStoreConfiguration *)configuration WK_API_AVAILABLE(macosx(WK_MAC_TBA), ios(WK_IOS_TBA));

    但是我们担心无法通过苹果的审核,同时这样做需要花更多的时间去看源码确保是可行的,作为一名有经验的工程师,我们认为这不是一条正路

  • 终于,我们意识到,既然支持persistent cookie的本质就是把cookie保存到文件系统里,那我们能不能自己来实现呢?

    对于我们的sample app,我们的方案如下:

    1. 两个WebView实例分别使用WKWebsiteDataStore.default()WKWebsiteDataStore.nonPersistent()
    2. 对于使用WKWebsiteDataStore.default()WebView实例,我们依靠其自身的cookie persistence机制
    3. 对于使用WKWebsiteDataStore.nonPersistent()WebView实例
      • 在恰当的时机,读取webView的所有cookie并保存到文件里
      • 在初始化该webView的时候,读取文件中的所有cookie并加载到webView

    简单明了的方案,当然我们的代码也应该体现出我们的水平。

    • 这里我们extend了WKWebsiteDataStore,暴露了persistCookiesrestoreCookies两个方法,暴露了cookieStorageFile这个属性
    • sample app中使用了最简单的NSKeyedArchiver来实现数据持久化(要注意在操作大文件时archive/unarchive是耗时的操作,如果可能尽量放到其他线程去做;但同时cookie的读写又必须在主线程上进行)

      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
      struct WKWebsiteDataStoreAssociatedKeys {
      static var cookieStorageFile: UInt8 = 0
      }

      extension WKWebsiteDataStore {
      var cookieStorageFile: URL? {
      get {
      return objc_getAssociatedObject(self, &WKWebsiteDataStoreAssociatedKeys.cookieStorageFile) as? URL
      }
      set {
      objc_setAssociatedObject(self, &WKWebsiteDataStoreAssociatedKeys.cookieStorageFile, newValue, .OBJC_ASSOCIATION_RETAIN)
      }
      }

      func persistCookies() {
      guard !self.isPersistent else {
      return
      }

      guard let cookieStorageFilePath = self.cookieStorageFile?.path else {
      return
      }

      self.httpCookieStore.getAllCookies { cookies in
      NSKeyedArchiver.archiveRootObject(cookies.filter { !$0.isSessionOnly }, toFile: cookieStorageFilePath)
      }
      }

      func restoreCookies() {
      guard !self.isPersistent else {
      return
      }

      guard let cookieStorageFilePath = self.cookieStorageFile?.path else {
      return
      }

      guard let cookies = NSKeyedUnarchiver.unarchiveObject(withFile: cookieStorageFilePath) as? [HTTPCookie] else {
      return
      }

      cookies.forEach { cookie in
      self.httpCookieStore.setCookie(cookie, completionHandler: nil)
      }
      }
      }
    • 在使用的时候,只要给webSiteDataStore设置cookieStorageFile并且在合适的时机调用restoreCookiespersistCookies即可。

总结

于是,我们设计的这套方案,”完美”(作为一名有经验的工程师,我们知道后续还会有各种各样奇奇怪怪的问题/需求出现)的解决了

  • 不同的WebView之间浏览状态隔离
  • Persistent cookie能够正常工作
  • 该方案可以简单的推广到N(N>2)个WebView的情况,只需设置不同的WKWebsiteDataStore.nonPersistent()cookieStorageFile即可

终于,我们有时间感慨,今晚的月色真美啊!

Thank you for making the world a better place!