WebKit 歷史棧緩存策略探索
文章由作者 波兒菜 授權(quán)發(fā)布,原文發(fā)表于掘金,點擊閱讀原文查看作者更多精彩文章
https://juejin.cn/post/6986283172522622983
背景
在一個新的業(yè)務(wù)方案實施過程中,發(fā)現(xiàn)數(shù)據(jù)上存在較大的差異,而這個差異是 WKWebView 的應(yīng)用方式不同帶來的。通過手工測試和上層代碼能模糊的解釋一些現(xiàn)象,但想要鐵板釘釘?shù)淖C明這些現(xiàn)象就得從 WebKit 源碼去分析,便于將來準(zhǔn)確的決策這些場景是對齊還是變更策略,或許還能從技術(shù)角度發(fā)現(xiàn)一些優(yōu)化點從而反哺業(yè)務(wù)。
現(xiàn)在面臨兩個問題:
1. WebKit 的常規(guī)歷史棧緩存策略是怎樣的?
2. WebKit 在跨域、重定向等場景下,歷史棧緩存策略有怎樣的變化?
其中,第 2 點是比較詭異的,不看 WebKit 源碼的情況下難以找到規(guī)律,下不了定論。
涉及 WebKit 基礎(chǔ)概念
App 內(nèi) WKWebView 運行時有三種進程協(xié)同工作:UIProcess 進程、WebContent 進程、Networking 進程。
WebContent 進程
網(wǎng)頁 DOM 及 JS 所處進程。進程數(shù)量可能有多個,取決于一些細節(jié)策略。
在該進程初始化時會創(chuàng)建唯一的 WebProcess 實例,并且作為 IPC::Connection 的 client,與其它進程通信的代理,需要關(guān)注的內(nèi)存結(jié)構(gòu):
-m_frameMap(WebFrame)(樹結(jié)構(gòu),在創(chuàng)建 WebPage 時創(chuàng)建)
-m_pageMap(WebPage)(UIProcess 進程創(chuàng)建 WebPageProxy 時 IPC 通知過來創(chuàng)建)
-m_mainFrame(WebFrame)
UIProcess 進程
應(yīng)用程序?qū)?yīng)的進程。
初始化 WKWebView 時,需關(guān)注的內(nèi)存結(jié)構(gòu):
-_processPool(WebProcessPool)
-m_processes (WebProcessProxy 數(shù)組)
-_page(WebPageProxy,通過 WebProcessProxy 實例創(chuàng)建,WKWebView 實例唯一一個)
-m_process(WebProcessProxy,會動態(tài)切換)
初始化后,WebPageProxy 做為了 IPC::Connection 的 client,與其它進程通信的代理。
WebPageProxy / WebProcessProxy 分別對應(yīng)了 WebContent 進程的 WebPage / WebProcess。
WebProcessPool(關(guān)聯(lián) WKWebViewConfiguration 的 WKProcessPool 對象)抽象了 WebContent 進程池,也就是說一個 WKWebView 是可以對應(yīng)多個 WebContent 進程。
Networking 進程
負(fù)責(zé)網(wǎng)絡(luò)相關(guān)處理,創(chuàng)建多個 WKWebView 也僅只有一個進程,本文不關(guān)注該進程。
歷史棧緩存策略簡述
WKWebView 可以通過goBack/goForward接口進行歷史棧的切換,切換時有一套緩存策略,命中時能省去請求網(wǎng)絡(luò)的時間。
WebContent 進程的 BackForwardCache 是一個單例,管理著歷史棧緩存。
UIProcess 進程的 WebProcessPool 抽象了 WebContent 進程池,每一個 WebProcessPool 都有唯一的 WebBackForwardCache 表示歷史棧緩存,對應(yīng)著 WebContent 進程池子里的各個 BackForwardCache 單例。
BackForwardCache 用了一個有序 hash 表存儲緩存元素,并設(shè)定了最大緩存數(shù)量:
ListHashSet<RefPtr<HistoryItem>> m_items;
unsigned m_maxSize {0};
緩存淘汰策略
BackForwardCache 和 WebBackForwardCache 的策略基本一致,現(xiàn)以 BackForwardCache 為例說明。
WebContent 進程 在切換頁面時,會將當(dāng)前頁面通過BackForwardCache::singleton().addIfCacheable(...);添加緩存:
bool BackForwardCache::addIfCacheable(HistoryItem& item, Page* page) {
...
item.setCachedPage(makeUnique<CachedPage>(*page));
item.m_pruningReason = PruningReason::None;
m_items.add(&item);
...
}
最大緩存數(shù)量具體代碼如下:
namespace WebKit {
void calculateMemoryCacheSizes(...) {
uint64_t memorySize = ramSize() / MB;
...
// back/forward cache capacity (in pages)
if (memorySize >= 512)
backForwardCacheCapacity = 2;
else if (memorySize >= 256)
backForwardCacheCapacity = 1;
else
backForwardCacheCapacity = 0;
...
}
...
基本可以認(rèn)為 iPhone 上一個 WebContent 進程最多兩個歷史棧緩存。
在歷史棧緩存發(fā)生變化的地方,都會命中一個修剪邏輯:
void BackForwardCache::prune(PruningReason pruningReason) {
while (pageCount() > maxSize()) {
auto oldestItem = m_items.takeFirst();
oldestItem->setCachedPage(nullptr);
oldestItem->m_pruningReason = pruningReason;
}
}
可以看出是實現(xiàn)了一個簡單的 LRU 淘汰策略。
最大緩存數(shù)量
前面說到 WebContent 進程最多兩個歷史棧緩存,實際上這個緩存數(shù)量是 UIProcess 進程決定的。在 UIProcess 進程中,WebProcessPool 初始化 WebBackForwardCache 時會設(shè)置最大緩存數(shù)量,并且在創(chuàng)建 WebProcessProxy 時通過 IPC 通知到對應(yīng)的 WebContent 進程去設(shè)置 BackForwardCache 的m_maxSize。
WebProcessPool 的 WebBackForwardCache 對應(yīng)了 WebContent 進程池里每一個的 BackForwardCache 單例,是一個一對多的模式,WebBackForwardCache 在修剪緩存元素析構(gòu)時會自動觸發(fā) IPC 通知到 WebContent 進程去清理對應(yīng)緩存:
WebBackForwardCacheEntry::~WebBackForwardCacheEntry() {
if (m_backForwardItemID && !m_suspendedPage) {
auto& process = this->process();
process.sendWithAsyncReply(Messages::WebProcess::ClearCachedPage(m_backForwardItemID), [] { });
}
}
所以緩存最大數(shù)量取決于 WebProcessPool 的數(shù)量,一個 WebProcessPool 就最多兩個歷史棧緩存,不管它的進程池有多少個 WebContent。
狀態(tài)同步
在歷史棧緩存狀態(tài)發(fā)生變化時,WebContent 進程會調(diào)用notifyChanged()通過 IPC 通知到 UIProcess 進程的對應(yīng) WebBackForwardCache 去同步狀態(tài):
notifyChanged() 最終調(diào)用到:
static void WK2NotifyHistoryItemChanged(HistoryItem& item) {
WebProcess::singleton().parentProcessConnection()->send(Messages::WebProcessProxy::UpdateBackForwardItem(toBackForwardListItemState(item)), 0);
}
重定向、跨域場景分析
請求數(shù)據(jù)前決議階段
WKWebView 在切換頁面時,真正發(fā)起網(wǎng)絡(luò)請求或使用緩存之前,會進行一些決議,大家熟知的 WKNavigationDelegate 的-webView:decidePolicyForNavigationAction:decisionHandler:就是在這個流程之中:
void WebPageProxy::decidePolicyForNavigationAction(...) {
...
auto listener = ... {
...
receivedNavigationPolicyDecision(policyAction, navigation.get(), processSwapRequestedByClient, frame, WTFMove(policies), WTFMove(sender));
...
}
...
//這個 m_navigationClient 和上層設(shè)置的 WKNavigationDelegate 代理關(guān)聯(lián),即會調(diào)用到 `-webView:decidePolicyForNavigationAction:decisionHandler:
//上層調(diào)用 decisionHandler(WKNavigationActionPolicyAllow) 后,會調(diào)用上面的 listener 關(guān)聯(lián)的閉包,執(zhí)行后續(xù)邏輯
m_navigationClient->decidePolicyForNavigationAction(*this, WTFMove(navigationAction), WTFMove(listener), process->transformHandlesToObjects(userData.object()).get());
...
}
重點關(guān)注的是后續(xù)的這個方法:
void WebPageProxy::receivedNavigationPolicyDecision(...) {
...
//注:這里改寫了源碼
Ref<WebProcessProxy>&& processForNavigation = process().processPool().processForNavigation(...);
...
bool shouldProcessSwap = processForNavigation.ptr() != sourceProcess.ptr();
if (shouldProcessSwap) {
...
continueNavigationInNewProcess(...);
}
...
}
這里做了一個關(guān)鍵操作是獲取 WebProcessProxy,然后判斷是否和來源的sourceProcess相同,如果不同則會用另外的 WebProcessProxy 去處理這個 Navigation。
當(dāng)發(fā)生了 WebProcessProxy 切換,continueNavigationInNewProcess里面會創(chuàng)建一個 ProvisionalPageProxy 并關(guān)聯(lián)到 WebPageProxy 的 m_provisionalPage 實例變量,標(biāo)記這里有一次切換 WebProcessProxy 的操作。
processForNavigation內(nèi)部會決議是否復(fù)用 WebProgressProxy,關(guān)鍵代碼如下:
void WebProcessPool::processForNavigationInternal(...) {
...
if (!sourceURL.isValid() || !targetURL.isValid() || sourceURL.isEmpty() || sourceURL.protocolIsAbout() || targetRegistrableDomain.matches(sourceURL))
//域名相同,返回原始的 WebProgressProxy
return completionHandler(WTFMove(sourceProcess), nullptr, "Navigation is same-site"_s);
...
//域名不同,創(chuàng)建新的 WebProgressProxy 返回
String reason = "Navigation is cross-site"_s;
return completionHandler(createNewProcess(), nullptr, reason);
}
targetRegistrableDomain 是targetURL的一級+二級域名,也就是說目標(biāo)和來源的 URL 允許三級子域名不同時去復(fù)用 Process,比如m.sogou.com和www.sogou.com。此時的時機是發(fā)起網(wǎng)絡(luò)請求之前,對該targetURL是否會重定向不得而知,所以這里只和是否跨域有關(guān)。
UIProcess 進程中的 WebProgressProxy 對 WebContent 進程的映射,不考慮 WebContent 的復(fù)用機制,基本可以認(rèn)為一個 WebProgressProxy 對應(yīng)一個進程。如果前后兩個頁面是兩個不同的 WebContent 進程,且沒有重定向操作,調(diào)用goBack/goForward時也能平滑的切換,并且分別復(fù)用到各自 WebContent 進程的歷史棧緩存。
頁面數(shù)據(jù)返回階段
前面提到,如果此次切換頁面會切換 WebProgressProxy,WebPageProxy 內(nèi)部就會創(chuàng)建一個 ProvisionalPageProxy 變量。在切換頁面拉取到網(wǎng)絡(luò)數(shù)據(jù)或者讀取到緩存數(shù)據(jù)時,會進行提交:
void WebPageProxy::commitProvisionalPage(...) {
...
//嘗試緩存當(dāng)前頁面信息
bool didSuspendPreviousPage = navigation ? suspendCurrentPageIfPossible(...) : false;
//清理當(dāng)前頁面信息,m_process 就是當(dāng)前的 WebProcessProxy
m_process->removeWebPage(...);
//頁面信息切換到新的 m_provisionalPage
//比如把 WebPageProxy 標(biāo)識當(dāng)前 WebProcessProxy 的 m_process 變量設(shè)置為 provisionalPage->process()
swapToProvisionalPage(std::exchange(m_provisionalPage, nullptr));
...
}
suspendCurrentPageIfPossible會嘗試去緩存當(dāng)前頁面的信息:
bool WebPageProxy::suspendCurrentPageIfPossible(...) {
...
// If the source and the destination back / forward list items are the same, then this is a client-side redirect. In this case,
// there is no need to suspend the previous page as there will be no way to get back to it.
if (fromItem && fromItem == m_backForwardList->currentItem()) {
RELEASE_LOG_IF_ALLOWED(ProcessSwapping, "suspendCurrentPageIfPossible: Not suspending current page for process pid %i because this is a client-side redirect", m_process->processIdentifier());
return false;
}
...
//創(chuàng)建 SuspendedPageProxy 變量,此時 m_suspendedPageCount 的值會加一
auto suspendedPage = makeUnique<SuspendedPageProxy>(*this, m_process.copyRef(), *mainFrameID, shouldDelayClosingUntilFirstLayerFlush);
m_lastSuspendedPage = makeWeakPtr(*suspendedPage);
...
//添加進歷史棧緩存
backForwardCache().addEntry(*fromItem, WTFMove(suspendedPage));
...
}
可以看到源碼中的注釋,在發(fā)生了client-side redirect時,即客戶端重定向,會立即返回,并不會走到后面的添加歷史棧緩存邏輯。而如果是服務(wù)器重定向,在 Networking 進程就會處理,這里其實并未感知到,所以就和常規(guī)的頁面切換一樣會把頁面加入歷史棧緩存。
看看更多的處理代碼,發(fā)現(xiàn)若沒有走到這個方法后面的邏輯讓m_suspendedPageCount計數(shù)加一,commitProvisionalPage函數(shù)里面m_process->removeWebPage(...)會調(diào)用到如下邏輯:
void WebProcessProxy::shutDown() {
...
//m_processPool 是裝有 WebProcessProxy 集合的 WebProcessPool
m_processPool->disconnectProcess(this);
...
}
void WebProcessPool::disconnectProcess(WebProcessProxy* process) {
...
//這里就會清理掉 m_backForwardCache 里面和當(dāng)前 process 關(guān)聯(lián)的歷史棧緩存了
//m_backForwardCache 是 WebBackForwardCache 類型,一個 WebProcessPool 唯一一個
m_backForwardCache->removeEntriesForProcess(*process);
...
}
它會清理當(dāng)前 WebProcessProxy 的所有歷史棧緩存,而不會影響到其它 WebProcessPool 或 WebProcessProxy。
如何理解client-side redirect?
判斷代碼很簡單:
fromItem && fromItem == m_backForwardList->currentItem()
走到這段邏輯的前提是切換頁面時切換了 WebProgressProxy,那目標(biāo) URL 就得跨域,比如從www.a.com到www.b.com,到這里表現(xiàn)如下:
fromItem : www.a.com
currentItem : www.b.com
那何時才能讓兩者相等?
推測可能是fromItem被強制更改,考慮到 JS window.location對象的replace()函數(shù)有較大嫌疑,測試在www.a.com頁面執(zhí)行window.location.replace('www.b.com'),果不其然復(fù)現(xiàn)了兩者相等的場景。
這么一看 WebKit 的處理似乎是合理的,因為replace()前的頁面已經(jīng)回不去了,但不知為何直接簡單粗暴的干掉replace()前的頁面歸屬的 WebProgressProxy 關(guān)聯(lián)的所有歷史棧緩存,可能 WebKit 這部分邏輯有優(yōu)化空間,后續(xù)有空再關(guān)注下。
結(jié)論
現(xiàn)在可以回答文章開頭的疑惑了。
WebKit 的常規(guī)歷史棧緩存策略是怎樣的?
限制最大緩存數(shù)量為兩個的 LRU 淘汰算法。
WebKit 在跨域、重定向等場景下,歷史棧緩存策略有怎樣的變化?
WKWebView 切換頁面時,發(fā)生cross-site + client-side redirect 時會清理當(dāng)前 WebProgressProxy 關(guān)聯(lián)的所有歷史棧緩存,后續(xù)切換到這些歷史棧時都需要重新請求網(wǎng)絡(luò)。
這種場景用戶切歷史棧時重新拉取網(wǎng)絡(luò),一般會卡住好幾秒,所以理論上應(yīng)該避免這種現(xiàn)象發(fā)生,盡量利用 WebKit 的緩存機制提高用戶體驗。給 Web 開發(fā)同學(xué)的建議就是,在跨域場景盡量避免使用window.location.replace()去重定向頁面,可以使用服務(wù)器重定向,或者前置頁面旁路上報等方案替代。
另外注意的是,觸發(fā)這種場景后,會讓歷史棧訪問量增加,所以在服務(wù)訪問量相關(guān)指標(biāo)數(shù)據(jù)分析層面這是一個值得關(guān)注的重要變量。
