Webview加载H5优化小记

行到水穷处,坐看云起时

一、概述

1、背景
  • 鉴于H5的优势,客户端的很多业务都由H5来实现,Webview成了App中H5业务的唯一载体。
  • WebView组件是iOS组件体系中非常重要的一个,之前的UIWebView 存在严重的性能和内存消耗问题,iOS 8之后推出WKWebView,旨在代替UIWebView;
  • WKWebView在性能、稳定性、内存占用上有很大的提升,支持更多的HTML5特性,高达60fps的滚动刷新率以及内置手势;可以通过KVO监控网络加载的进度,获取网页title;
  • 实践中,大部分App的H5业务将由WKWebview承载。
2、H5页面的体验问题

从用户角度,相比Native页面,H5页面的体验问题主要有两点:

  • 页面打开时间慢:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  • 响应流畅度较差:由于 WebKit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

这里讨论的是:第一点,怎样减少白屏时间。

二、Webview打开H5

通过Webview打开H5页面,请求并得到 HTML、CSS 和 JavaScript 等资源并对其进行处理从而渲染出 Web 页面。

1、加载流程
  • 初始化Webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 ->DOM 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片-> 页面完整展示

H5页面加载流程.png

  • DOM渲染之前耗时主要在两部分:初始化Webview数据请求,一般Webview首次初始化在400ms这个量级,二次加载能少一个量级。
  • 数据请求依赖网络,网络请求一般经过:DNS查询、TCP 连接、HTTP 请求和响应。数据包括HTML、JS和CSS资源,这些都是在webview在loadRequest:之后做的,这一阶段,用户所见到的都是白屏。(虽然4G已经成为主流,但是4G延迟明显高于Wifi)。
2、H5页面渲染

对H5页面的渲染,主要包括:渲染树构建、布局及绘制,具体可分为:

  • 处理 HTML 标记并构建 DOM 树。

  • 处理 CSS 标记并构建 CSSOM(CSS Object Model) 树。

  • 将 DOM 与 CSSOM 合并成一个渲染树。

  • 根据渲染树来布局,以计算每个节点的几何信息。

  • 将各个节点绘制到屏幕上。

说明:这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS 与 JavaScript 往往会多次修改 DOM 和 CSSOM。具体参考:DOM渲染机制与常见性能优化

3、总结
  • 分析Webview打开H5打开的过程,我们发现,在H5优化中,前端重任在肩;
1
2
3
4
降低请求量:合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
加快请求速度:预解析DNS,减少域名数,并行加载,CDN 分发。
缓存:HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存localStorage。
渲染:JS/CSS优化,加载顺序,服务端渲染,pipeline。
  • 但是客户端也很重要,主要优化DOM渲染之前这些事情,可以做有:减少DNS时间预初始化WebView 以及 HTML、JS、CSS等资源离线下载
  • 列举在某业务中笔者实践过的比较trick的优化方案,然后再引出笔者认为理想的方案。

二、WebView的客户端优化(trick版)

由于是接入第三方的H5页面,接入离线包方案,需要比较繁杂的商务沟通和技术挑战(业务逻辑和代码超级诡异),临时采用如下优化方案

1、预加载资源
  • 将首页面需要的JS文件CSS文件等资源放在一个URL地址(和业务url同域名);
  • 启动App后,间隔X秒去加载;加载的策略是,检查当前和上一次间隔时间,超时则加载,有效期忽略预加载请求。
2、预初始化Webview
  • 首次初始化Webview,需要初始化浏览器内核,需要的时间在400ms这个量级;二次初始化时间在几十ms这个量级;

  • 根据此特征:选择在APP 启动后X秒,预创建(初始化)一个 Webview 然后释放,这样等使用到 H5 模块,再加载 Webview时,加载时间也少了不少。

  • 结合步骤一中预加载公共资源,也需要Webview,所以选择在加载公共资源包时候,首次初始化Webview,加载资源,然后释放。

3、最终方案(迫不得已)

​ 由于第三方业务H5很多问题,和人力上不足;不得不需要客户端强行配合优化,在产品的要求下,不得不采用如下方案,方案的前提是:业务H5尽可能少修改,甚至不修改,客户端还要保证首屏加载快;

  • 预加载资源
  • 预创建Webview并加载首页H5,驻留在内存中,需要的时候,立刻显示。
4、方案的后遗症
  • 我不建议这种trick做法,因为自从开了这个口子,后续很多H5需求不走之前既定的离线包方案,在内存中预创建多个Webview (最多4个),加载H5时候不用新建Webview,从Webview池中获取;
  • 此种Webview池方案带来诸多隐患:内存压力、诡异的白屏、JS造成的内存泄露,页面的清空等等问题(填坑填到掉头发)。

三、离线包方案

1、概述
  • 离线包方案才是业务主流的H5加载优化方案,非常建议在客户端团队和前端团队推广,类似预创建Webview加载H5不应该成为主流。

  • 将每个独立的H5功能模块,相关HTML、Javascript、CSS 等页面内静态资源打包到一个压缩包内,客户端可以下载该离线包到本地,然后打开Webview,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。

  • 离线包可以提升用户体验(页面加载更快),还可以实现动态更新(在推出新版本或是紧急发布的时候,可以把修改的资源放入离线包,通过更新配置让应用自动下载更新)
2、方案描述

引用bang的离线包方案,简单描述如下

  • 后端使用构建工具把同一个业务模块相关的页面和资源打包成一个文件,同时对文件加密/签名。

  • 客户端根据配置表,在自定义时机去把离线包拉下来,做解压/解密/校验等工作。

  • 根据配置表,打开某个业务时转接到打开离线包的入口页面。

  • 拦截网络请求,对于离线包已经有的文件,直接读取离线包数据返回,否则走 HTTP 协议缓存逻辑。

  • 离线包更新时,根据版本号后台下发两个版本间的 diff 数据,客户端合并,增量更新。

说明:目前WKWebView已经能成为主流,但是WKWebView在实现离线包方案时,拦截网络请求有坑。

3、WKWebView拦截网络请求的坑
  • 虽然NSURLProtocol可以拦截监听每一个URL Loading System中发出request请求,记住是URL Loading System中那些类发出的请求,也支持AFNetwoking,UIWebView发出的request,NSURLProtocol都可以拦截和监听。
  • 因为WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。
  • 但是在 WebKit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC(进程间通信) 发送给 App Process。出于性能的原因,encode 的时候 将HTTPBody 和 HTTPBodyStream 这两个字段丢弃掉()
  • 因此,如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发起的所有 http(s)请求都会通过 IPC 传给主进程 NSURLProtocol 处理,导致 post 请求 body 被清空
1
2
3
4
5
6
7
8
9
10
11
//苹果开源的 WebKit2 源码暴露了私有API:
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]

//通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求:
Class cls = NSClassFromString(@"WKBrowsingContextController”);
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}

说明1:名目张胆使用私有API,是过不了AppStore审核的,具体使用什么办法,想来你也懂(hun xiao)。

说明2:一旦打开ATS开关:Allow Arbitrary Loads 选项设置为NO,通过 registerSchemeForCustomProtocol 注册了 http(s) scheme,WKWebView 发起的所有 http(s) 网络请求将被阻塞(即便将Allow Arbitrary Loads in Web Content 选项设置为YES);

说明3:iOS11之后可以通过WKURLSchemeHandler去完成对WKWebView的请求拦截,不需要再调用私有API解决上述问题了。

4、WKWebView自定义资源scheme
  • 向WKWebView 注册 customScheme, 比如 dynamic://, 而不是https或http,避免对https或http请求的影响
  • 保证使用离线包功能的请求,没有post方式,遇到customScheme请求,比如dynamic://www.dynamicalbumlocalimage.com/,通过 NSURLProtocol 拦截这个请求并加载离线数据。
  • iOS 11上, WebKit 提供的WKURLSchemeHandler可实现拦截,需要注意的只允许开发者拦截自定义 Scheme 的请求,不允许拦截 “http”、“https”、“ftp”、“file” 等的请求,否则会crash。

四、其他

1、LocalWebServer
  • 离线包方案中,除了拦截请求加载资源的方式,还有种在项目中搭建local web server,用以获得本地资源。市面有比较完善的框架
1
2
3
CocoaHttpServer (支持iOS、macOS及多种网络场景)
GCDWebServer (基于iOS,不支持 https 及 webSocket)
Telegraph (Swift实现,功能较上面两类更完善)
2、WKWebView loadRequest 问题
  • 在 WKWebView 上通过 loadRequest 发起的 post 请求 body 数据会丢失:
1
2
3
4
//同样是由于进程间通信性能问题,HTTPBody字段被丢弃
[request setHTTPMethod:@"POST"];
[request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]];
[wkwebview loadRequest: request];

解决:假如想通过-[WKWebView loadRequest:]加载 post 请求 (原始请求)request1: h5.nanhua.com/order/list,可以通过以下步骤实现:

  • 替换请求 scheme,生成新的 post 请求 request2: post://h5.nanhua.com/order/list, 同时将 request1 的 body 字段复制到 request2 的 header 中(WebKit 不会丢弃 header 字段);
  • 通过-[WKWebView loadRequest:] 加载新的 post 请求 request2;
  • 并且通过 +[WKBrowsingContextController registerSchemeForCustomProtocol:]注册 scheme: post://;
  • 注册 NSURLProtocol 拦截请求 post://h5.nanhua.com/order/list ,替换请求 scheme, 生成新的请求 request3: h5.nanhua.com/order/list,将 request2 header的body 字段复制到 request3 的 body 中,并使用 NSURLSession 加载 request3,最后将加载结果返回 WKWebView;
3、推荐资料
文章作者: 南华coder
文章链接: http://buaa0300/nanhuacoder.com/2019/04/11/iOS-WKWebView02/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 南华coder的空间