最近在研讀Vas Sonic的源碼,Sonic是一款輕量級的高性能Hybrid框架,由騰訊QQ會員團隊開發(fā),專注于提升H5頁面首屏加載速度檐春。
首屏就是指用戶在沒有滾動時候看到的內(nèi)容渲染完成并且可以交互的時間。至于加載時間么伯,則是整個頁面滾動到底部疟暖,所有內(nèi)容加載完畢并可交互的時間。
H5以其開發(fā)和維護的成本較低田柔,開發(fā)周期較短的天然優(yōu)勢滿足了APP快速迭代的需求俐巴。目前很多APP或多或少接入了H5頁面,但H5存在的缺點是加載速度慢硬爆,造成不好的用戶體驗欣舵。因此,如何優(yōu)化H5的加載速度可以有效提升用戶的滿意度缀磕。
話不多說缘圈,接下來我們看看Sonic這個開源庫到底是一個什么樣的實現(xiàn)原理,首先給大家奉上Sonic的GitHub地址 點我
看一個開源庫,我通常會摸清楚其類層次關(guān)系袜蚕,從整體把握其組件糟把,然后在抽繭剝絲。不然會像走入一個迷宮牲剃,有種“只在此山中遣疯,云深不知處”的感覺。
下面是我繪制Sonic iOS庫的UML類圖:
可以梳理出其包含SonicURLProtocol, SonicEngine, SonicSession, SonicSever和SonicConnection五個組件及其相互之間的聯(lián)系凿傅。下面我們從源碼中來分析這幾個組件的角色和發(fā)揮的作用缠犀。
1. SonicURLProtocol
看到SonicURLProtocol這個類,我們立刻就能聯(lián)想到Foundation庫的NSURLProtocol類狭归,用戶可以通過子類化NSURLProtocol類來對上層的URLRequest請求做攔截,并根據(jù)自己的需求場景做定制化響應(yīng)處理文判。具體介紹詳見iOS 開發(fā)中使用 NSURLProtocol 攔截 HTTP 請求过椎。SonicURLProtocol利用這個原理來對UIWebView的請求進行攔截,實現(xiàn)自定義頁面數(shù)據(jù)加載和緩存戏仓。
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我們才能夠繼續(xù)后續(xù)的處理赏殃。通過這個方法的實現(xiàn)里面進行請求的過濾敷待,篩選出webView的網(wǎng)絡(luò)請求進行處理的請求。也就是請求頭中含有key值SonicHeaderKeyLoadType對應(yīng)的值為SonicHeaderValueWebviewLoad的NSURLRequest需要被攔截仁热。
接著會根據(jù)請求頭中的delegate去SonicEngine中尋找SonicSession,如果找到了對應(yīng)的SonicSession榜揖,接下來會對這個request進行攔截。 那么SonicSession是什么時候被初始化并注冊到SonicEngine中的呢?后面我們會進行講解举哟。
下面我們繼續(xù)看第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];
}];
}
這個方法是在請求開始的時候,會被執(zhí)行妨猩。這里做的主要操作是注冊了回調(diào)潜叛,也就是請求結(jié)束返回結(jié)果后作出對應(yīng)的操作。核心操作是回調(diào)了callClientActionWithParams壶硅,也就是我們將要呈現(xiàn)的第三個方法威兜,代碼如下:
- (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;
}
}
可以看到根據(jù)返回的不同的Action作出相應(yīng)的處理。這里主要是把數(shù)據(jù)傳回請求發(fā)起者client(這里就是UIWebView)庐椒,幫助其正確渲染椒舵。
可以看到這個類和我們平時使用一樣,主要就是攔截瀏覽器的請求扼睬,然后自定義請求得到數(shù)據(jù)返回給瀏覽器逮栅,進行渲染。
2. SonicEngine
順藤摸瓜窗宇,我們看到第二個組件類SonicEngine,這是個單例對象類措伐,通過上面的類圖可以看到它主要作用是用來創(chuàng)建和管理SonicSession類,其對外暴露了兩個重要的接口:
通過url和delegate來創(chuàng)建SonicSession
- (void)createSessionWithUrl:(NSString *)url withWebDelegate:(id<SonicSessionDelegate>)aWebDelegate withConfiguration:(SonicSessionConfiguration *)configuration
外部請求發(fā)起者注冊結(jié)果回調(diào)處理
- (void)registerURLProtocolCallBackWithSessionID:(NSString *)sessionID completion:(SonicURLProtocolCallBack)protocolCallBack
這個類主要充當(dāng)中轉(zhuǎn)站的作用军俊,同時管理SonicSession類侥加,根據(jù)請求者和請求的URL分配Session來完成網(wǎng)絡(luò)請求。比較簡單粪躬,下面我們具體看下核心處理類SonicSession担败。
3. SonicSession
SonicSession由請求的url和delegate(WebView的持有者)唯一確定。首先我們看SonicSession何時后會被初始化镰官。
在Sonic iOS提供的官方例子中可以看到提前,他們推薦在WebView初始化的時候,創(chuàng)建的SonicSession泳唠。Session的創(chuàng)建由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) {
//創(chuàng)建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在初始化的時候會嘗試從本地緩存中讀取數(shù)據(jù)拓哺,如果數(shù)據(jù)存在,則直接將數(shù)據(jù)返回給請求著脖母,否則士鸥,會等待請求結(jié)束后,將數(shù)據(jù)返回過去谆级,并且將這次請求數(shù)據(jù)緩存下來烤礁。如果服務(wù)器最新的數(shù)據(jù)到達后讼积,會根據(jù)返回碼來選擇性對瀏覽器已經(jīng)渲染的視圖進行修正。
這里我們需要介紹下Sonic的緩存和更新思路鸽凶,Sonic將Html代碼人為分為模板(Template)和數(shù)據(jù)(Data)币砂。通過代碼注釋的方式,增加了“sonicdiff-xxx”來標(biāo)注一個數(shù)據(jù)塊的開始與結(jié)束玻侥。模板就是將數(shù)據(jù)塊摳掉之后的Html决摧,然后通過{albums}來表示這個是一個數(shù)據(jù)塊占位。數(shù)據(jù)就是JSON格式凑兰,直接Key-Value掌桩。如圖是官方一張圖
由于我們HTML頁面模板更新的頻率比較低,而HTML需要展示的數(shù)據(jù)則更新頻繁姑食。通過這個思路波岛,就可以實現(xiàn)HTML頁面的增量更新。具體思想可以前往VasSonic:手Q開源Hybrid框架介紹音半。
這樣客戶端就可以根據(jù)服務(wù)器返回的請求頭來增量更新HTML頁面则拷。具體代碼如下:
- (void)updateDidSuccess
{
switch (self.sonicServer.response.statusCode) {//獲得Response響應(yīng)頭
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);
}
}
...
}
由以上代碼可以看到,首頁會判斷服務(wù)器返回response的狀態(tài)碼曹鸠,304表示HTML的模板和數(shù)據(jù)均沒有更新煌茬,直接使用緩存的數(shù)據(jù)。這個時候客服端不需要進行任何操作彻桃。
如果是200坛善,則需要判斷是模板更新還是數(shù)據(jù)更新。接下來我們具體看下數(shù)據(jù)更新和模板更新會做什么操作:
- 數(shù)據(jù)更新函數(shù)代碼如下:
- (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模板邻眷,然后將更新后的數(shù)據(jù)和html模板進行合并眠屎。生成新的cacheItem,隨后更新本地數(shù)據(jù)。這里更新數(shù)據(jù)的格式是JSON格式肆饶,key為elementTag Id,value為tag顯示的數(shù)據(jù)改衩。可能有人會有疑惑驯镊,最新的數(shù)據(jù)怎么更新瀏覽器的顯示葫督。我們看到上面updateDidSuccess函數(shù)末尾有一段代碼。調(diào)用了webViewCallback回調(diào)阿宅,這個操作就完成了將更新的數(shù)據(jù)通過native調(diào)用js的方式來更新數(shù)據(jù)候衍。
- 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];
}
}
由于模板更新會更新整個網(wǎng)頁結(jié)構(gòu)笼蛛,因此洒放,避免不了需要重新刷新瀏覽器。首先會解析出返回htmlString中包含的模板和數(shù)據(jù),然后分別進行存儲滨砍,并更新本地變量往湿。最后利用 [self.delegate session:self requireWebViewReload:[SonicUtil sonicWebRequestWithSession:self withOrigin:sonicRequest]];
這句代碼來通知瀏覽器刷新妖异。
以上分析的是存在緩存,處理的結(jié)果领追,如果是第一次加載頁面他膳,那么就不會走這個策略。直接通知瀏覽器渲染并存儲網(wǎng)頁數(shù)據(jù)绒窑。比較簡單棕孙,這里就不再贅述。
4. SonicServer
SonicServer是一個中間層些膨,用來對HTTP連接的request和response進行預(yù)處理蟀俊。SonicServer內(nèi)部自己組裝NSHTTPURLResponse對象,然后返回給上層订雾。此外肢预,SonicServer還支持本地服務(wù)模式。當(dāng)設(shè)置enableLocalSever
為true時洼哎,如果本地存在緩存烫映,則直接將本地緩存數(shù)據(jù)傳給瀏覽器進行渲染,等到網(wǎng)絡(luò)數(shù)據(jù)接收完成時候噩峦,才通知數(shù)據(jù)的更新锭沟。
下面我們具體分析下在LocalServerMode模式下數(shù)據(jù)接收完成的處理操作。
- (void)connectionDidCompleteWithoutError:(SonicConnection *)connection
{
self.isCompletion = YES;
if (self.isInLocalServerMode) {//本地服務(wù)器模式壕探,這個時候請求徹底完成冈钦,數(shù)據(jù)也接收完成,直接更新緩存了
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];
}
上面代碼流程如下:
- 判斷服務(wù)器返回的網(wǎng)頁HtmlString和本地緩存的HtmlString的hash值是否一致李请,如果一致瞧筛,說明網(wǎng)頁沒有改變,命中304导盅,客戶端什么操作都不用做较幌。
- 判斷服務(wù)器返回的網(wǎng)頁模板和本地緩存HTML模板的差異,如果沒有變更白翻,那么判斷是data變更乍炉。這時候更新本地數(shù)據(jù)
- 最后只可能是網(wǎng)頁模板更新,則在請求頭標(biāo)記模板更新滤馍,交由上層進行處理岛琼。也就是SonicSession
最后回調(diào)SonicSession的serverDidCompleteWithoutError方法。
5. SonicConnection
SonicConnection最接近網(wǎng)絡(luò)層巢株,直接操作的是NSURLSession槐瑞,然后將NSURLSession返回的數(shù)據(jù)直接向上傳遞,比較簡單阁苞。
思考
這樣Sonic iOS的整體源碼就分析完成了困檩。如有不當(dāng)之處祠挫,歡迎大家批評指正。
總的來說悼沿,Sonic通過服務(wù)器和客戶端約定的協(xié)議等舔,可以實現(xiàn)網(wǎng)頁的緩存和部分動態(tài)更新功能,為有效提升H5的加載速度提供了一種新的思路糟趾,可以有效提升用戶體驗慌植。但是Sonic需要人為對網(wǎng)頁結(jié)構(gòu)進行劃分,還需要服務(wù)器的協(xié)助义郑,具有過高的侵入性涤浇,引入成本比較高。是否引入需要考慮項目對WebView的依賴程度魔慷。
值得學(xué)習(xí)的是Sonic在首次加載頁面的時候在未創(chuàng)建UIWebView之前建立起網(wǎng)絡(luò)鏈接只锭,等待UIWebView發(fā)起主資源請求到NSURLProtocol層完成攔截,并且將提前發(fā)起的數(shù)據(jù)流通過NSURLProtocol返回給WebKit,實現(xiàn)網(wǎng)絡(luò)提前的并行加載院尔。