屈服于 Apple 的“淫威”,开发者不得不将 App 的网页容器从 UIWebView 迁移到 WKWebView。我们在享受后者带来的性能和功能提升的同时,也被诸如 Cookie 同步、截图、白屏等问题弄得抓耳挠腮、狼狈不堪。无疑,上述问题会随着系统更新被逐渐修复,而开发者在产品系统适配的硬性要求下只得各凭本身缝缝补补,以期望减少在用户侧出现的问题。如题,下文我们对 Cookie 同步的情况进行一些说明,并总结出一种可行的方案。

问题回顾

简言之,WKWebView 的网络模块进程独立于 App 进程,App 进程通过 HTTPCookieStorage 管理的 Cookie 系统会自动使用 IPC 通信同步到 WKWebView。同 App 侧的 HTTPCookieStorage,WKWebView 的存储结构在代码层面表现为 WKWebsiteDataStore(iOS 9+) 和 WKHTTPCookieStore(iOS 11+),前者拥有后者。上述过程咋一看并不会有什么问题,但正因为是 IPC 来完成进程通信,它必然是异步执行的,即感官上的同步延迟。如果在这之前就进行需要验证 Cookie 的 WebView 请求,就是发现因缺少 Cookie 导致的鉴权失败。

一个典型的 🌰 是:用户通过通知等方式启动 App 打开一个和用户态关联的 Web URL,在用户登录完成之后(登录接口会进行 Set-Cookie 操作,写入用户的 auth_token 等数据)立即打开网页。此时会发现网页要求用户重新登录,如果通过 Charles 抓包进行观察的话,会发现此时 Cookies 中并没有上述 auth_token 等与用户态关联的鉴权信息。

换汤不换药的 WKHTTPCookieStore

从 iOS 11 开始,我们可以开始用官方的补救措施:WKHTTPCookieStore。它与 HTTPCookieStorage 接口很相似,我们或许很自然地想到用下面这样一段代码来同步 Cookie:

1
2
3
4
5
6
7
8
9
10
11
12

let group = DispatchGroup()
HTTPCookieStorage.shared.cookies?.forEach {
group.enter()
webView.configuration.websiteDataStore.httpCookieStore.setCookie($0) {
group.leave()
}
}
group.notify(queue: .main) {
webView.load(urlRequest)
}

在实践过程的测试初期,并没有发现什么异常。而在内测阶段,App 中存在很多的网页场景且每次加载之前都会进行上述的同步操作,就会几率性出现 setCookie 的 completionHandler 不执行的情况,从而导致当前网页且后续所有网页都无法正常加载,从 WKHTTPCookieStore 设置 Cookie 的接口不难看出,它本质上还是通过 IPC 异步与 Web 进程通信。当同步的 Cookie 操作频繁执行时,会导致 App 与 Web 进程间通信出现异常,而这个度很难去把控,所以放弃这种方式。

这里有提到监听 NSHTTPCookieManagerCookiesChangedNotification 来修改 WKHTTPCookieStore 的方式,诸君可自行尝试。

从一而终:JS 注入

通过 Javascript 注入 Cookie 算是一种老生常谈的同步手段了,它分为以下两个个步骤:

  1. 所有的 WKWebView 公用一个 WKProcessPool(不透明类型)和 WKWebsiteDataStore,后者使用 WKWebsiteDataStore.default() 返回的实例。
  2. 使用 WKUserScript 向 WKWebView 的 WKUserContentController 中注入 Javascript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

private func _syncCookies() {
// https://stackoverflow.com/a/32845148
var scripts: [String] = ["var cookieNames = document.cookie.split('; ').map(function(cookie) { return cookie.split('=')[0] } )"]
// Cookie 过期处理由系统进行管理,不进行手动删除操作
HTTPCookieStorage.shared.cookies?.forEach {
// 假设系统的 Cookie 同步行为没有完成,如果过滤具有 httpOnly 标示的 cookie,就会在后续网络请求中 cookie 丢失的问题。
// 即使在首次 loadRequest 中直接设置请求头的 cookie 字段注入该 cookie。
// guard $0.isHTTPOnly == false else { return }

// 当不存在此 cookie 时,才进行设置,避免注入同名 cookie
scripts.append("if (cookieNames.indexOf('\($0.name)') == -1) { document.cookie='\($0.javaScriptString)'; }")
}

let source = scripts.joined(separator: ";\n")
userContentController.addUserScript(
WKUserScript(
source: source,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
)
}

  1. 在用户退出登录时,清除 WKWebView 数据:
1
2
3
4
5
6
7
8
9
10
11

WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: Date(timeIntervalSince1970: 0.0),
completionHandler: completion
)

pool = nil

// Important: 销毁所有的 WKWebView,如果有常驻 WKWebView 则重建。在实践工程中发现几率性出现 Cookie 残留,即使已经执行了清除所有数据的操作。

按部就班之后,你会发现这个方式和完全靠系统来处理存在同样的问题:存在延迟,具体表现为第一次打开网页 Cookie 没有,第二次打开又有了。WTF!!!事实上,这算是 WKUserScript 注入的一个坑了。注意看,我们注入 Javascript 的时机是 atDocumentStart,文档对它的解释是这样的:A constant to inject the script after the creation of the webpage’s document element, but before loading any other content.从字面理解来看,在 HTML DOM 加载过程中,WKWebView 的网络请求就会带上这些 Cookie,但实际上并没有。

从本质上来看,App 侧注入的 document.cookie 是给 Web 侧增加 Cookie 环境。而从上面的操作结果来看,并不代表它在注入完成之后就会立刻生效。在此,我们可以在后台预先加载一个 URL(指向一个空白的能正常加载成功的页面即可,如 xxxx://xxxx/ios/cookie/sync)来完成 Cookie 环境的同步,然后再请求实际的 Web URL 来解决这个问题。

总结

从 UIWebView 时期与 App 公用 HTTPCookieStorage 到现在 WKWebView 的泾渭分明,最可靠的 Cookie 同步方式始终都是系统默认方案。无奈后者的技术架构迫使我们做一些“骚操作”去促使系统自动完成,全文单从 App 侧同步到 Web 侧的一些实践过程进行了阐述,最终总结出 Preload + Javascript + Clean 的一种基本可行的方式。而从 Web 侧到 App 侧,没有做任何额外的操作,而是完全依赖于系统行为,这或许埋下了一些隐患,但到目前来说我们没有遇到相关问题。其实,针对于服务器渲染的前端页面,在充分考虑安全性的前提下,将 Cookie 直接塞到 URL 的 query 中是一种更为直接简单的方式。