导读 一开始我们的H5页面秒开率只有30%左右,现在我们的H5页面秒开率达到了 75%。这中间巨大的差异究竟有哪些黑科技在里面?我们为什么要做H5页面的秒开优化?我们的秒开指标是如何统计的?客户端和H5是怎么配合做到 1+1>2的?监控是如何发现H5页面可优化项的?我们又通过监控发现了哪些可优化的问题呢?
::: hljs-center
1. 背景
:::
H5秒开优化是一个老生常谈的问题,本文将逐步介绍如何通过客户端 + H5 的优化手段(1+1>2)把秒开从 30% 提升到 75% ?后续接口预请求、客户端预渲染以及预加载2.0上线后还会再次助力指标提升。
为什么要做优化?
Global Web Performance Matters for ecommerce 的报告中指出:
-
47%的用户更在乎网页在2秒内是否完成加载。
-
52%的在线用户认为网页打开速度影响到他们对网站的忠实度。
-
每慢1秒造成页面 PV 降低11%,用户满意度也随之降低降低16%。
-
近半数移动用户因为在10秒内仍未打开页面从而放弃。
整体系统架构图:
::: hljs-center
2. 指标选择
:::
首先讲一下得物用来衡量秒开的指标 FMP,那为什么不选择 FCP 或者 LCP 呢?FCP 只有要渲染就会触发,LCP 兼容性不佳,得物希望站在用户的角度来衡量秒开这件事情,用户从点击打开一个WebView到首屏内容完整的呈现出来的时间点就是得物定义的FMP触发时机。
指标清楚了之后,再来看一下完整的 FMP 包含哪些耗时。
接下来将分为两大部分进行介绍,客户端优化部分和H5 优化部分。
::: hljs-center
3. 客户端优化
::: 通过 HTML 预加载、HTML 预请求、离线包、接口预请求、链接保活、预渲染等手段提升页面首屏打开速度,其中预加载、预请求、离线包分别可提升10%左右的秒开。
3.1 HTML预加载
通过配置由客户端提前下载好HTML主文档,当用户访问时直接使用已经下载好的HTML文档,以此减少HTML网络请求时间,从而提升网页打开速度。
3.1.1 如何确定需要下载的页面
前人栽树后人乘凉,得物App有很多的资源位,banner、金刚位、中通位等,这些位置显示什么内容,早就已经是智能推荐算法产出的了,那么就可以直接指定这些资源位进行预加载。
3.1.2 页面缓存管理
页面被预加载之后,总不能一直不更新吧?那么什么时候更新页面的缓存呢?
-
预加载的页面存放于内存中,关闭App缓存就会被清除
-
通过配置过期时间人为控制最大缓存时间
-
在页面进入后发起异步线程去更新HTML文档。
被现实打脸:
但是在后面的灰度过程中被现实狠狠的教育了一顿,发现有些SSR的页面会涉及到状态的变更,比如说:领劵场景。这些状态都是经过SSR服务渲染好的,用户在进入页面时还没有领劵,这个时候去更新HTML文档,实属更新了个寂寞,在用户领劵之后关闭页面再次进入,发现页面中的状态仍是让用户领劵,点击领劵又告诉人家你已经领过了。
改进措施
- H5 页面在打开时针对状态可能会发生变更的组件,再次请求接口获取最新的状态数据。
- 客户端由进入页面就更新HTML文档修改为:关闭WebView时更新HTML文档。
3.1.3 线上收益效果
至此问题也解决了,工程师的任务结束了吗?如果你认为功能做上去就算结束,那么此时此刻请你一定要转变思维,想一想我们的目标是什么?我们的目标是「提升秒开」,预加载只是一种提升秒开的手段,但是在功能做上去之后并不知道这个功能带来了多少秒开的收益,因此在把功能开发完成上线之后,就要开始关注上线之后的结果,来看看预加载的性能表现如何。从下图可以看到,预加载开启状态下可提升10%以上的秒开率。
3.1.4 遇到的挑战
预加载的页面基本上都是 SSR 服务的页面,预加载无形中造成了大量的请求,此时得物的SSR服务扛不住这么大的请求量;
即使SSR 服务扛得住,也会对后端整个服务链路造成压力。
(1)SSR服务扩容
要解决服务器压力问题,很自然就会想到增加机器,于是我们对SSR机器数量做了一次扩容,将机器数量提升了一倍,这个时候继续尝试扩大预加载的用户数量,但是仍然无法抗住这么大的QPS,而且此时还引发了第二个问题,算法部门的服务器发出了告警,于是放量计划又一次遇到了阻碍。
(2)破局者 CDN
利用CDN 服务器的缓存能力既可以减轻 SSR 服务器的压力又可以减少后端服务链路的压力,这么好的东西为什么不用呢?这里留个悬念,后面将H5优化部分会详细介绍。
(3)客户端配合改造
支持针对CDN域名进行全部开放预加载能力,针对非CDN域名保持原有放量比例。
3.1.5 开屏页预加载
在这个过程中还分析了页面的流量占比,发现开屏广告来源的页面流量占比也很高,那么是不是可以把开屏广告的HTML文档内容也给预加载下来呢?
开屏页面预加载策略
对预加载列表进行去重,开屏广告列表中可能会存在重复的页面,他们的背景图和生效时间是不同的;
增加了生效时间相关配置,开屏广告列表中存在于未来某段时间才会展示的页面;
添加黑白名单控制,开屏广告列表中可能会有第三方合作页面,他们不希望预加载统计会造成PV时不准确。
3.1.6 预加载展望
既然可以提前下载好HTML,那是不是可以更进一步,提前把页面内的资源加载好,这样在打开一个页面的时候可以减少大部分的网络请求从而更快速的把内容呈现给用户。这里还需要考虑如何跟下面讲到的离线包进行协作。
3.2 HTML预请求
在WebView初始化同时,去请求HTML主文档,等待HTML文档下载完成 且 WebView初始成功后渲染,减少用户等待时间,客户端请求成功后,WebView加载本地 HTML,并保存以供下次使用。预请求HTML开启状态下可提升8%左右的秒开。
预请求 VS 预加载
本质上HTML预加载和HTML预请求的区别就是下载HTML文档的时机不同, 预加载是在App启动后用户无任何操作的情况下就会去下载,但是预请求只会在用户单击打开H5页面的时候才会去下载。如果用户是第二次打开某个H5页面,此时发现本地有已经下载好的HTML且尚未过期就会直接使用,这个时候的行为表现就跟预加载的功能是一致的了。
3.2.1 遇到挑战
上线之后发现预请求只提升了2%左右的秒开,经过分析发现问题:
- 缓存有效时间太短,页面过期时间只配置了10分钟,也就是说在10分钟之后用户就要重新去下载一次,那能不能把缓存时间延长呢?
- H5页面是没有自更新能力,无法支持配置更长的缓存时间,跟预加载HTML问题一致。
3.2.2 深入挖掘
在本地用低端机对整个秒开耗时链路进行了分析,为什么要用低端机分析呢?低端机有个好处,天然的加上了慢放功能,可以最大程度发现问题。
(1)安卓 h5 页面加载与原生布局填充并行执行
从图中可以看出h5 页面加载之前 耗时 分布在 activityStart() 函数,该函数 包含了 onCreateView ,其中耗时最长是 布局填充 inflate(),因为 WebView 对象是提前创建好的,直接从对象池中取出的,所以耗时主要在 初始化过程,WebView 自身的初始化 WebViewChromiumFactoryProvider. startYourEngines (耗时 87 us,不到 1 ms),耗时还有 WebView 的一些其他初始化,jockey 的初始化 等等。
而秒开的计算是包含了 View 初始化到 WebVIew url 加载 的耗时,从而发现了优化点,可以将Webview loadUrl 前置,h5 页面加载 与 原生布局填充并行执行。在 onCreateView 时,创建 FrameLayout 进行返回,执行 WebView loadUrl 之后,主线程开始 对布局进行 inflate,布局加载成功后,将其 addView 到 FrameLayout 中,减少了 loadUrl 的阻塞时长。中高端机型有 15ms 左右优化,低端机型有 30 ~ 50 ms 优化 效果。
(2)双端下载HTML的时机提前至路由阶段
预请求HTML时机是在进入到 native 页面中,这个时间点距离用户单击事件已经过去100ms,那么是否可以将下载HTML的时机提前呢?经过一番探索,最终选择在路由阶段进行拦截,既可以统一收口而且距离用户点击的时间间隔可以忽略不计。通过这种方式将下载HTML时机提前了平均80ms+。
此时的流程变成了下面这样。
可能有的同学会问了,为什么不在用户点击的时候去下载呢?从用户点击到路由肯定还是有耗时的。
代码层面不好维护,如果在点击时就需要侵入到业务层面,入口千千万,很难进行维护管理;
从点击到路由这部分耗时在线下进行了性能测试,几乎可以忽略不计。
3.2.3 最终线上收益效果
在上述问题解决后,将缓存时间修改为1天,发现预请求HTML开启状态下可提升8%左右的秒开,已经和预加载的效果相差不大了。
3.3 离线包
通过提前将H5页面内所需的css、js等资源聚合在一个压缩包内,由客户端在App启动后进行下载解压缩,在后续访问H5页面时,匹配是否有本地离线资源,从而加速页面访问速度。
3.3.1 安卓实现
资源拦截这块安卓这边实现比较简单,WebView支持 shouldInterceptRequest, 可以在该方法内检测是否需要进行资源拦截,需要的话返回 WebResourceResponse 对象,不需要直接返回 null。
3.3.2 iOS 实现
但是在iOS 这边遇到了一些困难,调研了以下方案:
方案一:NSURLProtocol 拦截方式
NSURLProtocol 拦截方式,使用WKBrowsingContextController和registerSchemeForCustomProtocol。通过反射的方式拿到了私有的 class/selector。通过把注册把 http 和 https 请求交给 NSURLProtocol 处理。通过这种方式确实可以拦截请求,但是发现post请求的body会出现丢失的问题。而且NSURLProtocol一经注册就是全局开启。我们希望他只会拦截接入了离线包的页面,但是没有办法控制他,他会拦截所有页面的请求,包括第三方合作页面,显然这是无法接受的。
方案二:通过CustomProtocol拦截请求
在iOS 11及以上系统中, 拥有了加载自定义资源的API:WKURLSchemeHandler。
可以修改当前页面url为自定义 scheme 协议,比如:https://fast.dewu.com 修改为 duapp://fast.dewu.com 然后在客户端内注册该 scheme,前端配合修改页面内所有的资源请求未自适应协议,如:src="//fast.dewu.com/xxx" 就可以实现拦截。但是在测试过程中发现,接口为了安全起见只允许白名单内的域名发起跨域请求,且无法配置多个域名,导致该方案无法继续进行。
方案三:hook handlesURLScheme
仍然是使用 WKURLSchemeHandler 然后通过 hook WKWebview 的 handlesURLScheme 方法来支持 http 和 https 请求的代理。通过这种方式虽然可以拦截请求了,但是遇到了以下问题:
(1)body丢失问题
不过在 iOS 11.3 之后对这种情况做了修复处理,只有 blob 类型的数据会丢失。需要由JS来代理 fetch 和 XMLHttpRequest 的行为,在请求发起时将 body 内容通过 JSBridge 告知 native,并将请求交给客户端进行发起,客户端在请求完成后 callback js 方法。
(2)Cookie 丢失、无法使用问题
通过代理 document.cookie 赋值和取值动作,交由客户端来进行管理,但是这里需要额外注意一点,需要做好跨域校验,防止恶意页面对cookie进行修改。
3.3.3 遇到挑战
至此功能开发完成上线,先来一组线上收益数据,安卓开启离线情况有有10%左右的收益,但是iOS开启离线的反而秒开率更低。经过修复处理后iOS也可提升10%以上的秒开。
安卓和iOS实现差异
经过分析对比发现,安卓的拦截动作比较轻,可以判断是否需要拦截,不需要拦截可以交给WebView自己去请求。
但是iOS这边一旦页面开启拦截后,页面中所有的http和https请求都会被拦截掉,由客户端发起请求进行响应,无法将请求交还给WebView自己去发起。
iOS 缓存问题修复
页面中的资源经过客户端请求代理后原本第二次打开WebView本身会使用缓存的内存,现在缓存也失效了,于是只能在客户端内实现了一套缓存机制。
根据 http 协议来判定哪些资源可以被缓存以及缓存的时长
添加自定义的控制策略,仅允许部分类型的资源进行缓存