第39条:用 handler 块降低代码分散程度
本条要点:(作者总结)
- 在创建对象时,可以使用内联的 handler 块将相关业务逻辑一并声明。
- 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用 handler 块来实现,则可直接将块与相关对象放在一起。
- 设计 API 时如果用到了 handler 块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。
为用户界面编码时,一种常用的范式就是 “异步执行任务”(perform task asynchronously)。这种范式的好处在于:处理用户界面的显示及触摸操作所用的线程,不会因为要执行 I/O 或网络通信这类耗时的任务而阻塞。这个线程通常称为主线程(main thread)。假设把执行异步任务的方法做成同步的,那么在执行任务时,用户界面就变得无法响应用户输入了。某些情况下,如果应用程序在一定时间内无响应,那么就会自动终止。iOS 系统上的应用程序就是如此,“系统监控器”(system watchdog)在发现某个应用程序的主线程已经阻塞了一段时间之后,就会令其终止。
异步方法在执行完任务之后,需要以某种手段通知相关代码。实现此功能有很多方法。常用的技巧是设计一个委托协议,令关注此事件的对象遵从该协议。对象成为 delegate 之后,就可以在相关事件发生时(例如某个异步任务执行完毕时)得到通知了。
比如说,要写一个从 URL 中获取数据的类。使用委托模式设计出来的类会是这个样子:
1 #import <Foundation/Foundation.h> 2 3 @class EOCNetworkFetcher; 4 5 @protocol EOCNetworkFetcherDelegate <NSObject> 6 7 - (void)networkFetcher:(EOCNetworkFetcher *)networkFetcher didFinishWithData:(NSData *)data; 8 9 @end 10 11 @interface EOCNetworkFetcher : NSObject 12 13 @property (nonatomic, weak) id <EOCNetworkFetcherDelegate> delegate; 14 15 - (id)initWithURL:(NSURL *)url; 16 17 - (void)start; 18 19 @end
而其他类则可像下面这样使用此类所提供的 API:
1 - (void)fetchFooData { 2 3 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"]; 4 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 5 fetcher.delegate = self; 6 [fetcher start]; 7 } 8 9 10 11 //... 12 13 - (void)networkFetcher:(EOCNetworkFetcher *)networkFetcher didFinishWithData:(NSData *)data { 14 15 _fetchedFooData = data; 16 }
这种做法确实可行,而且没什么错误。然而如果改用块来写的话,代码会更清晰。块可以令这种 API 变得更紧致,同时令开发者调用起来更加方便。办法就是:把 completionhandler 定义为块类型,将其当作参数直接传给 start 方法:
1 #import <Foundation/Foundation.h> 2 3 typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data); 4 5 @interface EOCNetworkFetcher : NSObject 6 7 - (id)initWithURL:(NSURL *)url; 8 9 - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHand)handler; 10 11 @end
这和使用委托协议对象,不过多了个好处,就是可以在调用 start 方法时直接以内联形式定义 completion handler,以此方式来使用 “网络数据获取器”(network fetcher),可以令代码比原先易懂很多。例如,下面这个类就以块的形式来定义 completion handler,并以此为参数调用 API:
1 - (void)fetchFooData { 2 3 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat]"; 4 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 5 [fetcher startWithCompletionHandler:^(NSData *data) { 6 7 _fetchedFooData = data; 8 9 }]; 10 }
与使用委托模式的代码相比,用块写出来的代码显然更为整洁。异步任务执行完毕后所需运行的业务逻辑,和启动异步任务所用的代码放在了一起。而且,由于块声明在创建获取器的范围里,所以它可以访问此范围内的全部变量。本例比较简单,体现不出这一点,然而在更为复杂的场景中,会大有裨益。
委托模式有个缺点:如果类要分别使用多个获取器下载不同数据,那么就得在 delegate 回调方法里根据传入的获取器参数来切换。这种代码的写法如下:
1 - (void)fetchFooData { 2 3 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"]; 4 _footFetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 5 _footFetcher.delegate = self; 6 [_footFetcher start]; 7 } 8 9 - (void)fetchBarData { 10 11 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/bar.dat"]; 12 13 _barFetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 14 15 _barFetcher.delegate = self; 16 17 [_barFetcher start]; 18 } 19 20 21 22 - (void)networkFetcher:(EOCNetworkFetcher *)networkFetcher didFinishWithData:(NSData *)data { 23 24 if (networkFetcher == _footFetcher) { 25 26 _fetchedFoodata = data; 27 _fooFetcher = nil; 28 } else if (networkFetcher == _barFetcher) { 29 30 _fetchedBarData = data; 31 _barFetcher = nil; 32 } 33 34 // etc 35 36 }
这么写代码,不仅会令 delegate 回调方法变得很长,而且还要把网络数据获取器对象保存为实例变量,以便在判断语句中使用。这么做可能有其他原因,比如稍后要根据情况解除监听等,然而这种写法有副作用,通常很快就会使类的代码激增。改用块来写的好处是:无须保存获取器,也无须在回调方法里切换。每个 completion handler 的业务逻辑,都是和相关的获取器对象一起来定义的:
1 - (void)fetchFooData { 2 3 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/foo.dat"]; 4 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 5 [Fetcher startWithCompletionHandler:^(NSData *data) { 6 _fetchedFooData = data; 7 }]; 8 } 9 10 - (void)fetchBarData { 11 12 NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com/bar.dat"]; 13 14 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 15 16 [fetcher startWithCompletionHandler:^(NSData *data) { 17 18 _fetchedBarData = data; 19 }]; 20 21 }
这种写法还有其他用途,比如,现在很多基于块的 API 都使用块来处理错误。这又分为两种办法。可以分别用两个处理程序来处理操作失败的情况和操作成功的情况。也可以把处理失败情况所需的代码,与处理正常情况所用的代码,都封装到同一个 completion handler 块里。如果想采用两个独立的处理程序,那么可以这样设计 API:
1 #import <Foundation/Foundation.h> 2 3 @class EOCNetworkFetcher; 4 5 typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data); 6 7 typedef void(^EOCNetworkFetcherErrorHandler)(NSError *error); 8 9 @interface EOCNetworkFetcher : NSObject 10 11 - (id)initWithURL(NSURL *)url; 12 13 - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion failureHandler:(EOCNetworkFetcherErrorHandler)failure; 14 15 @end
依照此风格设计出来的 API,其调用方式如下:
1 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 2 3 [fetcher startWithCompletionHander:^(NSData *data) { 4 5 // Handle success 6 } failureHandler:^(NSError *error) { 7 8 // Handle failure 9 }];
这种 API 设计风格很好,由于成功和失败的情况要分别处理,所以调用此 API 的代码也就会按照逻辑,把应对成功和失败情况的代码分开来写,这将令代码更易读懂。而且,若有需要,还可以把处理失败情况或成功情况所用的代码省略。
另一种风格则像下面这样,把处理成功情况和失败情况所用的代码全放在一个块里:
1 #import <Foundation/Foundation.h> 2 3 @class EOCNetworkFetcher; 4 5 typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data, NSError *error); 6 7 @interface EOCNetworkFetcher : NSObject 8 9 - (id)initWithURl:(NSURL *)url; 10 11 - (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion; 12 13 @end
此种 API 的调用方式如下:
1 EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url]; 2 3 [fetcher startWithCompletionHander:^(NSData *data, NSError *error) { 4 5 if (error) { 6 // Handle failure 7 } else { 8 // Handle success 9 }];
这种方式需要在块代码中检测传入的 error 变量,并且要把所有逻辑代码都放在一处。
这种写法的缺点是:由于全部逻辑都写在一起,所以会令块变得比较长,且比较复杂。然而只有一个块的写法也有好处,那就是更为灵活。比方说,在传入错误信息时,可以把数据也传进来。有时数据正下载到一半,突然网络故障了。在这种情况下,可以把数据及相关的错误都回传给块。这样的话, completion handler 就能据此判断问题并适当处理了,而且还可利用已下载好的这部分数据做些事情。
把成功情况和失败情况放在同一个块中,还有个优点:调用 API 的代码可能会在处理成功响应的过程中发现错误。比方说,返回的数据可能太短了。这种情况需要和网络数据获取器所认定的失败情况按同一方式处理。此时,如果采用单一块的写法,那么就能把这种情况和获取器所认定的失败情况统一处理了。此时,如果采用单一块的写法,那么就能把这种情况和获取器所认定的失败情况统一处理了。要是把成功情况和失败情况交给两个不同的处理程序来负责,那么久没办法共享一份错误处理代码了,除非把这段代码单独放在一个方法里面,而这又违背了我们想把全部逻辑代码都放在一起的初衷。
总体来说,笔者建议使用同一个块来处理成功与失败情况,苹果公司似乎也是这样设计其 API 的。例如, Twitter 框架中的 TWRequest 及 MapKit 框架中的 MKLocalSearch 都只使用一个 Handler 块。
有时需要在相关时间点执行回调操作,这种情况也可以使用 Handler 块。比方说,调用网络数据获取器的代码,也许想在每次有下载进度时都得到通知。这可以通过委托模式实现。不过也可以使用本节讲的 handler 块,把处理下载进度的 Handler 定义成块类型,并新增一个此类型的属性:
1 typedef void(^EOCNetworkFetcherCompletionHandler)(float progress); 2 3 @property (nonatomic, copy)EOCNetworkFetcherProgressHandler progressHandler;
这种写法很好,因为它还是能把所有业务逻辑都放在一起:也就是把创建网络数据获取器和定义 progress handler 所用的代码写在一处。
基于 handler 来设计 API 还有个原因,就是某些代码必须运行在特定的线程上。比方说,Cocoa 与 Cocoa Touch 中的 UI操作必须在主线程上执行。这就相当于 GCD 中的 "主队列"(main queue)。因此,最好能由调用 API 的人来决定 handler 应该运行在哪个线程上。 NSNotificationCenter 就属于这种 API,它提供了一个方法,调用者可以经由此方法来注册想要接收的通知,等到相关事件发生时,通知中心就会执行注册好的那个块。调用者可以指定某个块应该安排在哪个执行队列里,然而这不是必需的。若没有指定队列,则按照默认方式执行,也就是说,将由投递通知的那个线程来执行。下列方法可用来新增观察者(observer):
1 - (id)addObserverForName:(NSString *)name object:(id)object queue:(NSOperationQueue *)queue usingBlock:(void(^)(NSNotification *))block;
此处传入的 NSOperationQueue 参数就表示出触发通知时用来执行块代码的那个队列。这是个“操作队列”(operation queue),而非“底层 GCD 队列”(low-level-GCD queue),不过两者语义相同。(第43条详细对比了 GCD 队列与其他方式的区别)
你也可以照此设计自己的 API,根据 API 所处的细节层次,可选用操作队列甚至 GCD 队列来作为参数。
END