輕量級高性能Hybrid框架VasSonic秒開實現(xiàn)解析

H5很重要疗涉,H5很重要著隆,H5很重要搞乏,重要的事情要說三遍辙售。VasSonic是騰訊開源的解決H5首屏渲染痛點的開源項目杀怠,本文通過解讀代碼來學(xué)習(xí)WebView的優(yōu)化思路袭蝗。

H5的優(yōu)劣

H5的優(yōu)勢很明顯肤视,跨平臺茵汰、迭代快、開發(fā)體驗好绑榴。H5的劣勢同樣明顯哪轿,加載慢,用戶體驗差翔怎。業(yè)內(nèi)大牛想盡各種方法來彌補H5的劣勢窃诉,初級使用緩存、預(yù)加載等常用方案姓惑,高級如Hybrid褐奴、ReactNative、Weex等H5的進階解決方案于毙。VasSonic專注于H5的秒開敦冬,使用的也是我們常見的性能優(yōu)化方案。本文嘗試了解VasSonic是如何用常見的手段將性能優(yōu)化做到極致的唯沮。

VasSonic解決什么問題

關(guān)于WebView為什么打開慢脖旱、加載慢,業(yè)界已經(jīng)有很多分析了介蛉,結(jié)論也是比較一致的萌庆,推薦美團點評技術(shù)團隊的WebView性能、體驗分析與優(yōu)化币旧,騰訊關(guān)于VasSonic的官方文章也有相關(guān)說明践险。

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

  1. WebView打開
  2. 頁面資源加載
  3. 數(shù)據(jù)更新導(dǎo)致頁面刷新

VasSonic的優(yōu)化都是為了加速上述三個階段,其經(jīng)驗可以總結(jié)為六個方面吹菱。

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

可以說是非常全面了瓦胎,具體細節(jié)可以參考騰訊祭出大招VasSonic,讓你的H5頁面首屏秒開尤揣!搔啊。

上述優(yōu)化的核心技術(shù)主要涉及幾個方面:

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

下面結(jié)合代碼來看看VasSonic是如何實現(xiàn)這些優(yōu)化點的。

準(zhǔn)備工作:
github VasSonic clone最新代碼芹缔,打開sonic-iOS目錄下的SonicSample坯癣。

WebView池

UIWebView并不是開源的,想要通過修改源碼來提升打開速度是不太現(xiàn)實的最欠。VasSonic采用的方案是預(yù)先創(chuàng)建WebView池示罗。在應(yīng)用啟動或者空閑的時候預(yù)先創(chuàng)建空的WebView,等真正要用的時候直接從池中獲取WebView芝硬。

Demo中只是簡單的預(yù)加載了一次WebView蚜点,通過創(chuàng)建空的WebView,可以預(yù)先啟動Web線程拌阴,完成WebView的一些全局性的初始化工作绍绘,對二次創(chuàng)建WebView能有數(shù)百毫秒的提升。在實際應(yīng)用中迟赃,我們可以采用WebView池的方式來進一步提升打開速度陪拘。

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

緩存設(shè)計

緩存類型

VasSonic將緩存的類型分成了四種,他們分別是模板纤壁、頁面左刽、數(shù)據(jù)和配置。

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

將模板和數(shù)據(jù)分離是實現(xiàn)動靜分離的核心技術(shù)酌媒,模板和數(shù)據(jù)是從頁面數(shù)據(jù)中自動分離出來的欠痴,緩存頁面數(shù)據(jù)的時候,SonicCache會調(diào)用splitTemplateAndDataFromHtmlData:分割模板和數(shù)據(jù)秒咨,代碼實現(xiàn)如下:

- (NSDictionary *)splitTemplateAndDataFromHtmlData:(NSString *)html
{
    // 使用sonicdiff這個tag來將HTML分割成模板和數(shù)據(jù)
    NSError *error = nil;
    NSRegularExpression *reg = [NSRegularExpression regularExpressionWithPattern:@"<!--sonicdiff-?(\\w*)-->([\\s\\S]+?)<!--sonicdiff-?(\\w*)-end-->" options:NSRegularExpressionCaseInsensitive error:&error];
    if (error) {
        return nil;
    }
    
    // 分割出來的數(shù)據(jù)喇辽,以sonicdiff指定的名字key保存到數(shù)據(jù)字典中
    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來替換動態(tài)數(shù)據(jù)的位置
    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的結(jié)果雨席。

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

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

// --數(shù)據(jù)
{
  "{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-->";
}

除了頁面菩咨、模板、數(shù)據(jù)類型的緩存外陡厘,還有一個非常重要的緩存是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大家應(yīng)該是比較清楚的雏亚,在HTTP的緩存設(shè)計中有重要作用缨硝,當(dāng)服務(wù)端發(fā)現(xiàn)客戶端請求帶的資源的ETag和服務(wù)端一樣的話,就不會返回完整的資源內(nèi)容了罢低,節(jié)省時間和帶寬查辩,templateTag也是類似的,當(dāng)templateTag不一樣的時候网持,服務(wù)端才會更新模板宜岛。

簡而言之,Config就是保存了這次請求頭中的一些重要信息功舀,留待下次請求的時候發(fā)還給服務(wù)端做優(yōu)化萍倡。

緩存Key

說完緩存類型,必須要說一下緩存的key辟汰,這個非常重要列敲。首次請求會調(diào)用saveFirstWithHtmlData:withResponseHeaders:withUrl緩存數(shù)據(jù)阱佛。入?yún)⒂衕tmlData、header和url戴而,前面已經(jīng)分析htmlData是需要緩存的頁面數(shù)據(jù)凑术,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];

    ......
}

首先根據(jù)url生成sessionID扶踊,然后再將sessionID和特定的SonicCacheItem實例綁定泄鹏。這里我們先說明每個固定url生成的sessionID是一樣的,這才能讓我們在相同的url請求的情況下使用緩存秧耗,具體的url生成sessionID的規(guī)則在SonicSession章節(jié)詳細說明备籽。

SonicCacheItem

每個緩存Key,也就是根據(jù)url生成的sessionID都會對應(yīng)一個SonicCacheItem的實例绣版,用來緩存所有的數(shù)據(jù)胶台。SonicCacheItem也就是一個緩存的數(shù)據(jù)結(jié)構(gòu),包含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關(guān)聯(lián)了一個緩存對象SonicCacheItem,同時也關(guān)聯(lián)了一次URL請求杭朱,VasSonic將這個請求抽象為SonicSession阅仔。SonicSession在VasSonic的設(shè)計里面非常關(guān)鍵。其將資源的請求和WebView脫離開來弧械,有了SonicSession八酒,結(jié)合SonicCache,我們就可以不依賴WebView去做資源的請求刃唐,這樣就可以實現(xiàn)WebView打開和資源加載并行羞迷、資源預(yù)加載等加速方案。

SessionID

每個sessionID唯一指定了一個SonicSession画饥,sessionID的生成規(guī)則如下:

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來算的抖甘,而是先經(jīng)過了sonicUrl的函數(shù)的處理热鞍。理解sonicUrl對url的處理有助于我們了解VasSonic的session管理機制。

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

  • 對于一般的url來說薇宠,sonicUrl會只保留scheme偷办、host和path,url其他部分的改變不會創(chuàng)建新的session
  • 新增了sonic_remain_params參數(shù)昼接,sonic_remain_params里面指定的query參數(shù)不同會創(chuàng)建新的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的代碼也比較簡單悴晰,這里就不貼了慢睡,有興趣的同學(xué)可以參考這里sonicUrl實現(xiàn)

自定義請求頭

之前提到過SonicCache的一種緩存類型是Config铡溪,SonicSession在初始化時候會根據(jù)緩存的Config更新請求頭漂辐,以便服務(wù)端根據(jù)這些信息做相應(yīng)的優(yōu)化。

- (void)setupData
{
    // 根據(jù)sessionID獲取緩存內(nèi)容
    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];
    // 根據(jù)緩存設(shè)置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) {
        // 設(shè)置eTag信息
        NSString *eTag = cfgDict[SonicHeaderKeyETag];
        if (eTag.length > 0) {
            [mCfgDict setObject:eTag forKey:@"If-None-Match"];
        }
        // 設(shè)置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;
}

除了會添加自定義的請求頭參數(shù)哈扮,以及將緩存的config加到請求頭里面外纬纪,在每次發(fā)起請求之前,都會同步cookies滑肉,這樣就可以保持狀態(tài)了包各,比如登陸狀態(tài)等等。

- (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];
    // 從系統(tǒng)cookies中讀取cookies信息靶庙,并添加到自定義請求頭
    NSHTTPCookieStorage *sharedHTTPCookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    NSArray *cookies = [sharedHTTPCookieStorage cookiesForURL:cUrl];
    NSDictionary *cookieHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
    
    [self addCustomRequestHeaders:cookieHeader];
}

做了上面這些工作问畅,我們可以抓包看最終一個請求會長成什么樣子。通過對Demo中LOAD WITH SONIC抓包發(fā)現(xiàn)請求頭中帶了sonic-load-type六荒、template-tag护姆、sonic-sdk-version等等,服務(wù)端正是基于這些參數(shù)做了優(yōu)化掏击。

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

網(wǎng)絡(luò)連接

VasSonic默認提供了基于URLSession的SonicConnection來發(fā)起請求和處理響應(yīng)卵皂。SonicConnection做的事情并不多,主要實現(xiàn)了兩個接口砚亭,并提供SonicSessionProtocol定義的網(wǎng)絡(luò)回調(diào)接口供session處理灯变。

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

// SonicSessionProtocol
// 收到響應(yīng)的時候回調(diào)
- (void)session:(SonicSession *)session didRecieveResponse:(NSHTTPURLResponse *)response;
// 加載數(shù)據(jù)之后回調(diào)
- (void)session:(SonicSession *)session didLoadData:(NSData *)data;
// 連接錯誤的時候回調(diào)
- (void)session:(SonicSession *)session didFaild:(NSError *)error;
// 結(jié)束加載的時候回調(diào)
- (void)sessionDidFinish:(SonicSession *)session;

如果需要在發(fā)起請求和處理響應(yīng)階段做一些自定義的動作的話,比如實現(xiàn)離線包方案等等钠惩,就可以自定義繼承于SonicConnection的Connection對象柒凉,在回調(diào)SonicSessionProtocol方法之前做些處理。

注冊自定義的Connection對象使用如下的方法篓跛,可以同時注冊多個膝捞,通過實現(xiàn)canInitWithRequest:來決定使用哪個Connection。

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

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

緩存處理

SonicSession根據(jù)請求響應(yīng)頭中cache-offline返回的存儲策略的不一樣會有不同的處理林艘,Sonic定義了如下幾種離線存儲的策略盖奈。

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

當(dāng)SonicSession在發(fā)起請求之后需要處理本地有緩存和沒有緩存兩種情況狐援。

沒有緩存的情況

沒有緩存钢坦,首次加載的情況下根據(jù)策略的處理方式也比較簡單,沒啥好說的啥酱,直接上代碼爹凹。

- (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];
    }
    ......
}

有緩存的情況

有緩存的情況相對來說要復(fù)雜一些,需要處理模板更新和數(shù)據(jù)更新兩種不同的情況镶殷。

- (void)updateDidSuccess
{
    ......
    // 處理模板更新的情況禾酱,模板更新是大動作,跟首次加載已經(jīng)區(qū)別不大绘趋,模板更新一定會導(dǎo)致數(shù)據(jù)更新
    if ([self isTemplateChange]) {
        self.cacheFileData = self.responseData;
        [self dealWithTemplateChange];
    // 模板不變颤陶,數(shù)據(jù)更新
    }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];
        }
    }

    ...... 
}

模板變化是直接調(diào)用了saveFirstWithHtmlData:withResponseHeaders:withUrl:來更新緩存,可見模板變化會導(dǎo)致之前的緩存都失效陷遮。

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

數(shù)據(jù)變化則是調(diào)用updateWithJsonData:withResponseHeaders:withUrl:來更新緩存滓走,該函數(shù)會將本地的緩存和服務(wù)端返回的數(shù)據(jù)做個diff,然后返回給前端更新界面拷呆。

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

攔截WebView請求

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

因為NSURLProtocol會攔截所有的請求,那如何只針對Sonic WebView發(fā)起的請求實現(xiàn)攔截呢项秉?可以通過canInitWithRequest:來實現(xiàn)绣溜,只有請求頭中帶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;
}

當(dāng)系統(tǒng)發(fā)起請求的時候娄蔼,Sonic并沒有真正的發(fā)起請求怖喻,而是用SessionID注冊了回調(diào),讓SonicSession在恰當(dāng)?shù)臅r候調(diào)動回調(diào)岁诉。

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

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

接下來我們看看SonicSession都是在什么時機調(diào)用回調(diào)函數(shù)的锚沸,首次加載、預(yù)加載和完全緩存狀態(tài)是不一樣的涕癣。

首次加載的時候哗蜈,根據(jù)網(wǎng)絡(luò)的實際回調(diào)時機調(diào)用即可,代碼如下:

- (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];
    ......
}

有預(yù)加載的情況下,根據(jù)預(yù)加載的情況構(gòu)造需要回調(diào)的動作距潘,代碼如下:

- (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;
}

完全緩存的情況下炼列,構(gòu)造完整的回調(diào)動作,代碼如下:

- (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;
}

這樣業(yè)務(wù)使用者只需要正常的實現(xiàn)UIWebViewDelegate的協(xié)議就可以了音比,不需要關(guān)心回調(diào)是來自真正的網(wǎng)絡(luò)連接俭尖、還是來自預(yù)加載,或者是完全的緩存洞翩,所有的緩存優(yōu)化就都能被封裝在SonicSession里面了稽犁。

這里有一點需要說明的是SonicURLProtocol和SonicConnection是不一樣的,雖然SonicConnection模仿了NSURLProtocol的接口菱农,但是其父類是NSObject缭付。SonicURLProtocol最大的功能是實現(xiàn)WebView的請求攔截,而SonicConnection則是SonicSession的網(wǎng)絡(luò)請求處理類循未。

頁面刷新

經(jīng)過上面的描述,我們基本已經(jīng)將整個流程都串起來了秫舌。

WebView發(fā)起請求 -> SonicURLProtocol實現(xiàn)請求攔截的妖,將控制權(quán)交給SonicSession
-> SonicSession根據(jù)SessionID獲取請求結(jié)果,回調(diào)請求過程足陨,請求結(jié)果可能來自緩存(SonicCache)嫂粟,也可能來自網(wǎng)絡(luò)請求(SonicConnection)
-> WebView根據(jù)結(jié)果展示頁面

整個流程最后的WebView頁面展示,也是非常重要的一塊優(yōu)化墨缘。

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

當(dāng)請求結(jié)束的時候星虹,SonicSession會根據(jù)是否是首次加載分別調(diào)用firstLoadDidFinishupdateDidSuccess,這兩個函數(shù)除了對緩存的不同處理外镊讼,還有一個非常重要的區(qū)別:前者調(diào)用了[self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];宽涌,后者則不會。也就是說前者會將請求結(jié)束的結(jié)果告訴WebView蝶棋,而后者不會卸亮,導(dǎo)致的結(jié)果就是前者會刷新頁面,而后者不會玩裙。但是updateDidSuccess中有這么一段代碼兼贸。

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

如果有webviewCallBack吃溅,那么這個回調(diào)是會被調(diào)用的溶诞,參數(shù)是經(jīng)過diff之后的數(shù)據(jù),看到這里應(yīng)該同學(xué)都明白了决侈,這就是局部刷新的實現(xiàn)機制螺垢。

Sonic給JS暴露一個方法叫getDiffDataCallback,JS只要設(shè)置該回調(diào),最終就是設(shè)置了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相關(guān)實現(xiàn)在sonic.js中蹂楣,有興趣的同學(xué)可以自行翻看js源碼。Demo中的更新邏輯如下:

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

    }else if(sonicStatus == 3){
        //局部刷新的時候需要更新頁面的數(shù)據(jù)塊和一些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);
});

結(jié)論

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

參考文獻

微信一鍵關(guān)注
微信一鍵關(guān)注
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末弃榨,一起剝皮案震驚了整個濱河市菩收,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌鲸睛,老刑警劉巖娜饵,帶你破解...
    沈念sama閱讀 211,884評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異官辈,居然都是意外死亡箱舞,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,347評論 3 385
  • 文/潘曉璐 我一進店門拳亿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來晴股,“玉大人,你說我怎么就攤上這事风瘦《游海” “怎么了?”我有些...
    開封第一講書人閱讀 157,435評論 0 348
  • 文/不壞的土叔 我叫張陵万搔,是天一觀的道長胡桨。 經(jīng)常有香客問我,道長瞬雹,這世上最難降的妖魔是什么昧谊? 我笑而不...
    開封第一講書人閱讀 56,509評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮酗捌,結(jié)果婚禮上呢诬,老公的妹妹穿的比我還像新娘涌哲。我一直安慰自己,他們只是感情好尚镰,可當(dāng)我...
    茶點故事閱讀 65,611評論 6 386
  • 文/花漫 我一把揭開白布阀圾。 她就那樣靜靜地躺著,像睡著了一般狗唉。 火紅的嫁衣襯著肌膚如雪初烘。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,837評論 1 290
  • 那天分俯,我揣著相機與錄音肾筐,去河邊找鬼。 笑死缸剪,一個胖子當(dāng)著我的面吹牛吗铐,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播杏节,決...
    沈念sama閱讀 38,987評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼唬渗,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了拢锹?” 一聲冷哼從身側(cè)響起谣妻,我...
    開封第一講書人閱讀 37,730評論 0 267
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎卒稳,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體他巨,經(jīng)...
    沈念sama閱讀 44,194評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡充坑,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,525評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了染突。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片捻爷。...
    茶點故事閱讀 38,664評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖份企,靈堂內(nèi)的尸體忽然破棺而出也榄,到底是詐尸還是另有隱情,我是刑警寧澤司志,帶...
    沈念sama閱讀 34,334評論 4 330
  • 正文 年R本政府宣布甜紫,位于F島的核電站,受9級特大地震影響骂远,放射性物質(zhì)發(fā)生泄漏囚霸。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,944評論 3 313
  • 文/蒙蒙 一激才、第九天 我趴在偏房一處隱蔽的房頂上張望拓型。 院中可真熱鬧额嘿,春花似錦、人聲如沸劣挫。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,764評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽压固。三九已至球拦,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間邓夕,已是汗流浹背刘莹。 一陣腳步聲響...
    開封第一講書人閱讀 31,997評論 1 266
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留焚刚,地道東北人点弯。 一個月前我還...
    沈念sama閱讀 46,389評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像矿咕,于是被迫代替她去往敵國和親抢肛。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,554評論 2 349

推薦閱讀更多精彩內(nèi)容