iOS runtime探究(二): 从runtime开始深入理解OC消息转发机制你要知道的runtime都在这里

时间:2022-05-07
本文章向大家介绍iOS runtime探究(二): 从runtime开始深入理解OC消息转发机制你要知道的runtime都在这里,主要内容包括你要知道的runtime都在这里、消息转发: unrecognized selector的最后三次机会、总结、下一步、备注、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

你要知道的runtime都在这里

转载请注明出处 https://cloud.tencent.com/developer/user/1605429

本文主要讲解runtime相关知识,从原理到实践,由于包含内容过多分为以下五篇文章详细讲解,可自行选择需要了解的方向:

  • 从runtime开始: 理解面向对象的类到面向过程的结构体
  • 从runtime开始: 深入理解OC消息转发机制
  • 从runtime开始: 理解OC的属性property
  • 从runtime开始: 实践Category添加属性与黑魔法method swizzling
  • 从runtime开始: 深入weak实现机理

本文是系列文章的第二篇文章从runtime开始: 深入理解OC消息转发机制,主要从runtime出发讲解OC的消息传递和消息转发机制。

你不知道的msg_send

我们知道在OC中的实例对象调用一个方法称作消息传递,比如有如下代码:

NSMutableString *str = [[NSMutableString alloc] initWithString: @"Jiaming Chen"];
[str appendString:@" is a good guy."];

上述代码中的第二句str称为消息的接受者,appendString:称作选择子也就是我们常用的selectorselector参数共同构成了消息,所以第二句话可以理解为将消息:"增加一个字符串: is a good guy"发送给消息的接受者str。 OC中里的消息传递采用动态绑定机制来决定具体调用哪个方法,OC的实例方法在转写为C语言后实际就是一个函数,但是OC并不是在编译期决定调用哪个函数,而是在运行期决定,因为编译期根本不能确定最终会调用哪个函数,这是由于运行期可以修改方法的实现,在后文会有讲解。举个栗子,有如下代码:

id num = @123;
//输出123
NSLog(@"%@", num);
//程序崩溃,报错[__NSCFNumber appendString:]: unrecognized selector sent to instance 0x7b27
[num appendString:@"Hello World"];

上述代码在编译期没有任何问题,因为id类型可以指向任何类型的实例对象,NSString有一个方法appendString:,在编译期不确定这个num到底具体指代什么类型的实例对象,并且在运行期还可以给NSNumber类型添加新的方法,因此编译期发现有appendString:的函数声明就不会报错,但在运行时找不到在NSNumber类中找不到appendString:方法,就会报错。这也就是消息传递的强大之处和弊端,编译期无法检查到未定义的方法,运行期可以添加新的方法。

讲了这么多OC究竟是怎么将实例方法转换为C语言的函数,又是如何调用这些函数的呢?这些都依靠强大的runtime

在深入代码之前介绍一个clang编译器的命令:

clang -rewrite-objc main.m 该命令可以将.m的OC文件转写为.cpp文件

有如下代码:

@interface Person : NSObject

@property (nonatomic, copy) NSString* name;
@property (nonatomic, assign) NSUInteger age;

- (void)showMyself;

@end

@implementation Person

@synthesize name = _name;
@synthesize age = _age;

- (void)showMyself {
    NSLog(@"My name is %@ I am %ld years old.", self.name, self.age);
}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
         //为了方便查看转写后的C语言代码,将alloc和init分两步完成
        Person *p = [Person alloc];
        p = [p init];
        p.name = @"Jiaming Chen";
        [p showMyself];
    }
    return 0;
}

通过上述clang命令可以转写代码,然后找到如下定义:

static NSString * _I_Person_name(Person * self, SEL _cmd) { return (*(NSString **)((char *)self + OBJC_IVAR_$_Person$_name)); }
extern "C" __declspec(dllimport) void objc_setProperty (id, SEL, long, id, bool, bool);

static void _I_Person_setName_(Person * self, SEL _cmd, NSString *name) { objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct Person, _name), (id)name, 0, 1); }

// @synthesize age = _age;
static NSUInteger _I_Person_age(Person * self, SEL _cmd) { return (*(NSUInteger *)((char *)self + OBJC_IVAR_$_Person$_age)); }
static void _I_Person_setAge_(Person * self, SEL _cmd, NSUInteger age) { (*(NSUInteger *)((char *)self + OBJC_IVAR_$_Person$_age)) = age; }

static void _I_Person_showMyself(Person * self, SEL _cmd) {
    NSLog((NSString *)&__NSConstantStringImpl__var_folders_1f_dz4kq57d4b19s4tfmds1mysh0000gn_T_main_f5b408_mi_0, ((NSString *(*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("name")), ((NSUInteger (*)(id, SEL))(void *)objc_msgSend)((id)self, sel_registerName("age")));
}

// @end

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;

        Person *p = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("alloc"));
        p = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)p, sel_registerName("init"));
        ((void (*)(id, SEL, NSString *))(void *)objc_msgSend)((id)p, sel_registerName("setName:"), (NSString *)&__NSConstantStringImpl__var_folders_1f_dz4kq57d4b19s4tfmds1mysh0000gn_T_main_f5b408_mi_1);
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)p, sel_registerName("showMyself"));

    }
    return 0;
}

关于属性property生成的gettersetter和实例变量相关代码在另一篇博客iOS @property探究(二): 深入理解中有详细介绍,本文不再赘述,本文仅针对自定义的方法来讲解。

可以发现转写后的C语言代码将实例方法转写为了一个静态函数。接下来一行一行的分析上述代码,第一行代码可以简要表示为如下代码:

Person *p = objc_msgSend(objc_getClass("Person"), sel_registerName("alloc"));

这一行代码做了三件事情,第一获取Person类,第二注册alloc方法,第三发送消息,将消息alloc发送给类对象,可以简单的将注册方法理解为,通过方法名获取到转写后C语言函数的函数指针。 第二行代码就可以简写为如下代码:

p = objc_msgSend(p, sel_registerName("init"));

这一行代码与上一行类似,注册了init方法,然后通过objc_msgSend函数将消息init发送给消息的接受者p。 第三行是一个对setter的调用,同样的也可以简写为如下代码:

//这一行是用来查找参数的地址,取名为name
(NSString *)&__NSConstantStringImpl__var_folders_1f_dz4kq57d4b19s4tfmds1mysh0000gn_T_main_f5b408_mi_1)
objc_msgSend(p, sel_registerName("setName:"), name);

这一行代码同样是先注册方法setName:然后通过objc_msgSend函数将消息setName:发送给消息的接收者,只是多了一个参数的传递。 同理,最后一行代码也可以简写为如下:

objc_msgSend(p, sel_registerName("showMyself"));

解释与上述相同,不再赘述。

到这里,我们应该就可以看出OC的runtime通过objc_msgSend函数将一个面向对象的消息传递转为了面向过程的函数调用。 objc_msgSend函数根据消息的接受者和selector选择适当的方法来调用,那它又是如何选择的呢?这就涉及到前一篇博客讲解的内容iOS runtime探究(一): 从runtime开始: 理解面向对象的类到面向过程的结构体,这一篇博客中详细讲解了OC的runtime是如何将面向对象的类映射为面向过程的结构体的,再来回顾一下几个主要的结构体:

文件objc/runtime.h中有如下定义:
struct objc_class {
    Class isa  OBJC_ISA_AVAILABILITY;

    Class super_class                                        
    const char *name                                         
    long version                                             
    long info                                                
    long instance_size                                       
    struct objc_ivar_list *ivars                             
    struct objc_method_list **methodLists                    
    struct objc_cache *cache                                 
    struct objc_protocol_list *protocols                     
}
/* Use `Class` instead of `struct objc_class *` */

文件objc/objc.h文件中有如下定义
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

/// Represents an instance of a class.
struct objc_object {
    Class isa  OBJC_ISA_AVAILABILITY;
};

/// A pointer to an instance of a class.
typedef struct objc_object *id;

注意结构体struct objc_class中包含一个成员变量struct objc_method_list **methodLists,通过名称我们分析出这个成员变量保存了实例方法列表,继续查找结构体struct objc_method_list的定义如下:

static struct /*_method_list_t*/ {
        unsigned int entsize;  // sizeof(struct _objc_method)
        unsigned int method_count;
        struct _objc_method method_list[5];
} _OBJC_$_INSTANCE_METHODS_Person __attribute__ ((used, section ("__DATA,__objc_const"))) = {
        sizeof(_objc_method),
        5,
        {{(struct objc_selector *)"showMyself", "v16@0:8", (void *)_I_Person_showMyself},
        {(struct objc_selector *)"name", "@16@0:8", (void *)_I_Person_name},
        {(struct objc_selector *)"setName:", "v24@0:8@16", (void *)_I_Person_setName_},
        {(struct objc_selector *)"age", "Q16@0:8", (void *)_I_Person_age},
        {(struct objc_selector *)"setAge:", "v24@0:8Q16", (void *)_I_Person_setAge_}}
};

struct _objc_method {
        struct objc_selector * _cmd;
        const char *method_type;
        void  *_imp;
};

我们发现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,这个缓存里缓存的正是搜索方法的匹配结果,这样在第二次及以后再访问时就可以采用映射的方式找到相关实现的具体位置。

到这里我们就已经弄清楚了整个发送消息的过程,但是当对象无法接收相关消息时又会发生什么?以及前文说的三次机会又是什么?下文将会介绍消息转发。

消息转发: unrecognized selector的最后三次机会

还是那个栗子:

id num = @123;
//输出123
NSLog(@"%@", num);
//程序崩溃,报错[__NSCFNumber appendString:]: unrecognized selector sent to instance 0x7b27
[num appendString:@"Hello World"];

前文介绍了进行一次发送消息会在相关的类对象中搜索方法列表,如果找不到则会沿着继承树向上一直搜索知道继承树根部(通常为NSObject),如果还是找不到并且消息转发都失败了就回执行doesNotRecognizeSelector:方法报unrecognized selector错。那么消息转发到底是什么呢?接下来将会逐一介绍最后的三次机会。

第一次机会: 所属类动态方法解析

首先,如果沿继承树没有搜索到相关方法则会向接收者所属的类进行一次请求,看是否能够动态的添加一个方法,注意这是一个类方法,因为是向接收者所属的类进行请求。

+(BOOL)resolveInstanceMethod:(SEL)name

举个栗子吧:

@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;
}

先看一下最后的输出结果吧:

2017-03-24 19:05:25.092404 OCTest[5142:1185077] resolveInstanceMethod: appendString:
2017-03-24 19:05:25.092810 OCTest[5142:1185077] dynamicAdditionMethodIMP

先看一下main函数,首先创建了一个Person的实例对象,一定要用id类型来声明,否则会在编译期就报错,因为找不到相关函数的声明,id类型由于可以指向任何类型的对象,因此编译时能够找到NSString类的相关方法声明就不会报错。 由于Person类没有声明和定义appendString:方法,所以运行时应该会报unrecognized selector错误,但是并没有,因为我们重写了类方法+ (BOOL)resolveInstanceMethod:(SEL)name,当找不到相关实例方法的时候就会调用该类方法去询问是否可以动态添加,如果返回True就会再次执行相关方法,接下来看一下如何给一个类动态添加一个方法,那就是调用runtime库中的class_addMethod方法,该方法的原型是

BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types);

通过参数名可以看出第一个参数是需要添加方法的类,第二个参数是一个selector,也就是实例方法的名字,第三个参数是一个IMP类型的变量也就是函数实现,需要传入一个C函数,这个函数至少有两个参数,一个是id self一个是SEL _cmd,第四个参数是函数类型。具体设置方法可以看注释。

第二次机会: 备援接收者

当对象所属类不能动态添加方法后,runtime就会询问当前的接受者是否有其他对象可以处理这个未知的selector,相关方法声明如下:

- (id)forwardingTargetForSelector:(SEL)aSelector;

该方法的参数就是那个未知的selector,这是一个实例方法,因为是询问该实例对象是否有其他实例对象可以接收这个未知的selector,如果没有就返回nil,可以自行实验。

第三次机会: 消息重定向

当没有备援接收者时,就只剩下最后一次机会,那就是消息重定向。这个时候runtime会将未知消息的所有细节都封装为NSInvocation对象,然后调用下述方法:

- (void)forwardInvocation: (NSInvocation*)invocation;

调用这个方法如果不能处理就会调用父类的相关方法,一直到NSObject的这个方法,如果NSObject都无法处理就会调用doesNotRecognizeSelector:方法抛出异常。

整个消息转发流程如下图所示:

消息转发流程

总结

本文通过对runtime的分析,详细解释了整个发送消息和消息转发的流程,对OC的runtime能有一个更清晰的掌握。

下一步

这两篇文章分别介绍了runtime如何将面向对象的类映射到面向过程的结构体以及runtime的消息发送和消息转发流程,下一篇文章将继续介绍runtime对实例变量的处理。感兴趣的读者可以继续学习下一篇文章从runtime开始: 理解OC的属性property

备注

由于作者水平有限,难免出现纰漏,如有问题还请不吝赐教。