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>
    <img src="//mc.vip.qq.com/img/img-1.png?max_age=2592000" alt="">
    <!--sonicdiff-data1-end-->
</span>

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

// --数据
{
  "{data1}" = "<!--sonicdiff-data1-->
\n    <p>\U793a\U4f8b\Uff1a</p>
\n    <img src=\"//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框架。

参考文献

微信一键关注