iOS 7 和 Mac OS X 10.9 Mavericks 中一个显著的变化就是对 Foundation URL 加载系统的彻底重构。
现在已经有人在深入苹果的网络层基础架构的地方做研究了,所以我想是时候来分享一些对于我对于这些新的 API 的看法和心得了,新的 API 将如何影响我们编写程序,以及它们对于 API 设计理念的影响。
网络基础概念
NSURLConnection
NSURLConnection基础介绍
NSURLConnection
作为 Core Foundation / CFNetwork 框架的 API 之上的一个抽象,在 2003 年,随着第一版的 Safari 的发布就发布了。NSURLConnection 这个名字,实际上是指代的 Foundation 框架的 URL 加载系统中一系列有关联的组件:NSURLRequest、NSURLResponse、NSURLProtocol、 NSURLCache、 NSHTTPCookieStorage、NSURLCredentialStorage 以及同名类 NSURLConnection。
NSURLConnection 作为 Core Foundation / CFNetwork 框架的 API 之上的一个抽象,在 2003 年,随着第一版的 Safari 的发布就发布了。NSURLConnection
这个名字,实际上是指代的 Foundation 框架的 URL 加载系统中一系列有关联的组件:NSURLRequest
、NSURLResponse
、NSURLProtocol
、 NSURLCache
、 NSHTTPCookieStorage
、NSURLCredentialStorage
以及同名类 NSURLConnection
。
NSURLRequest 被传递给 NSURLConnection。被委托对象(遵守以前的非正式协议
在一个请求被发送到服务器之前,系统会先查询共享的缓存信息,然后根据策略(policy)以及可用性(availability)的不同,一个已经被缓存的响应可能会被立即返回。如果没有缓存的响应可用,则这个请求将根据我们指定的策略来缓存它的响应以便将来的请求可以使用。
在把请求发送给服务器的过程中,服务器可能会发出鉴权查询(authentication challenge),这可以由共享的 cookie 或机密存储(credential storage)来自动响应,或者由被委托对象来响应。发送中的请求也可以被注册的 NSURLProtocol 对象所拦截,以便在必要的时候无缝地改变其加载行为。
关于NSConnection的常用代理
1 | NSURLConnectionDelegate |
不管怎样,NSURLConnection 作为网络基础架构,已经服务了成千上万的 iOS 和 Mac OS 程序,并且做的还算相当不错。但是这些年,一些用例——尤其是在 iPhone 和 iPad 上面——已经对 NSURLConnection 的几个核心概念提出了挑战,让苹果有理由对它进行重构。
NSURLSession
区别
和NSURLConnection 一样,NSURLSession 指的也不仅是同名类 NSURLSession,还包括一系列相互关联的类。NSURLSession 包括了与之前相同的组件,NSURLRequest 与 NSURLCache,但是把 NSURLConnection 替换成了 NSURLSession、NSURLSessionConfiguration 以及 NSURLSessionTask 的 3 个子类:NSURLSessionDataTask,NSURLSessionUploadTask,NSURLSessionDownloadTask。
与 NSURLConnection 相比,NSURLsession 最直接的改进就是可以配置每个 session 的缓存,协议,cookie,以及证书策略(credential policy),甚至跨程序共享这些信息。这将允许程序和网络基础框架之间相互独立,不会发生干扰。每个 NSURLSession 对象都由一个 NSURLSessionConfiguration 对象来进行初始化,后者指定了刚才提到的那些策略以及一些用来增强移动设备上性能的新选项。
NSURLSession 中另一大块就是 session task。它负责处理数据的加载以及文件和数据在客户端与服务端之间的上传和下载。NSURLSessionTask 与 NSURLConnection 最大的相似之处在于它也负责数据的加载,最大的不同之处在于所有的 task 共享其创造者 NSURLSession 这一公共委托者(common delegate)。
NSURLsessionTask 是一个抽象类,其下有 3 个实体子类可以直接使用:NSURLSessionDataTask、NSURLSessionUploadTask、NSURLSessionDownloadTask。这 3 个子类封装了现代程序三个最基本的网络任务:获取数据,比如 JSON 或者 XML,上传文件和下载文件。
当一个 NSURLSessionDataTask 完成时,它会带有相关联的数据,而一个 NSURLSessionDownloadTask 任务结束时,它会带回已下载文件的一个临时的文件路径。因为一般来说,服务端对于一个上传任务的响应也会有相关数据返回,所以 NSURLSessionUploadTask 继承自 NSURLSessionDataTask。
所有的 task 都是可以取消,暂停或者恢复的。当一个 download task 取消时,可以通过选项来创建一个恢复数据(resume data),然后可以传递给下一次新创建的 download task,以便继续之前的下载。
不同于直接使用 alloc-init 初始化方法,task 是由一个 NSURLSession 创建的。每个 task 的构造方法都对应有或者没有 completionHandler 这个 block 的两个版本,例如:有这样两个构造方法 –dataTaskWithRequest: 和 –dataTaskWithRequest:completionHandler:。这与 NSURLConnection 的 -sendAsynchronousRequest:queue:completionHandler: 方法类似,通过指定 completionHandler 这个 block 将创建一个隐式的 delegate,来替代该 task 原来的 delegate——session。对于需要 override 原有 session task 的 delegate 的默认行为的情况,我们需要使用这种不带 completionHandler 的版本。
NSURLSessionTask 的工厂方法
在 iOS 5 中,NSURLConnection 添加了 sendAsynchronousRequest:queue:completionHandler: 这一方法,对于一次性使用的 request, 大大地简化代码,同时它也是sendSynchronousRequest:returningResponse:error: 这个方法的异步替代品:1
2
3
4
5
6
7NSURL *URL = [NSURL URLWithString:@"http://example.com"];
NSURLRequest *request = [NSURLRequest requestWithURL:URL];
[NSURLConnection sendAsynchronousRequest:request
queue:[NSOperationQueue mainQueue]
completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) {
// ...
}];
NSURLSession 在 task 的构造方法上延续了这一模式。不同的是,这里不会立即运行 task,而是将该 task 对象先返回,允许我们进一步的配置,然后可以使用 resume 方法来让它开始运行。
Task
Data task
Data task 可以通过 NSURL 或 NSURLRequest 创建(使用前者相当于是使用一个对于该 URL 进行标准 GET 请求的 NSURLRequest,这是一种快捷方法):1
2
3
4
5
6
7
8
9
10NSURL *URL = [NSURL URLWithString:@"http://example.com"];
NSURLRequest *request = [NSURLRequest requestWithURL:URL];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithRequest:request
completionHandler:
^(NSData *data, NSURLResponse *response, NSError *error) {
// ...
}];
[task resume];
Upload task
Upload task 的创建需要使用一个 request,另外加上一个要上传的 NSData 对象或者是一个本地文件的路径对应的 NSURL:
1 | NSURL *URL = [NSURL URLWithString:@"http://example.com/upload"]; |
Download task
Download task 也需要一个 request,不同之处在于 completionHandler 这个 block。Data task 和 upload task 会在任务完成时一次性返回,但是 Download task 是将数据一点点地写入本地的临时文件。所以在 completionHandler 这个 block 里,我们需要把文件从一个临时地址移动到一个永久的地址保存起来:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 NSURL *URL = [NSURL URLWithString:@"http://example.com/file.zip"];
NSURLRequest *request = [NSURLRequest requestWithURL:URL];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDownloadTask *downloadTask = [session downloadTaskWithRequest:request
completionHandler:
^(NSURL *location, NSURLResponse *response, NSError *error) {
NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
NSURL *documentsDirectoryURL = [NSURL fileURLWithPath:documentsPath];
NSURL *newFileLocation = [documentsDirectoryURL URLByAppendingPathComponent:[[response URL] lastPathComponent]];
[[NSFileManager defaultManager] copyItemAtURL:location toURL:newFileLocation error:nil];
}];
[downloadTask resume];
关于matt的看法
NSURLSessionDownloadTask *downloadTask = [session downloadTaskWithRequest:request completionHandler:
^(NSURL *location, NSURLResponse *response, NSError *error) {
NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
NSURL *documentsDirectoryURL = [NSURL fileURLWithPath:documentsPath];
NSURL *documentURL = [documentsDirectoryURL URLByAppendingPathComponent:[response
suggestedFilename]];
[[NSFileManager defaultManager] moveItemAtURL:location
toURL:documentURL
error:nil];
}];
NSURLSession 既拥有 seesion 的 delegate 方法,又拥有 task 的 delegate 方法用来处理鉴权查询。session 的 delegate 方法处理连接层的问题,诸如服务器信任,客户端证书的评估,NTLM 和 Kerberos 协议这类问题,而 task 的 delegate 则处理以网络请求为基础的问题,如 Basic,Digest,以及代理身份验证(Proxy authentication)等。
在 NSURLConnection 中有两个 delegate 方法可以表明一个网络请求已经结束:NSURLConnectionDataDelegate 中的 -connectionDidFinishLoading: 和 NSURLConnectionDelegate 中的 -connection:didFailWithError:,而在 NSURLSession 中改为一个 delegate 方法:NSURLSessionTaskDelegate 的 -URLSession:task:didCompleteWithError:
NSURLSession 中表示传输多少字节的参数类型现在改为 int64_t,以前在 NSURLConnection 中相应的参数的类型是 long long。
由于增加了 completionHandler: 这个 block 作为参数,NSURLSession 实际上给 Foundation 框架引入了一种全新的模式。这种模式允许 delegate 方法可以安全地在主线程与运行,而不会阻塞主线程;Delgate 只需要简单地调用 dispatch_async 就可以切换到后台进行相关的操作,然后在操作完成时调用 completionHandler 即可。同时,它还可以有效地拥有多个返回值,而不需要我们使用笨拙的参数指针。以 NSURLSessionTaskDelegate 的 -URLSession:task:didReceiveChallenge:completionHandler: 方法来举例,completionHandler 接受两个参数:NSURLSessionAuthChallengeDisposition 和 NSURLCredential,前者为应对鉴权查询的策略,后者为需要使用的证书(仅当前者——应对鉴权查询的策略为使用证书,即 NSURLSessionAuthChallengeUseCredential 时有效,否则该参数为 NULL)
NSURLSessionConfiguration
NSURLSessionConfiguration 对象用于对 NSURLSession 对象进行初始化。NSURLSessionConfiguration 对以前 NSMutableURLRequest 所提供的网络请求层的设置选项进行了扩充,提供给我们相当大的灵活性和控制权。从指定可用网络,到 cookie,安全性,缓存策略,再到使用自定义协议,启动事件的设置,以及用于移动设备优化的几个新属性,你会发现使用 NSURLSessionConfiguration 可以找到几乎任何你想要进行配置的选项。
NSURLSession 在初始化时会把配置它的 NSURLSessionConfiguration 对象进行一次 copy,并保存到自己的 configuration 属性中,而且这个属性是只读的。因此之后再修改最初配置 session 的那个 configuration 对象对于 session 是没有影响的。也就是说,configuration 只在初始化时被读取一次,之后都是不会变化的。
NSURLSessionConfiguration 的工厂方法
NSURLSessionConfiguration 有三个类工厂方法,这很好地说明了 NSURLSession 设计时所考虑的不同的使用场景。
+defaultSessionConfiguration 返回一个标准的 configuration,这个配置实际上与 NSURLConnection 的网络堆栈(networking stack)是一样的,具有相同的共享 NSHTTPCookieStorage,共享 NSURLCache 和共享 NSURLCredentialStorage。
+ephemeralSessionConfiguration 返回一个预设配置,这个配置中不会对缓存,Cookie 和证书进行持久性的存储。这对于实现像秘密浏览这种功能来说是很理想的。
+backgroundSessionConfiguration:(NSString *)identifier 的独特之处在于,它会创建一个后台 session。后台 session 不同于常规的,普通的 session,它甚至可以在应用程序挂起,退出或者崩溃的情况下运行上传和下载任务。初始化时指定的标识符,被用于向任何可能在进程外恢复后台传输的守护进程(daemon)提供上下文。
配置属性
NSURLSessionConfiguration 拥有 20 个配置属性。熟练掌握这些配置属性的用处,可以让应用程序充分地利用其网络环境。
基本配置
HTTPAdditionalHeaders 指定了一组默认的可以设置出站请求(outbound request)的数据头。这对于跨 session 共享信息,如内容类型,语言,用户代理和身份认证,是很有用的。
1 | NSString *userPasswordString = [NSString stringWithFormat:@"%@:%@", user, password]; |
networkServiceType 对标准的网络流量,网络电话,语音,视频,以及由一个后台进程使用的流量进行了区分。大多数应用程序都不需要设置这个。
allowsCellularAccess 和 discretionary 被用于节省通过蜂窝网络连接的带宽。对于后台传输的情况,推荐大家使用 discretionary 这个属性,而不是 allowsCellularAccess,因为前者会把 WiFi 和电源的可用性考虑在内。
timeoutIntervalForRequest 和 timeoutIntervalForResource 分别指定了对于请求和资源的超时间隔。许多开发人员试图使用 timeoutInterval 去限制发送请求的总时间,但其实它真正的含义是:分组(packet)之间的时间。实际上我们应该使用 timeoutIntervalForResource 来规定整体超时的总时间,但应该只将其用于后台传输,而不是用户实际上可能想要去等待的任何东西。
HTTPMaximumConnectionsPerHost 是 Foundation 框架中 URL 加载系统的一个新的配置选项。它曾经被 NSURLConnection 用于管理私有的连接池。现在有了 NSURLSession,开发者可以在需要时限制连接到特定主机的数量。
HTTPShouldUsePipelining 这个属性在 NSMutableURLRequest 下也有,它可以被用于开启 HTTP 管线化(HTTP pipelining),这可以显着降低请求的加载时间,但是由于没有被服务器广泛支持,默认是禁用的。
sessionSendsLaunchEvents 是另一个新的属性,该属性指定该 session 是否应该从后台启动。
connectionProxyDictionary 指定了 session 连接中的代理服务器。同样地,大多数面向消费者的应用程序都不需要代理,所以基本上不需要配置这个属性。
cookie策略
HTTPCookieStorage 存储了 session 所使用的 cookie。默认情况下会使用 NSHTTPCookieShorage 的 +sharedHTTPCookieStorage 这个单例对象,这与 NSURLConnection 是相同的。
HTTPCookieAcceptPolicy 决定了什么情况下 session 应该接受从服务器发出的 cookie。
HTTPShouldSetCookies 指定了请求是否应该使用 session 存储的 cookie,即 HTTPCookieSorage 属性的值。
安全策略
URLCredentialStorage 存储了 session 所使用的证书。默认情况下会使用 NSURLCredentialStorage 的 +sharedCredentialStorage 这个单例对象,这与 NSURLConnection 是相同的。
TLSMaximumSupportedProtocol 和 TLSMinimumSupportedProtocol 确定 session 是否支持 SSL 协议。
缓存策略
URLCache 是 session 使用的缓存。默认情况下会使用 NSURLCache 的 +sharedURLCache 这个单例对象,这与 NSURLConnection 是相同的。
requestCachePolicy specifies when a cached response should be returned for a request. This is equivalent to NSURLRequest -cachePolicy.
requestCachePolicy 指定了一个请求的缓存响应应该在什么时候返回。这相当于 NSURLRequest 的 -cachePolicy 方法。
自定义的协议
protocolClasses 用来配置特定某个 session 所使用的自定义协议(该协议是 NSURLProtocol 的子类)的数组。
关于AFN的代码解析:
AFNetworking代码中有一些常用技巧,先说明下:1
2
3#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wgnu"
#pragma clang diagnostic pop
表示在这个区间里忽略一些特定的clang的编译警告,因为AFNetworking作为一个库被其他项目引用,所以不能全局忽略clang的一些警告,只能在有需要的时候局部这样做,作者喜欢用?:符号,所以经常见忽略-Wgnu警告的写法,详见这里
dispatch_once
为了保证线程安全,所有单例都用了dispatch_once生成,保证只执行一次,这也是iOS开发常用的技巧:1
2
3
4
5
6
7
8static dispatch_queue_t url_request_operation_completion_queue() {
static dispatch_queue_t af_url_request_operation_completion_queue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
af_url_request_operation_completion_queue = dispatch_queue_create("com.alamofire.networking.operation.queue", DISPATCH_QUEUE_CONCURRENT );
});
return af_url_request_operation_completion_queue;
}
weak & strong self
常看到一个block要使用self,会处理在外部声明一个weak变量指向self,在block里又声明一个strong变量指向weakSelf;1
2
3
4__weak __typeof(self)weakSelf = self;
self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^{
__strong __typeof(weakSelf)strongSelf = weakSelf;
}];
线程
先来看看 NSURLConnection 发送请求时的线程情况,NSURLConnection 是被设计成异步发送的,调用了start方法后,NSURLConnection 会新建一些线程用底层的 CFSocket 去发送和接收请求,在发送和接收的一些事件发生后通知原来线程的Runloop去回调事件。
NSURLConnection 的同步方法 sendSynchronousRequest 方法也是基于异步的,同样要在其他线程去处理请求的发送和接收,只是同步方法会手动block主线程,发送状态的通知也不是通过 RunLoop 进行。
使用NSURLConnection有几种选择:
主线程调用异步接口,会有一个Runloop相关的问题:
当在主线程调用 [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:YES] 时,请求发出,侦听任务会加入到主线程的 Runloop 下,RunloopMode 会默认为 NSDefaultRunLoopMode。这表明只有当前线程的Runloop 处于 NSDefaultRunLoopMode 时,这个任务才会被执行。但当用户滚动 tableview 或 scrollview 时,主线程的 Runloop 是处于 NSEventTrackingRunLoopMode 模式下的,不会执行 NSDefaultRunLoopMode 的任务,所以会出现一个问题,请求发出后,如果用户一直在操作UI上下滑动屏幕,那在滑动结束前是不会执行回调函数的,只有在滑动结束,RunloopMode 切回 NSDefaultRunLoopMode,才会执行回调函数。苹果一直把动画效果性能放在第一位,估计这也是苹果提升UI动画性能的手段之一。
所以若要在主线程使用 NSURLConnection 异步接口,需要手动把 RunloopMode 设为 NSRunLoopCommonModes。这个 mode 意思是无论当前 Runloop 处于什么状态,都执行这个任务。1
2
3NSURLConnection *connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[connection scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
[connection start];
子线程调用同步接口
若在子线程调用同步接口,一条线程只能处理一个请求,因为请求一发出去线程就阻塞住等待回调,需要给每个请求新建一个线程,这是很浪费的,这种方式唯一的好处应该是易于控制请求并发的数量。
子线程调用异步接口:
子线程调用异步接口,子线程需要有 Runloop 去接收异步回调事件,这里也可以每个请求都新建一条带有 Runloop 的线程去侦听回调,但这一点好处都没有,既然是异步回调,除了处理回调内容,其他时间线程都是空闲可利用的,所有请求共用一个响应的线程就够了。
AFNetworking 用的就是第三种方式,创建了一条常驻线程专门处理所有请求的回调事件,这个模型跟 nodejs 有点类似。网络请求回调处理完,组装好数据后再给上层调用者回调,这时候回调是抛回主线程的,因为主线程是最安全的,使用者可能会在回调中更新UI,在子线程更新UI会导致各种问题,一般使用者也可以不需要关心线程问题。
以下是相关线程大致的关系,实际上多个 NSURLConnection 会共用一个 NSURLConnectionLoader 线程,这里就不细化了,除了处理 socket 的 CFSocket 线程,还有一些 Javascript:Core 的线程,目前不清楚作用,归为 NSURLConnection里的其他线程。因为 NSURLConnection 是系统控件,每个iOS版本可能都有不一样,可以先把 NSURLConnection 当成一个黑盒,只管它的 start 和 callback 就行了。如果使用 AFHttpRequestOperationManager 的接口发送请求,这些请求会统一在一个 NSOperationQueue 里去发,所以多了上面 NSOperationQueue 的一个线程。
相关代码:相关代码:-networkRequestThread:, -start:, -operationDidStart:。
状态机
继承 NSOperation 有个很麻烦的东西要处理,就是改变状态时需要发 KVO 通知,否则这个类加入 NSOperationQueue 不可用了。 NSOperationQueue 是用 KVO 方式侦听 NSOperation 状态的改变,以判断这个任务当前是否已完成,完成的任务需要在队列中除去并释放。
AFURLConnectionOperation 对此做了个状态机,统一搞定状态切换以及发 KVO 通知的问题,内部要改变状态时,就只需要类似 self.state = AFOperationReadyState 的调用而不需要做其他了,状态改变的 KVO 通知在 setState 里发出。
总的来说状态管理相关代码就三部分,一是限制一个状态可以切换到其他哪些状态,避免状态切换混乱,二是状态 Enum值 与 NSOperation 四个状态方法的对应,三是在 setState 时统一发 KVO 通知。详见代码注释。
相关代码:AFKeyPathFromOperationState, AFStateTransitionIsValid, -setState:, -isPaused:, -isReady:, -isExecuting:, -isFinished:.
NSURLConnectionDelegate
处理 NSURLConnection Delegate 的内容不多,代码也是按请求回调的顺序排列下去,十分易读,主要流程就是接收到响应的时候打开 outputStream,接着有数据过来就往 outputStream 写,在上传/接收数据过程中会回调上层传进来的相应的callback,在请求完成回调到 connectionDidFinishLoading 时,关闭 outputStream,用 outputStream 组装 responseData 作为接收到的数据,把 NSOperation 状态设为 finished,表示任务完成,NSOperation 会自动调用 completeBlock,再回调到上层。
setCompleteBlock
NSOperation 在 iOS4.0 以后提供了个接口 setCompletionBlock,可以传入一个 block 作为任务执行完成时(state状态机变为finished时)的回调,AFNetworking直接用了这个接口,并通过重写加了几个功能:
消除循环引用
在 NSOperation 的实现里,completionBlock 是 NSOperation 对象的一个成员,NSOperation 对象持有着 completionBlock,若传进来的 block 用到了 NSOperation 对象,或者 block 用到的对象持有了这个 NSOperation 对象,就会造成循环引用。这里执行完 block 后调用 [strongSelf setCompletionBlock:nil] 把 completionBlock 设成 nil,手动释放 self(NSOperation对象) 持有的 completionBlock 对象,打破循环引用。
可以理解成对外保证传进来的block一定会被释放,解决外部使用使很容易出现的因对象关系复杂导致循环引用的问题,让使用者不知道循环引用这个概念都能正确使用。(非常好的解决方式)
dispatch_group
这里允许用户让所有 operation 的 completionBlock 在一个 group 里执行,但我没看出这样做的作用,若想组装一组请求(见下面的batchOfRequestOperations)也不需要再让completionBlock在group里执行,求解。
”The Deallocation Problem”
作者在注释里说这里重写的setCompletionBlock方法解决了”The Deallocation Problem”,实际上并没有。”The Deallocation Problem”简单来说就是不要让UIKit的东西在子线程释放。
这里如果传进来的block持有了外部的UIViewController或其他UIKit对象(下面暂时称为A对象),并且在请求完成之前其他所有对这个A对象的引用都已经释放了,那么这个completionBlock就是最后一个持有这个A对象的,这个block释放时A对象也会释放。这个block在什么线程释放,A对象就会在什么线程释放。我们看到block释放的地方是url_request_operation_completion_queue(),这是AFNetworking特意生成的子线程,所以按理说A对象是会在子线程释放的,会导致UIKit对象在子线程释放,会有问题。
但AFNetworking实际用起来却没问题,想了很久不得其解,后来做了实验,发现iOS5以后苹果对UIKit对象的释放做了特殊处理,只要发现在子线程释放这些对象,就自动转到主线程去释放,断点出来是由一个叫_objc_deallocOnMainThreadHelper 的方法做的
。如果不是UIKit对象就不会跳到主线程释放。AFNetworking2.0只支持iOS6+,所以没问题。(意思是现在是可以在子线程中释放UIKit资源)
batchOfRequestOperations
这里额外提供了一个便捷接口,可以传入一组请求,在所有请求完成后回调 complionBlock,在每一个请求完成时回调 progressBlock 通知外面有多少个请求已完成。详情参见代码注释,这里需要说明下 dispatch_group_enter 和dispatch_group_leave 的使用,这两个方法用于把一个异步任务加入 group 里。
一般我们要把一个任务加入到一个Group里是这样子的:1
2
3dispatch_group_async(group, queue, ^{
block();
});
这个写法等价于:1
2
3
4
5dispatch_async(queue, ^{
dispatch_group_enter(group);
block()
dispatch_group_leave(group);
});
异步任务回调后才算这个group任务完成,对batchOfRequest的实现来说就是请求完成并回调后,才算这个任务完成。
其实这跟retain/release差不多,都是计数,dispatch_group_enter时任务数+1,dispatch_group_leave时任务数-1,任务数为0时执行dispatch_group_notify的内容。
相关代码:-batchOfRequestOperations:progressBlock:completionBlock:
其他
锁
AFURLConnectionOperation 有一把递归锁,在所有会访问/修改成员变量的对外接口都加了锁,因为这些对外的接口用户是可以在任意线程调用的,对于访问和修改成员变量的接口,必须用锁保证线程安全。
序列化
AFNetworking 的多数类都支持序列化,但实现的是 NSSecureCoding 的接口,而不是 NSCoding,区别在于解数据时要指定 Class,用 -decodeObjectOfClass:forKey: 方法代替了 -decodeObjectForKey: 。这样做更安全,因为序列化后的数据有可能被篡改,若不指定 Class,-decode 出来的对象可能不是原来的对象,有潜在风险。另外,NSSecureCoding 是 iOS 6 以上才有的。
里在序列化时保存了当前任务状态,接收的数据等,但回调block是保存不了的,需要在取出来发送时重新设置。可以像下面这样持久化保存和取出任务:
1 | AFHTTPRequestOperation *operation = [[AFHTTPRequestOperation alloc] initWithRequest:request]; |
backgroundTask
这里提供了setShouldExecuteAsBackgroundTaskWithExpirationHandler 接口,决定APP进入后台后是否继续发送接收请求,并在后台执行时间超时后取消所有请求。在 dealloc 里需要调用 [application endBackgroundTask:] ,告诉系统这个后台任务已经完成,不然系统会一直让你的APP运行在后台,直到超时。
相关代码:-setShouldExecuteAsBackgroundTaskWithExpirationHandler:, -dealloc:
AFHTTPRequestOperation
AFHTTPRequestOperation 继承了 AFURLConnectionOperation,把它放一起说是因为它没做多少事情,主要多了responseSerializer,暂停下载断点续传,以及提供接口请求成功失败的回调接口 -setCompletionBlockWithSuccess:failure:。详见源码注释。
AFURLRequestSerialization
AFURLRequestSerialization用于帮助构建NSURLRequest,主要做了两个事情:
1.构建普通请求:格式化请求参数,生成HTTP Header。
2.构建multipart请求。
分别看看它在这两点具体做了什么,怎么做的
构建普通请求
格式化请求参数
一般我们请求都会按key=value的方式带上各种参数,GET方法参数直接加在URL上,POST方法放在body上,NSURLRequest没有封装好这个参数的解析,只能我们自己拼好字符串。AFNetworking提供了接口,让参数可以是NSDictionary, NSArray, NSSet这些类型,再由内部解析成字符串后赋给NSURLRequest。
1 | @{ |
第一部分是用户传进来的数据,支持包含NSArray,NSDictionary,NSSet这三种数据结构。
第二部分是转换成AFNetworking内自己的数据结构,每一个key-value对都用一个对象AFQueryStringPair表示,作用是最后可以根据不同的字符串编码生成各自的key=value字符串。主要函数是AFQueryStringPairsFromKeyAndValue,详见源码注释。
第三部分是最后生成NSURLRequest可用的字符串数据,并且对参数进行url编码,在AFQueryStringFromParametersWithEncoding这个函数里。
最后在把数据赋给NSURLRequest时根据不同的HTTP方法分别处理,对于GET/HEAD/DELETE方法,把参数加到URL后面,对于其他如POST/PUT方法,把数据加到body上,并设好HTTP头,告诉服务端字符串的编码。
HTTP Header
AFNetworking帮你组装好了一些HTTP请求头,包括语言Accept-Language,根据 [NSLocale preferredLanguages] 方法读取本地语言,高速服务端自己能接受的语言。还有构建 User-Agent,以及提供Basic Auth 认证接口,帮你把用户名密码做 base64 编码后放入 HTTP 请求头。详见源码注释。
常见的HTTP头,查看更多点击这里
其他格式化方式
HTTP请求参数不一定是要key=value形式,可以是任何形式的数据,可以是json格式,苹果的plist格式,二进制protobuf格式等,AFNetworking提供了方法可以很容易扩展支持这些格式,默认就实现了json和plist格式。详见源码的类AFJSONRequestSerializer和AFPropertyListRequestSerializer。
构建multipart请求
构建Multipart请求是占篇幅很大的一个功能,AFURLRequestSerialization里2/3的代码都是在做这个事。
Multipart协议介绍
Multipart是HTTP协议为web表单新增的上传文件的协议,协议文档是rfc1867,它基于HTTP的POST方法,数据同样是放在body上,跟普通POST方法的区别是数据不是key=value形式,key=value形式难以表示文件实体,为此Multipart协议添加了分隔符,有自己的格式结构,大致如下:
—AaB03x
content-disposition: form-data; name=“name”
bang
–AaB03x
content-disposition: form-data; name=”pic”; filename=“content.txt”
Content-Type: text/plain
… contents of bang.txt …
–AaB03x–
以上表示数据name=bang以及一个文件,content.txt是文件名,… contents of bang.txt …是文件实体内容。分隔符—AaB03x是可以自定义的,写在HTTP头部里:
Content-type: multipart/form-data, boundary=AaB03x
每一个部分都有自己的头部,表明这部分的数据类型以及其他一些参数,例如文件名,普通字段的key。最后一个分隔符会多加两横,表示数据已经结束:—AaB03x—。
实现
接下来说说怎样构造Multipart里的数据,最简单的方式就是直接拼数据,要发送一个文件,就直接把文件所有内容读取出来,再按上述协议加上头部和分隔符,拼接好数据后扔给NSURLRequest的body就可以发送了,很简单。但这样做是不可用的,因为文件可能很大,这样拼数据把整个文件读进内存,很可能把内存撑爆了。
第二种方法是不把文件读出来,不在内存拼,而是新建一个临时文件,在这个文件上拼接数据,再把文件地址扔给NSURLRequest的bodyStream,这样上传的时候是分片读取这个文件,不会撑爆内存,但这样每次上传都需要新建个临时文件,对这个临时文件的管理也挺麻烦的。
第三种方法是构建自己的数据结构,只保存要上传的文件地址,边上传边拼数据,上传是分片的,拼数据也是分片的,拼到文件实体部分时直接从原来的文件分片读取。这方法没上述两种的问题,只是实现起来也没上述两种简单,AFNetworking就是实现这第三种方法,而且还更进一步,除了文件,还可以添加多个其他不同类型的数据,包括NSData,和InputStream。
AFN里multipart请求的使用方式是这样:1
2
3
4
5
6
7
8
9
10AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
NSDictionary *parameters = @{@"foo": @"bar"};
NSURL *filePath = [NSURL fileURLWithPath:@"file://path/to/image.png"];
[manager POST:@"http://example.com/resources.json" parameters:parameters constructingBodyWithBlock:^(id formData) {
[formData appendPartWithFileURL:filePath name:@"image" error:nil];
} success:^(AFHTTPRequestOperation *operation, id responseObject) {
NSLog(@"Success: %@", responseObject);
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
NSLog(@"Error: %@", error);
}];
这里通过constructingBodyWithBlock向使用者提供了一个AFStreamingMultipartFormData对象,调这个对象的几种append方法就可以添加不同类型的数据,包括FileURL/NSData/NSInputStream,AFStreamingMultipartFormData内部把这些append的数据转成不同类型的 AFHTTPBodyPart,添加到自定义的 AFMultipartBodyStream 里。最后把 AFMultipartBodyStream 赋给原来 NSMutableURLRequest的bodyStream。NSURLConnection 发送请求时会读取这个 bodyStream,在读取数据时会调用这个 bodyStream 的 -read:maxLength: 方法,AFMultipartBodyStream 重写了这个方法,不断读取之前 append进来的 AFHTTPBodyPart 数据直到读完。
AFHTTPBodyPart 封装了各部分数据的组装和读取,一个 AFHTTPBodyPart 就是一个数据块。实际上三种类型 (FileURL/NSData/NSInputStream) 的数据在 AFHTTPBodyPart 都转成 NSInputStream,读取数据时只需读这个 inputStream。inputStream 只保存了数据的实体,没有包括分隔符和头部,AFHTTPBodyPart 是边读取变拼接数据,用一个状态机确定现在数据读取到哪一部份,以及保存这个状态下已被读取的字节数,以此定位要读的数据位置,详见 AFHTTPBodyPart 的-read:maxLength:方法。
AFMultipartBodyStream封装了整个multipart数据的读取,主要是根据读取的位置确定现在要读哪一个AFHTTPBodyPart。AFStreamingMultipartFormData对外提供友好的append接口,并把构造好的AFMultipartBodyStream赋回给NSMutableURLRequest,关系大致如下图:
NSInputStream子类
NSURLRequest 的 setHTTPBodyStream 接受的是一个 NSInputStream* 参数,那我们要自定义inputStream的话,创建一个 NSInputStream 的子类传给它是不是就可以了?实际上不行,这样做后用NSURLRequest 发出请求会导致 crash,提示 [xx _scheduleInCFRunLoop:forMode:]: unrecognized selector。
这是因为NSURLRequest实际上接受的不是 NSInputStream 对象,而是 CoreFoundation 的 CFReadStreamRef 对象,因为 CFReadStreamRef 和 NSInputStream 是 toll-free bridged,可以自由转换,但CFReadStreamRef 会用到 CFStreamScheduleWithRunLoop 这个方法,当它调用到这个方法时,object-c 的 toll-free bridging 机制会调用 object-c 对象 NSInputStream 的相应函数,这里就调用到了_scheduleInCFRunLoop:forMode:,若不实现这个方法就会crash。详见这篇文章。
关于证书相关的介绍:
证书怎么样验证的,怎能保证中间人不能伪造证书?
首先要知道非对称加密算法的特点,非对称加密有一对公钥私钥,用公钥加密的数据只能通过对应的私钥解密,用私钥加密的数据只能通过对应的公钥解密。
我们来看最简单的情况:一个证书颁发机构(CA),颁发了一个证书A,服务器用这个证书建立https连接。客户端在信任列表里有这个CA机构的根证书。
首先CA机构颁发的证书A里包含有证书内容F,以及证书加密内容F1,加密内容F1就是用这个证书机构的私钥对内容F加密的结果。(这中间还有一次hash算法,略过。)
建立https连接时,服务端返回证书A给客户端,客户端的系统里的CA机构根证书有这个CA机构的公钥,用这个公钥对证书A的加密内容F1解密得 到F2,跟证书A里内容F对比,若相等就通过验证。整个流程大致是:F->CA私钥加密->F1->客户端CA公钥解密->F。 因为中间人不会有CA机构的私钥,客户端无法通过CA公钥解密,所以伪造的证书肯定无法通过验证。
什么是SSL Pinning?
可以理解为证书绑定,是指客户端直接保存服务端的证书,建立https连接时直接对比服务端返回的和客户端保存的两个证书是否一样,一样就表明证书 是真的,不再去系统的信任证书机构里寻找验证。这适用于非浏览器应用,因为浏览器跟很多未知服务端打交道,无法把每个服务端的证书都保存到本地,但CS架 构的像手机APP事先已经知道要进行通信的服务端,可以直接在客户端保存这个服务端的证书用于校验。
为什么直接对比就能保证证书没问题?如果中间人从客户端取出证书,再伪装成服务端跟其他客户端通信,它发送给客户端的这个证书不就能通过验证吗?确 实可以通过验证,但后续的流程走不下去,因为下一步客户端会用证书里的公钥加密,中间人没有这个证书的私钥就解不出内容,也就截获不到数据,这个证书的私 钥只有真正的服务端有,中间人伪造证书主要伪造的是公钥。
为什么要用SSL Pinning?正常的验证方式不够吗?如果服务端的证书是从受信任的的CA机构颁发的,验证是没问题的,但CA机构颁发证书比较昂贵,小企业或个人用户 可能会选择自己颁发证书,这样就无法通过系统受信任的CA机构列表验证这个证书的真伪了,所以需要SSL Pinning这样的方式去验证。
AFSecurityPolicy
NSURLConnection已经封装了https连接的建立、数据的加密解密功能,我们直接使用NSURLConnection是可以访问 https网站的,但NSURLConnection并没有验证证书是否合法,无法避免中间人攻击。要做到真正安全通讯,需要我们手动去验证服务端返回的 证书,AFSecurityPolicy封装了证书验证的过程,让用户可以轻易使用,除了去系统信任CA机构列表验证,还支持SSL Pinning方式的验证。使用方法:
代码详细见这里:1
2
3
4
5
6
7
8//把服务端证书(需要转换成cer格式)放到APP项目资源里,AFSecurityPolicy会自动寻找根目录下所有cer文件
AFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModePublicKey];
securityPolicy.allowInvalidCertifAFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModePublicKey];
icates = YES;
[AFHTTPRequestOperationManager manager].securityPolicy = securityPolicy;
[manager GET:@"https://example.com/" parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
}];
AFSecurityPolicy分三种验证模式:
AFSSLPinningModelNone
这个模式表示不做SSL pinning,只跟浏览器一样在系统的信任机构列表里验证服务端返回的证书。若证书是信任机构签发的就会通过,若是自己服务器生成的证书,这里是不会通过的。
AFSSLPinningModeCertificate
这个模式表示用证书绑定方式验证证书,需要客户端保存有服务端的证书拷贝,这里验证分两步,第一步验证证书的域名/有效期等信息,第二步是对比服务端返回的证书跟客户端返回的是否一致。
这里还没弄明白第一步的验证是怎么进行的,代码上跟去系统信任机构列表里验证一样调用了SecTrustEvaluate,只是这里的列表换成了客户端保存的那些证书列表。若要验证这个,是否应该把服务端证书的颁发机构根证书也放到客户端里?
AFSSLPinningModePublicKey
这个模式同样是用证书绑定方式验证,客户端要有服务端的证书拷贝,只是验证时只验证证书里的公钥,不验证证书的有效期等信息。只要公钥是正确的,就能保证通信不会被窃听,因为中间人没有私钥,无法解开通过公钥加密的数据。