Post

iOS WKWebView 预载与 Cache 缓存技术|提升页面载入速度与资源管理全解析

针对 iOS WKWebView 使用者面临载入缓慢问题,深入解析 HTTP Cache 与预载技术,结合纯资源预载与 CachePolicy 设定,实现流量与效能平衡,加速页面响应并提升使用体验。

iOS WKWebView 预载与 Cache 缓存技术|提升页面载入速度与资源管理全解析

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS WKWebView 页面与档案资源 Preload 预载 / Cache 缓存研究

iOS WKWebView 预先下载与缓存资源提升页面载入速度研究。

Photo by [Antoine Gravier](https://unsplash.com/@antoine_gravphotos?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash){:target="_blank"}

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 大致运作流程如上图:

  1. Client 发起请求

  2. Server 响应 Cache 策略在 Response Header,系统 URLSession, WKWebView… 会依照 Cache Header 自动帮我们将 Response 缓存下来,后续请求也会自动套用这个策略

  3. 再次请求相同资源时,如果缓存未过期则直接从记忆体、磁碟读取本地缓存直接回应给 App

  4. 如果已过期(过期不代表无效),则发起真实网路请求问 Server,如果内容没更改 (虽过期待仍有效) Server 会直接回应 304 Not Modified (Empt Body),虽然真的有发起网路请求但是基本上是毫秒回应+无 Response Body 没什么流量耗损

  5. 如果内容有更改则重新给一次资料跟 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

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 🫥

前端优化下手 🎉🎉🎉

[Source: wedevs](https://wedevs.com/blog/348939/first-contentful-paint-largest-contentful-paint/){:target="_blank"}

Source: wedevs

参考 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 开发相关长篇文章。

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.