第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

 

posted @ 2017-08-17 23:36  鳄鱼不怕牙医不怕  阅读(200)  评论(0编辑  收藏  举报