Vas Sonic的源码分析
最近在研读Vas Sonic的源码,Sonic是一款轻量级的高性能Hybrid框架,由腾讯QQ会员团队开发,专注于提升H5页面首屏加载速度。
首屏就是指用户在没有滚动时候看到的内容渲染完成并且可以交互的时间。至于加载时间,则是整个页面滚动到底部,所有内容加载完毕并可交互的时间。
H5以其开发和维护的成本较低,开发周期较短的天然优势满足了APP快速迭代的需求。目前很多APP或多或少接入了H5页面,但H5存在的缺点是加载速度慢,造成不好的用户体验。因此,如何优化H5的加载速度可以有效提升用户的满意度。
话不多说,接下来我们看看Sonic这个开源库到底是一个什么样的实现原理,首先给大家奉上Sonic的GitHub地址 点我
看一个开源库,我通常会摸清楚其类层次关系,从整体把握其组件,然后在抽茧剥丝。不然会像走入一个迷宫,有种“只在此山中,云深不知处”的感觉。
下面是我绘制Sonic iOS库的UML类图:
可以梳理出其包含SonicURLProtocol, SonicEngine, SonicSession, SonicSever和SonicConnection五个组件及其相互之间的联系。下面我们从源码中来分析这几个组件的角色和发挥的作用。
1. SonicURLProtocol
看到SonicURLProtocol这个类,我们立刻就能联想到Foundation库的NSURLProtocol类,用户可以通过子类化NSURLProtocol类来对上层的URLRequest请求做拦截,并根据自己的需求场景做定制化响应处理。具体介绍详见iOS 开发中使用 NSURLProtocol 拦截 HTTP 请求。SonicURLProtocol利用这个原理来对UIWebView的请求进行拦截,实现自定义页面数据加载和缓存。
SonicURLProtocol有三个重要的方法:
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
NSString *value = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyLoadType];
if (value.length != 0 && [value isEqualToString:SonicHeaderValueWebviewLoad]) {
NSString * delegateId = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyDelegateId];
if (delegateId.length != 0) {
NSString * sessionID = sonicSessionID(request.URL.absoluteString);
SonicSession *session = [[SonicEngine sharedEngine] sessionWithDelegateId:delegateId];
if (session && [sessionID isEqualToString:session.sessionID]) {
return YES;
}
...
}
}
return NO;
}
这个方法重写了NSURLProtocol类的方法,主要过滤需要拦截的请求,只有这个方法返回YES我们才能够继续后续的处理。通过这个方法的实现里面进行请求的过滤,筛选出webView的网络请求进行处理的请求。也就是请求头中含有key值SonicHeaderKeyLoadType对应的值为SonicHeaderValueWebviewLoad的NSURLRequest需要被拦截。
接着会根据请求头中的delegate去SonicEngine中寻找SonicSession,如果找到了对应的SonicSession,接下来会对这个request进行拦截。 那么SonicSession是什么时候被初始化并注册到SonicEngine中的呢?后面我们会进行讲解。
下面我们继续看第2个方法,代码如下:
- (void)startLoading
{
NSThread *currentThread = [NSThread currentThread];
NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
__weak typeof(self) weakSelf = self;
[[SonicEngine sharedEngine] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
[weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
}];
}
这个方法是在请求开始的时候,会被执行。这里做的主要操作是注册了回调,也就是请求结束返回结果后作出对应的操作。核心操作是回调了callClientActionWithParams,也就是我们将要呈现的第三个方法,代码如下:
- (void)callClientActionWithParams:(NSDictionary *)params
{
SonicURLProtocolAction action = [params[kSonicProtocolAction]integerValue];
switch (action) {
case SonicURLProtocolActionRecvResponse:
{
NSHTTPURLResponse *resp = params[kSonicProtocolData];
[self.client URLProtocol:self didReceiveResponse:resp cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}
break;
case SonicURLProtocolActionLoadData:
{
NSData *recvData = params[kSonicProtocolData];
if (recvData.length > 0) {
[self.client URLProtocol:self didLoadData:recvData];
}
}
break;
case SonicURLProtocolActionDidSuccess:
{
[self.client URLProtocolDidFinishLoading:self];
}
break;
case SonicURLProtocolActionDidFaild:
{
NSError *err = params[kSonicProtocolData];
[self.client URLProtocol:self didFailWithError:err];
}
break;
}
}
可以看到根据返回的不同的Action作出相应的处理。这里主要是把数据传回请求发起者client(这里就是UIWebView),帮助其正确渲染。
可以看到这个类和我们平时使用一样,主要就是拦截浏览器的请求,然后自定义请求得到数据返回给浏览器,进行渲染。
2. SonicEngine
顺藤摸瓜,我们看到第二个组件类SonicEngine,这是个单例对象类,通过上面的类图可以看到它主要作用是用来创建和管理SonicSession类,其对外暴露了两个重要的接口:
通过url和delegate来创建SonicSession
- (void)createSessionWithUrl:(NSString *)url withWebDelegate:(id<SonicSessionDelegate>)aWebDelegate withConfiguration:(SonicSessionConfiguration *)configuration
外部请求发起者注册结果回调处理
- (void)registerURLProtocolCallBackWithSessionID:(NSString *)sessionID completion:(SonicURLProtocolCallBack)protocolCallBack
这个类主要充当中转站的作用,同时管理SonicSession类,根据请求者和请求的URL分配Session来完成网络请求。比较简单,下面我们具体看下核心处理类SonicSession。
3. SonicSession
SonicSession由请求的url和delegate(WebView的持有者)唯一确定。首先我们看SonicSession何时后会被初始化。
在Sonic iOS提供的官方例子中可以看到,他们推荐在WebView初始化的时候,创建的SonicSession。Session的创建由SonicEngine来完成,代码如下:
- (void)createSessionWithUrl:(NSString *)url withWebDelegate:(id<SonicSessionDelegate>)aWebDelegate withConfiguration:(SonicSessionConfiguration *)configuration
{
...
[self.lock lock];
SonicSession *existSession = self.tasks[sonicSessionID(url)];
if (existSession && existSession.delegate != nil) {
//session can only owned by one delegate
[self.lock unlock];
return;
}
if (!existSession) {
//创建Session
existSession = [[SonicSession alloc] initWithUrl:url withWebDelegate:aWebDelegate Configuration:configuration];
NSURL *cUrl = [NSURL URLWithString:url];
existSession.serverIP = [self.ipDomains objectForKey:cUrl.host];
__weak typeof(self) weakSelf = self;
__weak typeof(existSession)weakSession = existSession;
[existSession setCompletionCallback:^(NSString *sessionID){
[weakSession cancel];
[weakSelf.tasks removeObjectForKey:sessionID];
}];
[self.tasks setObject:existSession forKey:existSession.sessionID];
[existSession start]; //启动Session
[existSession release];
} else {
if (existSession.delegate == nil) {
existSession.delegate = aWebDelegate;
}
}
[self.lock unlock];
}
可以看到SonicSession是由url唯一确定,并一次只能绑定到WebView上。
Sonic在初始化的时候会尝试从本地缓存中读取数据,如果数据存在,则直接将数据返回给请求着,否则,会等待请求结束后,将数据返回过去,并且将这次请求数据缓存下来。如果服务器最新的数据到达后,会根据返回码来选择性对浏览器已经渲染的视图进行修正。
这里我们需要介绍下Sonic的缓存和更新思路,Sonic将Html代码人为分为模板(Template)和数据(Data)。通过代码注释的方式,增加了“sonicdiff-xxx”来标注一个数据块的开始与结束。模板就是将数据块抠掉之后的Html,然后通过{albums}来表示这个是一个数据块占位。数据就是JSON格式,直接Key-Value。如图是官方一张图
由于我们HTML页面模板更新的频率比较低,而HTML需要展示的数据则更新频繁。通过这个思路,就可以实现HTML页面的增量更新。具体思想可以前往VasSonic:手Q开源Hybrid框架介绍。
这样客户端就可以根据服务器返回的请求头来增量更新HTML页面。具体代码如下:
- (void)updateDidSuccess
{
switch (self.sonicServer.response.statusCode) {//获得Response响应头
case 304: //完全使用缓存
{
self.sonicStatusCode = SonicStatusCodeAllCached;
self.sonicStatusFinalCode = SonicStatusCodeAllCached;
//update headers
[[SonicCache shareCache] saveResponseHeaders:self.sonicServer.response.allHeaderFields withSessionID:self.sessionID];
}
break;
case 200: // Only need to request dynamic data.
{
if (![self.sonicServer isSonicResponse]) {
[[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
NSLog(@"Clear cache because while not sonic repsonse!");
break;
}
if ([self isTemplateChange]) {
[self dealWithTemplateChange];
}else{
[self dealWithDataUpdate];
}
NSString *policy = [self.sonicServer responseHeaderForKey:SonicHeaderKeyCacheOffline];
if ([policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
[[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
[[SonicCache shareCache]removeCacheBySessionID:self.sessionID];
}
if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
[[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];
}
}
}
break;
default:
{
}
break;
}
//use the call back to tell web page which mode used
if (self.webviewCallBack) {
NSDictionary *resultDict = [self sonicDiffResult];
if (resultDict) {
self.webviewCallBack(resultDict);
}
}
...
}
由以上代码可以看到,首页会判断服务器返回response的状态码,304表示HTML的模板和数据均没有更新,直接使用缓存的数据。这个时候客服端不需要进行任何操作。
如果是200,则需要判断是模板更新还是数据更新。接下来我们具体看下数据更新和模板更新会做什么操作:
- 数据更新函数代码如下:
- (void)dealWithDataUpdate
{
NSString *htmlString = nil;
if (self.sonicServer.isInLocalServerMode) {
NSDictionary *serverResult = [self.sonicServer sonicItemForCache];
htmlString = serverResult[kSonicHtmlFieldName];
}
SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData:self.sonicServer.responseData withHtmlString:htmlString withResponseHeaders:self.sonicServer.response.allHeaderFields withUrl:self.url];
if (cacheItem) {
self.sonicStatusCode = SonicStatusCodeDataUpdate;
self.sonicStatusFinalCode = SonicStatusCodeDataUpdate;
self.localRefreshTime = cacheItem.lastRefreshTime;
self.cacheFileData = cacheItem.htmlData;
self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
if (_diffData) {
[_diffData release];
_diffData = nil;
}
_diffData = [cacheItem.diffData copy];
self.isDataFetchFinished = YES;
}
}
这里主要工作是取出缓存的html模板,然后将更新后的数据和html模板进行合并。生成新的cacheItem,随后更新本地数据。这里更新数据的格式是JSON格式,key为elementTag Id,value为tag显示的数据。可能有人会有疑惑,最新的数据怎么更新浏览器的显示。我们看到上面updateDidSuccess函数末尾有一段代码。调用了webViewCallback回调,这个操作就完成了将更新的数据通过native调用js的方式来更新数据。
- Html模板更新:
- (void)dealWithTemplateChange
{
NSDictionary *serverResult = [self.sonicServer sonicItemForCache];
SonicCacheItem *cacheItem = [[SonicCache shareCache] saveHtmlString:serverResult[kSonicHtmlFieldName] templateString:serverResult[kSonicTemplateFieldName] dynamicData:serverResult[kSonicDataFieldName] responseHeaders:self.sonicServer.response.allHeaderFields withUrl:self.url];//更新缓存
if (cacheItem) {
self.sonicStatusCode = SonicStatusCodeTemplateUpdate;
self.sonicStatusFinalCode = SonicStatusCodeTemplateUpdate;
self.localRefreshTime = cacheItem.lastRefreshTime;
self.cacheFileData = self.sonicServer.responseData;
self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
self.isDataFetchFinished = YES;
if (!self.didFinishCacheRead) {
return;
}
NSString *opIdentifier = dispatchToMain(^{
NSString *policy = [self.sonicServer responseHeaderForKey:SonicHeaderKeyCacheOffline];
if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
if (self.delegate && [self.delegate respondsToSelector:@selector(session:requireWebViewReload:)]) { //通知浏览器重新加载页面
NSURLRequest *sonicRequest = [NSURLRequest requestWithURL:[NSURL URLWithString:self.url]];
[self.delegate session:self requireWebViewReload:[SonicUtil sonicWebRequestWithSession:self withOrigin:sonicRequest]];
}
}
});
[self.mainQueueOperationIdentifiers addObject:opIdentifier];
}
}
由于模板更新会更新整个网页结构,因此,避免不了需要重新刷新浏览器。首先会解析出返回htmlString中包含的模板和数据,然后分别进行存储,并更新本地变量。最后利用 [self.delegate session:self requireWebViewReload:[SonicUtil sonicWebRequestWithSession:self withOrigin:sonicRequest]];
这句代码来通知浏览器刷新。
以上分析的是存在缓存,处理的结果,如果是第一次加载页面,那么就不会走这个策略。直接通知浏览器渲染并存储网页数据。比较简单,这里就不再赘述。
4. SonicServer
SonicServer是一个中间层,用来对HTTP连接的request和response进行预处理。SonicServer内部自己组装NSHTTPURLResponse对象,然后返回给上层。此外,SonicServer还支持本地服务模式。当设置enableLocalSever
为true时,如果本地存在缓存,则直接将本地缓存数据传给浏览器进行渲染,等到网络数据接收完成时候,才通知数据的更新。
下面我们具体分析下在LocalServerMode模式下数据接收完成的处理操作。
- (void)connectionDidCompleteWithoutError:(SonicConnection *)connection
{
self.isCompletion = YES;
if (self.isInLocalServerMode) {//本地服务器模式,这个时候请求彻底完成,数据也接收完成,直接更新缓存了
do {
//if http status is 304, there is nothing changed
if (self.response.statusCode == 304) {
NSLog(@"response status 304!");
break;
}
self.htmlString = [[[NSString alloc]initWithData:self.responseData encoding:[self encodingFromHeaders]] autorelease];
NSDictionary *splitResult = [SonicUtil splitTemplateAndDataFromHtmlData:self.htmlString];
if (splitResult) {
self.templateString = splitResult[kSonicTemplateFieldName];
self.data = splitResult[kSonicDataFieldName];
}
NSMutableDictionary *headers = [[_response.allHeaderFields mutableCopy]autorelease];
if (![headers objectForKey:SonicHeaderKeyCacheOffline]) { // refresh this time
[headers setValue:@"true" forKey:[SonicHeaderKeyCacheOffline lowercaseString]];
}
NSString *htmlSha1 = nil;
NSString *responseEtag = [headers objectForKey:[SonicHeaderKeyETag lowercaseString]];
if (!responseEtag) {
responseEtag = htmlSha1 = getDataSha1([self.htmlString dataUsingEncoding:NSUTF8StringEncoding]);
[headers setObject:responseEtag forKey:[SonicHeaderKeyETag lowercaseString]];
}
NSString *requestEtag = [self.request.allHTTPHeaderFields objectForKey:HTTPHeaderKeyIfNoneMatch];
if ([responseEtag isEqualToString:requestEtag]) { // Case:hit 304
[headers setValue:@"false" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:304 HTTPVersion:nil headerFields:headers]autorelease];
// Update response data
...
break;
}
NSString *responseTemplateTag = [headers objectForKey:[SonicHeaderKeyTemplate lowercaseString]];
if (!responseTemplateTag) {
responseTemplateTag = getDataSha1([self.templateString dataUsingEncoding:NSUTF8StringEncoding]);
[headers setValue:responseTemplateTag forKey:[SonicHeaderKeyTemplate lowercaseString]];
}
NSString *requestTemplateTag = [self.request.allHTTPHeaderFields objectForKey:SonicHeaderKeyTemplate];
if ([responseTemplateTag isEqualToString:requestTemplateTag]) { // Case:data update
NSError *jsonError = nil;
NSMutableDictionary *jsonDict = [NSMutableDictionary dictionaryWithDictionary:self.data];
if (!htmlSha1) {
htmlSha1 = getDataSha1([self.htmlString dataUsingEncoding:NSUTF8StringEncoding]);
}
[jsonDict setObject:htmlSha1 forKey:SonicHeaderKeyHtmlSha1];
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonDict options:NSJSONWritingPrettyPrinted error:&jsonError];
if (!jsonError) {
[headers setValue:@"false" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:200 HTTPVersion:nil headerFields:headers]autorelease];
// Update response data
...
break;
}
}
// Case:template-change
[headers setValue:@"true" forKey:[SonicHeaderKeyTemplateChange lowercaseString]];
NSHTTPURLResponse *newResponse = [[[NSHTTPURLResponse alloc]initWithURL:_response.URL statusCode:200 HTTPVersion:nil headerFields:headers]autorelease];
...
_response = [newResponse retain];
break;
} while (true);
//First request need't to load again
if (![self isFirstLoadRequest]) {
[self.delegate server:self didRecieveResponse:self.response];
[self.delegate server:self didReceiveData:self.responseData];
}
}
[self.delegate serverDidCompleteWithoutError:self];
}
上面代码流程如下:
- 判断服务器返回的网页HtmlString和本地缓存的HtmlString的hash值是否一致,如果一致,说明网页没有改变,命中304,客户端什么操作都不用做。
- 判断服务器返回的网页模板和本地缓存HTML模板的差异,如果没有变更,那么判断是data变更。这时候更新本地数据
- 最后只可能是网页模板更新,则在请求头标记模板更新,交由上层进行处理。也就是SonicSession
最后回调SonicSession的serverDidCompleteWithoutError方法。
5. SonicConnection
SonicConnection最接近网络层,直接操作的是NSURLSession,然后将NSURLSession返回的数据直接向上传递,比较简单。
思考
这样Sonic iOS的整体源码就分析完成了。如有不当之处,欢迎大家批评指正。
总的来说,Sonic通过服务器和客户端约定的协议,可以实现网页的缓存和部分动态更新功能,为有效提升H5的加载速度提供了一种新的思路,可以有效提升用户体验。但是Sonic需要人为对网页结构进行划分,还需要服务器的协助,具有过高的侵入性,引入成本比较高。是否引入需要考虑项目对WebView的依赖程度。
值得学习的是Sonic在首次加载页面的时候在未创建UIWebView之前建立起网络链接,等待UIWebView发起主资源请求到NSURLProtocol层完成拦截,并且将提前发起的数据流通过NSURLProtocol返回给WebKit,实现网络提前的并行加载。