本文开始从三个大方向讲解消息转发:
1:什么场景下会应用到消息转发;
2:如何用代码调用实现消息转发;
3:消息转发的内部原理。
前言:
我们经常会在代码中遇到,可能你未加注意,经常可能会崩溃在一个地方:然后提示你
- -[__NSCFNumber lowercaseString]: unrecognized selector sent to
- instance 0x87
- *** Terminating app due to uncaught exception
- 'NSInvalidArgumentException', reason: '-[__NSCFNumber
- lowercaseString]: unrecognized selector sent to instance 0x87'
这个时候,系统已经走过一遍消息转发了,只是因为你未在你的系统内部调用代码实现相应的方法,从而导致消息没有找到可以实现方法的类和方法,导致崩溃。重点二字在于“转发”。
1:什么场景下会应用到消息转发;
1):比如说我写了一个Person类,但是没有实现appendString方法,我在别的类中调用,结果是崩溃,使用消息转发,避免崩溃。
2):可以让不同的类实现是一个方法:消息转发,当调用的时候都走到一个方法里去。间接的实现了多继承。(多个类都调用一个方法的时候)
2:如何用代码调用实现消息转发;
OC中调用方法就是向对象发送消息。
比如 :
1
|
[person run];
|
这实际上这是在给person这个对象发送run这个消息。当run这个方法只有定义没有实现的话就会报错
就是经典的报错
1
|
*** Terminating app due to uncaught exception 'NSInvalidArgumentException' , reason: '-[Person run]: unrecognized selector sent to instance
|
首先,该方法在调用时,系统会查看这个对象能否接收这个消息(查看这个类有没有这个方法,或者有没有实现这个方法。),如果不能并且只在不能的情况下,就会调用下面这几个方法,给你“补救”的机会,你可以先理解为几套防止程序crash的备选方案,我们就是利用这几个方案进行消息转发,注意一点,前一套方案实现后一套方法就不会执行。如果这几套方案你都没有做处理,那么程序就会报错crash。
方案一:
1
|
+ (BOOL)resolveInstanceMethod:(SEL)sel
|
1
|
+ (BOOL)resolveClassMethod:(SEL)sel
|
方案二:
1
|
- (id)forwardingTargetForSelector:(SEL)aSelector
|
方案三:
1
|
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
|
1
|
- (void)forwardInvocation:(NSInvocation *)anInvocation;
|
到目前为止上述就是消息转发我们需要用到的方法。下面就说一下这几套方案是怎样调用的。
2.1)第一次机会: 所属类动态方法解析
首先,系统会调用resolveInstanceMethod(当然,如果这个方法是一个类方法,就会调用resolveClassMethod)让你自己为这个方法增加实现。
咱们来看一个例子:
首先,创建了一个Person类的对象p,然后调用p的run方法,注意,这个run方法是没有写实现的。
进入Person类的.m文件,我实现了resolveInstanceMethod这个方法为我的Person类动态增加了一个run方法的实现。(什么是动态增加?其实就是在程序运行的时候给某类的某个方法增加实现。具体实现内容就为上面的void run 这个c函数。)
当外部调用[p run]时,由于我们没有实现run对应的方法,那么系统会调用resolveInstanceMethod让你去做一些其他操作。(当然,你也可以不做操作,只是在这个例子中,我为run方法动态增加了实现。)
继续运行,程序走到了我们C函数的部分,这样程序没有了崩溃。
这里再插入一下这段代码详细怎么写,参考代码:
@interface Person : NSObject
@property (nonatomic, copy) NSString* name;
@property (nonatomic, assign) NSUInteger age;
@end
@implementation Person
@synthesize name = _name;
@synthesize age = _age;
//如果需要传参直接在参数列表后面添加就好了
void dynamicAdditionMethodIMP(id self, SEL _cmd) {
NSLog(@"dynamicAdditionMethodIMP");
}
+ (BOOL)resolveInstanceMethod:(SEL)name {
NSLog(@"resolveInstanceMethod: %@", NSStringFromSelector(name));
if (name == @selector(appendString:)) {
class_addMethod([self class], name, (IMP)dynamicAdditionMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:name];
}
+ (BOOL)resolveClassMethod:(SEL)name {
NSLog(@"resolveClassMethod %@", NSStringFromSelector(name));
return [super resolveClassMethod:name];
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
id p = [[Person alloc] init];
[p appendString:@""];
}
return 0;
}
(注解:
看一下main函数,首先创建了一个Person
的实例对象,一定要用id
类型来声明,否则会在编译期就报错,因为找不到相关函数的声明,id
类型由于可以指向任何类型的对象,因此编译时能够找到NSString
类的相关方法声明就不会报错。
由于Person
类没有声明和定义appendString:
方法,所以运行时应该会报unrecognized selector
错误,但是并没有,因为我们重写了类方法+ (BOOL)resolveInstanceMethod:(SEL)name
,当找不到相关实例方法的时候就会调用该类方法去询问是否可以动态添加,如果返回True
就会再次执行相关方法,接下来看一下如何给一个类动态添加一个方法,那就是调用runtime
库中的class_addMethod
方法,该方法的原型是
- 1
- 1
通过参数名可以看出第一个参数是需要添加方法的类,第二个参数是一个selector
,也就是实例方法的名字,第三个参数是一个IMP
类型的变量也就是函数实现,需要传入一个C函数,这个函数至少有两个参数,一个是id self
一个是SEL _cmd
,第四个参数是函数类型。具体设置方法可以看注释
2.2)第二次机会: 备援接收者
下面讲一下第二套方法,forwardingTargetForSelector,这个方法返回你需要转发消息的对象。
我们接着这个例子来讲,为了便于演示消息转发,我们新建了一个汽车类Car,并且实现了Car的run方法。
现在我不去对方案一的resolveInstanceMethod做任何处理,直接调用父类方法。可以看到,系统已经来到了forwardingTargetForSelector方法,我们现在返回一个Car类的实例对象。
继续运行,程序就来到了Car类的run方法,这样,我们就实现了消息转发。
2.3)第三次机会: 消息重定向
继续我们的例子。如果我们不实现forwardingTargetForSelector,系统就会调用方案三的两个方法methodSignatureForSelector和forwardInvocation
methodSignatureForSelector用来生成方法签名,这个签名就是给forwardInvocation中的参数NSInvocation调用的。
开头我们要找的错误unrecognized selector sent to instance原因,原来就是因为methodSignatureForSelector这个方法中,由于没有找到run对应的实现方法,所以返回了一个空的方法签名,最终导致程序报错崩溃。
所以我们需要做的是自己新建方法签名,再在forwardInvocation中用你要转发的那个对象调用这个对应的签名,这样也实现了消息转发。
关于生成签名的类型"v@:"解释一下。每一个方法会默认隐藏两个参数,self、_cmd,self代表方法调用者,_cmd代表这个方法的SEL,签名类型就是用来描述这个方法的返回值、参数的,v代表返回值为void,@表示self,:表示_cmd。
现在我们回到最初,我们调用的是Person类的run方法,最终方法被Car类的对象来接受。这就是OC的消息转发机制。
注意一下第三套方法forwardInvocation:
调用这个方法如果不能处理就会调用父类的相关方法,一直到NSObject
的这个方法,如果NSObject
都无法处理就会调用doesNotRecognizeSelector:
方法抛出异常。
2.4)总
整个消息转发流程如下图所示:
3:消息转发的内部原理。
这里主要从runtime
出发讲解OC的消息传递和消息转发机制。
你不知道的msg_send
我们知道在OC中的实例对象调用一个方法称作消息传递
,比如有如下代码:
- 1
- 2
- 1
- 2
上述代码中的第二句str
称为消息的接受者,appendString:
称作选择子
也就是我们常用的selector
,selector
和参数
共同构成了消息
,所以第二句话可以理解为将消息:"增加一个字符串: is a good guy"
发送给消息的接受者str
。
OC中里的消息传递
采用动态绑定机制来决定具体调用哪个方法,OC的实例方法在转写为C语言后实际就是一个函数,但是OC并不是在编译期决定调用哪个函数,而是在运行期决定,因为编译期根本不能确定最终会调用哪个函数,这是由于运行期可以修改方法的实现,在后文会有讲解。举个栗子,有如下代码:
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
上述代码在编译期没有任何问题,因为id
类型可以指向任何类型的实例对象,NSString
有一个方法appendString:
,在编译期不确定这个num
到底具体指代什么类型的实例对象,并且在运行期还可以给NSNumber
类型添加新的方法,因此编译期发现有appendString:
的函数声明就不会报错,但在运行时找不到在NSNumber
类中找不到appendString:
方法,就会报错。这也就是消息传递的强大之处和弊端,编译期无法检查到未定义的方法,运行期可以添加新的方法。
讲了这么多OC究竟是怎么将实例方法转换为c语言的函数,又是如何调用这些函数的呢?这些都依靠强大的runtime
。
在深入代码之前介绍一个clang
编译器的命令:
clang -rewrite-objc main.m
该命令可以将.m的OC文件转写为.cpp文件
有如下代码:
- 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
- 27
- 28
- 29
- 30
- 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
- 27
- 28
- 29
- 30
通过上述clang
命令可以转写代码,然后找到如下定义:
- 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
- 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
关于属性property
生成的getter
、setter
和实例变量相关代码在另一篇博客iOS @property探究(二): 深入理解中有详细介绍,本文不再赘述,本文仅针对自定义的方法来讲解。
可以发现转写后的C语言代码将实例方法转写为了一个静态函数。接下来一行一行的分析上述代码,第一行代码可以简要表示为如下代码:
- 1
- 1
这一行代码做了三件事情,第一获取Person
类,第二注册alloc
方法,第三发送消息,将消息alloc
发送给类对象,可以简单的将注册方法理解为,通过方法名获取到转写后C语言函数的函数指针。
第二行代码就可以简写为如下代码:
- 1
- 1
这一行代码与上一行类似,注册了init
方法,然后通过objc_msgSend
函数将消息init
发送给消息的接受者p
。
第三行是一个对setter
的调用,同样的也可以简写为如下代码:
- 1
- 2
- 3
- 4
- 1
- 2
- 3
- 4
这一行代码同样是先注册方法setName:
然后通过objc_msgSend
函数将消息setName:
发送给消息的接收者,只是多了一个参数的传递。
同理,最后一行代码也可以简写为如下:
- 1
- 1
解释与上述相同,不再赘述。
到这里,我们应该就可以看出OC的runtime
通过objc_msgSend
函数将一个面向对象的消息传递转为了面向过程的函数调用。
objc_msgSend
函数根据消息的接受者和selector
选择适当的方法来调用,那它又是如何选择的呢?这就涉及到前一篇博客讲解的内容iOS runtime探究(一): 从runtime开始: 理解面向对象的类到面向过程的结构体,这一篇博客中详细讲解了OC的runtime
是如何将面向对象的类映射为面向过程的结构体的,再来回顾一下几个主要的结构体:
- 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
- 27
- 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
- 27
注意结构体struct objc_class
中包含一个成员变量struct objc_method_list **methodLists
,通过名称我们分析出这个成员变量保存了实例方法列表,继续查找结构体struct objc_method_list
的定义如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
我们发现struct objc_method_list
中还包含了一个未知的结构体struct _objc_method
同时也找到它的定义,为了方便查看将两者写在一起。
结构体struct objc_method_list
里面包含以下几个成员变量:结构体struct _objc_method
的大小、方法个数以及最重要的方法列表,方法列表存储的是方法描述结构体struct _objc_method
,该结构体里保存了选择子、方法类型以及方法的具体实现。可以看出方法的具体实现就是一个函数指针,也就是我们自定义的实例方法,选择子也就是selector
可以理解为是一个字符串类型的名称,用于查找对应的函数实现(由于苹果没有开源selector的相关代码,但是可以查到GNU OC中关于selector的定义,也是一个结构体但是结构体里存储的就是一个字符串类型的名称)。
这样就能解释objc_msgSend
的工作原理的,为了匹配消息的接收者和选择子,需要在消息的接收者所在的类中去搜索这个struct objc_method_list
方法列表,如果能找到就可以直接跳转到相关的具体实现中去调用,如果找不到,那就会通过super_class
指针沿着继承树向上去搜索,如果找到就跳转,如果到了继承树的根部(通常为NSObject)还没有找到,那就会调用NSObjec
的一个方法doesNotRecognizeSelector:
,这个方法就会报unrecognized selector
错误(其实在调用这个方法之前还会进行消息转发,还有三次机会来处理,消息转发在后文会有介绍)。
这样一看,要发送消息真的好复杂,需要经过这么多步骤,难道不会影响性能吗?当然了,这样一次次搜索和静态绑定那样直接跳转到函数指针指向的位置去执行来比肯定是耗时很多的,因此,类对象也就是结构体struct objc_class
中有一个成员变量struct objc_cache
,这个缓存里缓存的正是搜索方法的匹配结果,这样在第二次及以后再访问时就可以采用映射的方式找到相关实现的具体位置。
感谢几个作者的文章。非常感谢!
原文链接
http://www.cocoachina.com/ios/20150604/12013.html
http://blog.csdn.net/u014205968/article/details/67639289