美文网首页Android架构技术干货iOS Developer
轻量级高性能Hybrid框架VasSonic秒开实现解析

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

作者: 疯魔程序员 | 来源:发表于2017-09-10 22:18 被阅读3414次

    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=\"//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框架。

    参考文献

    微信一键关注微信一键关注

    相关文章

      网友评论

      • fc18f69e6ff0:楼主这个是不是不适用于wkwebview
        疯魔程序员:@闭上眼睛 嗯,wk搞起来比较黑科技,估计他们还没有支持
        fc18f69e6ff0:@疯魔程序员 现在我去适配wkwebview 不行 缓存机制就失效了
        疯魔程序员:不知道最新版支持了没有,我写这个文章的时候还不支持
      • bc090e750c0e:写得很好,分析的很仔细~

      本文标题:轻量级高性能Hybrid框架VasSonic秒开实现解析

      本文链接:https://www.haomeiwen.com/subject/gmotsxtx.html