腾讯推出大招VasSonic,让你的H5页面首屏秒开,作为腾讯开源组件分享给大家。从最初立项优化页面加载速度,到不断探索优化,再到整理代码和文档,最后在Github上开源,24小时内获得1600多颗star。我们很高兴看到我们的成就受到如此多的关注。借此机会,我们回顾一下VasSonic的成长历程,希望能让大家更加了解VasSonic。项目背景Web相信大家都不陌生。其具有快速迭代发布的天然优势,但也存在加载速度慢、体验差等被诟病的问题。此前,手Q上很多页面的首屏打开速度居高不下,有的甚至需要3秒以上,这意味着用户打开页面3秒后才能进行互动。体验很差,很多用户都受不了。时间长了直接丢了。为了提高用户体验和业务用户留存率,我们很多业务一开始都是通过web开发的,在页面模型验证符合预期后,将H5页面转为原生界面。我们很快意识到这不是一种健康可持续的发展模式。一方面,存在重复性人力的浪费。另一方面,原生商城的经营活动除了速度更快外,很难再进行修改。所以后来团队改变了入口的方向,安排人力集中在如何加快页面打开速度上。经过一系列的试错和优化探索,我们最终开发了VasSonic框架,让H5页面首屏秒开,给用户更好的H5体验。下面跟大家分享一下VasSonic框架的开发过程。业务形态任何技术框架都是结合具体的业务形态进行开发和优化的。技术是为了更好地为业务服务,业务也会带动技术的发展。我先介绍一下这里的业务形态。我们是手Q增值产品部的增值服务团队。我们负责很多年轻人喜欢的手Q个性化增值服务,比如泡泡、小部件、主题等等。手Q上的大部分业务还是基于H5开发的,大家可能对手Q的业务形态有一个简单的了解。比如下图中的游戏分发中心、会员特权中心、个性换装商城等。这部分商场的特点比较明显。页面上的很多数据都是动态的,是由我们的产品经理在后台配置的。这些是非常常见的页面。我们通常把html/js/css等静态资源放在CDN上,然后在页面加载完成后,通过CGI拉取最新的数据进行拼接展示。这样我们就可以利用本地部署、就近访问等CDN的诸多优势,同时提高服务器的并发能力。这种传统模式的加载过程是这样的:用户点击之后,会经历一系列的终端初始化过程,比如进程启动、Runtime初始化、WebView创建等等。初始化完成后,WebView开始请求Html加载CDN上的页面。页面对相应的数据发起CGI请求或者通过localStorage获取数据,数据返回后更新DOM。可以看出,上述过程存在几个问题:从外网统计数据来看,用户终端用时超过1s。这意味着网络完全空闲等待超过1秒,非常浪费;页面的资源和数据完全依赖于网络,尤其是当用户处于弱网场景时,页面会出现长时间白屏,体验非常差;因为页面的数据依赖动态拉取,所以经常会看到页面加载后,先把一些模块转成菊花,然后再展示,体验不好。同时这里涉及到大量的数据更新,DOM经常更新,在性能上也有很大的开销。因此,针对以上问题,我们也做了很多的优化和探索。VasSonic前世优化终端针对终端耗时超过1s的情况,我们重构了QWebView框架:启动过程完全拆分,设计成一个状态机,依次执行View相关的拆分和模块化设计,尽可能按需提供。延迟加载,IO异步化X5核心在手Q独立进程中提前预加载,创建WebView对象复用池关于第四点,我们在Android平台上分享一些细节。由于Android系统生态原因,用户的系统版本和系统Webkit内核处于极度分裂状态,所以我司在手Q和微信中统一使用X5内核。与系统WebView相比,X5内核在第一次启动时,创建WebView比较耗时,所以我们尝试复用WebView,但是WebView是绑定在ActivityContext上的。在销毁和复用时,需要释放Activity的Context,否则会发生内存泄漏。在这种情况下,有没有办法两全其美呢?计算机中有一句经典的话:计算机领域的任何问题都可以通过引入中间层来解决。所以我们通过封装实现了一个Contextshell,里面包裹着真正的实现体,逻辑调用其实就是调用对应实现体的函数。经过实验,发现Android系统本身就提供了这样一个MutableContextWrapper作为Context的中间层。我们将Activity上下文包装在MutableContextWrapper中。在销毁时,我们会将WebView的Context设置为Application的Context,从而释放ActivityContext。类似如下://precreateWebViewMutableContextWrappercontextWrapper=newMutableContextWrapper(BaseApplicationImpl.sApplication);mPool[0]=newWebView(contextWrapper);//resetWebViewct=(MutableContextWrapper)webview.getContext();ct.setBaseContext(getApplication());//重用WebView((MutableContextWrapper)webview.getContext()).setBaseContext(activityContext);静态直出“直出”这个概念对于前端同学来说并不陌生。为了优化首屏体验,大部分主流页面都会在服务器端拉取首屏数据,通过NodeJs进行渲染,然后生成包含首屏数据的Html文件,这样在首屏显示时,内容可以解决转菊花的问题。当然,这种页面“直出”的方式也会带来一个问题。服务器需要拉取首屏的数据,这意味着服务器上的处理时间会增加。不过由于现在Html发布到CDN,而WebView是直接从CDN获取,所以这个耗时并不影响用户。手Q中有一套自动化构建系统Vnues。当产品经理修改数据并发布后,可以一键启动构建任务。Vnues系统会自动同步最新的代码和数据,然后用首屏生成新的Html发布到CDN。离线预推页面发布到CDN后,WebView需要发起网络请求拉取。当用户处于弱网络或网速较差的环境时,加载时间会很长。所以我们通过离线预推的方式,将页面的资源提前拉取到本地。当用户加载资源时,相当于从本地加载。即使没有网络,也能显示首屏页面。这就是大家熟悉的离线包。手Q使用7Z生成离线包。同时,离线包服务器对业务对应的新离线包和历史离线包进行BsDiff二进制差分,生成增量包,进一步降低下载离线包时的带宽成本和下载消耗的流量。从完整的离线包(253KB)缩减为增量包(3KB)。经过一系列的优化,在Android平台上,点击到页面首屏显示的耗时从平均3s多降低到1.8s,优化了40%以上。VasSonic的诞生虽然通过静态直输出和离线预推进行了优化,速度达到了1.8s,但还有很大的优化空间。当我们准备继续深入优化的时候,我们的业态已经发生了新的变化。之前我们页面内容的数据主要是产品经理配置的,用户看到的内容基本一致。现在,为了更好地在页面上为用户推荐喜欢的内容,我们在后台引入了机器学习和随机算法,进行智能个性化推荐。比如左边是新用户推荐新品,右边是活跃用户推荐的新潮商品。此外,一些内容是由随机算法推荐的。这意味着不同的用户看到不同的内容,同一个用户可能在不同的时间看到不同的内容。所以,为了满足业务的需要,我们只能实时拉取用户数据,经过服务端渲染后返回给客户端,是一种动态直接输出的方案。但是动态直出方案有几个比较明显的问题:服务器实时拉取数据进行渲染,导致白屏时间长,因为服务器要先实时拉取个人数据,然后再渲染直出,这会花费无法控制的时间;不能使用离线预推等首屏缓存策略,因为每个用户看到的内容不同,我们无法通过静态直接输出的方式将所有的Html发布到CDN;加载优化采用了push等方式,但是之前优化积累的经验给我们提供了思路:优化白屏问题,核心还是要从提高资源加载速度入手。所以我们专注于资源加载的深度优化。并行加载首先从加载过程来看,我们发现这里的WebView访问还是串行的,直到终端初始化完成WebView才发起请求。虽然终端耗时优化了很多,但是从外网的统计数据来看,终端初始化仍然需要几百毫秒,而且这段时间网络处于空闲状态。因此,性能还不够极端。我们优化了代码。这两个操作并行处理,流程改为:并行处理后,速度有所提升,但是我们发现在某些场景下,终端初始化速度更快,但是没有返回数据。意思是内核在白等,内核是支持边加载边渲染的。我们能否在并行化的同时利用内核的这一特性?所以我们加了一个中间层来桥接内核和数据,内部称之为流式拦截:启动子线程去请求页面的主资源,子线程不断的将网络数据读入内存,即即,网络流(NetStream)和内存流(MemStream)之间的转换;WebView初始化完成后,提供一个中间的BridgeStream连接WebView和数据流;当WebView读取数据时,中间的BridgeStream会先读取内存中的数据并返回。然后继续从网络上读取数据。通过这种桥接流的方式,整个内核不需要等待,边加载边继续解析。这种并行方式将首屏速度优化了15%以上,进一步提升了页面加载速度。通过动态缓存的并行加载,我们大大提升了WebView请求的速度,但是在弱网场景下白屏时间还是很长,用户体验很差。所以我们在想,能不能把用户加载页面的内容缓存起来,当用户点击页面的时候,我们先加载显示页面缓存,让用户第一时间看到内容,然后请求同时一个新的页面数据,新的页面数据拉下来后,我们可以重新加载。保存页面内容的工作非常简单,因为现在我们的资源读取是通过中间层BridgeStream来管理的,我们只需要缓存整个读取的内容即可。于是我们实现了动态缓存的方案,但是很快就发现了问题。用户打开页面后,首先看到的是历史页面。当用户准备操作时,页面突然白白一闪,又重新加载。显然,这极大地影响了体验,这对于用户和产品经理来说都是不能接受的。所以我们在想,能不能只做局部刷新,只刷新变化的元素呢?通过分析,我们发现同一个用户的页面中,大部分数据保持不变,只有少量数据变化频繁,所以我们提出了模板和数据块的概念:页面中变化频繁的数据我们称之为它是一个数据块,数据块以外的数据称为模板。页面分离我们将整个页面html通过VasSonic标签进行划分,标签内包裹的内容为数据,标签外的内容为模板。首先,我们扩展了Html内容,通过代码注释添加了“sonicdiff-xxx”来标记一个数据块的开始和结束。template就是数据块切出来后的Html,然后用{albums}表示这是一个数据块占位符。数据为JSON格式,直接Key-Value。当然,为了完美兼容Html,我们对协议头进行了扩展,比如添加accept-diff标记是否支持增量更新,template-tag标记模板的md5等。OK,用以上规则或公式,我们可以实现增量更新。请求规范规定,为了支持区分客户端是否支持增量更新的能力,VasSonic扩展了头部字段FieldDescriptionRequestHeader(Y/N)ResponseHeader(Y/N)accept-diff表示终端是否支持VasSonic模式,支持true,否则不支持YNIf-none-匹配本地缓存etag,NYtemplate-tag模板唯一标识(hash值)供服务端判断是否命中304YNetag的唯一标识(hash值)页面内容,客户端使用本地验证或者服务端使用判断模板发生变化。YYtemplate-change标记模板是否发生变化。客户端使用NYcache-offline。客户端使用它并根据不同的类型执行不同的行为。contentfalse显示返回内容,不需要缓存到磁盘storecachestodisk,如果缓存已经加载,下次加载,否则显示返回内容http灾备字段,如果http表示终端六小时内不会使用sonic请求url模式介绍VasSonic根据是否有本地缓存??以及本地缓存数据和服务器数据的区别分为以下四种模式。方式描述条件第一次加载没有本地缓存??,即第一次加载的页面etag为null或者template_tag被完全缓存。有本地缓存??,缓存内容和服务器内容完全一致。etag一致数据更新有本地缓存??,本地模板内容与服务端模板内容相同,但数据块变化etag不一致和template_tag一致模板更新有本地缓存??,和缓存的模板内容与服务器的模板内容不一致etag不一致,template_tag不一致。第一次加载,我们会在请求头中支持accept-。diff为真,sdk版本号标识最先加载的信息。当请求返回时,VasSonic会在延迟几秒后将页面提取成模板和数据(避免激烈的IO竞争)并保存在本地。此时在终端缓存目录下,页面会对应三个缓存文件xxx.html、xxx.template、xxx.data,其中xxx为页面的唯一标识(即sonicSessionId)。对于页面不是第一次加载的场景,VasSonic先加载本地缓存。同时我们会在请求头中带上当前缓存的md5和模板。后台比对模板md5后,分为以下几种情况:非首次加载完全缓存在本地有缓存,缓存内容与服务器内容完全一致。如果第一次加载的增量数据没有变化,会在响应头中返回template-change=false,响应包体中返回的数据将不再完整。html,而是一段JSON数据,以及所有的数据块。我们现在需要对本地数据进行差分,找出真正的增量数据。如上图,后台返回了N条数据,但是只有一条数据发生了变化,所以我们只需要将变化的数据提交到页面即可。在一般情况下,差异数据远小于所有数据。如果页面把数据拆分得更细,那么页面的变化就会更小,这就看前端同学对数据块的细化程度了。客户端获取到变化的数据块(diff_data)后,只需要通知页面设置的回调接口(getDiffDataCallback)更新界面元素即可。这里javascript通信方式也可以自由定义(可以使用webview标准的javascript通信方式,也可以使用伪协议方式),只要页面和终端一致即可。对于数据更新场景,终端也会将新的数据和模板拼接成一个新的页面,让缓存保持最新。当终端初始化比较慢的时候,WebView加载缓存的时候,页面可能已经是最新的了,不需要刷新数据。非首次加载的模板更新方式不同于数据更新方式。由于业务需要,页面模板会发生变化。终端获取到新的模板和数据后,在子线程本地合并生成新的缓存,然后回调通知终端刷新WebView加载新的缓存。我们来看看最终的流程图。与动态缓存相比,优化了很多细节:我们从第2步开始,SonicSession会先读取缓存。会抛出消息通知WebView读取缓存。如果Webview准备好了,缓存会直接加载。如果没有,缓存会先放在内存中。同时SonicSession也会将模板等信息带到后台拉取新的内容,后台会在Sonic-Diff之后返回新的数据。SonicSession获取到新数据后,会先与本地数据进行Diff。如果发现WebView加载了缓存,则直接将增量数据提交给页面。否则,继续组装最新的页面,替换内存中的缓存,保存到本地。如果此时WebView已经准备好,直接进入第5步加载最新的内容。效果统计这是我们外网的统计数据。在数据更新模式下,首屏耗时约1秒,相比普通动态直显优化了50%以上。模板更新会比第一次高,因为页面加载了两次,但是从模式比来看,我们的页面大部分都是数据更新。对于模板更新耗时的情况,之前优化中积累的经验给我们提供了思路。核心是从提前获取资源的方向入手,所以我们优先考虑如何预加载模板更新。预加载其实整个SonicSession可以在没有WebView的情况下独立完成所有逻辑。当用户点击页面时,我们可以绑定WebView和SonicSession。所以我们支持两种预加载方式,一种是通过后台推送的方式提前获取数据。另一种是JSAPI,页面可以调用JSAPI预加载用户可能操作的下一个页面。通过这两种方式,我们可以提前拉回所需的增量更新数据,对比效果图1:没有使用VasSonic有用的接口,更好的性能,更高的可靠性,快速响应解决开源问题和PR。这些改进最终会原封不动地用在手Q上,都是为了更快的WebView加载速度。谈话是廉价的,阅读他妈的代码。如果您对VasSonic感兴趣,请不要忘记STARVasSonic。谢谢阅读~
