輕量級高性能Hybrid框架VasSonic秒開實現解析

H5很重要,H5很重要,H5很重要,重要的事情要說三遍。VasSonic是騰訊開源的解決H5首屏渲染痛點的開源項目,本文通過解讀代碼來學習WebView的優化思路。

H5的優劣

H5的優勢很明顯,跨平臺、迭代快、開發體驗好。H5的劣勢同樣明顯,加載慢,用戶體驗差。業內大牛想盡各種方法來彌補H5的劣勢,初級使用緩存、預加載等常用方案,高級如Hybrid、ReactNative、Weex等H5的進階解決方案。VasSonic專注于H5的秒開,使用的也是我們常見的性能優化方案。本文嘗試了解VasSonic是如何用常見的手段將性能優化做到極致的。

VasSonic解決什么問題

關于WebView為什么打開慢、加載慢,業界已經有很多分析了,結論也是比較一致的,推薦美團點評技術團隊的WebView性能、體驗分析與優化,騰訊關于VasSonic的官方文章也有相關說明

WebView加載慢的問題主要集中在如下三個階段:

  1. WebView打開
  2. 頁面資源加載
  3. 數據更新導致頁面刷新

VasSonic的優化都是為了加速上述三個階段,其經驗可以總結為六個方面。

  • WebView池:預先初始化WebView
  • 靜態直出:服務端拉取數據渲染完畢后,通過CDN加速訪問
  • 離線預推:離線包方案
  • 并行加速:WebView的打開和資源的請求并行
  • 動態緩存:動態頁面緩存在客戶端,用戶下次打開的時候先打開緩存頁面,然后再刷新
  • 動靜分離:為了提升體驗,將頁面分為靜態模板和動態數據,實現局部刷新
  • 預加載:在打開頁面之前將資源數據都準備好,提升頁面打開的速度

可以說是非常全面了,具體細節可以參考騰訊祭出大招VasSonic,讓你的H5頁面首屏秒開!

上述優化的核心技術主要涉及幾個方面:

  • WebView池
  • 緩存設計
  • 資源請求和WebView分離設計
  • 動靜分離設計

下面結合代碼來看看VasSonic是如何實現這些優化點的。

準備工作:
github VasSonic clone最新代碼,打開sonic-iOS目錄下的SonicSample。

WebView池

UIWebView并不是開源的,想要通過修改源碼來提升打開速度是不太現實的。VasSonic采用的方案是預先創建WebView池。在應用啟動或者空閑的時候預先創建空的WebView,等真正要用的時候直接從池中獲取WebView。

Demo中只是簡單的預加載了一次WebView,通過創建空的WebView,可以預先啟動Web線程,完成WebView的一些全局性的初始化工作,對二次創建WebView能有數百毫秒的提升。在實際應用中,我們可以采用WebView池的方式來進一步提升打開速度。

//start web thread
UIWebView *webPool = [[UIWebView alloc]initWithFrame:CGRectZero];
[webPool loadHTMLString:@"" baseURL:nil]; // 注意loadHTMLString是必須的

緩存設計

緩存類型

VasSonic將緩存的類型分成了四種,他們分別是模板、頁面、數據和配置。

    /*
     * template
     */
    SonicCacheTypeTemplate,
    /*
     * html
     */
    SonicCacheTypeHtml,
    /*
     * dynamic data
     */
    SonicCacheTypeData,
    /*
     * config
     */
    SonicCacheTypeConfig,

將模板和數據分離是實現動靜分離的核心技術,模板和數據是從頁面數據中自動分離出來的,緩存頁面數據的時候,SonicCache會調用splitTemplateAndDataFromHtmlData:分割模板和數據,代碼實現如下:

- (NSDictionary *)splitTemplateAndDataFromHtmlData:(NSString *)html
{
    // 使用sonicdiff這個tag來將HTML分割成模板和數據
    NSError *error = nil;
    NSRegularExpression *reg = [NSRegularExpression regularExpressionWithPattern:@"<!--sonicdiff-?(\\w*)-->([\\s\\S]+?)<!--sonicdiff-?(\\w*)-end-->" options:NSRegularExpressionCaseInsensitive error:&error];
    if (error) {
        return nil;
    }
    
    // 分割出來的數據,以sonicdiff指定的名字key保存到數據字典中
    NSArray *metchs = [reg matchesInString:html options:NSMatchingReportCompletion range:NSMakeRange(0, html.length)];
    
    NSMutableDictionary *dataDict = [NSMutableDictionary dictionary];
    [metchs enumerateObjectsUsingBlock:^(NSTextCheckingResult *obj, NSUInteger idx, BOOL * _Nonnull stop) {
        NSString *matchStr = [html substringWithRange:obj.range];
        NSArray *seprateArr = [matchStr componentsSeparatedByString:@"<!--sonicdiff-"];
        NSString *itemName = [[[seprateArr lastObject]componentsSeparatedByString:@"-end-->"]firstObject];
        NSString *formatKey = [NSString stringWithFormat:@"{%@}",itemName];
        [dataDict setObject:matchStr forKey:formatKey];
    }];
    
    // 分割出來的模板,用key來替換動態數據的位置
    NSMutableString *mResult = [NSMutableString stringWithString:html];
    [dataDict enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *value, BOOL * _Nonnull stop) {
        [mResult replaceOccurrencesOfString:value withString:key options:NSCaseInsensitiveSearch range:NSMakeRange(0, mResult.length)];
    }];
    
    //if split HTML faild , we can return nothing ,it is not a validat sonic request.
    if (dataDict.count == 0 || mResult.length == 0) {
        return nil;
    }
    
    return @{@"data":dataDict,@"temp":mResult};
}

還是以Demo為例看split的結果。

// 原始頁面數據
<span id="data1Content">
    <!--sonicdiff-data1-->
    <p>示例:</p>
    ![](//mc.vip.qq.com/img/img-1.png?max_age=2592000)
    <!--sonicdiff-data1-end-->
</span>

// 分離之后的結果
// --模板
<span id="data1Content">
    {data1}
</span>

// --數據
{
  "{data1}" = "<!--sonicdiff-data1-->
\n    <p>\U793a\U4f8b\Uff1a</p>
\n    <img src=\"http://mc.vip.qq.com/img/img-1.png?max_age=2592000\" alt=\"\">
\n    <!--sonicdiff-data1-end-->";
}

除了頁面、模板、數據類型的緩存外,還有一個非常重要的緩存是config。先看下config的生成。

- (NSDictionary *)createConfigFromResponseHeaders:(NSDictionary *)headers
{
    //Etag,template-tag
    NSString *eTag = headers[@"Etag"];
    NSString *templateTag = headers[@"template-tag"];
    NSString *csp = headers[SonicHeaderKeyCSPHeader];
    NSTimeInterval timeNow = (long)[[NSDate date ]timeIntervalSince1970]*1000;
    NSString *localRefresh = [@(timeNow) stringValue];
    
    //save configs
    eTag = eTag.length > 0? eTag:@"";
    templateTag = templateTag.length > 0? templateTag:@"";
    eTag = eTag.length > 0? eTag:@"";
    csp = csp.length > 0? csp:@"";
    
    NSDictionary *cfgDict = @{
                              SonicHeaderKeyETag:eTag,
                              SonicHeaderKeyTemplate:templateTag,
                              kSonicLocalRefreshTime:localRefresh,
                              kSonicCSP:csp
                              };
    return cfgDict;
}

ETag大家應該是比較清楚的,在HTTP的緩存設計中有重要作用,當服務端發現客戶端請求帶的資源的ETag和服務端一樣的話,就不會返回完整的資源內容了,節省時間和帶寬,templateTag也是類似的,當templateTag不一樣的時候,服務端才會更新模板。

簡而言之,Config就是保存了這次請求頭中的一些重要信息,留待下次請求的時候發還給服務端做優化。

緩存Key

說完緩存類型,必須要說一下緩存的key,這個非常重要。首次請求會調用saveFirstWithHtmlData:withResponseHeaders:withUrl緩存數據。入參有htmlData、header和url,前面已經分析htmlData是需要緩存的頁面數據,htmlData會被存成html、template和dynamicData三種類型,headers前面也提到了是緩存成config,那這個url的作用就是生成緩存的key。

- (SonicCacheItem *)saveFirstWithHtmlData:(NSData *)htmlData
                      withResponseHeaders:(NSDictionary *)headers
                                  withUrl:(NSString *)url
{
    NSString *sessionID = sonicSessionID(url);
    
    if (!htmlData || headers.count == 0 || sessionID.length == 0) {
        return nil;
    }
    
    SonicCacheItem *cacheItem = [self cacheForSession:sessionID];

    ......
}

首先根據url生成sessionID,然后再將sessionID和特定的SonicCacheItem實例綁定。這里我們先說明每個固定url生成的sessionID是一樣的,這才能讓我們在相同的url請求的情況下使用緩存,具體的url生成sessionID的規則在SonicSession章節詳細說明。

SonicCacheItem

每個緩存Key,也就是根據url生成的sessionID都會對應一個SonicCacheItem的實例,用來緩存所有的數據。SonicCacheItem也就是一個緩存的數據結構,包含htmlData、templateString、dynamicData、diffData等等。

/**
 * Memory cache item.
 */
@interface SonicCacheItem : NSObject

/** Html. */
@property (nonatomic,retain)NSData         *htmlData;

/** Config. */
@property (nonatomic,retain)NSDictionary   *config;

/** Session. */
@property (nonatomic,readonly)NSString     *sessionID;

/** Template string. */
@property (nonatomic,copy)  NSString       *templateString;

/** Generated by local dynamic data and server dynamic data. */
@property (nonatomic,retain)NSDictionary   *diffData;

/** Sonic divide HTML to tepmlate and dynamic data.  */
@property (nonatomic,retain)NSDictionary   *dynamicData;

/** Is there file cache exist. */
@property (nonatomic,readonly)BOOL         hasLocalCache;

/** Last refresh time.  */
@property (nonatomic,readonly)NSString     *lastRefreshTime;

/** Cache some header fields which will be used later. */
@property (nonatomic,readonly)NSDictionary *cacheResponseHeaders;

/** Initialize an item with session id. */
- (instancetype)initWithSessionID:(NSString *)aSessionID;

@end

SonicSession

講緩存的時候我們提到過作為緩存Key的sessionID,每個sessionID關聯了一個緩存對象SonicCacheItem,同時也關聯了一次URL請求,VasSonic將這個請求抽象為SonicSession。SonicSession在VasSonic的設計里面非常關鍵。其將資源的請求和WebView脫離開來,有了SonicSession,結合SonicCache,我們就可以不依賴WebView去做資源的請求,這樣就可以實現WebView打開和資源加載并行、資源預加載等加速方案。

SessionID

每個sessionID唯一指定了一個SonicSession,sessionID的生成規則如下:

NSString *sonicSessionID(NSString *url)
{
    if ([[SonicClient sharedClient].currentUserUniq length] > 0) {
        return stringFromMD5([NSString stringWithFormat:@"%@_%@",[SonicClient sharedClient].currentUserUniq,sonicUrl(url)]);
    }else{
        return stringFromMD5([NSString stringWithFormat:@"%@",sonicUrl(url)]);
    }
}

每個url都能唯一的確定一個sessionID,需要注意的是,算md5的時候并不是直接拿請求的url來算的,而是先經過了sonicUrl的函數的處理。理解sonicUrl對url的處理有助于我們了解VasSonic的session管理機制。

其實sonicUrl做的事情比較簡單。

  • 對于一般的url來說,sonicUrl會只保留scheme、host和path,url其他部分的改變不會創建新的session
  • 新增了sonic_remain_params參數,sonic_remain_params里面指定的query參數不同會創建新的session。

舉栗說明:

// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com")

// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com:8080") 

// output: @"https://www.example.com"
sonicUrl(@"https://www.example.com/?foo=foo")  

// output: @"https://www.example.com/path"
sonicUrl(@"https://www.example.com/path?foo=foo")

// output @"https://www.example.com/path/foo=foo&"
sonicUrl(@"https://www.example.com/path?foo=foo&bar=bar&sonic_remain_params=foo")

sonicUrl的代碼也比較簡單,這里就不貼了,有興趣的同學可以參考這里sonicUrl實現

自定義請求頭

之前提到過SonicCache的一種緩存類型是Config,SonicSession在初始化時候會根據緩存的Config更新請求頭,以便服務端根據這些信息做相應的優化。

- (void)setupData
{
    // 根據sessionID獲取緩存內容
    SonicCacheItem *cacheItem = [[SonicCache shareCache] cacheForSession:_sessionID];
    self.isFirstLoad = cacheItem.hasLocalCache;
    
    if (!cacheItem.hasLocalCache) {
        self.cacheFileData = cacheItem.htmlData;
        self.cacheConfigHeaders = cacheItem.config;
        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
        self.localRefreshTime = cacheItem.lastRefreshTime;
    }
    
    [self setupConfigRequestHeaders];
}

- (void)setupConfigRequestHeaders
{
    NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionaryWithDictionary:self.request.allHTTPHeaderFields];
    // 根據緩存設置Etag、templateTag等
    NSDictionary *cfgDict = [self getRequestParamsFromConfigHeaders];
    if (cfgDict) {
        [mCfgDict addEntriesFromDictionary:cfgDict];
    }
    // 添加一些自定義的緩存頭
    [mCfgDict setObject:@"true" forKey:@"accept-diff"];
    [mCfgDict setObject:@"true" forKey:@"no-Chunked"];
    [mCfgDict setObject:@"GET" forKey:@"method"];
    [mCfgDict setObject:@"utf-8" forKey:@"accept-Encoding"];
    [mCfgDict setObject:@"zh-CN,zh;" forKey:@"accept-Language"];
    [mCfgDict setObject:@"gzip" forKey:@"accept-Encoding"];
    [mCfgDict setObject:SonicHeaderValueSDKVersion  forKey:SonicHeaderKeySDKVersion];
    [mCfgDict setObject:SonicHeaderValueSonicLoad forKey:SonicHeaderKeyLoadType];
    // 可以自定義UA,方便app判斷
    NSString *userAgent = [SonicClient sharedClient].userAgent.length > 0? [SonicClient sharedClient].userAgent:[[SonicClient sharedClient] sonicDefaultUserAgent];
    [mCfgDict setObject:userAgent forKey:@"User-Agent"];

    NSURL *cUrl = [NSURL URLWithString:self.url];

    // 替換域名為ip,免去dns解析的耗時
    if (self.serverIP.length > 0) {
        NSString *host = [cUrl.scheme isEqualToString:@"https"]? [NSString stringWithFormat:@"%@:443",self.serverIP]:[NSString stringWithFormat:@"%@:80",self.serverIP];
        NSString *newUrl = [self.url stringByReplacingOccurrencesOfString:cUrl.host withString:host];
        cUrl = [NSURL URLWithString:newUrl];
        [mCfgDict setObject:cUrl.host forKey:@"Host"];
    }
    
    [self.request setAllHTTPHeaderFields:mCfgDict];
}

- (NSDictionary *)getRequestParamsFromConfigHeaders
{
    NSDictionary *cfgDict = self.cacheConfigHeaders;
    NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionary];
    
    if (cfgDict) {
        // 設置eTag信息
        NSString *eTag = cfgDict[SonicHeaderKeyETag];
        if (eTag.length > 0) {
            [mCfgDict setObject:eTag forKey:@"If-None-Match"];
        }
        // 設置templateTag信息
        NSString *tempTag = cfgDict[SonicHeaderKeyTemplate];
        if (tempTag.length > 0 ) {
            [mCfgDict setObject:tempTag forKey:@"template-tag"];
        }
    }else{
        [mCfgDict setObject:@"" forKey:@"If-None-Match"];
        [mCfgDict setObject:@"" forKey:@"template-tag"];
    }
    
    return mCfgDict;
}

除了會添加自定義的請求頭參數,以及將緩存的config加到請求頭里面外,在每次發起請求之前,都會同步cookies,這樣就可以保持狀態了,比如登陸狀態等等。

- (void)start
{
    dispatchToMain(^{
        if (self.delegate && [self.delegate respondsToSelector:@selector(sessionWillRequest:)]) {
            [self.delegate sessionWillRequest:self];
        }
        [self syncCookies];
    });

    [self requestStartInOperation];
}

- (void)syncCookies
{
    NSURL *cUrl = [NSURL URLWithString:self.url];
    // 從系統cookies中讀取cookies信息,并添加到自定義請求頭
    NSHTTPCookieStorage *sharedHTTPCookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    NSArray *cookies = [sharedHTTPCookieStorage cookiesForURL:cUrl];
    NSDictionary *cookieHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
    
    [self addCustomRequestHeaders:cookieHeader];
}

做了上面這些工作,我們可以抓包看最終一個請求會長成什么樣子。通過對Demo中LOAD WITH SONIC抓包發現請求頭中帶了sonic-load-type、template-tag、sonic-sdk-version等等,服務端正是基于這些參數做了優化。

GET /demo/indexv3 HTTP/1.1
Host: mc.vip.qq.com
accept-diff: true
Accept: */*
sonic-load-type: __SONIC_HEADER_VALUE_SONIC_LOAD__
template-tag: 37141a61d0497851179bc4f27867290921e1367e
Accept-Encoding: gzip
If-None-Match: 9a498fe9148d127c8ebd970ebac425ba6e6532b3
Accept-Language: zh-CN,zh;
no-Chunked: true
User-Agent: Mozilla/5.0 (iPhone; U; CPU iPhone OS 2_2 like Mac OS X;en-us) AppleWebKit/525.181 (KHTML, like Gecko) Version/3.1.1 Mobile/5H11 Safari/525.20
sonic-sdk-version: Sonic/1.0
Connection: keep-alive
Cookie: dataImg=1; templateFlag=1
method: GET

網絡連接

VasSonic默認提供了基于URLSession的SonicConnection來發起請求和處理響應。SonicConnection做的事情并不多,主要實現了兩個接口,并提供SonicSessionProtocol定義的網絡回調接口供session處理。

- (void)startLoading; // 開始請求
- (void)stopLoading;  // 取消請求

// SonicSessionProtocol
// 收到響應的時候回調
- (void)session:(SonicSession *)session didRecieveResponse:(NSHTTPURLResponse *)response;
// 加載數據之后回調
- (void)session:(SonicSession *)session didLoadData:(NSData *)data;
// 連接錯誤的時候回調
- (void)session:(SonicSession *)session didFaild:(NSError *)error;
// 結束加載的時候回調
- (void)sessionDidFinish:(SonicSession *)session;

如果需要在發起請求和處理響應階段做一些自定義的動作的話,比如實現離線包方案等等,就可以自定義繼承于SonicConnection的Connection對象,在回調SonicSessionProtocol方法之前做些處理。

注冊自定義的Connection對象使用如下的方法,可以同時注冊多個,通過實現canInitWithRequest:來決定使用哪個Connection。

+ (BOOL)registerSonicConnection:(Class)connectionClass;
+ (void)unregisterSonicConnection:(Class)connectionClass;

值得注意的是,SonicConnection的所有接口設計都類似NSURLProtocol協議,但他并不繼承自NSURLProtocol,原因在本文最后WebView請求攔截部分會有提到。

緩存處理

SonicSession根據請求響應頭中cache-offline返回的存儲策略的不一樣會有不同的處理,Sonic定義了如下幾種離線存儲的策略。

/**
 * 存儲但不刷新頁面
 */
#define SonicHeaderValueCacheOfflineStore  @"store"
/**
 * 存儲而且刷新頁面
 */
#define SonicHeaderValueCacheOfflineStoreRefresh   @"true"
/**
 * 不存儲但刷新頁面
 */
#define SonicHeaderValueCacheOfflineRefresh  @"false"
/**
 * Sonic模式關閉,并在接下來6個小時內不再使用
 */
#define SonicHeaderValueCacheOfflineDisable   @"http"

當SonicSession在發起請求之后需要處理本地有緩存和沒有緩存兩種情況。

沒有緩存的情況

沒有緩存,首次加載的情況下根據策略的處理方式也比較簡單,沒啥好說的,直接上代碼。

- (void)firstLoadDidFinish
{
    ......
    if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
        [[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];
        self.isDataUpdated = YES;
        break;
    }
                
    if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
        SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
        if (cacheItem) {
            self.localRefreshTime = cacheItem.lastRefreshTime;
            self.sonicStatusCode = SonicStatusCodeFirstLoad;
            self.sonicStatusFinalCode = SonicStatusCodeFirstLoad;
        }
        if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
            [[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
        }
        
        [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
    }
    ......
}

有緩存的情況

有緩存的情況相對來說要復雜一些,需要處理模板更新和數據更新兩種不同的情況。

- (void)updateDidSuccess
{
    ......
    // 處理模板更新的情況,模板更新是大動作,跟首次加載已經區別不大,模板更新一定會導致數據更新
    if ([self isTemplateChange]) {
        self.cacheFileData = self.responseData;
        [self dealWithTemplateChange];
    // 模板不變,數據更新
    }else{
        [self dealWithDataUpdate];
    }
    
    // 處理其他離線緩存策略
    NSString *policy = [self responseHeaderValueByIgnoreCaseKey: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];
        }
    }

    ...... 
}

模板變化是直接調用了saveFirstWithHtmlData:withResponseHeaders:withUrl:來更新緩存,可見模板變化會導致之前的緩存都失效。

- (void)dealWithTemplateChange
{
    SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
    ......
}

數據變化則是調用updateWithJsonData:withResponseHeaders:withUrl:來更新緩存,該函數會將本地的緩存和服務端返回的數據做個diff,然后返回給前端更新界面。

- (void)dealWithDataUpdate
{
    SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
    ......
}

攔截WebView請求

現在SonicSession結合SonicCache能獨立高效處理URL請求,那么如何使用SonicSession來接管WebView的請求呢?iOS下所有的URL請求都是走URL Loading System的,攔截WebView的請求只需要自定義實現NSURLProtocol協議就可以了。

因為NSURLProtocol會攔截所有的請求,那如何只針對Sonic WebView發起的請求實現攔截呢?可以通過canInitWithRequest:來實現,只有請求頭中帶SonicHeaderValueWebviewLoad的才會被攔截。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{    
    NSString *value = [request.allHTTPHeaderFields objectForKey:SonicHeaderKeyLoadType];
    
    if (value.length == 0) {
        return NO;
    }
    
    if ([value isEqualToString:SonicHeaderValueSonicLoad]) {
        return NO;
        
    }else if([value isEqualToString:SonicHeaderValueWebviewLoad]) {
        return YES;
        
    }
    
    return NO;
}

當系統發起請求的時候,Sonic并沒有真正的發起請求,而是用SessionID注冊了回調,讓SonicSession在恰當的時候調動回調。

- (void)startLoading
{
    NSThread *currentThread = [NSThread currentThread];

    NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
    
    __weak typeof(self) weakSelf = self;
    
    // 在SonicSession中注冊回調函數
    [[SonicClient sharedClient] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
        
        [weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
        
    }];
}

接下來我們看看SonicSession都是在什么時機調用回調函數的,首次加載、預加載和完全緩存狀態是不一樣的。

首次加載的時候,根據網絡的實際回調時機調用即可,代碼如下:

- (void)firstLoadRecieveResponse:(NSHTTPURLResponse *)response
{
    [self dispatchProtocolAction:SonicURLProtocolActionRecvResponse param:response];
}

- (void)firstLoadDidLoadData:(NSData *)data
{
    [self dispatchProtocolAction:SonicURLProtocolActionLoadData param:data];
}

- (void)firstLoadDidFaild:(NSError *)error
{
    [self dispatchProtocolAction:SonicURLProtocolActionDidFaild param:error];
    ......
}

- (void)firstLoadDidFinish
{
    [self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];
    ......
}

有預加載的情況下,根據預加載的情況構造需要回調的動作,代碼如下:

- (NSArray *)preloadRequestActions
{
    NSMutableArray *actionItems = [NSMutableArray array];
    if (self.response) {
        NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:self.response];
        [actionItems addObject:respItem];
    }
    
    if (self.isCompletion) {
        if (self.error) {
            NSDictionary *failItem = [self protocolActionItem:SonicURLProtocolActionDidFaild param:self.error];
            [actionItems addObject:failItem];
        }else{
            if (self.responseData.length > 0) {
                NSData *recvCopyData = [[self.responseData copy]autorelease];
                NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];
                [actionItems addObject:recvItem];
            }
            NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];
            [actionItems addObject:finishItem];
        }
    }else{
        if (self.responseData.length > 0) {
            NSData *recvCopyData = [[self.responseData copy]autorelease];
            NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];
            [actionItems addObject:recvItem];
        }
    }
    
    return actionItems;
}

完全緩存的情況下,構造完整的回調動作,代碼如下:

- (NSArray *)cacheFileActions
{
    NSMutableArray *actionItems = [NSMutableArray array];
    
    NSHTTPURLResponse *response = nil;
    if (self.response && [self isCompletionWithOutError] && self.isDataUpdated) {
        response = self.response;
    }else{
        NSDictionary *respHeader = self.cacheResponseHeaders;
        response = [[[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:self.url] statusCode:200 HTTPVersion:@"1.1" headerFields:respHeader]autorelease];
    }
    
    NSMutableData *cacheData = [[self.cacheFileData mutableCopy] autorelease];
    
    NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:response];
    NSDictionary *dataItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:cacheData];
    NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];
    
    [actionItems addObject:respItem];
    [actionItems addObject:dataItem];
    [actionItems addObject:finishItem];
    
    self.didFinishCacheRead = YES;

    return actionItems;
}

這樣業務使用者只需要正常的實現UIWebViewDelegate的協議就可以了,不需要關心回調是來自真正的網絡連接、還是來自預加載,或者是完全的緩存,所有的緩存優化就都能被封裝在SonicSession里面了。

這里有一點需要說明的是SonicURLProtocol和SonicConnection是不一樣的,雖然SonicConnection模仿了NSURLProtocol的接口,但是其父類是NSObject。SonicURLProtocol最大的功能是實現WebView的請求攔截,而SonicConnection則是SonicSession的網絡請求處理類。

頁面刷新

經過上面的描述,我們基本已經將整個流程都串起來了。

WebView發起請求 -> SonicURLProtocol實現請求攔截,將控制權交給SonicSession
-> SonicSession根據SessionID獲取請求結果,回調請求過程,請求結果可能來自緩存(SonicCache),也可能來自網絡請求(SonicConnection)
-> WebView根據結果展示頁面

整個流程最后的WebView頁面展示,也是非常重要的一塊優化。

- (void)sessionDidFinish:(SonicSession *)session
{
    dispatch_block_t opBlock = ^{
        
        self.isCompletion = YES;
        
        if (self.isFirstLoad) {
            [self firstLoadDidFinish];
        }else{
            [self updateDidSuccess];
        }
        
    };
    dispatchToSonicSessionQueue(opBlock);
}

當請求結束的時候,SonicSession會根據是否是首次加載分別調用firstLoadDidFinishupdateDidSuccess,這兩個函數除了對緩存的不同處理外,還有一個非常重要的區別:前者調用了[self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];,后者則不會。也就是說前者會將請求結束的結果告訴WebView,而后者不會,導致的結果就是前者會刷新頁面,而后者不會。但是updateDidSuccess中有這么一段代碼。

- (void)updateDidSuccess
{
    ......   
    // 如果js注冊了數據刷新的回調,就調用該回調
    if (self.webviewCallBack) {
        NSDictionary *resultDict = [self sonicDiffResult];
        if (resultDict) {
            self.webviewCallBack(resultDict);
        }
    }
    ......
}

如果有webviewCallBack,那么這個回調是會被調用的,參數是經過diff之后的數據,看到這里應該同學都明白了,這就是局部刷新的實現機制。

Sonic給JS暴露一個方法叫getDiffDataCallback,JS只要設置該回調,最終就是設置了self.webViewCallBack

JSExportAs(getDiffData,
- (void)getDiffData:(NSDictionary *)option withCallBack:(JSValue *)jscallback
);

- (void)getDiffData:(NSDictionary *)option withCallBack:(JSValue *)jscallback
{
    JSValue *callback = self.owner.jscontext.globalObject;
    
    [[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate:self.owner completion:^(NSDictionary *result) {
       
        if (result) {
            
            NSData *json = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
            NSString *jsonStr = [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding];
            
            [callback invokeMethod:@"getDiffDataCallback" withArguments:@[jsonStr]];
        }
        
    }];
}

這部分的js相關實現在sonic.js中,有興趣的同學可以自行翻看js源碼。Demo中的更新邏輯如下:

//0-狀態獲取失敗 1-sonic首次 2-頁面刷新 3-局部刷新 4-完全cache
sonic.getSonicData(function(sonicStatus, reportSonicStatus, sonicUpdateData){
    if(sonicStatus == 1){
        //首次沒有特殊的邏輯處理,直接執行sonic完成后的邏輯,比如上報等
    }else if(sonicStatus == 2){

    }else if(sonicStatus == 3){
        //局部刷新的時候需要更新頁面的數據塊和一些JS操作
        var html = '';
        var id = '';
        var elementObj = '';
        for(var key in sonicUpdateData){
            id = key.substring(1,key.length-1);
            html = sonicUpdateData[key];
            elementObj = document.getElementById(id+'Content');
            elementObj.innerHTML = html;
        }

    }else if(sonicStatus == 4){

    }
    afterInit(reportSonicStatus);
});

結論

總結來看VasSonic并不是與眾不同的新技術,但是其對HTML、客戶端WebView有著深入的了解,通過司空見慣的一些技術的極致搭配和使用,極大的提升了WebView的性能。仔細研究SonicSession和SonicCache的實現對于了解VasSonic的設計思想非常重要。最后感謝騰訊團隊給開源界帶來這么優秀的WebView框架。

參考文獻

微信一鍵關注
微信一鍵關注
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,563評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,694評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,672評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,965評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,690評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,019評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,013評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,188評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,718評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,438評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,667評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,149評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,845評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,252評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,590評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,384評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容