作者简介:
陈志兴,腾讯 SNG 增值产品部高级工程师,主要负责手 Q 个性化业务、手 Q WebView 等项目。喜欢阅读优秀的开源项目,听听音乐,偶尔也会打打竞技类游戏。
本文根据作者在 2017GMTC 全球移动技术大会的上分享的 ppt 整理:
http://ppt.geekbang.org/slide/download/862/593b7cecd6ce2.pdf/19
特别感谢卢景伦(腾讯 SNG 增值产品部高级工程师)将 ppt 精华汇总成文,方便大家阅读学习。
2017 年 8 月 8 日,SNG 增值产品部 Vas 团队研发的轻量级高性能 Hybrid 框架 VasSonic 通过了公司最终审核,作为腾讯开源组件分享给大家。从当初立项优化页面加载速度,到不断摸索、优化,再到整理代码、文档,最终在 Github 上开源,并且在 24 小时内获取 star 数超过 1600。我们非常高兴看到我们的成果收到这么多的关注,趁此机会,正好回顾一下 VasSonic 的成长历程,也希望能够让大家更了解 VasSonic。
VasSonic GitHub 链接:
https://github.com/Tencent/VasSonic
Web 相信大家再熟悉不过了,它具有快速迭代发布的天然优势,但也存在中一些让人诟病的问题,比如加载速度慢,体验差等。在此之前,手 Q 上很多页面首屏打开速度居高不下,甚至有些耗时达到 3s 以上,这意味着用户打开页面必须经过 3 秒之后才能进行交互操作,体验相当差,很多用户忍受不了这个漫长的时间直接流失掉了。
为了提升用户体验和业务用户留存率,我们很多业务一开始通过 Web 开发,等页面模型验证符合预期后,再将 H5 页面转化成原生界面。我们很快意识到这不是一种健康的可持续的开发模式,一方面存在重复人力浪费,另外一方面原生商城除了速度快一点,要运营活动改版都很难。
所以后来团队改了切入方向,安排人力专心研究如何加快页面打开速度,经过了一系列的摸爬滚打和优化探索,最终我们研发出了 VasSonic 框架,让 H5 页面首屏达到秒开,给用户一个更好的 H5 体验。下面就和大家分享 VasSonic 框架的发展历程。
任何一个技术框架都是结合具体的业务形态来进行发展优化的,技术是为了更好地服务业务,业务也会驱动技术的发展。在此首先介绍一下业务形态,我们是来自手 Q 增值产品部门的 VAS 团队,负责手机 QQ 上很多深受年轻人喜欢的个性化增值服务,比如气泡、挂件、主题等等。手 Q 上大部分的业务还是基于 H5 开发的,大家对手 Q 的业务形态可能有简单的了解。比如下图的游戏分发中心、会员特权中心、个性化装扮商城等。这部分商城的特点比较明显,页面的很多数据都是动态的,是由我们的产品经理在后台配置的。
这些都是很常见页面,我们通常将 html/js/css 等静态资源放到 CDN 上,然后页面加载后,再通过 CGI 去拉取最新的数据,进行拼接展示, 这样子可以利用到 CDN 的多地部署和就近接入等优势,同时提高了服务器的并发能力。这种传统模式的加载流程如下所示:
用户点击后,经过终端一系列初始化流程,比如进程启动、Runtime 初始化、创建 WebView 等等。
完成初始化后,WebView 开始去 CDN 上面请求 Html 加载页面。
页面发起 CGI 请求对应的数据或者通过 localStorage 获取数据,数据回来后再对 DOM 进行操作更新。
可以看出上述流程存在着几个问题:
从外网统计数据来看,用户的终端耗时在 1s 以上,这意味着在这 1s 多的时间里,网络完全是空闲在等待的,非常浪费;
页面的资源和数据完全依赖于网络,特别是用户在弱网络场景下,页面会出现很长时间的白屏,体验非常差;
因为页面的数据依赖于动态拉取,加载完页面后,往往是看到一些模块先转菊花,再展示,体验也是不好的。同时这里涉及到较多数据更新,经常要更新 DOM,性能上也有不少开销。
所以针对以上几个问题,我们也对应做了很多优化和探索。
VasSonic 的前世
优化终端
针对终端耗时 1s 以上的情况,我们对手 Q WebView 框架进行了重构:
启动流程彻底拆分,设计为一个状态机按序按需执行
View 相关拆分模块化设计,尽可能懒加载,IO 异步化
X5 内核在手 Q 中的独立进程中提前预加载
创建 WebView 对象复用池
关于第四点,我们想分享一些 Android 平台上的细节,由于 Android 系统的生态原因,导致用户的系统版本和系统 Webkit 内核处于极其分裂状态,所以我们公司在手 Q 和微信统一使用 X5 内核。相对系统 WebView 来说,首次启动 X5 内核时,创建 WebView 比较耗时,因此我们尽量想复用 WebView,但是 WebView 却是与 Activity Context 绑定。销毁复用的时候,需要释放 Activity 的 Context,否则会内存泄露。针对这种情况,有没有一种两全其美的办法呢?
计算机有一句经典的名言:
计算机领域任何一个问题都可以通过引入中间层来解决。
于是我们通过包装的方式,实现了一个 Context 的壳,真正的实现体包装在里面,逻辑调用真正调用到对应的实现体的函数。 经过实验发现,Android 系统本身提供了这么一个MutableContextWrapper
,作为 Context 的一个中间层。
我们会将 Activity context 包在 MutableContextWrapper 里面,destory 的时候,会将 WebView 的 Context 设置为 Application 的 Context,从而释放 Activity Context。
类似如下:
//precreate WebView MutableContextWrapper contextWrapper = new MutableContextWrapper(BaseApplicationImpl.sApplication); mPool[0] = new WebView(contextWrapper); //reset WebView ct =(MutableContextWrapper)webview.getContext(); ct.setBaseContext(getApplication()); //reuse 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;
虽然动态直出方案下,页面首屏无法通过离线预推等方式进行加载优化,但前面优化积累的经验给我们提供了思路:要优化白屏问题,核心还是得从提升资源加载速度方向入手。所以我们重点在资源加载方面进行了深度优化。
首先在加载流程方面,我们发现这里 WebView 访问依然是串行的, WebView 要等终端初始化完成之后,才发起请求。虽然终端耗时优化了不少,但是从外网的统计数据来看,终端初始化还是存在几百毫秒的耗时,而这段时间内网络是在空等的。
因此性能上不够极致,我们优化代码,这两个操作并行处理,流程改为:
并行处理后速度有所改善,但我们发现在某些场景下,终端初始化比较快,但数据没有完成返回,这意味着内核在空等,而内核是支持边加载边渲染的,我们在并行的同时,能否也利用内核的这个特性呢?
于是我们加入了一个中间层来桥接内核和数据,内部称为流式拦截:
启动子线程请求页面主资源,子线程中不断讲网络数据读取到内存中,也就是网络流 (NetStream) 和内存流 (MemStream) 之间的转换;
当 WebView 初始化完成的时候,提供一个中间层 BridgeStream 来连接 WebView 和数据流;
当 WebView 读取数据的时候,中间层 BridgeStream 会先把内存的数据读取返回后,再继续读取网络的数据。
通过这种桥接流的方式,整个内核无需等待,继续做到边加载边解析。这种并行的方式让首屏的速度优化 15% 以上,进一步提升了页面加载速度。
通过并行加载,我们极大地提升了 WebView 请求的速度,但是在弱网络场景下白屏时间还是非常长,用户体验非常糟糕。于是我们在思考,是否能够将用户的已经加载的页面内容缓存下来,等用户下此点击页面的时候,我们先加载展示页面缓存,第一时间让用户看到内容,然后同时去请求新的页面数据,等新的页面数据拉取下来之后,我们再重新加载一遍即可。
保存页面内容这个工作很简单,因为现在我们资源读取都是通过中间层 BridgeStream 来管理的,只需要将整个读取的内容缓存下来即可。
于是我们就按动态缓存这种方案去实现了,但很快就发现了问题。用户打开页面之后,先是看到历史页面,等用户准备去操作的时候,突然页面白闪一下,重新加载了一遍,这种体验非常差,特别在一些低端机器上,这个白闪的过程太明显,非常影响体验,这是用户和产品经理都不能接受的。于是我们在思考,能否只做局部的刷新,仅刷新变化的元素呢?
通过分析,我们发现同一个用户的页面,大部分数据都是不变的,经常变化的只有少量数据,于是我们提出了模板 (template) 和数据块 (data) 的概念:页面中经常变化的数据我们称为数据块,除了数据块之外的数据称为模板。
我们将整个页面 html 通过 VasSonic 标签进行划分,包裹在标签中的内容为 data,标签外的内容为模版。
首先我们对 Html 内容进行了扩展,通过代码注释的方式,增加了“sonicdiff-xxx”来标注一个数据块的开始与结束。
而模板就是将数据块抠掉之后的 Html,然后通过{albums}来表示这个是一个数据块占位。
数据就是 JSON 格式,直接 Key-Value。
当然,为了完美地兼容 Html,我们对协议头部进行了扩展,比如增加 accept-diff 来标注是否支持增量更新、template-tag 来标注模板的 md5 是多少等。OK,有了上面这个规则或者公式后,我们就可以实现增量更新了。
VasSonic 为了支持区分客户端是否支持增量更新等能力,对头部字段进行了扩展。
cache-offline 字段说明
列表如下:
模式介绍
VasSonic 根据本地是否有缓存以及本地缓存数据跟服务器数据的差异情况分为以下四种模式。
首次加载
我们会在请求头部带上支持 accept-diff 为 true 和 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 如果 Ready,则直接进行第 5 步 load 最新的内容即可。
效果统计
这个是我们外网的统计数据。在数据更新模式下,首屏的耗时在 1s 左右,相比普通的动态直出,优化了 50% 以上。模板更新这个会比首次高,是因为加载了两次页面,不过从模式占比上来看,我们大部分页面都是数据更新。针对模板更新这种耗时比较高的情况,前面优化积累的经验给我们提供了思路,核心还是从提前获取资源方向入手,因此我们优先考虑如何预加载模板更新。
预加载
实际上整个 SonicSession 在没有 WebView 的情况下,也是可以独立完成所有逻辑的,当用户点击页面的时候,我们在将 WebView 和 SonicSession 绑定起来即可。于是我们支持了两种预加载的模式,一种是通过后台 push 的方式,来提前获取数据。还有一种就是 JSAPI,页面可以调用 JSAPI 来预加载用户可能操作的下一个页面。通过这两种方式,我们可以把需要的增量更新数据提前拉取回来
展望未来
开源只是故事的开始,我们仍会持续对 VasSonic 做改进,包括更易用的接口、更好的性能、更高的可靠性,同时快速响应解决开源后的 issue 和 PR。这些改进最终也会原封不动地在手 Q 内使用,这一切都是为了更快的 WebView 加载速度。
Talk is cheap,read the fucking code. If you are interested in VasSonic, don't forget to STAR VasSonic.
Thank you for reading ~