iOS WKWebView 頁面與檔案資源 Preload 預載 / Cache 緩存研究
iOS WKWebView 預先下載與緩存資源提升頁面載入速度研究。
Photo by Antoine Gravier
背景
不知為何,一直跟 “Cache” 緩存蠻有緣的,之前也負責研究實踐過 AVPlayer 的「 iOS HLS Cache 實踐方法探究之旅 」與「 AVPlayer 實踐本地 Cache 功能大全 」;不同於串流緩存目的是減少播放流量, 這次的主要任務是提升 In-app WKWebView 載入速度 ,其中也牽涉到 WKWebView 的預先加載與緩存研究;不過老實說 WKWebView 的場景更為複雜,不同於 AVPlayer 串流影音是一個或是多個連續的 Chunk 檔案,只需要針對檔案做 Cache,WKWebView 除了本身頁面檔案還有引入的資源檔案( .js, .css, font, image…) 再經由 Browser Engine 渲染出頁面呈現給使用者,這中間不是 App 可以控制的環節太多,從網路到前端頁面 JavaScript 語法效能、渲染方式,都需要花費時間。
本篇文章只是就 iOS 技術上可行性進行研究,並不一定是最終解法,綜觀來說此議題還是請前端從前端下手比較能達成四兩撥千斤的效果 ,請前端夥伴優化第一個畫面出現的時間(First Contentful Paint) 與完善 HTTP Cache 機制,一方面能加速 Web/mWeb 自身,同時影響 Android/iOS in-app WebView 速度,並且也會提升 Google SEO 權重 。
技術細節
iOS 限制
根據 Apple Review Guidelines 2.5.6 :
Apps that browse the web must use the appropriate WebKit framework and WebKit JavaScript. You may apply for an entitlement to use an alternative web browser engine in your app. Learn more about these entitlements .
Apps 內只能使用 Apple 提供的 WebKit Framework (WKWebView) 不允許使用第三方或自行修改過的 WebKit 引擎 ,否則將不允許上架;另外 iOS 17.4 開始,為符合法規,歐盟地區可以在 取得 Apple 特別許可 後 使用其他 Browser Engine 。
蘋果不給的,我們也不能做。
[未驗證] 查資料說就連 iOS 版的 Chrome, Firefox 也都是只能用 Apple WebKit (WKWebView)。
另外還有一個很重要的事:
WKWebView 是跑在 App 主執行緒之外的獨立執行緒,因此所有請求、操作都不會經過我們的 App。
HTTP Cache Flow
在 HTTP 協議中就有包含 Cache 協議,並且在所有跟網路有關的元件(URLSession, WKWebView…)當中系統都已經幫我們實作好了 Cache 機制,因此 Client App 這邊不需要做任何實現,也不推薦大家自己幹一套自己的 Cache 機制,直接走 HTTP 協議才是最快最穩定最有效的路。
HTTP Cache 大致運作流程如上圖:
- Client 發起請求
- Server 響應 Cache 策略在 Response Header,系統 URLSession, WKWebView… 會依照 Cache Header 自動幫我們將 Response 緩存下來,後續請求也會自動套用這個策略
- 再次請求相同資源時,如果緩存未過期則直接從記憶體、磁碟讀取本地緩存直接回應給 App
- 如果已過期(過期不代表無效),則發起真實網路請求問 Server,如果內容沒更改 (雖過期待仍有效) Server 會直接回應 304 Not Modified (Empt Body),雖然真的有發起網路請求但是基本上是毫秒回應+無 Response Body 沒什麼流量耗損
- 如果內容有更改則重新給一次資料跟 Cache Header。
緩存除了本地 Cache、在 Network Proxy Server 或途經的路上也可能有網路的緩存。
常見 HTTP Response Cache Header 參數:
1
2
3
4
5
expires: RFC 2822 日期
pragma: no-cache
# 較新的參數:
cache-control: private/public/no-store/no-cache/max-age/s-max-age/must-revalidate/proxy-revalidate...
etag: XXX
常見 HTTP Request Cache Header 參數:
1
2
If-Modified-Since: 2024-07-18 13:00:00
IF-None-Match: 1234
在 iOS 中網路有關的元件(URLSession, WKWebView…)會自己處理 HTTP Request/Response Cache Header 並自動做緩存,我們不需自己處理 Cache Header 參數。
更詳細的 HTTP Cache 運作細節可參考「 Huli 大大寫的循序漸進理解 HTTP Cache 機制 」
iOS WKWebView 總攬
回到 iOS 上,因為我們只能使用 Apple WebKit,因此只能從蘋果提供的 WebKit 方法下手,探究有機會達成預載緩存的方式。
上圖是使用 ChatGPT 4o 簡介的所有 Apple iOS WebKit (WKWebView) 相關的方法,並附上簡短說明;綠色部分為跟資料儲存有關的方法。
跟大家分享其中比較幾個有趣的方法:
- WKProcessPool:可以讓多個 WKWebView 之間共享資源、數據、Cookie…等等。
- WKHTTPCookieStore:可以管理 WKWebView Cookie,WKWebView 與 WKWebView 之間或是 App 內的 URLSession Cookie 與 WKWebView。
- WKWebsiteDataStore:管理網站緩存檔案。(只能讀資訊跟清除)
- WKURLSchemeHandler:當 WKWebView 無法認得處理的 URL Scheme 則可註冊自定義 Handler 處理。
- WKContentWorld:可以把注入的 JavaScript (WKUserScript) 腳本分組管理。
- WKFindXXX:可以控制頁面搜尋功能。
- WKContentRuleListStore:可以在 WKWebView 內實現內容阻擋器功能(e.g. 遮擋廣告之類的)。
iOS WKWebView 預載緩存可行性方案研究
完善 HTTP Cache ✅
如同前文介紹的 HTTP Cache 機制,我們可以請 Web Team 完善活動頁面的 HTTP Cache 設定,Client iOS 這邊只需要簡單的檢查一下 CachePolicy 設定就好,其他的事系統都做好了!
CachePolicy 設定
URLSession:
1
2
3
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .useProtocolCachePolicy
let session = URLSession(configuration: configuration)
URLRequest/WKWebView:
1
2
3
4
var request = URLRequest(url: url)
request.cachePolicy = .reloadRevalidatingCacheData
//
wkWebView.load(request)
- useProtocolCachePolicy : 默認,照默認 HTTP Cache 控制。
- reloadIgnoringLocalCacheData : 不使用本地快取,每次請求都從網絡加載數據(但允許網路, Proxy 快取…)。
- reloadIgnoringLocalAndRemoteCacheData : 無論本地或遠端快取,總是從網絡加載數據。
- returnCacheDataElseLoad : 如果有快取數據則使用快取數據,否則從網絡加載數據。
- returnCacheDataDontLoad : 僅使用快取數據,如果沒有快取數據也不打網路請求。
- reloadRevalidatingCacheData : 發送請求檢查本地快取是否過期,如果沒有過期(304 Not Modified)則使用快取數據,否則從網絡重新加載數據。
設定快取大小
App 全域:
1
2
3
4
5
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
URLCache.shared = urlCache
個別 URLSession:
1
2
3
4
5
6
let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
let configuration = URLSessionConfiguration.default
configuration.urlCache = cache
另外同前述,WKWebView 是跑在 App 主執行緒之外的獨立執行緒,因此 URLRequest, URLSession 的快取跟 WKWebView 的是不共用的。
如何在 WKWebView 中使用 Safari 開發者工具?
檢查是否是使用本地 Cache 快取。
Safari 啟用開發者功能:
WKWebView 啟用 isInspectable:
1
2
3
4
5
func makeWKWebView() -> WKWebView {
let webView = WKWebView(frame: .zero)
webView.isInspectable = true // is only available in ios 16.4 or newer
return webView
}
WKWebView 加上 webView.isInspectable = true
才能在 Debug Build 版使用 Safari 開發者工具。
p.s. 這是我另開的測試 WKWebView 用 Project
在 webView.load
的地方下一個斷點。
開始測試:
Build & Run:
執行到 webView.load 斷點時,點擊「逐行偵錯」。
回到 Safari,選擇工具列的「開發」->「模擬器」->「你的專案」->「about:blank」。
- 因為頁面尚未開始載入 所以網址會是 about:blank
- 如果沒出現 about:blank 就再回到 XCode 點一次逐行偵錯按鈕,直到出現為止
出現該頁面對應的開發者工具:
回 XCode 點擊繼續執行:
再回到 Safari 開發者工具就能看到資源載入狀況跟完整的開發者工具功能了 (元件、儲存空間調試…等等)
如果網路資源有 HTTP Cache,傳算大小則會顯示「磁碟」:
點進去也能看到緩存資訊。
清除 WKWebView 快取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Clean Cookies
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
// Clean Stored Data, Cache Data
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
let store = WKWebsiteDataStore.default()
store.fetchDataRecords(ofTypes: dataTypes) { records in
records.forEach { record in
store.removeData(
ofTypes: record.dataTypes,
for: records,
completionHandler: {
print("clearWebViewCache() - \(record)")
}
)
}
}
可使用以上方法清除 WKWebView 已緩存的資源、本地數據、Cookie 數據。
但完善 HTTP Cache 只是做到緩存部分(第二次進入很快),預載(第一次進入)不會有影響。 ✅
完善 HTTP Cache + WKWebView Preload 全頁面 😕
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class WebViewPreloader {
static let shared = WebViewPreloader()
private var _webview: WKWebView = WKWebView()
private init() { }
func preload(url: URL) {
let request = URLRequest(url: url)
Task { @MainActor in
webview.load(request)
}
}
}
WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer")
基於完善 HTTP Cache 之後,第二次 Load WKWebView 會有緩存,我們可以先在列表或首頁偷先把裡面的 URL 都 Load 過一次讓他有緩存,使用者進去之後就會比較快。
經過測試,原理上可行;但是對性能、網路流量損耗太大 ;使用者可能根本沒進去詳細頁,但我們為了做預載把所有頁面全都 Load 了一遍,有點亂槍打鳥的感覺。
個人認為現實上不可行,且利大於弊、因噎廢食。😕
完善 HTTP Cache + WKWebView Preload 純資源🎉
基於上面方法的優化,我們可以搭配 HTML Link Preload 方法,僅針對頁面裡面會用到的資源檔案(e.g. .js, .css, font, image…)進行 Preload,讓使用者進去之後可以直接使用緩存資源,不用再發起網路請求拿資源檔案。
意即我不預載整個頁面的所有東西了,我只預載頁面會用到的資源檔案,這些檔案可能也是跨頁面共用的;頁面檔案 .html 還是從網路拿取再結合預載檔案渲染出頁面。
請注意:這邊依然走的是 HTTP Cache,因此這些資源也要支援 HTTP Cache,否則之後請求還是會走網路。
請注意:這邊依然走的是 HTTP Cache,因此這些資源也要支援 HTTP Cache,否則之後請求還是會走網路。
請注意:這邊依然走的是 HTTP Cache,因此這些資源也要支援 HTTP Cache,否則之後請求還是會走網路。
1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html lang="zh-tw">
<head>
<link rel="preload" href="https://cdn.zhgchg.li/dist/main.js" as="script">
<link rel="preload" href="https://image.zhgchg.li/v2/image/get/campaign.jpg" as="image">
<link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/glyphicons-halflings-regular.woff2" as="font">
<link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/Simple-Line-Icons.woff2?v=2.4.0" as="font">
</head>
</html>
常見支援檔案類型:
- .js script
- .css style
- font
- image
Web Team 將以上 HTML 內容放在與 App 約定好的路徑,我們的 WebViewPreloader
改去 Load 這個路徑,WKWebView Load 的同時就會解析 <link> preload 資源產生緩存了。
1
2
3
WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer/preload")
// or 統一都在
WebViewPreloader.shared.preload("https://zhgchg.li/assets/preload")
經過測試,可以在流量損耗與預載中取得一個不錯的平衡 。 🎉
缺點應該是需要維護這份 Cache 資源列表,跟還是需要 Web 優化頁面渲染跟載入,不然第一個頁面出現的體感時間依然會很久。
URLProtocol ❌
另外想到我們的老朋友 URLProtocol ,所有基於 URL Loading System
的請求 (URLSession, openURL…) 都可以被攔截下來操作。
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
class CustomURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
// 判斷是否要處理這個請求
if let url = request.url {
return url.scheme == "custom"
}
return false
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
// 返回請求
return request
}
override func startLoading() {
// 處理請求並加載數據
// 改成緩存策略,先從本地讀檔案
if let url = request.url {
let response = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: -1, textEncodingName: nil)
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
let data = "This is a custom response!".data(using: .utf8)!
self.client?.urlProtocol(self, didLoad: data)
self.client?.urlProtocolDidFinishLoading(self)
}
}
override func stopLoading() {
// 停止加載數據
}
}
// AppDelegate.swift didFinishLaunchingWithOptions:
URLProtocol.registerClass(CustomURLProtocol.self)
抽象想法是在背景偷發 URLReqeust -> URLProtocol -> 從中自己下載所有資源,使用者 -> WKWebView -> Request -> URLProtocol -> 回應預載的資源。
一樣同前述,WKWebView 是跑在 App 主執行緒之外的獨立執行緒,因此 URLProtocol 是攔截不到 WKWebView 的請求的。
但聽說上黑魔法好像可以,不推薦、會延伸其他問題(送審被拒)
此路不通 ❌。
WKURLSchemeHandler 😕
蘋果在 iOS 11 推出的新方法,感覺是為了補足 WKWebView 無法使用 URLProtocol 的特型;但是這個方法跟 AVPlayer 的 ResourceLoader 比較類似, 只有系統無法辨識的 Scheme 才會丟給我們自己訂的 WKURLSchemeHandler 進行處理 。
抽象想法一樣是在背景偷發 WKWebView -> Request -> WKURLSchemeHandler -> 從中自己下載所有資源,使用者 -> WKWebView -> Request -> WKURLSchemeHandler -> 回應預載的資源。
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
import WebKit
class CustomSchemeHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
// 處理自定義
let url = urlSchemeTask.request.url!
if url.scheme == "custom-scheme" {
// 改成緩存策略,先從本地讀檔案
let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: nil)
urlSchemeTask.didReceive(response)
let html = "<html><body><h1>Hello from custom scheme!</h1></body></html>"
let data = html.data(using: .utf8)!
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// 停止
}
}
let webViewConfiguration = WKWebViewConfiguration()
webViewConfiguration.setURLSchemeHandler(CustomSchemeHandler(), forURLScheme: "mycacher")
let customURL = URL(string: "mycacher://zhgchg.li/campaign/summer")!
webView.load(URLRequest(url: customURL))
- 因為 http/https 是系統能處理的 Scheme 所以我們不能自定義 http/https 的處理;需要把 Scheme 換成系統認不得的 Scheme (e.g.
mycacher://
)。 - 頁面裡面統一都要用相對路徑才會自動套上
mycacher://
讓我們的 Handler 捕獲。 - 如果不想改 http/https 又想獲取 http/https 請求只能上黑魔法, 不推薦、 會延伸其他問題(送審被拒)
- 自行緩存頁面檔案並響應,頁面中使用的 Ajax, XMLHttpRequest, Fetch 請求可能會被 CORS 同源政策 阻擋 ,要降低網站安全性才能使用 (因為會變成 mycacher:// 發送請求打 http://zhgchg.li/xxx,不同源)
- 可能需要自己實現 Cache Policy,例如那何時該更新?有效多久? (這就跟 HTTP Cache 在做的事一樣了)
綜合以上,雖然原理上可行,但是實現上投入巨大;整體來說不符合效益並且很難擴充跟保持穩定性 😕
感覺 WKURLSchemeHandler 這方法比較適合針對網頁內有很大的資源檔案需要下載,宣告一個自訂的 Scheme 丟給 App 去處理,互相合作渲染出網頁。
橋接 WKWebView 網路請求改由 App 發送 🫥
WKWebView 改成打 App 定好的接口 (WkUserScript) 替代 Ajax, XMLHttpRequest, Fetch,由 App 去請求資源。
以此案例幫助不大,因為是第一個畫面出現的時間太慢,而不是後續加載太慢;並且此方法會造成 Web x App 有過深根奇怪的依賴關係 🫥
從 Service Worker 下手 ❌
基於安全性問題,只有蘋果自己的 Safari App 支援,WKWebView 不支援❌。
WKWebView 性能優化 🫥
優化提升 WKWebView Load View 的性能。
WKWebView 本身像是骨架、Web 頁面是血肉,研究下來優化骨架(e.g. 復用 WKProcessPool)的效果很有限,可能是 0.0003 -> 0.000015 秒的區別。
Local HTML, Local 資源檔案 🫥
類似 Preload 方式,只是改成將活動頁放入 App Bundle 或是啟動時從遠端拿。
放整個 HTML 頁面可能也會遇到 CORS 同源問題;純放網頁資源檔案感覺可以使用 「完善 HTTP Cache + WKWebView Preload 純資源」方式取代;放 App Bundle 徒增 App Size、從遠端拿就是 WKWebView Preload 🫥
前端優化下手 🎉🎉🎉
參考 wedevs 優化建議 ,前端 HTML 頁面應該會有四個載入階段,從一開始載完頁面檔案 ( .html) First Paint (空白頁) 到 First Contentful Paint (渲染出頁面骨架) 再到 First Meaningful Paint (補上頁面內容) 到 Time To Interactive(最後可讓使用者互動)。
用我們的頁面測試;瀏覽器、WKWebView 會先請求頁面本體 .html 再載入需要用到的資源,同時依照程式指引構建出畫面給使用者,對比文章發現頁面階段其實只有 First Paint (空白)到 Time To Interactive (First Contentful Paint 只有 Navigation Bar 應該不太算…),少了中間的分階段渲染給使用者,因此使用者整體等待時間會拉長。
並且目前只有資源類的檔案有設定 HTTP Cache,頁面本體沒有。
另外也可以參考 Google PageSpeed Insights 建議進行優化,例如壓縮、減少腳本大小. .等等
因為 in-app WKWebView 的核心還是 Web 頁面本身;因此從前端網頁下手調整是個很好的四兩撥千斤方式 。🎉🎉🎉
使用者體驗下手 🎉🎉🎉
一個簡單的實現,從使用者體驗下手,增加 Loading Progress Bar,不要只展示空白頁面讓使用者不知所措,讓他知道頁面正在加載中並且進度到哪裡。🎉🎉🎉
結論
以上就是本次探究 WKWebView 預載與緩存可行方案的一些發想研究,技術反而不是最大的問題,重點還是選擇,哪些方式才是對使用者最有效對開發端投入成本最低的方案,選擇這些方式可能小小改了些地方就能直接達成目標;選擇錯誤的方式會導致投入巨大的資源繞圈圈並且很有可能在後續難以維護跟使用。
辦法總比困難多,有時候是缺少想像。
說不定也有我沒想到的神級組合做法,歡迎大家協助補充。
參考資料
WKWebView Preload 純資源🎉 方案可參考以下影片
另外作者也有提到 WKURLSchemeHandler 的方法。
影片中的完整 Demo Repo 如下:
iOS 老司機週報
老司機週報中關於 WkWebView 的分享也值得一看。
雜談
久違的回歸撰寫 iOS 開發相關長篇文章。
有任何問題及指教歡迎 與我聯絡 。
===
View the English version of this article here.
本文首次發表於 Medium ➡️ 前往查看