VasSonic深度剖析之iOS端

Sonic是腾讯团队研发的一个轻量级的高性能的Hybrid框架,专注于提升页面首屏加载速度。

这些概念性的东西可以在这里查看,这篇文章我们主要来分析iOS端的表象然后再分析源码。
咋们还是放一张使用Sonic前后效果图,效果还是很明显的

Soinc效果展示.gif

先来从表象过一下Sonic对请求和响应做了哪些变化

Sonic对请求和响应做了规范约定


规范约定.png

cache-offline字段说明.png

所以我们可以看看下面四种场景请求和响应对于没使用sonic都有啥不一样

首次加载

首次加载,因为客户端本地没有数据,所以请求头部会有这样的一些数据

{
    "If-None-Match" = "";
    "accept-Language" = "zh-CN,zh;";
    "accept-diff" = true;
    "template-tag" = "";
    ...
}

从上面的表中可以看到If-None-Match为""表示本地没有缓存的该url的html文件;accept-diff为true表示客户端支持Sonicn规范;template-tag为""表示本地没有缓存该url的template文件。
首次加载服务器返回的响应头部为

{
    "Cache-Offline" = true;
    Etag = 8f53de5336b29c851d3befe20d6f74c8405faa47;
    "template-change" = true;
    "template-tag" = ecad7ac4506804fd64f1fdd6d30d6079d730b50e;
...
}

内容为一个完整的html文件

<html lang="en">
...
    <span id="data1Content">
    <!-- 模拟数据更新 -->
    <!--sonicdiff-data1-->
    <h2>当前时间:1503389501</h2>
    <!--sonicdiff-data1-end-->
    </span>
...
</html>

Cache-Offline我看了一下服务器的源码,只会返回true和http(后面sonic可能会添加),一般是返回true表示缓存到磁盘并展示返回内容;Etag有值表示本次html内容的唯一标识;template-change为true表示本次请求返回的模版文件和客户端本地缓存的模版文件不一样,首次加载本地就没有模版文件当然不一样咯;template-tag有值模版文件的唯一标识。
这时候你可以看看客户端本地对于该url缓存了4个文件,名字用url通过md5加密


客户端本地缓存文件.png

cfg文件内容为

<plist version="1.0">
<dict>
    <key>Etag</key>
    <string>486fde9e2343fcf44563fea7d30570e695aa31ad</string>
    <key>template-tag</key>
    <string>ecad7ac4506804fd64f1fdd6d30d6079d730b50e</string>
...
</dict>
</plist>

很熟悉吧,就是把服务器头部返回的保存起来了;data文件内容为

<plist version="1.0">
<dict>
    <key>{data1}</key>
    <string><!--sonicdiff-data1-->
    <h2>当前时间:1503389501</h2>
    <!--sonicdiff-data1-end--></string>
</dict>
</plist>

这个就是本次请求服务器返回的动态变化的值,也就是你前端标记了sonicdiff的标签在本次响应中的值;html就不需要说了,就是一个完整的html文件

<html lang="en">
...
    <span id="data1Content">
    <!-- 模拟数据更新 -->
    <!--sonicdiff-data1-->
    <h2>当前时间:1503389501</h2>
    <!--sonicdiff-data1-end-->
    </span>
...
</html>

temp是sonic根据前端html文件抽离出来的模版

<html lang="en">
...
    <span id="data1Content">
    <!-- 模拟数据更新 -->
    {data1}
    </span>
   ...
</html>

这样我们的首次加载就完成了,服务器返回完整的html文件、客户端本地缓存本次请求相关的文件

数据变化

第二次打开网页的时候你能马上看到页面内容,是因为Sonic加载了本地对该url缓存的html文件;请求头部变成了

{
    "If-None-Match" = bbf85d933f2d42554af04b618f0875d958fa39fb;
    "accept-diff" = true;
    "template-tag" = ecad7ac4506804fd64f1fdd6d30d6079d730b50e;
...
}

相对于首次加载,为""的key都有了值,是因为在本地读取了sonic对于该url缓存的cfg文件;服务器响应头部变成了

{
    "Cache-Offline" = true;
    Etag = c70b4b4aa53295c1c6320ec1ec1dee809332669f;
    "template-change" = false;
    "template-tag" = ecad7ac4506804fd64f1fdd6d30d6079d730b50e;
...
}

你可以看到template-tag和请求时一样,说明模版没有变化,template-change为false是个意思;Etag和请求时If-None-Match不同说明页面内容有变化;这时候服务器返回了如下内容

{
    "data": {
        "{title}": "<title>Sonic Demo<\/title>",
        "{data1}": "<!--sonicdiff-data1-->\n    <h2>\u5f53\u524d\u65f6\u95f4\uff1a1503390256<\/h2>\n    <!--sonicdiff-data1-end-->"
    },
    "template-tag": "ecad7ac4506804fd64f1fdd6d30d6079d730b50e",
    "html-sha1": "af3f21e49dcf76ae538095a18d82bf10692db06e",
    "diff": ""
}

是不是很惊讶,sonic做了处理,对于模版没有变化的页面只返回了动态数据变化的部分;没错,这大大的降低了流量,也加快了请求;这时候你再看看本地的缓存文件,temp文件内容不变,其他三个文件内容更新了(自己理解一下)
数据更新请求完了,客户端先加载本地缓存的html文件、服务器返回变化的部分、客户端本地更新必要的文件

模版变化

模版变化其实和首次加载只有一点点区别,就是客户端会先加载本地缓存的html文件;服务器响应依然会返回整个html文件(其实我觉得这个sonic可以再优化优化,估计是遇到了什么困难),客户端本地会更新所有四个文件

完全缓存

这个很好理解了,客户端会先加载本地缓存的html文件,服务器返回状态码304表示内容和客户端一摸一样,客户端只需要更新cfg文件

再来从源码发现Sonic对请求和响应做了哪些变化

依然老规矩,我们会按照官方说的首次加载、数据更新和完全缓存三种情况(官方说四种但是首次加载和模版更新客户端逻辑一样)来看一下客户端会经过哪些源码。

注:阅读此部分请你先下载我写的超简单支持Sonic的PHP服务器iOS客户端,然后仔细阅读NSURLProtocol简单实用Mac搭建PHP本地环境在本地部署好环境,最后结合Sonic之iOS端实现原理一起看本文章;理所当然的本文我会按照这两份源码来讲解;iOS和Web交互中间件使用WebViewJavascriptBridge。

下载服务器代码后在index.php中注释掉这一行关闭模版更新(首次加载和模版更新客户端逻辑一样)

...
//每次重新打开此界面都改变此值,模拟模版变化
$templateFlag = $_COOKIE['templateFlag'];
//模拟模版更新
// setcookie('templateFlag',!$templateFlag);
...

如果你本地php服务器环境搭建完成了,你浏览器中输入类似http://localhost/sonic-php/sample/index.php地址就会看到下面的页面,记得把客户端中的地址也改改

前端界面.png

首次加载

初始化

我们看看WebViewController.m的init方法:

...
[[SonicClient sharedClient] createSessionWithUrl:@"http://localhost/sonic-php/sample/index.php" withWebDelegate:self];

还记得这里说的客户端并行请求数据吗?说的就是在ViewController初始化的时候Sonic就开始向服务器请求数据而不用等到webView调用loadRequest;我们进入SonicClient,sharedClient就是获取SonicClient的单例对象,init时调了[self setupClient]

- (void)setupClient {
    self.lock = [NSRecursiveLock new];
    self.tasks = [NSMutableDictionary dictionary];
    self.ipDomains = [NSMutableDictionary dictionary];
}

其中lock采用NSRecursiveLock防止死锁;tasks用于存放当前url和session对应关系,因为你可以同时存在几个带webView的vc;ipDomains存放域名和地址的对应关系,举个例子,我们的演示demo地址是http://localhost/sonic-php/sample/index.php,如果你做了下面的操作

[[SonicClient sharedClient] addDomain:@"localhost" withIpAddress:@"127.0.0.1"];

那么发起该url请求时SonicSession的serverIP值就会被设置为127.0.0.1并且请求头部也会加上一些参数(其实我提了一个issues并给腾讯团队提了pull request),最后发起请求的地址也就成了http://127.0.0.1/sonic-php/sample/index.php
现在来看看

if ([[SonicCache shareCache] isServerDisableSonic:sonicSessionID(url)]) 
        return;

[SonicCache shareCache]同样的是获取SonicCache的单例对象,在init的时[self setupInit]

- (void)setupInit
{
    self.maxCacheCount = 3;
    self.lock = [NSRecursiveLock new];
    self.memoryCache = [NSMutableDictionary dictionaryWithCapacity:self.maxCacheCount];
    self.recentlyUsedKey = [NSMutableArray arrayWithCapacity:self.maxCacheCount];
    [self setupCacheDirectory];
    [self setupCacheOfflineTimeCfgDict];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(memoryWarningClearCache) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
}

maxCacheCount表示内存缓存中保留的SonicCacheItem个数;memoryCache表示内存缓存的SonicCacheItem;recentlyUsedKey相当于做了一个先进先出的操作,如果当前内存缓存数量超过了maxCacheCount那么就删除最先加入的;setupCacheDirectory是创建缓存目录;setupCacheOfflineTimeCfgDict是服务器容灾措施,如果服务器访问量太大,会给一部分客户响应头cache-offline值设置为http让这部分客户端暂时不能对于特定url访问服务器(offlineCacheTimeCfg会对单个url配置限制时间),本地如果有缓存的该配置文件就填充offlineCacheTimeCfg属性;最后一句话是如果应用内存紧张了就删除内存缓存的数据。
sonicSessionID(url)是Sonic用md5加密并标示标示每个url的

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

Sonic给我们设一个属性currentUserUniq,让我们可以设置每次进入网页的身份(你的app当然可以退出再登陆其他账号),Sonic好分用户对应文件夹村存放缓存文件。
所以我们首次加载的时候[[SonicCache shareCache] isServerDisableSonic:sonicSessionID(url)肯定是返回NO的,因为我们没有发起过请求,本地自然没有服务器对于容灾的配置文件,SonicCache的offlineCacheTimeCfg自然也是空的。然后我们走下面的代码

[self.lock lock];
SonicSession *existSession = self.tasks[sonicSessionID(url)];
//这里都会为nil,因为我们在控制器返回时从self.tasks中remove了该会话
if (existSession && existSession.delegate != nil) {
    //session can only owned by one delegate
    [self.lock unlock];
    return;
}

加锁进行后面的操作,找到tasks中该url是否已经有一个对应的回话了,这里我们要看看WebViewController.m的dealloc

[[SonicClient sharedClient] removeSessionWithWebDelegate:self];

也就是说如果你的控制器能正常释放且你应用不会打开两个相同url界面的话, 这里的existSession肯定是nil的,我们不分析两个界面同时打开相同url

NSURL *cUrl = [NSURL URLWithString:url];
NSString *serverIP = [self.ipDomains objectForKey:cUrl.host]?:@"";
existSession = [[SonicSession alloc] initWithUrl:url withServerIP:serverIP withWebDelegate:aWebDelegate];

这里就是创建会话了,serverIP就是该url在self.ipDomains中对应的值,这个前面已经提过了;我们看看SonicSession初始化都做了什么

...
_sessionID = [sonicSessionID(aUrl) copy];
[self setupData];
- (void)setupData {
    SonicCacheItem *cacheItem = [[SonicCache shareCache] cacheForSession:_sessionID];
    ...
}

[[SonicCache shareCache] cacheForSession:_sessionID]是获取该会话id在本地对应的缓存文件,我们看看cacheForSession

SonicCacheItem *cacheItem = nil;
cacheItem = self.memoryCache[sessionID];
if (!cacheItem) {
    cacheItem = [[SonicCacheItem alloc] initWithSessionID:sessionID];
    [self memoryCacheItem:cacheItem];
    [self setupCacheItemFromFile:cacheItem];
    [cacheItem release];
}
...

self.memoryCache[sessionID]表示先从内存缓存中找到该会话是否有保存的cacheItem,如果没有就用sessionID初始化一个,memoryCacheItem表示记录到内存缓存中

- (void)memoryCacheItem:(SonicCacheItem *)cacheItem {
    //保存到内存缓存中
    [self.memoryCache setObject:cacheItem forKey:cacheItem.sessionID];
    //找到该cache对应的下标
    NSUInteger index = [self.recentlyUsedKey indexOfObject:cacheItem.sessionID];
    if (index != NSNotFound) {
        [self.recentlyUsedKey removeObjectAtIndex:index];
    }
    //并把该cache放到第一个,用来记录最近使用的cache
    [self.recentlyUsedKey insertObject:cacheItem.sessionID atIndex:0];
    //如果需要缓存的cache个数大于限制
    if (self.recentlyUsedKey.count > self.maxCacheCount) {
        //我们就去掉最后一个,也就是最先加入的
        NSString *lastUsedKey = [self.recentlyUsedKey lastObject];
        [self.memoryCache removeObjectForKey:lastUsedKey];
        [self.recentlyUsedKey removeObject:lastUsedKey];
    }
}

再来看看[self setupCacheItemFromFile:cacheItem]

- (void)setupCacheItemFromFile:(SonicCacheItem *)item {
    //检查该item是否有html data config template四个文件
    //如果有任何一个文件不存在,就删除所有文件
    if (![self isAllCacheExist:item.sessionID]) {
        [self removeFileCacheOnly:item.sessionID];
        return;
    }
    //如果四个文件都存在,就取出里面的数据
    NSData *htmlData = [NSData dataWithContentsOfFile:[self filePathWithType:SonicCacheTypeHtml sessionID:item.sessionID]];
    NSDictionary *config = [NSDictionary dictionaryWithContentsOfFile:[self filePathWithType:SonicCacheTypeConfig sessionID:item.sessionID]];
    NSString *sha1 = config[kSonicSha1];
    NSString *htmlSha1 = getDataSha1(htmlData);
    //如果检测出来内容的sha值不是cfg中保存的值,说明数据发生了错误,需要删除文件
    if (![sha1 isEqualToString:htmlSha1]) {
        [self removeFileCacheOnly:item.sessionID];
    }else{
        //如果所有数据正常,就赋值给item
        item.htmlData = htmlData;
        item.config = config;
    }
}

这里的意思很明确了,如果本地有该url的缓存文件就读取放到cacheItem中,如果没有那么本地啥文件都没有;接下来我们接着看SonicSession的setupData剩下的部分:

//是不是第一次加载,通过item的htmlData数据来判断
self.isFirstLoad = cacheItem.hasLocalCache;
//如果本地有该会话缓存的数据就给本session赋值
if (!cacheItem.hasLocalCache) {
   self.cacheFileData = cacheItem.htmlData;
   self.cacheConfigHeaders = cacheItem.config;
   self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
   self.localRefreshTime = cacheItem.lastRefreshTime;
}
[self setupConfigRequestHeaders];

setupConfigRequestHeaders里面的内容就不说了,就是设置请求的头部,里面有很多Sonic扩展的key可以在这里看到。
接下来继续看SonicClient中createSessionWithUrl::剩下的部分

[existSession setCompletionCallback:^(NSString *sessionID){
     [existSession cancel];
     [self.tasks removeObjectForKey:sessionID];
}];
[self.tasks setObject:existSession forKey:existSession.sessionID];
[existSession start];

设置会话完成时从tasks中删除该会话,把当前会话加到tasks中,然后开始start

dispatchToMain(^{
//通知控制器(因为self.delegate已经指向了打开webView的控制器),我要对该url进行请求了
if (self.delegate && [self.delegate respondsToSelector:@selector(sessionWillRequest:)]) {
  [self.delegate sessionWillRequest:self];
}
  //如果控制器有设置cookies,在这里同步添加到请求
  [self syncCookies];
});
//异步执行请求
[self requestStartInOperation];

requestStartInOperation就是异步请求数据(按照程序执行的先后顺序,后面讲这个),看到这里我们要回过头来看WebViewController.m的viewDidLoad了

webView发起请求
...
if ([[SonicClient sharedClient] sessionWithWebDelegate:self]) 
  [self.webView loadRequest:sonicWebRequest(request)];
...
- (SonicSession *)sessionWithWebDelegate:(id<SonicSessionDelegate>)aWebDelegate {
    //检测aWebDelegate是否实现了SonicSessionDelegate协议
    if (!ValidateSessionDelegate(aWebDelegate)) 
        return nil;
    SonicSession *findSession = nil;
    [self.lock lock];
    //从tasks中找到代理是aWebDelegate的会话
    for (SonicSession *session in self.tasks.allValues) {
        if (session.delegate == aWebDelegate) {
             findSession = session;
             break;
       }
    }
    [self.lock unlock];
    return findSession;
}

如果findSession为nil有下面两种情况

1: aWebDelegate没有实现SonicSessionDelegate协议
2:tasks中没有delegate为aWebDelegate的会话

第二种情况往往是控制器没有delloc导致的,如果你也遇到了就需要好好的检查一下了。代码走到这里后我们就要好好看一下sonicWebRequest(request)了

#define SonicHeaderValueWebviewLoad   @"__SONIC_HEADER_VALUE_WEBVIEW_LOAD__"

NSMutableURLRequest *request = [[originRequest mutableCopy]autorelease];
[request setValue:SonicHeaderValueWebviewLoad forHTTPHeaderField:SonicHeaderKeyLoadType];
[request setValue:sonicSessionID(request.URL.absoluteString) forHTTPHeaderField:SonicHeaderKeySessionID];
######拦截webView请求

这里我们要注意SonicHeaderValueWebviewLoad宏,只有请求头部设置了此值webView的loadRequest才会被SonicURLProtocol所拦截(前面我让你看过NSURLProtocol文章)。所以接下来我们当然是看SonicURLProtocol.m的startLoading方法咯

NSThread *currentThread = [NSThread currentThread];
//得到当前会话的id
NSString *sessionID = [self.request valueForHTTPHeaderField:SonicHeaderKeySessionID];
//让SonicClient接管此请求
[[SonicClient sharedClient] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
   //SonicClient请求返回的数据通过callClientActionWithParams处理
   [self performSelector:@selector(callClientActionWithParams:) onThread:currentThread withObject:param waitUntilDone:NO];
}];

callClientActionWithParams写法基本是固定的,就是在各个请求阶段取出SonicClient回调的数据传输给self.client去继续渲染界面。
看到这里我们大概明白了,webView的loadRequest被SonicClient接管了,我们现在来看看SonicClient的registerURLProtocolCallBackWithSessionID,这个是重点

...
[session preloadRequestActionsWithProtocolCallBack:protocolCallBack];
- (void)preloadRequestActionsWithProtocolCallBack:(SonicURLProtocolCallBack)protocolCallBack {
    dispatch_block_t opBlock = ^{
        self.protocolCallBack = protocolCallBack;
        //如果不是第一次请求该url,是不是第一次是根据本地是否有该url缓存文件判断的
        //或者模版更新sonic让webView重新loadRequest或者如果当前数据请求Sonic已经完成了(因为self.isDataUpdated只会在请求完成且没有错误才被设置),意思就是说我在webView在调用loadRequest前Sonic就已经获取完了整个请求数据
        if (self.isDataUpdated || !self.isFirstLoad) {
            //如果protocolCallBack不为空
            if (protocolCallBack)
                //直接就可以告诉SonicURLProtocol请求完成并返回数据,展示界面
                [self dispatchProtocolActions:[self cacheFileActions]];
            //如果模版更新sonic让webView重新loadRequest或者请求已经完成了,设置sonicStatus的状态为完全缓存;如果Sonic请求完数据会直接存到本地的,状态说是完全缓存也是理所当然的
            if (self.isDataUpdated) 
                self.sonicStatusFinalCode = SonicStatusCodeAllCached;
        } else {
            //如果是第一次加载数据且Sonic请求还没有完成或者请求出错了,就把Sonic已经完成的阶段回调给SonicURLProtocol,self.isCompletion只会在请求发生错误或者请求完成时设置
            //还没有完成的阶段,后面会陆续回调给SonicURLProtocol
            if (self.isFirstLoad) 
                [self dispatchProtocolActions:[self preloadRequestActions]];
        }
    };
    //把block丢给SonicSessionQueue,先后顺序执行
    dispatchToSonicSessionQueue(opBlock);
}

上面的代码就是Sonic的核心了,为什么第二次访问url会那么快,是因为第二次是直接取对应的cacheItem让webView展示。在这里我们是首次加载,所以会走else代码块,为了方便讲解,我们假设现在Sonic一个请求阶段都没有完成,那么这时候我们就要回到刚才SonicSession的requestStartInOperation了

Class customRequest = [self canCustomRequest];
if (!customRequest) {
   //If there no custom request ,then use the default
   customRequest = [SonicConnection class];
}
...
//用户如果有自定义的请求连接方式(比如:用户继承SonicConnection,用AFNetWorking发起请求等)
- (Class)canCustomRequest {
    Class findDestClass = nil;
    for (NSInteger index = sonicRequestClassArray.count - 1; index >= 0; index--) {
        Class itemClass = sonicRequestClassArray[index];
        //构造一个此类的canInitWithRequest方法,调用
        NSMethodSignature *sign = [itemClass methodSignatureForSelector:@selector(canInitWithRequest:)];
        NSInvocation *invoke = [NSInvocation invocationWithMethodSignature:sign];
        invoke.target = itemClass;
        NSURLRequest *argRequest = self.request;
        [invoke setArgument:&argRequest atIndex:2];
        invoke.selector = @selector(canInitWithRequest:);
        [invoke invoke];
        BOOL canCustomRequest;
        //如果有能处理该requeset的(用户继承SonicConnection,canInitWithRequest返回YES)
        [invoke getReturnValue:&canCustomRequest];
        if (canCustomRequest) {
            findDestClass = itemClass;
            break;
        }
    }
    return findDestClass;
}

这里就比较有意思了,SonicSDK默认使用的SonicConnection类作为连接,你可以看到SonicConnection内部是用NSURLSessionDataTask发起的请求,用户可以自己继承SonicConnection用比如AFNetWorking等发起请求;所以[self canCustomRequest]方法就是获取用户自定义的请求方式是否有能响应该request的,也就是canInitWithRequest:返回YES的,如果没有就用初始化SonicConnection。接下来当然是初始化请求,然后建立请求咯

...
//初始化请求类
SonicConnection *cRequest = [[customRequest alloc]initWithRequest:self.request];
//把请求类保存到自己mCustomConnection属性中
self.mCustomConnection = cRequest;
[cRequest release];
//设置请求类发起请求的各个阶段需要通知到自己处理
//然后处理结果会通过self.protocolCallBack给到SonicURLProtocol
self.mCustomConnection.session = self;
//开始加载请求
[self.mCustomConnection startLoading];

因为我们是源码讲解,所以我们假设发起请求的类是SonicConnection;其实SonicConnection代码没啥看不懂的,就是数据请求哈,我们只讲重要的请求阶段完成后发生了啥(特别需要SonicSession的一些属性设置)

收到服务器响应

首先当然是收到服务器响应阶段了

[self.session session:self.session didRecieveResponse:(NSHTTPURLResponse *)response];

刚才说了SonicConnection的session就是SonicSession,我们会到SonicSession中

- (void)session:(SonicSession *)session didRecieveResponse:(NSHTTPURLResponse *)response {
...
self.response = response;
self.cacheResponseHeaders = response.allHeaderFields;
if (self.isFirstLoad)
    [self firstLoadRecieveResponse:response];
...
}
- (void)firstLoadRecieveResponse:(NSHTTPURLResponse *)response {
    [self dispatchProtocolAction:SonicURLProtocolActionRecvResponse param:response];
}
//回传给SonicURLProtocol
- (void)dispatchProtocolAction:(SonicURLProtocolAction)action param:(NSObject *)param {
    NSDictionary *actionParam = [self protocolActionItem:action param:param];
    if (self.protocolCallBack) {
        self.protocolCallBack(actionParam);
    }
}

把响应和头部存在SonicSession属性中,如果是第一次加载,回传给SonicURLProtocol。前面说了SonicURLProtocol中

- (void)callClientActionWithParams:(NSDictionary *)params {
...
}

方法是固定的,没啥特别的;服务器返回的code有以下几种情况

1000:第一次请求,及本地没有缓存文件,此时服务器会返回整个前端内容
2000:非第一次请求,本地和服务器内容模版有变化,此时服务器会返回整个前端内容
304:非第一次请求,本地可服务器内容完全一样,此时服务器不返回前端任何内容
503:服务器要求客户端不用Sonic重新加载请求
200:非第一次请求,本地和服务器内容模版无变化,但动态数据有变化,此时服务器会返回前端变化的数据
注:至于服务器是怎么判断返回什么的,你就去看看源码咯

所以我们继续走SonicConnection中请求下一个阶段

收到服务器数据
[self.session session:self.session didLoadData:data]
//收到请求返回的数据
- (void)session:(SonicSession *)session didLoadData:(NSData *)data {
    dispatch_block_t opBlock = ^{
        if (!self.responseData)
            self.responseData = [NSMutableData data];
       //如果有数据,就不断的添加到self.responseData中
        if (data) {
            NSData *copyData = [data copy];
            [self.responseData appendData:data];
            [copyData release];
            //如果是第一次加载,执行firstLoadDidLoadData
            if (self.isFirstLoad)
                [self firstLoadDidLoadData:data];
        }
    };
    dispatchToSonicSessionQueue(opBlock);
}
- (void)firstLoadDidLoadData:(NSData *)data
{
   //回传给SonicURLProtocol
    [self dispatchProtocolAction:SonicURLProtocolActionLoadData param:data];
}

收到服务器响应阶段也是如此简单,就是把返回的数据存在SonicSession的responseData中;接下来在SonicConnection中可能会走下面两个方法

//请求完成
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
...
}
//请求变无效
- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(nullable NSError *)error {
...
}
请求完成
//如果发生错误,执行此
if (error)
    [self.session session:self.session didFaild:error];
else//如果未发生错误,执行此
    [self.session sessionDidFinish:self.session];

如果发生错误,走SonicSession的下面代码

//请求完成,且发生错误
- (void)session:(SonicSession *)session didFaild:(NSError *)error {
    dispatch_block_t opBlock = ^{
        //把错误信息记录到SonicSession的error中
        self.error = error;
        //标记请求已经完成
        self.isCompletion = YES;
        //请求错误但是服务器返回304表示完全缓存,也就是说服务器本次请求的数据和客户端保存的数据完全一样
        if (self.response.statusCode == 304) {
            //是不是第一次加载
            if (self.isFirstLoad)
                [self firstLoadDidFinish];
            else
                [self updateDidSuccess];
        }else{//请求错误且服务器没返回304,说明真的出错了
            if (self.isFirstLoad)
                [self firstLoadDidFaild:error];
            else
                [self updateDidFaild];
        }
    };
    dispatchToSonicSessionQueue(opBlock);
}
...

正常的逻辑我们会走到[self firstLoadDidFaild:error],因为本地没有缓存自然不会返回304

//第一次加载请求,并且失败了
- (void)firstLoadDidFaild:(NSError *)error {
    //传给SonicURLProtocol
    [self dispatchProtocolAction:SonicURLProtocolActionDidFaild param:error];
    //如果self.completionCallback有值,就执行
    [self checkAutoCompletionAction];
}
- (void)checkAutoCompletionAction {
    //主线程依次执行回调
    dispatchToMain(^{
        if (!self.delegate) 
            if (self.completionCallback) 
                self.completionCallback(self.sessionID);
    });
}

这里的completionCallback还记得SonicClient的createSessionWithUrl::里面吗

[existSession setCompletionCallback:^(NSString *sessionID){
     [existSession cancel];
     [self.tasks removeObjectForKey:sessionID];
}];

其实就是如果第一次请求发生错误,就取消该url的会话并从tasks中删除,如果第一次请求真的失败了,那么整个代码流程也就走完了。接下来我们来分析请求完成,并且没有失败的情况,会走SonicSession的sessionDidFinish:

self.isCompletion = YES;
if (self.isFirstLoad) 
   [self firstLoadDidFinish];
else
   [self updateDidSuccess];
//第一次加载请求完成,且没有错误
- (void)firstLoadDidFinish {
    //通知SonicURLProtocol请求完成
    [self dispatchProtocolAction:SonicURLProtocolActionDidFinish param:nil];
    if (![self isCompletionWithOutError]) {
        return;
    }
    //服务器返回状态
    switch (self.response.statusCode) {
        //如果本地缓存内容和服务器内容模版无变化,但是动态数据有变化
        case 200: {
            //如果服务器设置了客户端的行为,也就是有cache-offline字段,这个
            //服务器一般都有设置,并且值为true
            if ([self isSonicResponse]) {
                //获取服务器对于该客户端指派的行为
                //true:缓存到磁盘并展示返回内容
                //false:展示返回内容,无需缓存到磁盘
                //store:缓存到磁盘,如果已经加载缓存,则下次加载,否则展示返回内容
                //http:容灾字段,如果http表示终端六个小时之内不会采用sonic请求该URL
                NSString *policy = [self responseHeaderValueByIgnoreCaseKey:SonicHeaderKeyCacheOffline];
                //把服务器返回的数据存在cacheFileData中,后面会进行操作
                self.cacheFileData = self.responseData;
                //如果值为http,表示终端六个小时之内不会采用sonic请求该URL
                if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable]) {
                    //这时候就需要把这些配置写入文件了
                    [[SonicCache shareCache] saveServerDisabeSonicTimeNow:self.sessionID];
                    self.isDataUpdated = YES;
                    break;
                }
                //如果值为true、false、store,表示需要缓存到磁盘
                if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
                    //把本次请求的内容分html、data、cfg、template存放到本地缓存目录
                    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;
                    }
                    //如果值为false,需要删除本地缓存
                    if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) 
                        [[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
                    //去掉服务器对客户端的访问限制,万一之前设置了,现在没设置,就可以在这里删除
                    [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
                }
            }else{
                self.cacheFileData = self.responseData;
            }
            //设置数据已经更新到本地标志
            self.isDataUpdated = YES;
        }
            break;
        case 503://要求客户端
            [self webViewRequireloadNormalRequest];
            break;
        default:
            break;
    }
    //如果设置有completionCallback,挨个回调
    [self checkAutoCompletionAction];
}

第一次成功请求会走200代码块,其实demo中注释我已经写的很多了,相信你也能看懂;那么如果第一次请求完成且没有错误,该走的代码也就走完了,这时候你去缓存目录看看就会多出那四个对应的文件

请求变无效

其实和请求完成发生错误走的是同一个方法,就是通知SonicURLProtocol发生错误了,如果self.completionCallback有值,就挨个回调
文章写到这里,其实大部分的代码我们都已经讲过了,我们注意到请求完成且没有错误响应数据才会写入文件,我们假设我们请求完成且没有错误且本地所有文件正常创建,接下来分析第二次请求模版不变数据更新的情况

数据更新

其实前面的步骤都是差不多的,从SonicClient的createSessionWithUrl开始有点不一样

...
existSession = [[SonicSession alloc] initWithUrl:url withServerIP:serverIP withWebDelegate:aWebDelegate];
...
[self setupData];
- (void)setupData
{
    //得到该会话的缓存数据 html template data cconfig
    SonicCacheItem *cacheItem = [[SonicCache shareCache] cacheForSession:_sessionID];
    //是不是第一次加载
    self.isFirstLoad = cacheItem.hasLocalCache;
    //如果本地有该会话缓存的数据 如果有缓存的数据,就给self赋值,添加请求头部时有用
    if (!cacheItem.hasLocalCache) {
        self.cacheFileData = cacheItem.htmlData;
        self.cacheConfigHeaders = cacheItem.config;
        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
        self.localRefreshTime = cacheItem.lastRefreshTime;
   }
}

因为本地有缓存文件,所以这时候的cacheItem各项其实是有值的,self.isFirstLoad自然也是NO;
同样在SonicURLProtocol中startLoading中

[[SonicClient sharedClient] registerURLProtocolCallBackWithSessionID:sessionID completion:^(NSDictionary *param) {
...
}];
- (void)preloadRequestActionsWithProtocolCallBack:(SonicURLProtocolCallBack)protocolCallBack {
...
if (self.isDataUpdated || !self.isFirstLoad) {
     //直接就可以告诉SonicURLProtocol请求完成并返回数据,展示界面
     [self dispatchProtocolActions:[self cacheFileActions]];
...
}
}

这时候if是为真的,Sonic直接就把缓存的数据丢给SonicURLProtocol了,webView自然就直接显示缓存文件了,这当然是秒开咯!然后SonicConnection还是会继续发起请求的,我们直接看请求完成且没有错误时应该执行什么吧

if (error)
     [self.session session:self.session didFaild:error];
else
     [self.session sessionDidFinish:self.session];
- (void)sessionDidFinish:(SonicSession *)session {
    dispatch_block_t opBlock = ^{
        //标记请求完成
        self.isCompletion = YES;
        if (self.isFirstLoad) 
            [self firstLoadDidFinish];
        else
            [self updateDidSuccess];
    };
    dispatchToSonicSessionQueue(opBlock);
}

这时候会走 [self updateDidSuccess]方法,这个方法我们要好好的分析一下了

- (void)updateDidSuccess {
    if (![self isCompletionWithOutError])
        return;
    switch (self.response.statusCode) {
            //服务器返回304表示完全缓存
        case 304: {
            //记录下来 就可以直接返回了,因为完全缓存客户端并不需要做什么
            self.sonicStatusCode = SonicStatusCodeAllCached;
            self.sonicStatusFinalCode = SonicStatusCodeAllCached;
        }
            break;
        //200表示模版有更新、或者模版未更新但动态数据有更新
        case 200: {
            //如果服务器响应中没有设置cache-offline,直接返回;说明此请求不满足Sonic规范
            if (![self isSonicResponse])
                break;
            //如果是模版更新
            if ([self isTemplateChange]) {
                self.cacheFileData = self.responseData;
                [self dealWithTemplateChange];
            }else{//如果是模版未变化但动态数据更新
                [self dealWithDataUpdate];
            }
            //获取服务器对于该客户端指派的行为
            //true:缓存到磁盘并展示返回内容
            //false:展示返回内容,无需缓存到磁盘
            //store:缓存到磁盘,如果已经加载缓存,则下次加载,否则展示返回内容
            //http:容灾字段,如果http表示终端六个小时之内不会采用sonic请求该URL
            NSString *policy = [self responseHeaderValueByIgnoreCaseKey:SonicHeaderKeyCacheOffline];
            //store true false需要删除限制
            if ([policy isEqualToString:SonicHeaderValueCacheOfflineStore] || [policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
                //去掉服务器对客户端的访问限制,万一之前设置了,现在没设置,就可以在这里删除
                [[SonicCache shareCache] removeServerDisableSonic:self.sessionID];
            }
            //false 删除该sessionID的内存缓存以及本地缓存
            if ([policy isEqualToString:SonicHeaderValueCacheOfflineRefresh])
                [[SonicCache shareCache] removeCacheBySessionID:self.sessionID];
            //http 需要保存限制
            if ([policy isEqualToString:SonicHeaderValueCacheOfflineDisable])
                [[SonicCache shareCache] saveServerDisabeSonicTimeNow:self.sessionID];
        }
            break;
        default:
            break;
    }
    //用callBack告诉网页数据变化了
    //如果有设置[[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate:self completion:^(NSDictionary *result) {...}会自动触发
    if (self.webviewCallBack) {
        NSDictionary *resultDict = [self sonicDiffResult];
        if (resultDict) 
            self.webviewCallBack(resultDict);
    }
    [self checkAutoCompletionAction];
}
//如果是模版更新
- (void)dealWithTemplateChange{
    //重新把响应写入到本地缓存文件
    SonicCacheItem *cacheItem = [[SonicCache shareCache] saveFirstWithHtmlData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
    //如果写入成功,给当前会话设置一些值
    if (cacheItem) {
        self.sonicStatusCode = SonicStatusCodeTemplateUpdate;
        self.sonicStatusFinalCode = SonicStatusCodeTemplateUpdate;
        self.localRefreshTime = cacheItem.lastRefreshTime;
        self.cacheFileData = self.responseData;
        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
        //标记数据已经更新到本地
        self.isDataUpdated = YES;
        //因为不是第一次加载数据,SonicClient会直接把缓存内容丢给SonicURLProtocol,didFinishCacheRead就是用来标记是否已经丢给了SonicURLProtocol
        //这个if一般都是不执行的
        if (!self.didFinishCacheRead) {
            return;
        }
        dispatchToMain(^{
            //获取服务器对于该客户端指派的行为
            //true:缓存到磁盘并展示返回内容
            //false:展示返回内容,无需缓存到磁盘
            //store:缓存到磁盘,如果已经加载缓存,则下次加载,否则展示返回内容
            //http:容灾字段,如果http表示终端六个小时之内不会采用sonic请求该URL
            NSString *policy = [self responseHeaderValueByIgnoreCaseKey:SonicHeaderKeyCacheOffline];
            //true false需要重新更新一下界面
            //因为模版有更新,当前webView显示的内容是本地缓存的
            if ([policy isEqualToString:SonicHeaderValueCacheOfflineStoreRefresh] || [policy isEqualToString:SonicHeaderValueCacheOfflineRefresh]) {
                if (self.delegate && [self.delegate respondsToSelector:@selector(session:requireWebViewReload:)]) {
                    NSURLRequest *sonicRequest = [NSURLRequest requestWithURL:[NSURL URLWithString:self.url]];
                    [self.delegate session:self requireWebViewReload:sonicWebRequest(sonicRequest)];
                }
            }
        });
    }
}
//如果是动态数据变化
- (void)dealWithDataUpdate {
    //只需要把变化的部分写入
    SonicCacheItem *cacheItem = [[SonicCache shareCache] updateWithJsonData:self.responseData withResponseHeaders:self.response.allHeaderFields withUrl:self.url];
    if (cacheItem) {
        self.sonicStatusCode = SonicStatusCodeDataUpdate;
        self.sonicStatusFinalCode = SonicStatusCodeDataUpdate;
        self.localRefreshTime = cacheItem.lastRefreshTime;
        self.cacheFileData = cacheItem.htmlData;
        self.cacheResponseHeaders = cacheItem.cacheResponseHeaders;
        if (_diffData) {
            [_diffData release];
            _diffData = nil;
        }
        //把这次请求和之前本地保存的数据不同的部分保存起来
        _diffData = [cacheItem.diffData copy];
        //标志数据更新到本地完成
        self.isDataUpdated = YES;
    }
}

其中要注意的是模版更新需要让webView重新loadRequest,也就是WebViewController.m下面的代码会执行并且SonicURLProtocol会重新拦截一次

- (void)session:(SonicSession *)session requireWebViewReload:(NSURLRequest *)request{
    [self.webView loadRequest:request];
}

数据更新需要把diffData通知给前端,也就是WebViewController.m下面的方法会被调用

__weak typeof(webViewJavascriptBridge) weakWebViewJavascriptBridge = webViewJavascriptBridge;
//简单动态数据变化,传给前端
[[SonicClient sharedClient] sonicUpdateDiffDataByWebDelegate:self completion:^(NSDictionary *result) {
   __strong typeof(webViewJavascriptBridge) strongWebViewJavascriptBridge = weakWebViewJavascriptBridge;
   if (result) {
        NSData *json = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
        NSString *jsonStr = [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding];
       [strongWebViewJavascriptBridge callHandler:@"getDiffDataCallback" data:jsonStr];
     }
}];

那么数据变化的请求代码也走完了,是不是觉得还是很简单呢

完全缓存

完全缓存比起数据更新情况前面都是一样的只是在SonicSession的updateDidSuccess有不同

- (void)updateDidSuccess {
    if (![self isCompletionWithOutError])
        return;
    switch (self.response.statusCode) {
            //服务器返回304表示完全缓存
        case 304: {
            //记录下来 就可以直接返回了,因为完全缓存客户端并不需要做什么
            self.sonicStatusCode = SonicStatusCodeAllCached;
            self.sonicStatusFinalCode = SonicStatusCodeAllCached;
        }
...
}

这里会走304代码块,只是记录一下状态,然后就没有其他的操作了

后记

写这篇文章前我和我公司服务器相关人员一起研究了3.5天弄懂了Sonic工作原理,然后花了2天看iOS端的SonicSDK的源码,最后写这篇文章用了2天;前前后后收获很多,最开始自己不知道文章从何写起,到现在我已经能按照自己思想重新组装SonicSDK了;文章我自认为你们看了还是有很多不懂的地方,虽然我已经自己阅读了两遍;这个技术非常有意思,对于应用内部打开网页很慢的问题这就是一个良药;最后感谢自己的群小伙伴们每天自认为忙的要死,作为群主的我自然想让他们多学一点知识,就有了写这篇文章的动力;最后希望你们多多在文章下提出不懂的地方我好随时修改,让大家都受益。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,599评论 18 139
  • 减脂成功取决于减脂方式的是否适合,而减脂方式是否适合来源于及时的数据反馈及分析。在减脂过程中最直观的数据表现就是称...
    陈雅诗Nicole阅读 285评论 2 6
  • 她的季节阅读 134评论 0 0
  • 早晨叫醒我的不是闹钟,而是清脆的鸟叫声,这时天应该才微亮,鸟儿总是喜欢在我家窗沿上飞来飞去,一唱一和的对话,母亲一...
    各各她阅读 233评论 0 0
  • 2016年的最后一天,过的和往常一样。没有像以前一样的追求形式感,写一些告别旧的一年,迎接新的希望之类的话。因为2...
    胡桃夹子的梦阅读 319评论 0 1