WKWebView实现浏览历史恢复

问题

我们发现,使用WKWebView做套壳浏览器,有个很严重的问题,浏览历史不同持久化。

什么是浏览历史?

WKWebView有个属性叫作backForwardList (注意是get-only的)

1
var backForwardList: WKBackForwardList { get }

A WKBackForwardList object maintains a list of visited pages used to go back and forward to the most recent page.

也就是说,我们在浏览器里看到的前进/后退按钮,其实就可以由它控制。backList表示所有可以后退到的item,forwardList表示所有可以前进到的item,其中的每个item是WKBackForwardListItem的实例,包含url等信息

1
2
var backList: [WKBackForwardListItem] { get }
var forwardList: [WKBackForwardListItem] { get }

同时,WKWebView暴露了下面的api允许自身在浏览历史中跳转。

1
2
3
4
5
6
// Navigates to the back item in the back-forward list.
func goBack() -> WKNavigation?
// Navigates to the forward item in the back-forward list.
func goForward() -> WKNavigation?
// Navigates to an item from the back-forward list and sets it as the current item.
func go(to: WKBackForwardListItem) -> WKNavigation?

持久化问题

浏览器有一个很常见的必备需求 – 保存浏览历史。比如用户访问了A, B, C三个网站,并且退出App的时候处于B网站。那么下次冷启动App的时候,用户自然而然会expect进入B的页面,并且可以后退/前进到A/C。

然而我们发现,WKWebView本身并没有这样的方法,每次app重新启动初始化WKWebView的时候,backForwardList是全新的,这意味着在用户看来,之前的浏览历史全部消失了!

解决方法

  1. 最直观的想法是希望WKWebView本身暴露这样一个功能(然而并没有)
  2. 那么退而求其次我们可以记录用户的访问记录,在app启动的时候构建我们自己的backForwardList,然后assign给WKWebView(很遗憾前面我们提到了backForwardList是get only的,我们无法这样实现)
  3. 再退一步,虽然backForwardList本身是get only的,但是我们可以利用WKWebView暴露的load接口来模拟实现

我们来简单实现一下方法3, 这是我们的sample app,有前进/后退两个按钮和一个WebView。

Persist:

1
2
3
4
5
6
7
8
9
10
11
12
guard let currentItem = self.webView.backForwardList.currentItem else {
return
}

guard let filePath = self.backforwardHistoryFilePath else {
return
}

let urls = (self.webView.backForwardList.backList + [currentItem] + self.webView.backForwardList.forwardList).compactMap { $0.url }
let currentIndexButLast = self.webView.backForwardList.forwardList.count
let backforwardHistory = BackforwardHistory(urls: urls, currentIndexButLast: Int32(currentIndexButLast))
NSKeyedArchiver.archiveRootObject(backforwardHistory, toFile: filePath)

Restore:

1
2
3
4
5
6
7
8
9
10
if let filePath = self.backforwardHistoryFilePath, let backforwardHistory = NSKeyedUnarchiver.unarchiveObject(withFile: filePath) as? BackforwardHistory {
backforwardHistory.urls.forEach { url in
self.webView.load(URLRequest(url: url))
}

for _ in 0..<backforwardHistory.currentIndexButLast {
self.webView.goBack()
}

}

思路很简单,

  1. 需要保存浏览历史的时候,拿到WKWebViewbackList, currentItem, forwardList来保存url,通过forwardList.count来计算当前页面的index,然后通过NSKeyedArchiver(我们先不考虑performance问题)来做持久化
  2. 需要恢复浏览历史的时候,通过NSKeyedUnarchiver反序列化文件,然后依次加载url,同时结合currentIndexButLastgoBack来实现跳转到指定页面

通过上面的实现,我们发现当访问了A->B->C重启app之后,webview可以恢复历史并且显示C页面,但是却出现了无法后退到B的问题。原因是

  • 调用WKWebViewload之后,页面加入到backForwardList是需要时间的;官方没有介绍具体的时机,推测大概是在didFinish左右
  • 上面的code里我们从文件中取出所有的url并连续调用webview.load,这导致前面的页面还没有加入到backForwardList之中我们就跳转到了其他页面,于是最后的结果就是WKWebViewbackForwardList中只有最后一个url
  • 因此在点击后退按钮调用goBack()的时候,backForwardList中没有足够的item,无法后退

一种可能的解决思路是等前一个页面加载完成之后,我们再加载第二个页面,但这样做一是用户体验会差太多,二是不够可靠因为无法确切的知道页面加入到backForwardList的时机。

如何优雅的解决这个问题呢?

Stack Overflow上有同学提出了类似的问题

Why I can’t save WKWebView to [NSUserDefaults standardUserDefaults]?

Firefox for iOS的工程师给出了他们的解决思路

There is no easy answer here. The WKWebView cannot be archived and it does also not participate in UI state preservation/restoration. You already discovered this.

For Firefox for iOS we took a different route to work around these limitations. This far from ideal but it does work.

When we restore a tab that has session info (previously visited pages) attached, we load a special html page from a local web server (running on localhost) that modifies the push state of the page and pushes previously visited URLs on the history stack.

Because these URLs need to be of same origin, we basically push urls like `http://localhost:1234/history/item?url=http://original.url.com

In our UI we translate this to display original.url.com. When the user goes back in history to load these, we intercept and instead of loading the page from localhost, we load the original page.

It is a big hack but that is all we have at this point.

See the source code at https://github.com/mozilla/firefox-ios

总结一下:

  1. 当app启动需要恢复浏览历史的时候,启动一个本地的Web server并且加载一个特定的本地页面
  2. 提取之前保存的所有url信息,构造本地url越过同源策略限制,传递到特定本地页面中,该页面通过调用pushState来达到修改backForwradList的目的
  3. 当本地加载这些特殊url的时候,之前启动的本地server可以将其重定向到正确的url

实现一下

  1. 首先Firefox for iOS是基于Swift的开源项目,我们可以在遵守开源协议的基础上参考/借鉴其具体实现。
  2. 下面的sample code从Firefox的repo中提取出来,用到了GCDWebServerSwiftyJSON

SessionRestore.html

文件来源:

https://github.com/mozilla-mobile/firefox-ios/blob/v10.x/Client/Assets/SessionRestore.html

code很少注释也写的很清楚,在我们通过http://localhost:{port}/errors/restore?history={"currentPage": -1, "history": ["http://1.com", "http://2.com"]}加载这个页面的时候

  1. 脚本会取出url中的history参数{"currentPage": -1, "history": ["http://1.com", "http://2.com"]},然后将url转换成local格式{"currentPage": -1, "history": ["/errors/error.html?url=http://1.com", "/errors/error.html?url=http://2.com"]}
  2. 通过pushState达到修改WKWebViewbackForwardList的效果
  3. 通过go跳转到backForwardList中某个位置
  4. 最后通知native端刷新页面以显示当前页面
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
47
48
49
50
51
52
53
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!DOCTYPE html>
<html>

<head>
<meta name="referrer" content="never">
</head>

<body>
<script>
/**
* This file is responsible for restoring session history.
* It uses the DOM history API to push pages onto the back/forward stack. Since that API
* is bound by same origin restrictions, we're only able to push pages with the current origin
* (which is a page hosted on localhost). As a workaround, push all to-be-restored URLs as
* error pages so that they will redirect to the correct URLs when loaded.
*/
(function () {
function getRestoreURL(url) {
// If the url already points to an error page just return the url as is
if (url.indexOf(document.location.origin + '/errors/error.html') === 0) {
return url;
}
// Otherwise, push an error page to trigger a redirect when loaded.
return '/errors/error.html?url=' + escape(url);
}
var index = document.location.href.search("history");
// Pull the session out of the history query argument.
// The session is a JSON-stringified array of all URLs to restore for this tab, plus the last active index.
var sessionRestoreComponents = JSON.parse(unescape(document.location.href.substring(index + "history=".length)));
var urlList = sessionRestoreComponents['history'];
var currentPage = sessionRestoreComponents['currentPage'];
// First, replace the session restore page (this page) with the first URL to be restored.
history.replaceState({}, "", getRestoreURL(urlList[0]));
// Then push the remaining pages to be restored.
for (var i = 1; i < urlList.length; i++) {
history.pushState({}, '', getRestoreURL(urlList[i]));
}
// We'll end up at the last page pushed, so set the selected index to the current index in the session history.
history.go(currentPage);
// Finally, reload the page to trigger the error redirection, which will load the actual URL.
// For some reason (maybe a WebKit bug?), document.location still points to SessionRestore.html at this point,
// so wait until the next tick when the location points to the correct index and URL.
setTimeout(function () {
webkit.messageHandlers.sessionRestoreMessageHandler.postMessage({ type: "reload" });
}, 0);
})();
</script>
</body>

</html>

WebServer

文件来源:

https://github.com/mozilla-mobile/firefox-ios/blob/v10.x/Client/Application/WebServer.swift

这里的实现比较简单,利用GCDWebServer这个库,起了一个本地server并暴露了路由方法。

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
class WebServer {

static let instance = WebServer()

let server = GCDWebServer()

var base: String {
return "http://localhost:\(self.server.port)"
}

func start() throws {
guard !self.server.isRunning else {
return
}

try self.server.start(
options: [
GCDWebServerOption_Port: 6571,
GCDWebServerOption_BindToLocalhost: true,
GCDWebServerOption_AutomaticallySuspendInBackground: true
]
)
}

/// Convenience method to register a dynamic handler. Will be mounted at $base/$module/$resource
func registerHandlerForMethod(_ method: String, module: String, resource: String, handler: @escaping (_ request: GCDWebServerRequest?) -> GCDWebServerResponse?) {
// Prevent serving content if the requested host isn't a whitelisted local host.
let wrappedHandler = {(request: GCDWebServerRequest?) -> GCDWebServerResponse? in
guard let request = request, request.url.isLocal else {
return GCDWebServerResponse(statusCode: 403)
}

return handler(request)
}
server.addHandler(forMethod: method, path: "/\(module)/\(resource)", request: GCDWebServerRequest.self, processBlock: wrappedHandler)
}

}

SessionRestoreHandler

文件来源:

https://github.com/mozilla-mobile/firefox-ios/blob/8a33d5d19852d521ab422c31015fc6b3e001378c/Client/Frontend/Browser/SessionRestoreHandler.swift

这个文件主要是用来注册两个路由

  • /errors/restore?history=...是app刚启动恢复浏览记录的时候加载的url
  • /errors/error.html?url=...SessionRestore.html最后加载的url,这里我们会将实际的url取出并加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SessionRestoreHandler {
static func register(_ webServer: WebServer) {
// Register the handler that accepts /errors/restore?history=... requests.
webServer.registerHandlerForMethod("GET", module: "errors", resource: "restore") { _ in
guard let sessionRestorePath = Bundle.main.path(forResource: "SessionRestore", ofType: "html"), let sessionRestoreString = try? String(contentsOfFile: sessionRestorePath) else {
return GCDWebServerResponse(statusCode: 404)
}

return GCDWebServerDataResponse(html: sessionRestoreString)
}

// Register the handler that accepts /errors/error.html?url=... requests.
webServer.registerHandlerForMethod("GET", module: "errors", resource: "error.html") { request in
guard let url = request?.url.originalURLFromErrorURL else {
return GCDWebServerResponse(statusCode: 404)
}

return GCDWebServerDataResponse(redirect: url, permanent: false)
}
}
}

WebView

文件来源:

https://github.com/mozilla-mobile/firefox-ios/blob/master/Client/Frontend/Browser/Tab.swift

逻辑很清楚,将之前保存的urlcurrentPage信息拼好传到url里然后加载

1
2
3
4
5
6
7
8
9
10
let jsonDict: [String: AnyObject] = [
"history": backforwardHistory.urls.compactMap { $0.absoluteString } as AnyObject,
"currentPage": -backforwardHistory.currentIndexButLast as AnyObject
]

if let json = JSON(jsonDict).rawString(.utf8, options: [])?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
if let restoreUrl = URL(string: "\(WebServer.instance.base)/errors/restore?history=\(json)") {
self.webView.load(URLRequest(url: restoreUrl))
}
}

Info.plist

因为我们需要加载localhost,所以需要在Info.plist里面添加如下key-value.

1
2
3
4
5
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>

总结

  1. WKWebViewbackForwardList是只读的,这一点限制了我们对浏览历史进行恢复操作
  2. Firefox的做法通过HTML提供的pushState接口,绕过了这个限制,从另一个角度修改了backForwardList
  3. 刚刚发现Firefox最新的code对这部分进行了一些修改,但是换汤不换药,思路还是一致的
  4. 除了这个方法,或许(肯定)还会有其他的方法,我们之后再来讨论。
Thank you for making the world a better place!