Sonic是腾讯团队研发的一个轻量级的高性能的Hybrid框架,专注于提升页面首屏加载速度。
该框架使用终端应用层原生传输通道取代系统浏览器内核自身资源传输通道来请求页面主资源,在移动终端初始化的同时并行请求页面主资源并做到流式拦截,减少传统方案上终端初始化耗时长导致页面主资源发起请求时机慢或传统并行方案下必须等待主资源完成下载才能交给内核加载的影响。
通过客户端和服务器端双方遵守Sonic格式规范(通过在html内增加注释代码区分模板和数据),该框架做到智能地对页面内容进行动态缓存和增量更新,减少对网络的依赖和数据传输的大小,大大提升H5页面的加载速度,让H5页面的体验更加接近原生,提升用户体验及用户留存率。
VasSonic依赖于三端的配合,如果由于前端、后台不支持等问题,可以考虑客户端接入
VasSonic+预加载或者VasSonic+离线包组合方式,来提升页面加载速度。
WebView加载慢的问题主要集中在如下三个阶段:
VasSonic的优化都是为了加速上述三个阶段,其经验可以总结为六个方面:
为了优化首屏体验,大部分主流的页面都会在服务器端拉取首屏数据后通过NodeJs进行渲染,然后生成一个包含了首屏数据的Html文件,这样子展示首屏的时候,就可以解决内容转菊花的问题了。 当然这种页面“直出”的方式也会带来一个问题,服务器需要拉取首屏数据,意味着服务端处理耗时增加。 不过因为现在Html都会发布到CDN上,WebView直接从CDN上面获取,这块耗时没有对用户造成影响。
页面发布到CDN上面去后,那么WebView需要发起网络请求去拉取。当用户在弱网络或者网速比较差的环境下,这个加载时间会很长。于是我们通过离线预推的方式,把页面的资源提前拉取到本地,当用户加载资源的时候,相当于从本地加载,即使没有网络,也能展示首屏页面。这个也就是大家熟悉的离线包。 手Q使用7Z生成离线包, 同时离线包服务器将新的离线包跟业务对应的历史离线包进行BsDiff做二进制差分,生成增量包,进一步降低下载离线包时的带宽成本,下载所消耗的流量从一个完整的离线包(253KB)降低为一个增量包(3KB)。
首先在加载流程方面,我们发现这里WebView访问依然是串行的, WebView要等终端初始化完成之后,才发起请求。虽然终端耗时优化了不少,但是从外网的统计数据来看,终端初始化还是存在几百毫秒的耗时,而这段时间内网络是在空等的。 (注:在传输通道方面,我们选择了标准的http/https通道,原因是http/https通道支持流式传输,也支持chunk等特性,同时接入成本低,更加通用。)
因此性能上不够极致,我们优化代码,这两个操作并行处理,流程改为:
并行处理后速度有所改善,但我们发现在某些场景下,终端初始化比较快,但数据没有完成返回,这意味着内核在空等,而内核是支持边加载边渲染的,我们在并行的同时,能否也利用内核的这个特性呢?
于是我们加入了一个中间层来桥接内核和数据,内部称为流式拦截:
通过并行加载,我们极大地提升了WebView请求的速度,但是在弱网络场景下白屏时间还是非常长,用户体验非常糟糕。基于离线包和webso方案的优化实践经验,我们首先将用户的已经加载的页面内容缓存下来,等用户下此点击页面的时候,我们先加载展示页面缓存,第一时间让用户看到内容,然后同时去请求新的页面数据,等新的页面数据拉取下来之后,我们再重新加载一遍即可。
保存页面内容这个工作很简单,因为现在我们资源读取都是通过中间层BridgeStream来管理的,只需要将整个读取的内容缓存下来即可。 于是我们就按动态缓存这种方案去实现了,但很快就发现了问题。用户打开页面之后,先是看到历史页面,等用户准备去操作的时候,突然页面白闪一下,重新加载了一遍,这种体验非常差,特别在一些低端机器上,这个白闪的过程太明显,非常影响体验,这是用户和产品经理都不能接受的。
通过分析,我们发现同一个用户的页面,大部分数据都是不变的,经常变化的只有少量数据,于是我们提出了模板(template)和数据块(data)的概念:页面中经常变化的数据我们称为数据块,除了数据块之外的数据称为模板。
在页面分离这块,我们沿用了webso方案中的动静分离的思想,并扩展了部分新的字段。首先我们将整个页面html通过VasSonic标签进行划分,包裹在标签中的内容为data,标签外的内容为模版。
首先我们对Html内容进行了扩展,通过代码注释的方式,增加了“sonicdiff-xxx”来标注一个数据块的开始与结束。 而模板就是将数据块抠掉之后的Html,然后通过{albums}来表示这个是一个数据块占位。 数据就是JSON格式,直接Key-Value。 当然,为了完美地兼容Html,我们对协议头部进行了扩展,比如增加accept-diff来标注是否支持增量更新、template-tag来标注模板的md5是多少等。OK,有了上面这个规则或者公式后,我们就可以实现增量更新了。
实际上整个SonicSession在没有WebView的情况下,也是可以独立完成所有逻辑的,当用户点击页面的时候,我们在将WebView和SonicSession绑定起来即可。于是我们支持了两种预加载的模式,一种是通过后台push的方式,来提前获取数据。还有一种就是JSAPI,页面可以调用JSAPI来预加载用户可能操作的下一个页面。通过这两种方式,我们可以把需要的增量更新数据提前拉取回来
Demo在 AppDelegate 上提前做了一些初始化
// 注册网络层接管
[NSURLProtocol registerClass:[SonicURLProtocol class]];
//start web thread
UIWebView *webPool = [[UIWebView alloc]initWithFrame:CGRectZero];
[webPool loadHTMLString:@"" baseURL:nil];
这里其实也是为了去掉UIWebView的第一次初始化的开销,实际情况上我们可以按需选择时机来做这一步
优化UIWebView的创建开销
VasSonic 的资源加载和UIWebView的生命周期是分离的
从代码上不难发现 SonicWebViewController 就是一层 WebView 的包装,在 init 的时候其实就发起了目标网页的请求
- (instancetype)initWithUrl:(NSString *)aUrl useSonicMode:(BOOL)isSonic unStrictMode:(BOOL)state
{
......
[[SonicEngine sharedEngine] createSessionWithUrl:self.url withWebDelegate:self];
......
}
而在 loadView 的地方才真正创建 UIWebView
- (void)loadView
{
......
self.webView = [[UIWebView alloc]initWithFrame:self.view.bounds];
......
[self.webView loadRequest:[SonicUtil sonicWebRequestWithSession:session withOrigin:request]];
......
}
换句话说,这两个行为可以算是并行的,这样的话就连 UIWebView重复创建的时间都没有浪费,已经开始请求资源了。
SonicEngine是一个中央的调度者,负责一些配置的读取,操作的控制(例如缓存),维护 SonicSession 队列,通过delegate和session绑定
对于资源加载的控制,实际上由 SonicSession 完成,一个 Session 对应着一个主文档的加载
它的组成部分
真正发起请求连接的是 SonicConnection,但是如前面所说它只是一层封装,SonicServer 其实也只做了两件事情
isFirstLoadRequest,以保证WebView的边加载边解析渲染的特性没有丢掉
isInLocalServerMode,需要通过参数 enableLocalSever 来激活
前面提到的Session的主请求,以及resoureLoader的子请求,都会被 SonicURLProtocol 所拦截
- (void)startLoading
{
NSThread *currentThread = [NSThread currentThread];
__weak typeof(self) weakSelf = self;
// 通过sessionID,把protocol的请求和session的请求绑起来
NSString * sessionID = sonicSessionID(self.request.mainDocumentURL.absoluteString);
SonicSession *session = [[SonicEngine sharedEngine] sessionById:sessionID];
// 先判断是不是子资源的请求,否则就是主文档的
if ([session.resourceLoader canInterceptResourceWithUrl:self.request.URL.absoluteString]) {
// 用block的方式建立关联
[session.resourceLoader preloadResourceWithUrl:self.request.URL.absoluteString withProtocolCallBack:^(NSDictionary *param) {
[weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
}];
}else{
NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
// 用block的方式建立关联,session会保存这个block,在connection的回调里面call回来
[[SonicEngine sharedEngine] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
[weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
}];
}
}
这样的话,逻辑就全部保留在Session内部,而无需分散代码了
从缓存的数据结构来看,这里会把主文档分割开四个部分来缓存
实际上就是我们在让WebView加载的过程中,自己去加载一次这份数据并保存起来,等到WebView加载的时候,询问我们自己的CacheModel,有的话直接返回
Session发起了主文档的加载,url,requestS
WebView发起主文档的加载,url, requestW
虽然url一样,但是request不一样,Session的 requestS 不会被网络层拦截,WebView的 requestW 会被拦截
WebView的 requestW 被拦截的时候,实际上是没有发起真实的请求的,而是和Session的 requestS 绑在了一起,绑定的key就是sessionID,存在 requestS 的header里,然后等待 requestS 的返回结果,或者从 CacheModel 返回
在Session的 requestS 的 response 回来的时候,就马上发起了子资源的请求,确保了在Webkit的子资源请求之前发起,且实际上它们和主文档是并行加载的
因此当Session的主文档加载完了,也就是Webkit主文档加载完了,轮到Webkit发起子资源请求的时候,其实已经有部分已经完成了,相当于通过并行加载,优化了不少加载时间
就此,数据就给到了Webkit进行排版和渲染了
在AppDelegate
中注册SonicURLProtocol
[NSURLProtocol registerClass:[SonicURLProtocol class]];
@interface SonicWebViewController : UIViewController
#pragma mark - Sonic Session Delegate
/*
* sonic请求发起前回调
*/
- (void)sessionWillRequest:(SonicSession *)session
{
//可以在请求发起前同步Cookie等信息
}
/*
* sonic要求webView重新load指定request
*/
- (void)session:(SonicSession *)session requireWebViewReload:(NSURLRequest *)request
{
[self.webView loadRequest:request];
}
/*
* 在初始化ViewController的时候发起sonic的请求
*/
- (instancetype)initWithUrl:(NSString *)aUrl
{
if (self = [super init]) {
self.url = aUrl;
self.clickTime = (long long)[[NSDate date]timeIntervalSince1970]*1000;
//使用sonic链接创建一个会话
[[SonicClient sharedClient] createSessionWithUrl:self.url withWebDelegate:self];
}
return self;
}
/*
* 在初始化WebView之后立即发起带有sonic信息的请求
*/
- (void)loadView
{
[super loadView];
self.webView = [[UIWebView alloc]initWithFrame:self.view.bounds];
self.webView.delegate = self;
self.view = self.webView;
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:self.url]];
/*
* 查询当前ViewController是否成功创建sonic会话,如果已经创建,那么包装request成sonic请求,以便在NSURLProtocol层拦截
* 否则走正常模式加载请求,不会在NSURLProtocol层拦截
*/
if ([[SonicClient sharedClient] sessionWithWebDelegate:self]) {
[self.webView loadRequest:sonicWebRequest(request)];
}else{
[self.webView loadRequest:request];
}
}
/*
* 此接口由页面驱动,由前端sonic组件向终端发起请求获取会话结果
*/
- (void)getDiffData:(NSDictionary *)option withCallBack:(JSValue *)jscallback
{
/*
* 根据发起sonic会话的ViewController来查询需要的结果
*/
[[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate:self.owner completion:^(NSDictionary *result) {
/*
* 这里将result传递回页面即可
*/
NSData *json = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
NSString *jsonStr = [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding];
JSValue *callback = self.owner.jscontext.globalObject;
[callback invokeMethod:@"getDiffDataCallback" withArguments:@[jsonStr]];
}];
}
- (void)dealloc
{
[[SonicClient sharedClient] removeSessionWithWebDelegate:self];
}
参考:
https://tech.meituan.com/2017/06/09/webviewperf.html
https://github.com/Tencent/VasSonic/blob/master/assets/sonic发展历程.md