第四章 协议与分类—第23条:通过委托与数据源协议进行对象间通信

Objective-C语言有一项特性叫做"协议"(protocol),它与Java的"接口"(interface)类似。Objective-C不支持多重继承,因而我们把某个类应该实现的一系列方法定义在协议里面。协议最为常见的用途是实现委托模式(参见第23条),不过也有其他用法。理解并善用协议可令代码变得更易维护,因为协议这种方式能很好地描述接口。
"分类"(Category)也是Objective-C的一项重要语言特性。利用分类机制,我们无须继承子类即可直接为当前类添加方法,而在其他编程语言中,则需通过继承子类来实现。由于Objective-C运行期系统是高度动态的,所以才能支持这一特性,然而,其中也隐藏着一些陷阱,因此在使用分类之前,应该先理解它。


对象之间经常需要相互通信,而通信方式有很多种。Objective-C开发者广泛使用一种名叫"委托模式"(Delegate pattern)的编程设计模式来实现对象间的通信,该模式的主旨是:定义一套接口,某对象若想接受另一个对象的委托,则需遵从此接口,以便成为其"委托对象"(delegate)。而这"另一个对象"则可以给其委托对象回传一些信息,也可以在发生相关事件时通知委托对象。
此模式可将数据与业务逻辑解耦。比方说,用户界面里有个显示一系列数据所用的视图,那么,此视图只应包含显示数据所需的逻辑代码,而不应决定要显示何种数据以及数据之间如何交互等问题。视图对象的属性中,可以包含负责数据与事件处理的对象。这两种对象分别称为"数据源"(data source)与"委托"(delegate)。
在Objective-C中,一般通过"协议"这项语言特性来实现此模式,整个Cocoa系统框架都是这么做的。如果你的代码也这样写,那么就能和系统框架很好地融合在一起了。
为演示此模式,我们举个例子,假设要编写一个从网上获取数据的类。此类也许要从远程服务器的某个资源里获取数据。那么远程服务器可能过很长时间才会应答,而在获取数据的过程中阻塞应用程序则是一种非常糟糕的做法。于是,在这种情况下,我们通常会使用委托模式: 获取网络数据的类含有一个"委托对象",在获取完数据之后,它会回调这个委托对象。下图演示了此概念: EOCDataModel对象就是EOCNetworkFetcher的委托对象。EOCDataModel请求EOCNetworkFetcher"以异步方式执行一项任务"(perform a task asynchronously),而EOCNetworkFetcher在执行完这项任务之后,就会通知其委托对象,也就是EOCDataModel。

屏幕快照 2017-04-25 19.00.08.png

利用协议机制,很容易就能以Objective-C代码实现此模式。上图所演示的这种情况下,协议可以这样来定义:

@protocol EOCNetworkFetcherDelegate
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher 
        didReceiveData:(NSData*)data;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
        didFailWithError:(NSError*)error;
@end

委托协议名通常是在相关类名后面加上Delegate一词,整个类名采用"驼峰法"来写。以这种方式来命名委托协议的话,使用此代码的人很快就能理解其含义了。
有个这个协议之后,类就可以用一个属性来存放其委托对象了。在本例中,这个类就是EOCNetworkFetcher类。于是,此类的接口可以写成这样:

@interface EOCNetworkFetcher : NSObject
@property (nonatomic, weak) id <EOCNetworkFetcherDelegate> delegate;
@end

一定要注意: 这个属性需定义成weak,而非strong,因为两者之间必须为"非拥有关系"(nonowning relationship)。通常情况下,扮演delegate的那个对象也要持有本对象。例如在本例中,想使用EOCNetworkFetcher的那个对象就会持有本对象,直到用完本对象之后,才会释放。假如声明属性的时候用strong将本对象与委托对象之间定为"拥有关系",那么就会引入"保留环"(retain cycle)。因此,本类中存放委托对象的这个属性要么定义成weak,要么定义成unsafe_unretained,如果需要在相关对象销毁时自动清空(autoniling,参见第6条),则定义为前者,若不需要自动清空,则定义为后者。下图演示了本对象与委托对象之间的所有权关系。

屏幕快照 2017-04-25 19.08.55.png

实现委托对象的办法是声明某个类遵从委托协议,然后把协议中想实现的那些方法在类里实现出来。某类若要遵从委托协议,可以在其接口中声明,也可以在"class-continuation分类"(参见第27条)中声明。如果要向外界公布此类实现了某协议,那么就在接口中声明,而如果这个协议是个委托协议的话,那么通常只会在类的内部使用。所以说,这种情况一般都是在"class-continuation分类"里声明的:

@implementation EOCDataModel () <EOCNetworkFetcherDelegate>
@end

@implementation EOCDataModel
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher 
        didReceiveData:(NSData*)data {
    /* Handle data */
}
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
        didFailWithError:(NSError*)error {
    /* Handle error */
}
@end

委托协议中的方法一般都是"可选的"(optional),因为扮演"受委托者"角色的这个对象未必关心其中的所有方法。在本例中,DataModel类可能并不大关心获取数据的过程中是否有错误发生,所以此类也许不会实现"networkFetcher:didFailWithError:"方法。为了指明可选方法,委托协议经常使用@optional关键字来标注其大部分或全部的方法:

@protocol EOCNetworkFetcherDelegate
@optional
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher 
        didReceiveData:(NSData*)data;
- (void)networkFetcher:(NetworkFetcher*)fetcher
        didFailWithError:(EOCNSError*)error;
@end

如果要在委托对象上调用可选方法,那么必须提前使用类型信息查询方法(参见第14条)判断这个委托对象是否能响应相关选择子。以EOCNetworkFetcher为例,应该这样写:

NSData *data = /* data obtained from network */;
if ([_delegate respondsToSelector:@selector(networkFetcher:didReceiveData:)]) {
    [_delegate networkFetcher:self didReceiveData:data];
}

这段代码用"respondsToSelector:"来判断委托对象是否实现了相关方法。如果实现了,就调用,如果没实现,就不执行任何操作。这样的话,delegate对象就可以完全按照其需要来实现委托协议中的方法了,不用担心因为哪个方法没实现而导致程序出问题。即便没有设置委托对象,程序也能照常运行,因为给nil发送消息将使if语句的值成为false。
delegate对象中的方法名也一定要起的很恰当才行。方法名应该准确描述当前发生的事件以及delegate对象为何要获知此事件。在本例中,delegate对象里的方法名读起来非常清晰,表明某个"网络数据获取器"(newwork fetcher)对象刚刚接收到某份数据。正如上一段代码所示,在调用delegate对象中的方法时,总是应该把发起委托的实例也一并传入方法中,这样,delegate对象在实现相关方法时,就能根据传入的实例分别执行不同的代码了。比方说可以这样写:

- (void)networkFetcher:(EOCNetworkFetcher*)fetcher 
        didReceiveData:(NSData*)data {
    if (fetcher == _myFetcherA) {
        /* Handle data */
    } else if (fetcher == _myFetcherB) {
        /* Handle data */
    }
}

上面这段代码表明,委托对象有两个不同的"网络数据获取器",所以它必须根据传入的参数来判断到底是哪个EOCNetworkFetcher获取到了数据。若没有此信息,则委托对象在同一时间只能使用一个网络数据获取器,这么做不太好。
delegate里的方法也可以用于从获取委托对象中获取信息。比方说,EOCNetworkFetcher类也许想提供一种机制: 在获取数据时如果遇到了"重定向"(redirect),那么将询问其委托对象是否应该发生重定向。delegate对象中的相关方法可以写成这样:

- (BOOL)networkFetcher:(EOCNetworkFetcher *)fetcher shouldFollowRedirectToURL:(NSURL *)url;

通过这个例子,大家应该很容易理解此模式为何叫做"委托模式":因为对象把应对某个行为的责任委托给另外一个类了。
也可以用协议定义一套接口,令某类经由该接口获取其所需的数据。委托模式的这一用法旨在向类提供数据,故而又称"数据源模式"(Data Source Pattern)。在此模式中,信息从数据源(Data Source)流向类(Class);而在常规的委托模式中,信息则从类流向受委托者(Delegate)。下图演示了这两条信息流。

屏幕快照 2017-04-26 09.56.29.png

比方说,用户界面框架中的"列表视图"(list view)对象可能会通过数据源协议来获取要在列表中显示的数据。除了数据源之外,列表视图还有一个受委托者,用于处理用户与列表的交互操作。将数据源协议与委托协议分离,能使接口更加清晰,因为这两部分的逻辑代码也分开了。另外,"数据源"与"受委托者"可以是两个不同的对象。然而一般情况下,都用同一个对象来扮演这两种角色。
在实现委托模式与数据源模式时,如果协议中的方法是可选的,那么就会写出一大批类似下面这样的代码来:

if ([_delegate respondsToSelector:@selector(someClassDidSomething:)]) {
      [_delegate someClassDidSomething];
}

很容易用代码查出某个委托对象是否能响应特定的选择子,可是如果频繁执行此操作的话,那么除了第一次检测的结果有用之外,后续的检测可能都是多余的。如果委托对象本身没变,那么不太可能会突然响应某个原来不能响应的选择子,也不太会突然无法响应某个原来可以响应的选择子。鉴于此,我们通常把委托对象能否响应某个协议方法这一信息缓存起来,以优化程序效率。假设在"网络数据获取器"那个例子中,delegate对象所遵从的协议里有个表示数据获取进度的回调方法,每当数据获取有进度时,委托对象就会得到通知。这个方法在网络数据获取进度的回调方法,每当数据获取有进度时,委托对象就会得到通知。这个方法在网络数据获取器的生命期(life cycle)里会多次调用,如果每次都检查委托对象是否能响应此选择子,那就显得多余了。
将刚才说的那个选择子加入之后,delegate对象所要实现的委托协议就扩充成:

@protocol EOCNetworkFetcherDelegate
@optional
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher 
        didReceiveData:(NSData*)data;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
        didFailWithError:(NSError*)error;
- (void)networkFetcher:(EOCNetworkFetcher*)fetcher
   didUpdateProgressTo:(float)progress;
@end

扩充后的协议只增加了一个方法,就是可选的"networkFetcher:didUpdateProgressTo:"方法。将方法响应能力缓存起来的最佳途径是使用"位段"(bitfield)数据类型。这是一项乏人问津的C语言特性,但在此处用起来却正合适。我们可以把结构体中某个字段所占用的二进制位个数设为特定的值。比如像这样:

struct data {
    unsigned int fieldA : 8;
    unsigned int fieldB : 4;
    unsigned int fieldC : 2;
    unsigned int fieldD : 1;
};

在结构体中,fieldA位段将占用8个二进制位,fieldB占用4个,fieldC占用两个,fieldD占用1个。于是,fieldA可以表示0至255之间的值,而fieldD则可以表示0或1这两个值。我们可以像fieldD这样,把委托对象是否实现了协议中的相关方法这一信息缓存起来。如果创建的结构体中只有大小为1的位段,那么就能把许多Boolean值塞入一小块数据里面了。以网络数据获取器为例,可以在该实例中嵌入一个含有位段的结构体作为实例变量,而结构体中的每个位段则表示delegate对象是否实现了协议中的相关方法。此结构体的用法如下:

@interface EOCNetworkFetcher () {
    struct {
        unsigned int didReceiveData      : 1;
        unsigned int didFailWithError    : 1;
        unsigned int didUpdateProgressTo : 1;
    } _delegateFlags;
}
@end

笔者使用第27条所讲的"class-continuation分类"来新增实例变量,而新增的这个实例变量是个结构体,其中含有三个位段,每个位段都与delegate所遵从的协议中某个可选方法相对应。在EOCNetworkFetcher类里,可以像下面这样查询并设置结构体中的位段:

// Set flag
_delegateFlags.didReceiveData = 1;

// Check flag
if (_delegateFlags.didReceiveData) {
    // Yes, flag set
}

这个结构体用来缓存委托对象是否能响应特定的选择子。实现缓存功能所用的代码可以写在delegate属性所对应的设置方法里:

- (void)setDelegate:(id<EOCNetworkFetcher)delegate {
    _delegate = delegate;
    _delegateFlags.didReceiveData = [delegate respondsToSelector:@selector(networkFetcher:didReceiveData:)];
    _delegateFlags.didFailWithError = [delegate respondsToSelector:@selector(networkFetcher:didFailWithError:)];
    _delegateFlags.didUpdateProgressTo = [delegate respondsToSelector:@selector(networkFetcher:didUpdateProgressTo:)];
}

这样的话,每次调用delegate的相关方法之前,就不用检测委托对象是否能响应给定的选择子了,而是直接查询结构体里的标志:

if (_delegateFlags.didUpdateProgressTo) {
    [_delegate networkFetcher:self didUpdateProgressTo:currentProgress];
}

在相关方法要调用很多次时,值得进行这种优化。而是否需要优化,则应依照具体代码来定。这就需要分析代码性能,并找出瓶颈,若发现执行速度需要改进,则可使用此技巧。如果要频繁通过数据源协议从数据源中获取多份相互独立的数据,那么这项优化技术极有可能会提高程序效率。

要点

  • 委托模式为对象提供了一套接口,使其可由此将相关事件告知其他对象。
  • 将委托对象应该支持的接口定义成协议,在协议中把可能需要处理的事件定义成方法。
  • 当某对象需要从另外一个对象中获取数据时,可以使用委托模式。这种情况下,该模式亦称"数据源协议"(data source protocol)。
  • 若有必要,可实现含有位段的结构体,将委托对象是否能响应相关协议方法这一信息缓存至其中。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容