WKWebView 的性能优化
起因
随着移动设备性能不断增强,web 页面的性能体验逐渐变得可以接受,又因为 web 开发模式的诸多好处(跨平台,动态更新,减体积,无限扩展),APP 客户端里出现越来越多内嵌 web 页面),很多 APP 把一些功能模块改成用 H5 实现。
虽然说 H5 页面性能变好了,但如果没针对性地做一些优化,体验还是很糟糕的,主要两部分体验:
- 页面启动白屏时间:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
- 响应流畅度:由于 webkit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。
由于以上原因,公司准备从第一点入手,做 webview 的优化项目(达到秒开 webview )。因为UIWebView
在 iOS12 就被标记废弃了,所以决定先从WKWebView
入手研究。
思路
webview 加载过程
打开一个页面的过程有很多优化点,包括前端和客户端,常规的前端和后端的性能优化已有前辈们总结过最佳实践,主要的是:
- 降低请求量:合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
- 加快请求速度:预解析DNS,减少域名数,并行加载,CDN 分发。
- 缓存:HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存 localStorage。
- 渲染:JS/CSS优化,加载顺序,服务端渲染模板直出。
- 客户端:预请求web所需数据
大家可以看出,主要是第三阶段之前,用户看到的页面一直处于白屏。首先要优化的就是这段时间。
打开一个页面的过程有很多优化点,包括前端和客户端,常规的前端和后端的性能优化已有前辈们总结过最佳实践,主要的是:
下面只讲客户端优化部分:
减少第一阶段耗时
- 在使用前预先初始化好 webView,从而减小耗时。
- 在初始化的同时,通过 Native 来完成一些网络请求等过程,使得 webView 初始化不是完全的阻塞后续过程。
- webview 池,可以用两个或多个 webview 重复使用,而不是每次打开 H5 都新建 webview。
减少第二阶段耗时
- 离线包
- 预先下载离线包,可以达到立即展示的效果。
- 离线包可以很方便地根据版本做增量更新。
- 离线包以压缩包的方式下发,同时会经过加密和校验,防止运营商和第三方对其劫持篡改。
- 数据缓存
- 第一次打开会有延迟,但是后续打开就会很快
- 可以自己控制缓存,方便管理
- 客户端代替请求
- 客户端可以在网络请求上做像 DNS 预解析/ IP 直连/长连接/并行请求等更细致的优化
难点
方案是通用的,不区分 UIWebView 和 WKWebView,但是目前很少有以 WKWebView 为目标的方案,那么以上技术方案在 WKWebView 中实现有什么难点呢?
难点在 NSURLProtocol
WKWebView 无法使用 NSURLProtocol 拦截 http 请求
这个问题网上早有方案:
[WKBrowsingContextController registerSchemeForCustomProtocol:@"schemes"];
WKWebView 使用 NSURLProtocol 拦截后,HTTPBody的数据会丢失
从网上克隆了 webkit 进行编译调试,尝试解决 Body 丢失的问题(体验到了啥叫大型项目的编译速度)
从图上重点标注的地方可以看到:
WKWebView 的网络请求是在另外一个进程中操作的,然后如果 app 主进程需要拦截请求的话,通过 XPC 来进行两个进程间的通信。
苹果出于性能或其他考虑,会在给主进程的 URLProtocol 传输请求时将
HTTPBody
和HTTPBodyStream
置为 nil 。
源代码
尝试解决方案:
- 使用 runtime 黑魔法,在其将 HTTPBody 置为 nil 之前,先保存下来?
因为网络请求是在其他进程中操作,没有办法在主进程使用 runtime 进行拦截。也就是说在 app 中决定拦截 http 请求的那一刻起,拦截到的请求注定是没有 HTTPBody 的。 - 使用 任何方式进入到 Networking 进程做一些操作 ?
尝试了 Mac 端的 XPC demo,XPC 的回调是在各自进程,是不能操作其他进程的。 - 在
HTTPBody
置为 nil 之前,是否会有代码走到主进程,然后拿到 request 进行操作?
抱歉,经过测试,在HTTPBody
置为 nil 之前,主进程不会收到关于 request 的调用
解决
难到就没有任何方法解决了么?无意中看到一个特别有趣的想法又点燃了我的希望。
既然 Networking 进程会将 HTTPBody
置为 nil ,那我要做的就是两点:
1. 不让其置为 nil
2. 或者在其置为 nil 之前,先将 HTTPBody
保存下来
第一点:上面已经尝试失败;第二点:在 native 端也尝试失败,那在 H5 侧做保存操作呢?
要拦截的是 H5 的请求,那说明 H5 侧肯定是知道请求参数的。
尝试 H5 与 native 结合来解决 HTTPBody
丢失问题
H5 发起发起请求有三种方式:
1. Form
2. XMLHttpRequest
3. Fetch
Fetch
是在 iOS10 以后支持的,从通用场景看,只需要处理 Form
和 XMLHttpRequest
发起的带 HTTPBody
的请求就可以.
基本所有 H5 开发者,肯定知道 H5 里面也有黑魔法,就是原型:
XMLHttpRequest
对应的是 XMLHttpRequest.prototype.send
方法
Form
对应的是 HTMLFormElement.prototype.submit
方法
我们对以上方法使用 WKUserScript
在 WKUserScriptInjectionTimeAtDocumentStart
时机做对应拦截。这样 H5 在发起请求前,先将 POST 的数据发送给 native 存储(WKScriptMessageHandler
)。然后在 native 拦截到匹配到的请求,尝试接管,并重新设置 HTTPBody
,而且由于拦截到的是 request ,只需要补齐HTTPBody
,其他在 h5 中原本对 request 做的各种操作也是存在的,这样就能解决问题了
这个方法提供了一种解决HTTPBody
丢失问题的可能,并且大部分 app,使用应该完全够用。本人已经按照上述方案实现,并接入到 app 中,在解决了一些细节问题后,将各个流程中的 H5 页面走了一遍,目前没有发现不支持的请求。
参考:
WebView性能、体验分析与优化
移动 H5 首屏秒开优化方案探讨
IMYWebLoader
VasSonic