轻量级高性能Hybrid框架VasSonic秒开实现解析

1,207
原文链接: mp.weixin.qq.com

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池的方式来进一步提升打开速度。

  1. //start web thread

  2. UIWebView *webPool = [[UIWebView alloc]initWithFrame:CGRectZero];

  3. [webPool loadHTMLString:@"" baseURL:nil]; // 注意loadHTMLString是必须的

缓存设计

缓存类型

VasSonic将缓存的类型分成了四种,他们分别是模板、页面、数据和配置。

  1.    /*

  2.     * template

  3.     */

  4.    SonicCacheTypeTemplate,

  5.    /*

  6.     * html

  7.     */

  8.    SonicCacheTypeHtml,

  9.    /*

  10.     * dynamic data

  11.     */

  12.    SonicCacheTypeData,

  13.    /*

  14.     * config

  15.     */

  16.    SonicCacheTypeConfig,

将模板和数据分离是实现动静分离的核心技术,模板和数据是从页面数据中自动分离出来的,缓存页面数据的时候,SonicCache会调用 splitTemplateAndDataFromHtmlData:分割模板和数据,代码实现如下:

                                
  1. - (NSDictionary *)splitTemplateAndDataFromHtmlData :(NSString *)html

  2. {

  3.     // 使用sonicdiff这个tag来将HTML分割成模板和数据

  4.     NSError * error = nil;

  5.     NSRegularExpression * reg = [NSRegularExpression regularExpressionWithPattern:@ "<!--sonicdiff-?(\\w*)-->([\\s\\S]+?)<!--sonicdiff-?(\\w*)-end-->" options :NSRegularExpressionCaseInsensitive error:&error];

  6.     if ( error) {

  7.         return nil ;

  8.     }

  9.     // 分割出来的数据,以sonicdiff指定的名字key保存到数据字典中

  10.     NSArray * metchs = [reg matchesInString:html options :NSMatchingReportCompletion range:NSMakeRange (0, html.length)];

  11.     NSMutableDictionary * dataDict = [NSMutableDictionary dictionary];

  12.     [metchs enumerateObjectsUsingBlock:^(NSTextCheckingResult *obj , NSUInteger idx, BOOL * _Nonnull stop) {

  13.         NSString * matchStr = [html substringWithRange:obj .range];

  14.         NSArray * seprateArr = [matchStr componentsSeparatedByString :@"<!--sonicdiff-"];

  15.         NSString * itemName = [[[seprateArr lastObject]componentsSeparatedByString :@"-end-->"]firstObject ];

  16.         NSString * formatKey = [NSString stringWithFormat :@"{%@}", itemName];

  17.         [dataDict setObject: matchStr forKey:formatKey ];

  18.     }];

  19.     // 分割出来的模板,用key来替换动态数据的位置

  20.     NSMutableString * mResult = [NSMutableString stringWithString :html];

  21.     [dataDict enumerateKeysAndObjectsUsingBlock :^(NSString *key, NSString *value, BOOL * _Nonnull stop ) {

  22.         [mResult replaceOccurrencesOfString:value withString :key options: NSCaseInsensitiveSearch range :NSMakeRange( 0, mResult .length)];

  23.     }];

  24.     //if split HTML faild , we can return nothing ,it is not a validat sonic request.

  25.     if ( dataDict.count == 0 || mResult .length == 0) {

  26.         return nil ;

  27.     }

  28.     return @{@ "data":dataDict ,@"temp": mResult};

  29. }

还是以Demo为例看split的结果。

  1. // 原始页面数据

  2. <span id="data1Content">

  3.    <!--sonicdiff-data1-->

  4.    <p>示例:</p>

  5.    <img src="//mc.vip.qq.com/img/img-1.png?max_age=2592000" alt="">

  6.    <!--sonicdiff-data1-end-->

  7. </span>

  8. // 分离之后的结果

  9. // --模板

  10. <span id="data1Content">

  11.    {data1}

  12. </span>

  13. // --数据

  14. {

  15.  "{data1}" = "<!--sonicdiff-data1-->

  16. \n    <p>\U793a\U4f8b\Uff1a</p>

  17. \n    <img src=\"//mc.vip.qq.com/img/img-1.png?max_age=2592000\" alt=\"\">

  18. \n    <!--sonicdiff-data1-end-->";

  19. }

除了页面、模板、数据类型的缓存外,还有一个非常重要的缓存是config。先看下config的生成。

  1. - (NSDictionary *)createConfigFromResponseHeaders:(NSDictionary *)headers

  2. {

  3.    //Etag,template-tag

  4.    NSString *eTag = headers[@"Etag"];

  5.    NSString *templateTag = headers[@"template-tag"];

  6.    NSString *csp = headers[SonicHeaderKeyCSPHeader];

  7.    NSTimeInterval timeNow = (long)[[NSDate date ]timeIntervalSince1970]*1000;

  8.    NSString *localRefresh = [@(timeNow) stringValue];

  9.    //save configs

  10.    eTag = eTag.length > 0? eTag:@"";

  11.    templateTag = templateTag.length > 0? templateTag:@"";

  12.    eTag = eTag.length > 0? eTag:@"";

  13.    csp = csp.length > 0? csp:@"";

  14.    NSDictionary *cfgDict = @{

  15.                              SonicHeaderKeyETag:eTag,

  16.                              SonicHeaderKeyTemplate:templateTag,

  17.                              kSonicLocalRefreshTime:localRefresh,

  18.                              kSonicCSP:csp

  19.                              };

  20.    return cfgDict;

  21. }

ETag大家应该是比较清楚的,在HTTP的缓存设计中有重要作用,当服务端发现客户端请求带的资源的ETag和服务端一样的话,就不会返回完整的资源内容了,节省时间和带宽,templateTag也是类似的,当templateTag不一样的时候,服务端才会更新模板。

简而言之,Config就是保存了这次请求头中的一些重要信息,留待下次请求的时候发还给服务端做优化。

缓存Key

说完缓存类型,必须要说一下缓存的key,这个非常重要。首次请求会调用 saveFirstWithHtmlData:withResponseHeaders:withUrl缓存数据。入参有htmlData、header和url,前面已经分析htmlData是需要缓存的页面数据,htmlData会被存成html、template和dynamicData三种类型,headers前面也提到了是缓存成config,那这个url的作用就是生成缓存的key。

                                    
  1. - (SonicCacheItem *)saveFirstWithHtmlData :(NSData *)htmlData

  2.                      withResponseHeaders :(NSDictionary *)headers

  3.                                  withUrl :(NSString *)url

  4. {

  5.     NSString * sessionID = sonicSessionID (url);

  6.     if (! htmlData || headers .count == 0 || sessionID.length == 0) {

  7.         return nil ;

  8.     }

  9.     SonicCacheItem * cacheItem = [self cacheForSession :sessionID];

  10.     ......

  11. }

首先根据url生成sessionID,然后再将sessionID和特定的 SonicCacheItem实例绑定。这里我们先说明每个固定url生成的sessionID是一样的,这才能让我们在相同的url请求的情况下使用缓存,具体的url生成sessionID的规则在 SonicSession章节详细说明。

SonicCacheItem

每个缓存Key,也就是根据url生成的sessionID都会对应一个SonicCacheItem的实例,用来缓存所有的数据。SonicCacheItem也就是一个缓存的数据结构,包含htmlData、templateString、dynamicData、diffData等等。

  1. /**

  2. * Memory cache item.

  3. */

  4. @interface SonicCacheItem : NSObject

  5. /** Html. */

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

  7. /** Config. */

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

  9. /** Session. */

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

  11. /** Template string. */

  12. @property (nonatomic,copy)  NSString       *templateString;

  13. /** Generated by local dynamic data and server dynamic data. */

  14. @property (nonatomic,retain)NSDictionary   *diffData;

  15. /** Sonic divide HTML to tepmlate and dynamic data.  */

  16. @property (nonatomic,retain)NSDictionary   *dynamicData;

  17. /** Is there file cache exist. */

  18. @property (nonatomic,readonly)BOOL         hasLocalCache;

  19. /** Last refresh time.  */

  20. @property (nonatomic,readonly)NSString     *lastRefreshTime;

  21. /** Cache some header fields which will be used later. */

  22. @property (nonatomic,readonly)NSDictionary *cacheResponseHeaders;

  23. /** Initialize an item with session id. */

  24. - (instancetype)initWithSessionID:(NSString *)aSessionID;

  25. @end

SonicSession

讲缓存的时候我们提到过作为缓存Key的sessionID,每个sessionID关联了一个缓存对象SonicCacheItem,同时也关联了一次URL请求,VasSonic将这个请求抽象为SonicSession。SonicSession在VasSonic的设计里面非常关键。其将资源的请求和WebView脱离开来,有了SonicSession,结合SonicCache,我们就可以不依赖WebView去做资源的请求,这样就可以实现WebView打开和资源加载并行、资源预加载等加速方案。

SessionID

每个sessionID唯一指定了一个SonicSession,sessionID的生成规则如下:

  1. NSString *sonicSessionID(NSString *url)

  2. {

  3.    if ([[SonicClient sharedClient].currentUserUniq length] > 0) {

  4.        return stringFromMD5([NSString stringWithFormat:@"%@_%@",[SonicClient sharedClient].currentUserUniq,sonicUrl(url)]);

  5.    }else{

  6.        return stringFromMD5([NSString stringWithFormat:@"%@",sonicUrl(url)]);

  7.    }

  8. }

每个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。

举栗说明:

  1. // output: @"https://www.example.com"

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

  3. // output: @"https://www.example.com"

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

  5. // output: @"https://www.example.com"

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

  7. // output: @"https://www.example.com/path"

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

  9. // output @"https://www.example.com/path/foo=foo&"

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

sonicUrl的代码也比较简单,这里就不贴了,有兴趣的同学可以参考这里sonicUrl实现。

自定义请求头

之前提到过SonicCache的一种缓存类型是Config,SonicSession在初始化时候会根据缓存的Config更新请求头,以便服务端根据这些信息做相应的优化。

  1. - (void)setupData

  2. {

  3.    // 根据sessionID获取缓存内容

  4.    SonicCacheItem *cacheItem = [[SonicCache shareCache] cacheForSession:_sessionID];

  5.    self.isFirstLoad = cacheItem.hasLocalCache;

  6.    if (!cacheItem.hasLocalCache) {

  7.        self.cacheFileData = cacheItem.htmlData;

  8.        self.cacheConfigHeaders = cacheItem.config;

  9.        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;

  10.        self.localRefreshTime = cacheItem.lastRefreshTime;

  11.    }

  12.    [self setupConfigRequestHeaders];

  13. }

  14. - (void)setupConfigRequestHeaders

  15. {

  16.    NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionaryWithDictionary:self.request.allHTTPHeaderFields];

  17.    // 根据缓存设置Etag、templateTag等

  18.    NSDictionary *cfgDict = [self getRequestParamsFromConfigHeaders];

  19.    if (cfgDict) {

  20.        [mCfgDict addEntriesFromDictionary:cfgDict];

  21.    }

  22.    // 添加一些自定义的缓存头

  23.    [mCfgDict setObject:@"true" forKey:@"accept-diff"];

  24.    [mCfgDict setObject:@"true" forKey:@"no-Chunked"];

  25.    [mCfgDict setObject:@"GET" forKey:@"method"];

  26.    [mCfgDict setObject:@"utf-8" forKey:@"accept-Encoding"];

  27.    [mCfgDict setObject:@"zh-CN,zh;" forKey:@"accept-Language"];

  28.    [mCfgDict setObject:@"gzip" forKey:@"accept-Encoding"];

  29.    [mCfgDict setObject:SonicHeaderValueSDKVersion  forKey:SonicHeaderKeySDKVersion];

  30.    [mCfgDict setObject:SonicHeaderValueSonicLoad forKey:SonicHeaderKeyLoadType];

  31.    // 可以自定义UA,方便app判断

  32.    NSString *userAgent = [SonicClient sharedClient].userAgent.length > 0? [SonicClient sharedClient].userAgent:[[SonicClient sharedClient] sonicDefaultUserAgent];

  33.    [mCfgDict setObject:userAgent forKey:@"User-Agent"];

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

  35.    // 替换域名为ip,免去dns解析的耗时

  36.    if (self.serverIP.length > 0) {

  37.        NSString *host = [cUrl.scheme isEqualToString:@"https"]? [NSString stringWithFormat:@"%@:443",self.serverIP]:[NSString stringWithFormat:@"%@:80",self.serverIP];

  38.        NSString *newUrl = [self.url stringByReplacingOccurrencesOfString:cUrl.host withString:host];

  39.        cUrl = [NSURL URLWithString:newUrl];

  40.        [mCfgDict setObject:cUrl.host forKey:@"Host"];

  41.    }

  42.    [self.request setAllHTTPHeaderFields:mCfgDict];

  43. }

  44. - (NSDictionary *)getRequestParamsFromConfigHeaders

  45. {

  46.    NSDictionary *cfgDict = self.cacheConfigHeaders;

  47.    NSMutableDictionary *mCfgDict = [NSMutableDictionary dictionary];

  48.    if (cfgDict) {

  49.        // 设置eTag信息

  50.        NSString *eTag = cfgDict[SonicHeaderKeyETag];

  51.        if (eTag.length > 0) {

  52.            [mCfgDict setObject:eTag forKey:@"If-None-Match"];

  53.        }

  54.        // 设置templateTag信息

  55.        NSString *tempTag = cfgDict[SonicHeaderKeyTemplate];

  56.        if (tempTag.length > 0 ) {

  57.            [mCfgDict setObject:tempTag forKey:@"template-tag"];

  58.        }

  59.    }else{

  60.        [mCfgDict setObject:@"" forKey:@"If-None-Match"];

  61.        [mCfgDict setObject:@"" forKey:@"template-tag"];

  62.    }

  63.    return mCfgDict;

  64. }

除了会添加自定义的请求头参数,以及将缓存的config加到请求头里面外,在每次发起请求之前,都会同步cookies,这样就可以保持状态了,比如登陆状态等等。

  1. - (void)start

  2. {

  3.    dispatchToMain(^{

  4.        if (self.delegate && [self.delegate respondsToSelector:@selector(sessionWillRequest:)]) {

  5.            [self.delegate sessionWillRequest:self];

  6.        }

  7.        [self syncCookies];

  8.    });

  9.    [self requestStartInOperation];

  10. }

  11. - (void)syncCookies

  12. {

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

  14.    // 从系统cookies中读取cookies信息,并添加到自定义请求头

  15.    NSHTTPCookieStorage *sharedHTTPCookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];

  16.    NSArray *cookies = [sharedHTTPCookieStorage cookiesForURL:cUrl];

  17.    NSDictionary *cookieHeader = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];

  18.    [self addCustomRequestHeaders:cookieHeader];

  19. }

做了上面这些工作,我们可以抓包看最终一个请求会长成什么样子。通过对Demo中LOAD WITH SONIC抓包发现请求头中带了sonic-load-type、template-tag、sonic-sdk-version等等,服务端正是基于这些参数做了优化。

  1. GET /demo/indexv3 HTTP/1.1

  2. Host: mc.vip.qq.com

  3. accept-diff: true

  4. Accept: */*

  5. sonic-load-type: __SONIC_HEADER_VALUE_SONIC_LOAD__

  6. template-tag: 37141a61d0497851179bc4f27867290921e1367e

  7. Accept-Encoding: gzip

  8. If-None-Match: 9a498fe9148d127c8ebd970ebac425ba6e6532b3

  9. Accept-Language: zh-CN,zh;

  10. no-Chunked: true

  11. 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

  12. sonic-sdk-version: Sonic/1.0

  13. Connection: keep-alive

  14. Cookie: dataImg=1; templateFlag=1

  15. method: GET

网络连接

VasSonic默认提供了基于URLSession的 SonicConnection来发起请求和处理响应。 SonicConnection做的事情并不多,主要实现了两个接口,并提供 SonicSessionProtocol定义的网络回调接口供session处理。

                                                            
  1. - (void) startLoading; // 开始请求

  2. - (void) stopLoading;  // 取消请求

  3. // SonicSessionProtocol

  4. // 收到响应的时候回调

  5. - (void) session:( SonicSession *)session didRecieveResponse :(NSHTTPURLResponse *) response;

  6. // 加载数据之后回调

  7. - (void) session:( SonicSession *)session didLoadData :(NSData *) data;

  8. // 连接错误的时候回调

  9. - (void) session:( SonicSession *)session didFaild :(NSError *) error;

  10. // 结束加载的时候回调

  11. - (void) sessionDidFinish:( SonicSession *)session ;

如果需要在发起请求和处理响应阶段做一些自定义的动作的话,比如实现离线包方案等等,就可以自定义继承于SonicConnection的Connection对象,在回调 SonicSessionProtocol方法之前做些处理。

注册自定义的Connection对象使用如下的方法,可以同时注册多个,通过实现 canInitWithRequest:来决定使用哪个Connection。

                                                                    
  1. + ( BOOL) registerSonicConnection:( Class) connectionClass;

  2. + ( void) unregisterSonicConnection:( Class) connectionClass;

值得注意的是,SonicConnection的所有接口设计都类似NSURLProtocol协议,但他并不继承自 NSURLProtocol,原因在本文最后WebView请求拦截部分会有提到。

缓存处理

SonicSession根据请求响应头中 cache-offline返回的存储策略的不一样会有不同的处理,Sonic定义了如下几种离线存储的策略。

                                                                            
  1. /**

  2. * 存储但不刷新页面

  3. */

  4. #define SonicHeaderValueCacheOfflineStore  @ "store"

  5. /**

  6. * 存储而且刷新页面

  7. */

  8. #define SonicHeaderValueCacheOfflineStoreRefresh   @ "true"

  9. /**

  10. * 不存储但刷新页面

  11. */

  12. #define SonicHeaderValueCacheOfflineRefresh  @ "false"

  13. /**

  14. * Sonic模式关闭,并在接下来6个小时内不再使用

  15. */

  16. #define SonicHeaderValueCacheOfflineDisable   @ "http"

当SonicSession在发起请求之后需要处理本地有缓存和没有缓存两种情况。

没有缓存的情况

没有缓存,首次加载的情况下根据策略的处理方式也比较简单,没啥好说的,直接上代码。

  1. - (void)firstLoadDidFinish

  2. {

  3.    ......

  4.    if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {

  5.        [[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];

  6.        self.isDataUpdated = YES;

  7.        break;

  8.    }

  9.    if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {

  10.        SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];

  11.        if (cacheItem) {

  12.            self.localRefreshTime = cacheItem.lastRefreshTime;

  13.            self.sonicStatusCode = SonicStatusCodeFirstLoad;

  14.            self.sonicStatusFinalCode = SonicStatusCodeFirstLoad;

  15.        }

  16.        if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {

  17.            [[SonicCache shareCache] removeCacheBySessionID:self.sessionID];

  18.        }

  19.        [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];

  20.    }

  21.    ......

  22. }

有缓存的情况

有缓存的情况相对来说要复杂一些,需要处理模板更新和数据更新两种不同的情况。

  1. - (void)updateDidSuccess

  2. {

  3.    ......

  4.    // 处理模板更新的情况,模板更新是大动作,跟首次加载已经区别不大,模板更新一定会导致数据更新

  5.    if ([self isTemplateChange]) {

  6.        self.cacheFileData = self.responseData;

  7.        [self dealWithTemplateChange];

  8.    // 模板不变,数据更新

  9.    }else{

  10.        [self dealWithDataUpdate];

  11.    }

  12.    // 处理其他离线缓存策略

  13.    NSString *policy = [self responseHeaderValueByIgnoreCaseKey:SonicHeaderKeyCacheOffline];

  14.    if ([policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {

  15.        [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];

  16.    }

  17.    if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {

  18.        if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {

  19.            [[SonicCache shareCache]removeCacheBySessionID:self.sessionID];

  20.        }

  21.        if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {

  22.        [[SonicCache shareCache] saveServerDisableSonicTimeNow:self.sessionID];

  23.        }

  24.    }

  25.    ......

  26. }

模板变化是直接调用了 saveFirstWithHtmlData:withResponseHeaders:withUrl:来更新缓存,可见模板变化会导致之前的缓存都失效。

                                                                                
  1. - ( void) dealWithTemplateChange

  2. {

  3.     SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData: self. responseData withResponseHeaders: self. response. allHeaderFields withUrl: self. url];

  4.     ......

  5. }

数据变化则是调用 updateWithJsonData:withResponseHeaders:withUrl:来更新缓存,该函数会将本地的缓存和服务端返回的数据做个diff,然后返回给前端更新界面。

                                                                                    
  1. - ( void) dealWithDataUpdate

  2. {

  3.     SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData: self. responseData withResponseHeaders: self. response. allHeaderFields withUrl: self. url];

  4.     ......

  5. }

拦截WebView请求

现在SonicSession结合SonicCache能独立高效处理URL请求,那么如何使用SonicSession来接管WebView的请求呢?iOS下所有的URL请求都是走URL Loading System的,拦截WebView的请求只需要自定义实现 NSURLProtocol协议就可以了。

因为NSURLProtocol会拦截所有的请求,那如何只针对Sonic WebView发起的请求实现拦截呢?可以通过 canInitWithRequest:来实现,只有请求头中带 SonicHeaderValueWebviewLoad的才会被拦截。

                                                                                            
  1. + ( BOOL) canInitWithRequest:( NSURLRequest *)request

  2. {    

  3.     NSString *value = [request .allHTTPHeaderFields objectForKey :SonicHeaderKeyLoadType ];

  4.     if (value .length == 0) {

  5.         return NO ;

  6.     }

  7.     if ([value isEqualToString :SonicHeaderValueSonicLoad ]) {

  8.         return NO ;

  9.     }else if ([value isEqualToString :SonicHeaderValueWebviewLoad ]) {

  10.         return YES ;

  11.     }

  12.     return NO ;

  13. }

当系统发起请求的时候,Sonic并没有真正的发起请求,而是用SessionID注册了回调,让SonicSession在恰当的时候调动回调。

  1. - (void)startLoading

  2. {

  3.    NSThread *currentThread = [NSThread currentThread];

  4.    NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];

  5.    __weak typeof(self) weakSelf = self;

  6.    // 在SonicSession中注册回调函数

  7.    [[SonicClient sharedClient] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {

  8.        [weakSelf performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];

  9.    }];

  10. }

接下来我们看看SonicSession都是在什么时机调用回调函数的,首次加载、预加载和完全缓存状态是不一样的。

首次加载的时候,根据网络的实际回调时机调用即可,代码如下:

  1. - (void)firstLoadRecieveResponse:(NSHTTPURLResponse *)response

  2. {

  3.    [self dispatchProtocolAction:SonicURLProtocolActionRecvResponse param:response];

  4. }

  5. - (void)firstLoadDidLoadData:(NSData *)data

  6. {

  7.    [self dispatchProtocolAction:SonicURLProtocolActionLoadData param:data];

  8. }

  9. - (void)firstLoadDidFaild:(NSError *)error

  10. {

  11.    [self dispatchProtocolAction:SonicURLProtocolActionDidFaild param:error];

  12.    ......

  13. }

  14. - (void)firstLoadDidFinish

  15. {

  16.    [self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];

  17.    ......

  18. }

有预加载的情况下,根据预加载的情况构造需要回调的动作,代码如下:

  1. - (NSArray *)preloadRequestActions

  2. {

  3.    NSMutableArray *actionItems = [NSMutableArray array];

  4.    if (self.response) {

  5.        NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:self.response];

  6.        [actionItems addObject:respItem];

  7.    }

  8.    if (self.isCompletion) {

  9.        if (self.error) {

  10.            NSDictionary *failItem = [self protocolActionItem:SonicURLProtocolActionDidFaild param:self.error];

  11.            [actionItems addObject:failItem];

  12.        }else{

  13.            if (self.responseData.length > 0) {

  14.                NSData *recvCopyData = [[self.responseData copy]autorelease];

  15.                NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];

  16.                [actionItems addObject:recvItem];

  17.            }

  18.            NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];

  19.            [actionItems addObject:finishItem];

  20.        }

  21.    }else{

  22.        if (self.responseData.length > 0) {

  23.            NSData *recvCopyData = [[self.responseData copy]autorelease];

  24.            NSDictionary *recvItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:recvCopyData];

  25.            [actionItems addObject:recvItem];

  26.        }

  27.    }

  28.    return actionItems;

  29. }

完全缓存的情况下,构造完整的回调动作,代码如下:

  1. - (NSArray *)cacheFileActions

  2. {

  3.    NSMutableArray *actionItems = [NSMutableArray array];

  4.    NSHTTPURLResponse *response = nil;

  5.    if (self.response && [self isCompletionWithOutError] && self.isDataUpdated) {

  6.        response = self.response;

  7.    }else{

  8.        NSDictionary *respHeader = self.cacheResponseHeaders;

  9.        response = [[[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:self.url] statusCode:200 HTTPVersion:@"1.1" headerFields:respHeader]autorelease];

  10.    }

  11.    NSMutableData *cacheData = [[self.cacheFileData mutableCopy] autorelease];

  12.    NSDictionary *respItem = [self protocolActionItem:SonicURLProtocolActionRecvResponse param:response];

  13.    NSDictionary *dataItem = [self protocolActionItem:SonicURLProtocolActionLoadData param:cacheData];

  14.    NSDictionary *finishItem = [self protocolActionItem:SonicURLProtocolActionDidFinish param:nil];

  15.    [actionItems addObject:respItem];

  16.    [actionItems addObject:dataItem];

  17.    [actionItems addObject:finishItem];

  18.    self.didFinishCacheRead = YES;

  19.    return actionItems;

  20. }

这样业务使用者只需要正常的实现 UIWebViewDelegate的协议就可以了,不需要关心回调是来自真正的网络连接、还是来自预加载,或者是完全的缓存,所有的缓存优化就都能被封装在SonicSession里面了。

这里有一点需要说明的是SonicURLProtocol和SonicConnection是不一样的,虽然SonicConnection模仿了NSURLProtocol的接口,但是其父类是NSObject。SonicURLProtocol最大的功能是实现WebView的请求拦截,而SonicConnection则是SonicSession的网络请求处理类。

页面刷新

经过上面的描述,我们基本已经将整个流程都串起来了。

  1. WebView发起请求 -> SonicURLProtocol实现请求拦截,将控制权交给SonicSession

  2. -> SonicSession根据SessionID获取请求结果,回调请求过程,请求结果可能来自缓存(SonicCache),也可能来自网络请求(SonicConnection)

  3. -> WebView根据结果展示页面

整个流程最后的WebView页面展示,也是非常重要的一块优化。

  1. - (void)sessionDidFinish:(SonicSession *)session

  2. {

  3.    dispatch_block_t opBlock = ^{

  4.        self.isCompletion = YES;

  5.        if (self.isFirstLoad) {

  6.            [self firstLoadDidFinish];

  7.        }else{

  8.            [self updateDidSuccess];

  9.        }

  10.    };

  11.    dispatchToSonicSessionQueue(opBlock);

  12. }

当请求结束的时候,SonicSession会根据是否是首次加载分别调用 firstLoadDidFinishupdateDidSuccess,这两个函数除了对缓存的不同处理外,还有一个非常重要的区别:前者调用了 [selfdispatchProtocolAction:SonicURLProtocolActionDidFinishparam:nil];,后者则不会。也就是说前者会将请求结束的结果告诉WebView,而后者不会,导致的结果就是 前者会刷新页面,而后者不会。但是 updateDidSuccess中有这么一段代码。

                                                                                                        
  1. - ( void) updateDidSuccess

  2. {

  3.     ......  

  4.     // 如果js注册了数据刷新的回调,就调用该回调

  5.     if (self .webviewCallBack ) {

  6.         NSDictionary *resultDict = [self sonicDiffResult];

  7.         if (resultDict ) {

  8.             self. webviewCallBack( resultDict);

  9.         }

  10.     }

  11.     ......

  12. }

如果有 webviewCallBack,那么这个回调是会被调用的,参数是经过diff之后的数据,看到这里应该同学都明白了,这就是局部刷新的实现机制。

Sonic给JS暴露一个方法叫 getDiffDataCallback,JS只要设置该回调,最终就是设置了 self.webViewCallBack

                                                                                                                
  1. JSExportAs (getDiffData ,

  2. - ( void) getDiffData:( NSDictionary *)option withCallBack :(JSValue *) jscallback

  3. );

  4. - ( void) getDiffData:( NSDictionary *)option withCallBack :(JSValue *) jscallback

  5. {

  6.     JSValue *callback = self. owner. jscontext. globalObject;

  7.     [[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate: self. owner completion:^( NSDictionary *result ) {

  8.         if (result ) {

  9.             NSData *json = [NSJSONSerialization dataWithJSONObject: result options: NSJSONWritingPrettyPrinted error :nil ];

  10.             NSString *jsonStr = [[NSString alloc] initWithData: json encoding: NSUTF8StringEncoding];

  11.             [callback invokeMethod :@"getDiffDataCallback" withArguments:@[ jsonStr]];

  12.         }

  13.     }];

  14. }

这部分的js相关实现在sonic.js中,有兴趣的同学可以自行翻看js源码。Demo中的更新逻辑如下:

  1. //0-状态获取失败 1-sonic首次 2-页面刷新 3-局部刷新 4-完全cache

  2. sonic.getSonicData(function(sonicStatus, reportSonicStatus, sonicUpdateData){

  3.    if(sonicStatus == 1){

  4.        //首次没有特殊的逻辑处理,直接执行sonic完成后的逻辑,比如上报等

  5.    }else if(sonicStatus == 2){

  6.    }else if(sonicStatus == 3){

  7.        //局部刷新的时候需要更新页面的数据块和一些JS操作

  8.        var html = '';

  9.        var id = '';

  10.        var elementObj = '';

  11.        for(var key in sonicUpdateData){

  12.            id = key.substring(1,key.length-1);

  13.            html = sonicUpdateData[key];

  14.            elementObj = document.getElementById(id+'Content');

  15.            elementObj.innerHTML = html;

  16.        }

  17.    }else if(sonicStatus == 4){

  18.    }

  19.    afterInit(reportSonicStatus);

  20. });

结论

总结来看VasSonic并不是与众不同的新技术,但是其对HTML、客户端WebView有着深入的了解,通过司空见惯的一些技术的极致搭配和使用,极大的提升了WebView的性能。仔细研究SonicSession和SonicCache的实现对于了解VasSonic的设计思想非常重要。最后感谢腾讯团队给开源界带来这么优秀的WebView框架。

参考文献