问题
我们发现,使用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 | var backList: [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
是全新的,这意味着在用户看来,之前的浏览历史全部消失了!
解决方法
- 最直观的想法是希望
WKWebView
本身暴露这样一个功能(然而并没有) - 那么退而求其次我们可以记录用户的访问记录,在app启动的时候构建我们自己的
backForwardList
,然后assign给WKWebView
(很遗憾前面我们提到了backForwardList
是get only的,我们无法这样实现) - 再退一步,虽然
backForwardList
本身是get only的,但是我们可以利用WKWebView
暴露的load
接口来模拟实现
我们来简单实现一下方法3, 这是我们的sample app,有前进/后退两个按钮和一个WebView。
Persist:1
2
3
4
5
6
7
8
9
10
11
12guard 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
10if 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()
}
}
思路很简单,
- 需要保存浏览历史的时候,拿到
WKWebView
的backList, currentItem, forwardList
来保存url
,通过forwardList.count
来计算当前页面的index,然后通过NSKeyedArchiver
(我们先不考虑performance问题)来做持久化 - 需要恢复浏览历史的时候,通过
NSKeyedUnarchiver
反序列化文件,然后依次加载url,同时结合currentIndexButLast
和goBack
来实现跳转到指定页面
通过上面的实现,我们发现当访问了A->B->C重启app之后,webview可以恢复历史并且显示C页面,但是却出现了无法后退到B的问题。原因是
- 调用
WKWebView
的load
之后,页面加入到backForwardList
是需要时间的;官方没有介绍具体的时机,推测大概是在didFinish
左右 - 上面的code里我们从文件中取出所有的
url
并连续调用webview.load
,这导致前面的页面还没有加入到backForwardList
之中我们就跳转到了其他页面,于是最后的结果就是WKWebView
的backForwardList
中只有最后一个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
总结一下:
- 当app启动需要恢复浏览历史的时候,启动一个本地的Web server并且加载一个特定的本地页面
- 提取之前保存的所有url信息,构造本地url越过同源策略限制,传递到特定本地页面中,该页面通过调用
pushState
来达到修改backForwradList
的目的 - 当本地加载这些特殊
url
的时候,之前启动的本地server可以将其重定向到正确的url
实现一下
- 首先Firefox for iOS是基于Swift的开源项目,我们可以在遵守开源协议的基础上参考/借鉴其具体实现。
- 下面的sample code从Firefox的repo中提取出来,用到了
GCDWebServer
和SwiftyJSON
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"]}
加载这个页面的时候
- 脚本会取出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"]}
- 通过
pushState
达到修改WKWebView
的backForwardList
的效果 - 通过
go
跳转到backForwardList
中某个位置 - 最后通知native端刷新页面以显示当前页面
1 | <!-- This Source Code Form is subject to the terms of the Mozilla Public |
WebServer
文件来源:
https://github.com/mozilla-mobile/firefox-ios/blob/v10.x/Client/Application/WebServer.swift
这里的实现比较简单,利用GCDWebServer
这个库,起了一个本地server并暴露了路由方法。
1 | class WebServer { |
SessionRestoreHandler
文件来源:
这个文件主要是用来注册两个路由
/errors/restore?history=...
是app刚启动恢复浏览记录的时候加载的url
/errors/error.html?url=...
是SessionRestore.html
最后加载的url
,这里我们会将实际的url
取出并加载
1 | class SessionRestoreHandler { |
WebView
文件来源:
https://github.com/mozilla-mobile/firefox-ios/blob/master/Client/Frontend/Browser/Tab.swift
逻辑很清楚,将之前保存的url
和currentPage
信息拼好传到url
里然后加载
1 | let jsonDict: [String: AnyObject] = [ |
Info.plist
因为我们需要加载localhost,所以需要在Info.plist
里面添加如下key-value.
1 | <key>NSAppTransportSecurity</key> |
总结
WKWebView
的backForwardList
是只读的,这一点限制了我们对浏览历史进行恢复操作- Firefox的做法通过HTML提供的
pushState
接口,绕过了这个限制,从另一个角度修改了backForwardList
- 刚刚发现Firefox最新的code对这部分进行了一些修改,但是换汤不换药,思路还是一致的
- 除了这个方法,或许(肯定)还会有其他的方法,我们之后再来讨论。