导读:一开始我们的H5页面秒开率只有30%左右,现在我们的H5页面秒开率达到了75%。这中间巨大的差异究竟有哪些黑科技在里面?我们为什么要做H5页面的秒开优化?我们的秒开指标是如何统计的?客户端和H5是怎么配合做到1+1>2的?监控是如何发现H5页面可优化项的?我们又通过监控发现了哪些可优化的问题呢?
H5秒开优化是一个老生常谈的问题,本文将逐步介绍如何通过客户端+H5的优化手段(1+1>2)把秒开从30%提升到75%?后续接口预请求、客户端预渲染以及预加载2.0上线后还会再次助力指标提升。
为什么要做优化?
GlobalWebPerformanceMattersforecommerce的报告中指出:
整体系统架构图:
指标清楚了之后,再来看一下完整的FMP包含哪些耗时。
接下来将分为两大部分进行介绍,客户端优化部分和H5优化部分。
通过HTML预加载、HTML预请求、离线包、接口预请求、链接保活、预渲染等手段提升页面首屏打开速度,其中预加载、预请求、离线包分别可提升10%左右的秒开。
前人栽树后人乘凉,得物App有很多的资源位,banner、金刚位、中通位等,这些位置显示什么内容,早就已经是智能推荐算法产出的了,那么就可以直接指定这些资源位进行预加载。
页面被预加载之后,总不能一直不更新吧?那么什么时候更新页面的缓存呢?
被现实打脸:
但是在后面的灰度过程中被现实狠狠的教育了一顿,发现有些SSR的页面会涉及到状态的变更,比如说:领劵场景。这些状态都是经过SSR服务渲染好的,用户在进入页面时还没有领劵,这个时候去更新HTML文档,实属更新了个寂寞,在用户领劵之后关闭页面再次进入,发现页面中的状态仍是让用户领劵,点击领劵又告诉人家你已经领过了。
改进措施
(1)SSR服务扩容
要解决服务器压力问题,很自然就会想到增加机器,于是我们对SSR机器数量做了一次扩容,将机器数量提升了一倍,这个时候继续尝试扩大预加载的用户数量,但是仍然无法抗住这么大的QPS,而且此时还引发了第二个问题,算法部门的服务器发出了告警,于是放量计划又一次遇到了阻碍。
(2)破局者CDN
利用CDN服务器的缓存能力既可以减轻SSR服务器的压力又可以减少后端服务链路的压力,这么好的东西为什么不用呢?这里留个悬念,后面将H5优化部分会详细介绍。
(3)客户端配合改造
支持针对CDN域名进行全部开放预加载能力,针对非CDN域名保持原有放量比例。
开屏页面预加载策略
既然可以提前下载好HTML,那是不是可以更进一步,提前把页面内的资源加载好,这样在打开一个页面的时候可以减少大部分的网络请求从而更快速的把内容呈现给用户。这里还需要考虑如何跟下面讲到的离线包进行协作。
预请求VS预加载
本质上HTML预加载和HTML预请求的区别就是下载HTML文档的时机不同,预加载是在App启动后用户无任何操作的情况下就会去下载,但是预请求只会在用户单击打开H5页面的时候才会去下载。如果用户是第二次打开某个H5页面,此时发现本地有已经下载好的HTML且尚未过期就会直接使用,这个时候的行为表现就跟预加载的功能是一致的了。
上线之后发现预请求只提升了2%左右的秒开,经过分析发现问题:
在本地用低端机对整个秒开耗时链路进行了分析,为什么要用低端机分析呢?低端机有个好处,天然的加上了慢放功能,可以最大程度发现问题。
从图中可以看出h5页面加载之前耗时分布在activityStart()函数,该函数包含了onCreateView,其中耗时最长是布局填充inflate(),因为WebView对象是提前创建好的,直接从对象池中取出的,所以耗时主要在初始化过程,WebView自身的初始化WebViewChromiumFactoryProvider.startYourEngines(耗时87us,不到1ms),耗时还有WebView的一些其他初始化,jockey的初始化等等。
而秒开的计算是包含了View初始化到WebVIewurl加载的耗时,从而发现了优化点,可以将WebviewloadUrl前置,h5页面加载与原生布局填充并行执行。在onCreateView时,创建FrameLayout进行返回,执行WebViewloadUrl之后,主线程开始对布局进行inflate,布局加载成功后,将其addView到FrameLayout中,减少了loadUrl的阻塞时长。中高端机型有15ms左右优化,低端机型有30~50ms优化效果。
此时的流程变成了下面这样。
可能有的同学会问了,为什么不在用户点击的时候去下载呢?从用户点击到路由肯定还是有耗时的。
通过提前将H5页面内所需的css、js等资源聚合在一个压缩包内,由客户端在App启动后进行下载解压缩,在后续访问H5页面时,匹配是否有本地离线资源,从而加速页面访问速度。
资源拦截这块安卓这边实现比较简单,WebView支持shouldInterceptRequest,可以在该方法内检测是否需要进行资源拦截,需要的话返回WebResourceResponse对象,不需要直接返回null。
但是在iOS这边遇到了一些困难,调研了以下方案:
在iOS11及以上系统中,拥有了加载自定义资源的API:WKURLSchemeHandler。
(1)body丢失问题
不过在iOS11.3之后对这种情况做了修复处理,只有blob类型的数据会丢失。需要由JS来代理fetch和XMLHttpRequest的行为,在请求发起时将body内容通过JSBridge告知native,并将请求交给客户端进行发起,客户端在请求完成后callbackjs方法。
(2)Cookie丢失、无法使用问题
通过代理document.cookie赋值和取值动作,交由客户端来进行管理,但是这里需要额外注意一点,需要做好跨域校验,防止恶意页面对cookie进行修改。
至此功能开发完成上线,先来一组线上收益数据,安卓开启离线情况有有10%左右的收益,但是iOS开启离线的反而秒开率更低。经过修复处理后iOS也可提升10%以上的秒开。
安卓和iOS实现差异
经过分析对比发现,安卓的拦截动作比较轻,可以判断是否需要拦截,不需要拦截可以交给WebView自己去请求。
iOS缓存问题修复
页面中的资源经过客户端请求代理后原本第二次打开WebView本身会使用缓存的内存,现在缓存也失效了,于是只能在客户端内实现了一套缓存机制。
从下图可以看到离线包的下载错误率在6%左右浮动,这么高的下载错误率肯定是无法接受的,经过一系列优化手段,把离线包下载错误率从6%左右浮动下降至0.3%左右浮动。
先来看下优化前的流程图和问题点。
通过埋点发现大量unknownhost、网络请求失败、网络连接断开的情况。分析代码发现下载未做队列控制,会同时并发下载多个离线包,从而导致多个下载任务争抢资源的情况。针对发现的问题点做出了以下优化:
下面是优化之后的流程图:
针对离线资源是直接存储在磁盘上的,每次访问都会有磁盘IO耗时,经过在低端机器上做测试发现这个耗时会在0-10ms之间进行波动,后面会把内存合理的利用起来,通过设置内存上限,文件数量上限,甚至是文件类型,并通过LRU策略进行内存文件的淘汰更新。
客户端会在App启动后获取配置,保存支持预请求的页面地址及对应的接口信息,在用户打开WebView时,会并行发起对应预请求的接口,并保存结果。当JS执行开始获取首屏数据时,会先询问客户端是否已经存有对应的响应数据,如果此时已经拿到数据则无需发起请求,否则js也会发起接口请求并开启竞速模式。以下是整体流程图:
那么客户端怎么知道这个页面需要请求什么接口呢?以及接口的参数是什么呢?那自然少不了配置平台,它支持以下功能:
首先即使是在SSR的情况下,首屏内容中仍然可能有部分组件是骨架直出的,需要等待页面渲染执行时才会去请求数据,另外还有一部分页面是SPA的。针对这两种情况都能做到很好的补充。
开启后DNS90分位耗时从80ms降至0ms,TCP建连90分位耗时从65ms分位耗时降为0,DNS平均耗时从55ms降为4.3ms,TCP建连平均耗时从30ms降为2.5ms。
可以通过对域名链接提前发起一个HEAD请求从而建立链接,网络框架会自动将连接放入连接池。并在默认无操作5分钟后进行释放,在五分钟内重复执行上述动作即可一直保持链接。
另外这里需要注意下连接池的数量问题,如果连接池的数据太小,但是域名比较多的话,通过预建连保持的链接很容易就会被释放掉,这就需要通过域名收敛或者调大连接池的数量来进行优化。
那预建连会不会增加服务器的压力呢?这个肯定是会的,首先会针对预建连功能本身就行灰度策略,在HTML页面通过CDN托管后,直接针对cdn域名进行全量开启,从而不用担心cdn域名扛不住压力。
来看一下线上效果,通过下图可以看到在开启后DNS90分位耗时从80ms降至0ms,TCP建连90分位耗时从65ms分位耗时降为0,DNS平均耗时从55ms降为4.3ms,TCP建连平均耗时从30ms降为2.5ms。
客户端提前通过WebView将页面渲染好,等待用户访问时,可直接展示。从而达到瞬开效果。但是这种功能肯定不能对所有的页面进行开放,而且存在一定的弊端。
下图【开学季】是业务上已经进行预渲染的H5页面,可以看到在打开【开学季】页面时,页面已经渲染完毕,丝毫没有等待过程。
后面计划把这种能力放大到通用的webview上,针对大促以及PV量高的页面进行开放。
SSR服务端渲染(英语:serversiderender)一般情况下,一个H5页面的数据渲染完全由客户端来完成,先通过AJAX请求到页面数据并把相应的数据填充到模板,形成完整的页面来呈现给用户。而服务端渲染把数据的请求和模板的填充放在了服务端,并把渲染的完整的页面返回给客户端。
SSR对于秒开有平均15%的提升,既然是服务端渲染,就会对服务器造成压力,尤其是在预加载HTML功能开启后,那得物是如何解决的呢?
通过这么多优化手段仍然无法满足预加载的需求,并且通过分析发现网络阶段耗时较长,最终还是搬出了CDN这个大杀器,一直没上CDN的原因有很多,主要有以下几方面:
(1)得物的页面是千人千面的每个人看到的内容都不同
通过上述优化4即可解决,将原本SSR渲染的内容修改CSR,由于已经上了CDN了,后续计划将这部分内容再次修改回SSR,这样用户可以更快的看到商品而不是骨架,然后通过CSR的方式来更新内容。
(2)页面状态变更,无法及时更新缓存
这个问题在上述客户端预加载优化部分已经有解决方案了,可以通过在页面打开后针对有需要的组件再次请求接口刷新数据以确保数据的准确性,但是这部分工作量也是比较大的,梳理出来的需要刷新状态的组件就有30+,而且之后开发的组件都需要考虑状态更新的事情。
(3)HTML模板内容变更无法及时更新
引起模板内容变更的地方有两处,第一个场景就是在搭建器场景下,运营可以动态修改模板内容导致页面结构变更(低频次),第二个场景是项目发版后模板内容需要更新(高频)。
这个问题可以通过在感知到内容变更时自动调用CDN服务商的刷新缓存接口来更新CDN缓存内容。
通过puppeteer将SPA页面渲染出来并将HTML文档进行保存,配合上述页面刷新策略,并将HTML通过CDN进行托管,让你的SPA页面像SSR一样丝滑。
主要实现方案是通过基于webpack的插件prerender-spa-plugin,并配置需要预渲染的路由,这样经过打包之后就会产出对应路由的页面。方案本身是通用的,但是每接入一个页面都需要人工check。
优化思路也比较单间,将首屏所需要的css文件通过内联方式内嵌到HTML中,由SSR服务一并返回,并对css文件进行拆分,按需加载。
思路有了,接下来就看怎么去实现了,最初尝试了MiniCssExtractPlugin插件他可以把css分成单独的文件,并且每一个js会对应生成一个css文件,但是他需要建立在webpack5之上,然而项目中使用的next版本是9.5,于是就想着升级到最新版next12,升级后发现,在构建中其他包各种报错,发现有些包并不支持最新的next12,在尝试一天的修复之后,仍未解决,且升级到最新版不确定是否会引发其他稳定性问题,暂且搁置寻找其他方法。
经过不懈的努力,通过阅读next源码发现了端倪,发现在打包时将所有的公共css通过splitChunks进行分组,由于项目中组件都是动态引入的,这里直接在next.config.js中修改webpack打包参数,将splitChunks.cacheGroups.styles配置删除,使用默认的chunks:async配置,即可实现按需引入。
(1)避免图片src为空
虽然src属性为空字符串,但浏览器仍然会向服务器发起一个HTTP请求,尤其是在SSR服务器压力扛不住的情况下,因此这里需要特别注意一下。
(2)图片压缩以及格式选择
WebP的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha透明以及动画的特性,在JPEG和PNG上的转化效果都相当优秀、稳定和统一。
通过向图片服务器传递参数选择合适的分辨率。
(1)打包优化
(2)非关键js、css延迟加载
(3)媒体资源加载优化
(4)其他资源优化
(5)页面渲染优化
(6)代码层面优化
为了帮助开发者更好地衡量和改进前端页面性能,W3C性能小组引入了PerformanceAPI,其中NavigationTimingAPI实现了自动、精准的页面性能打点。得物前端性能监控指标也都是从PerformanceAPI中获取数据进行上报统计分析的。
SDK数据采集完毕后,会上报到阿里云sls日志平台,随后通过flink实时消费清洗数据后落库至clickhouse中,平台后端通过读取clickhouse数据并做各种聚合处理后使用。
做优化之前首先要建立监控指标,互联网称之为抓手,没有监控指标的情况下,任你怎么优化,都不知道优化的效果怎么样,更不知道下一步该做什么?以及还有哪些问题没解决。因此优化之前指标先行,当然一定要指标的准确性。
指标大盘主要包含以下功能:
在正常情况下,完成上述的优化措施后用户基本是可以秒开H5页面的了。但异常情况总是会有的,用户的网络环境和系统环境千差万别,甚至WebView也可能发生内部崩溃。当发生问题时,用户看到的可能就直接只是一个白屏页面了,所以进一步的优化手段就是需要去检测是否发生白屏以及相应的应对措施。
检测白屏最直观的方案就是对WebView进行截图,遍历截图的像素点的颜色值,如果非纯色的颜色点超过一定的阈值,就可以认为不是白屏。首先获取包含WebView视图的Bitmap对象,然后把截图缩小到指定的分辨率大小如:100*auto,遍历检测图片的像素点,当非纯色的像素点大于5%的时候就可以认为是非白屏的情况,但是还有很多列外的情况,我们通过图片识别技术对截图进行分析,可以很好的感知当前是否白屏、是不是在loading、是不是特殊页面等。
白屏是一个重要的指标,我们针对整体白屏率快速拉升、新增白屏页面发出告警通知,便于开发人员及时介入开始排查问题。
CDN的重要性不言而喻,它可以加速资源访问速度,从而提升用户体验,我们通过对线上埋点数据分析,找出CDN未覆盖的资源列表,从而推动各业务同学优化。
为什么要监控HTTP请求呢?我们先来看一下HTTPS相对于HTTP新增的特点:
包含图片未压缩、图片分辨率异常、图片传输大小大于300kb异常、动图资源传输大小大于1M异常功能。
上面列出来一堆功能,对于业务的同学可能比较烦恼,我一个页面具体有哪些问题呢?你不能让我去上面的功能里面一个个看,哪个异常是我负责的页面的吧?这个功能本身就行将现有的功能利用起来,通过一个页面path进行聚合分析。
H5异常一直是使用sentry进行监控的,但是sentry系统因缺乏同PV、DAU数据的关联性,因此无法衡量产品异常发生后所带来的严重程度。在业务域关联上的缺失导致异常问题无法根据业务域进行划分。用户行为日志也尚未与Native端侧打通,在问题分析时容易遇到上下文不全的瓶颈。还有一个问题是sentry会有限流措施,当qps较高时会丢弃一部分异常数据。
由于sentry已经可以帮助我们进行一定的问题排查分析能力,我们不打算做sentry同样的功能,而是做sentry不支持的部分,针对上述问题我们设计了以下功能:
虽然目前秒开率已经做到了75%以上,但是同时我们还有一个重要的指标,90分位耗时,致力于提升末尾用户H5页面使用体验,在90分位优化完成后,可能会考虑继续深入优化95分位耗时。