第12条:理解消息转发机制

  

  本条要点:(作者总结)

  • 若对象无法响应某个选择子,则进入消息转发流程。
  • 通过运行期的动态方法解析功能,我们可以在需要用到某个方法时再将其加入类中。
  • 对象可以把其无法解读的某些选择子转交给其他对象类处理。
  • 经过上述两步之后,如果还是没办法处理选择子,那就启动完整的消息转发机制。

 

  第11条讲解了对象的消息传递机制,并强调了其重要性。第12条则要讲解另外一个重要的问题,就是对象在收到无法解读的消息之后会发生什么情况。

  若想令类能理解某条消息,我们必须以程序码实现出对应的方法才行。但是,在编译期向类发送了其无法解读的消息并不会报错,因为在运行期可以继续向类中添加方法,所以编译器在编译时还无法确知类中到底会不会有某个方法实现。当对象接收到无法解读的消息后,就会启动“消息转发”(message forwarding)机制,程序员可经由此过程告诉对象应该如何处理未知消息。

  你可能早就遇到过经由消息转发流程所处理的消息了,只是未加留意。如果控制台中看到下面这种提示信息,那就说明你曾向某个对象发送过一条其无法解读的消息,从而启动了消息转发机制,并将此消息转发给了 NSObject 的默认实现:

1     -[__NSCFNumber lowercaseString]: unrecognized selector sent to instance 0x87
2     *** Terminating app due to uncaught exception
3     'NSInvalidArgumentException', reason: '-[_NSCFNumber lowercaseSting]: unrecognized selector sent to instance 0x87'

  上面这段异常信息是由 NSObject 的 “doesNotRecognizeSelector:” 方法所抛出的,此异常表明: 消息接收者的类型是 __NSCFNumber,而该接收者无法理解名为 lowercaseSting 的选择子。本例所列举的这种情况并不奇怪,因为 NSNumber 类里本来就没有名为 lowercaseString 的方法。控制台中看到的那个 __NSCFNumber 是为了实现 “无缝桥接”(toll-free bridging)而使用的内部类(internal class),配置 NSNumber 对象时也会一并创建此对象。在本例中,消息转发过程以应用程序崩溃而告终,不过,开发者在编写自己的类时,可于转发过程中设置挂钩,用以执行预定的逻辑,而不使应用程序崩溃。

  消息转发分为两个阶段。第一阶段先征询接收者,所属的类,看其是否能动态添加方法,以处理当前这个“未知的选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。第二个阶段涉及“完整的消息转发机制”(full forwarding mechanism)。如果运行期系统已经把第一阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。此时,运行期系统会请求接收者以其他手段来处理与消息相关的方法调用。这又细分为两小步。首先,请接收者看看有没有其他对象能处理这条消息。若有,则运行期系统会把消息转给那个对象,于是消息转发过程结束,一切如常。若没有“备援的接收者”( replacement receiver),则启动完整的消息转发机制,运行期系统会把与消息有关的全部细节都封装到 NSInvocation 对象中,再给接收者最后一次机会,令其设法解决当前还未处理的这条消息。

 动态方法解析:

  对象在收到无法解读的消息后,首先将调用其所属类的下列类方法:

1 + (BOOL)resolveInstanceMethod:(SEL)sel OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0);

  该方法的参数就是那个未知的选择子,其返回值为 Boolean 类型,表示这个类是否能新增一个实例方法用以处理此选择子。在继续往下执行转发机制之前,本类有机会新增一个处理此选择子的方法。假如尚未实现的方法不是实例方法而不是类方法,那么运行期系统就会调用另外一个方法,该方法与 "resolveInstaceMethod:" 类似,叫做:

1 + (BOOL)resolveClassMethod:(SEL)sel OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0);

  使用这种办法的前提是: 相关方法的实现代码已经写好,只等着运行的时候动态插在类里面就可以了。此方案常用来实现 @dynamic 属性,比如说,要访问 CoreData 框架中 NSManagedObjects 对象的属性时就可以这么做,因为实现这些属性所需的存取方法在编译期就能确定。

  下列代码演示了如何用 "resolveInstanceMethod:" 来实现 @dynamic 属性:

 1 id autoDictionaryGetter(id self, SEL _cmd);
 2 void autoDictionarySetter(id self, SEL _cmd, id value);
 3 
 4 + (BOOL)resolveInstanceMethod:(SEL)sel {
 5     NSString *selectorString = NSStringFromSelector(sel);
 6     if ( /* selector is from a @dynamic property*/ ) {
 7         if ([selectorString hasPrefix:@"set"]) {
 8             class_addMethod(self, sel, (IMP)autoDictionarySetter, "v@:@");
 9         } else {
10             class_addMethod(self, sel, (IMP)autoDictionaryGetter, "@@:");
11         }
12         return YES;
13     }
14     return [super resolveInstanceMethod:sel];
15 }

  首先将选择子化为字符串,然后检测其是否表示设置方法。若前缀为  set ,则表示设置方法,否则就是获取方法。不管哪种情况,都会把处理该选择子的方法加到类里面,所添加的方法是用纯 C 函数实现的。C 函数可能会用代码来操作相关的数据结构,类之中的属性数据就存放在那些数据结构里面。以 CoreData 为例,这些存取方法也许要和后端数据库通信,以便获取或更新相应的值。

 备援接收者:

  当前接收者还有第二次机会能处理未知的选择子,在这一步中,运行期系统会问它: 能不能吧这条消息转给其他接收者来处理。与该步骤对应的方法如下:

1 - (id)forwardingTargetForSelector:(SEL)aSelector OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0);

  方法参数代表未知的选择子,若当前接收者能找到备援对象,则将其返回,若找不到,就返回 nil。通过此方案,我们可以用 "组合"(composition)来模拟出 "多重继承"(multiple inheritance)的某些特性。在一个对象内部,可能还有一系列其他对象,该对象可经由此方法将能够处理某选择子的相关内部对象返回,这样的话,在外界看来,好

像是该对象亲自处理了这些消息似的。

  请注意,我们无法操作经由这一步所转发的消息。若是想在发送给备援接收者之前先修改消息内容,那就得通过完整的消息转发机制来做了。

 完整的消息转发:

  如果转发算法已经来到这一步的话,那么唯一能做的就是启用完整的消息转发机制了。首先创建 NSInvocation 对象,把与尚未处理的那条消息有关的全部细节都封于其中。此对象包含选择子、目标(target)及参数。在触发 NSInvocation 对象时,"消息派发系统"(message-dispatch system)将亲自出马,把消息指派给目标对象。

  此步骤会调用下列方法来转发消息:

1 - (void)forwardInvocation:(NSInvocation *)anInvocation OBJC_SWIFT_UNAVAILABLE("");

  这个可以实现的很简单:只需改变调用目标,使消息在新目标上得以调用即可。然而这样实现出来的方法与 “备援接收者” 方案所实现的方法等效,所以很少有人采用这么简单的实现方式。比较有用的实现方式为: 在触发消息前,先以某种方式改变消息内容,比如追加另外一个参数,或是改换选择子,等等。

  实现此方法时,若发现某调用操作不应由本类处理,则需要调用超类的同名方法。这样的话,继承体系中的每个类都有机会处理此调用请求,直至 NSObject。如果最后调用了 NSObject 类的方法,那么该方法还会继续调用 "doesNotRecognizeSelector:" 以抛出异常,此异常表明选择子最终未能得到处理。

 消息转发全流程:

  这张流程图描述了消息转发机制处理消息的各个步骤:

  接收者在每一步中均有机会处理消息。步骤越往后,处理消息的代价越大。最好能在第一步就处理完,这样的话,运行期系统就可以将此方法缓存起来了。如果这个类的实例稍后还收到同名选择子,那么根本无须启动消息转发流程。若想在第三步里把消息转给备援的接收者,那还不如把转发操作提前到第二步。因为第三步只能修改了调用目标,这项改动放在第二步执行会更为简单,不然的话,还得创建并处理完整的 NSInvocation。

 以完整的例子演示动态方法解析:

  为了说明消息转发机制的意义,下面示范如何以动态方法解析来实现 @dynamic 属性。假设要编写一个类似于 “字典” 的对象,它里面可以容纳其他对象,只不过开发者要直接通过属性来存取其中的数据。这个类的设计思路是: 由开发者来添加属性定义,并将其声明为 @dynamic,而类则会自动处理相关属性值的存放与获取操作。怎么样,这项功能听起来不错吧,该类的接口可以写成:

 1 #import <Foundation/Foundation.h>
 2 
 3 @interface EOCAutoDictionary : NSObject
 4 
 5 @property (nonatomic, strong) NSString *string;
 6 @property (nonatomic, strong) NSNumber *number;
 7 @property (nonatomic, strong) NSDate *date;
 8 @property (nonatomic, strong) id opaqueObject;
 9 
10 @end

  本例中,这些属性具体是什么其实无关紧要。笔者用了这么多种数据类型,只是想演示此功能很有用。在类的内部,每个属性的值还是会存放在字典里,所以我们现在类中编写如下代码,并将属性声明为 @dynamic,这样的话,编译器就不会为其生成实例变量及存取方法了:

 1 #import "EOCAutoDictionary.h"
 2 #import <objc/runtime.h>
 3 
 4 @interface EOCAutoDictionary ()
 5 
 6 @property (nonatomic, strong) NSMutableDictionary *backingStore;
 7 
 8 @end
 9 
10 @implementation EOCAutoDictionary
11 
12 @dynamic string, number, date, opaqueObject;
13 
14 - (instancetype)init {
15     if (self = [super init]) {
16         _backingStore = [NSMutableDictionary new];
17     }
18     return self;
19 }

  本例的关键在于 resolveInstanceMethod: 方法的实现代码:

1 + (BOOL)resolveInstanceMethod:(SEL)sel {
2     NSString *selectorString = NSStringFromSelector(sel);
3     if ([selectorString hasPrefix:@"set"]) {
4         class_addMethod(self, sel, (IMP)autoDictionarySetter, "v@:@");
5     } else {
6         class_addMethod(self, sel, (IMP)autoDictionaryGetter, "@@:");
7     }
8     return YES;
9 }

  当开发者首次在 EOCAutoDictionary 实例上访问某个属性时,运行期系统还找不到对应的选择子,因为所需的选择子既没有直接实现,也没有合成出来。现在假设要写入 opaqueObject 属性,那么系统就会以 "setOpaqueObject:" 为选择子来调用上面这个方法。同理,在读取属性时,系统也会调用上述方法,只不过传入的选择子是 opaqueObject。resolveInstanceMethod 方法会判断选择子的前缀是否为 set,以此分辨其是 set 选择子还是 get 选择子。在这两种情况下,都要向类中新增一个处理该选择子所用的方法,这两个方法分别以 autoDictionarySetter 及 autoDictionaryGetter 函数指针的形式出现。此时就用到了 class_addMethod 方法,它可以向类中动态的添加方法,用以处理给定的选择子。第三个参数为函数指针,指向待添加的方法。而最后一个参数则表示待添加方法的 "类型编码"(type encoding)。在本例中,编码开头的字符表示方法的返回值类型,后续字符则表示其所接受的各个参数。

  getter 函数可以用下列代码实现:

 1 id autoDictionaryGetter(id self, SEL _cmd) {
 2     // Get the backing store from the object
 3     EOCAutoDictionary *typedSelf = (EOCAutoDictionary *)self;
 4     NSMutableDictionary *backingStore = typedSelf.backingStore;
 5     
 6     // The key is simply the selector name
 7     NSString *key = NSStringFromSelector(_cmd);
 8     
 9     // Return the value
10     return [backingStore objectForKey:key];
11 }

  而 setter 函数则可以这么写:

 1 void autoDictionarySetter(id self, SEL _cmd, id value) {
 2     // Get the backing store from the object
 3     EOCAutoDictionary *typedSelf = (EOCAutoDictionary *)self;
 4     NSMutableDictionary *backingStore = typedSelf.backingStore;
 5     
 6     /** The selector will be for example, "setOpaqueObject:".
 7      * We need to remove the "set", ":" and lowercase the first
 8      * letter of the remainder.
 9      */
10     
11     NSString *selectorString = NSStringFromSelector(_cmd);
12     NSMutableString *key = [selectorString mutableCopy];
13     
14     // Remove the ':' at the end
15     [key deleteCharactersInRange:NSMakeRange(key.length - 1, 1)];
16     
17     // Remove the 'set' prefix
18     [key deleteCharactersInRange:NSMakeRange(0, 3)];
19     
20     // Lowercase the first character
21     NSString *lowercaseFirstChar = [[key substringToIndex:1] lowercaseString];
22     [key replaceCharactersInRange:NSMakeRange(0, 1) withString:lowercaseFirstChar];
23     
24     if (value) {
25         [backingStore setObject:value forKey:key];
26     } else {
27         [backingStore removeObjectForKey:key];
28     }
29 }

  EOCAutoDictionary 的用法很简单:

1     EOCAutoDictionary *dict = [EOCAutoDictionary new];
2     dict.date = [NSDate dateWithTimeIntervalSince1970:475372800];
3     NSLog(@"dict.date = %@", dict.date);

  打印:

1 dict.date 1985-01-24 00:00:00 +0000

  其他属性的访问方式与 date 类似,要想添加新属性,只需要 @property 来定义,并将其声明为 @dynamic 即可。在 iOS 的 CoreAnimation 框架中,CALayer 类就用了与本例相似的实现方式,这使得 CALayer 成为 "兼容于键值编码的"(key-value-coding-compliant)(该词的大意是,除了使用存取方法和 “点语法”之外,还可以用字符串做键,通过 "valueForKey:" 与 "setValue:forKey:" 这种形式来访问属性) 的容器类,也就是等于说,能够向里面随意添加属性,然后以键值对的形式来访问。于是,开发者就可以向其中新增自定义的属性了,这些属性值的存储工作由基类直接负责,我们只需要在 CALayer 的子类中定义新属性即可。

END

posted @ 2017-06-24 23:04  鳄鱼不怕牙医不怕  阅读(264)  评论(0编辑  收藏  举报