Objective-C Runtime 消息机制 - 代码背后发生的事情



- 作者: SwiftCafe


说到 Objective-C Runtime ,可能不是大家常常提及的内容。但它确实又和大家平时的开发过程息息相关,即使使用 Swift 语言,也依然离不开 Objective-C Runtime。 咱们就来一探究竟吧。

什么是 Objective-C Runtime

使用过 Objective-C 进行开发的同学一定会注意到 Objective-C 中的 Selector 机制。 为什么要把它称为 Selector 呢,它和函数和方法有什么区别呢? 比如给 UIButton 添加事件的时候:

1
[button addTarget:self action:@selector(buttonClciked) forControlEvents:UIControlEventTouchUpInside];

为什么给 action 参数传递进来的是一个 selector 而不是一个函数的名称呢?

这就要从 Objective-C Runtime 说起。 所有的 Objective-C 方法调用都是基于 Objective-C Runtime 进行的。 比如最简单的方法调用:

1
[person sayHello];

如果按照面向对象的思维去解释,可以将这行代码解释为调用 person 对象的 sayHello 方法。 但如果从 Objective-C Runtime 的角度来说,这个代码实际上是在发送一个消息

要牢牢记住上面的代码是发送消息。 刚刚那段代码,编译器实际上会将它转换成这样一个函数调用:

1
objc_msgSend(person,sayHello)

objc_msgSend 是 Objective-C Runtime 中的函数,这个函数定义在 <objc/message.h> 头文件中。

我们在 Objective-C 中所有通过一对方括号所进行的方法调用,其实都是通过 Objective-C Runtime 的 objc_msgSend 函数发送的一个消息传递。

objc_msgSend

那么既然所有的方法调用本质上都是通过 objc_msgSend 进行的消息传递。 那么 objc_msgSend 这个函数做了什么呢?

objc_msgSend 负责 Objective-C Runtime 中消息机制的核心 - 叫做消息分发

在了解消息分发之前,咱们还需要了解 runtime 中关于类的定义,<objc/runtime.h> 头文件中定义了这样一个结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct objc_class {
Class isa OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
Class super_class OBJC2_UNAVAILABLE;
const char *name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list *ivars OBJC2_UNAVAILABLE;
struct objc_method_list **methodLists OBJC2_UNAVAILABLE;
struct objc_cache *cache OBJC2_UNAVAILABLE;
struct objc_protocol_list *protocols OBJC2_UNAVAILABLE;
#endif
}

大家可能会想了, 怎么又冒出这么一个 struct 结构呢? 我们不是已经在 Objective-C 用诸如这样的代码定义过我们的类了么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface Person : NSObject
- (void) sayHello;
@end
@implementation Person
- (void)sayHello {
NSLog(@"hello");
}
@end

简单来说呢,咱们用 Objective-C 代码定义了类,比如我们这里定义的 Person 类,都是应用层的逻辑。它服务于我们开发的 App 的需求 - 比如,有这样一个 Person 类,它可以通过 sayHello 方法向命令行输出内容。

但在系统层级,我们定义的 Person 类是如何在内存中表示的?

对 Person 类中方法的调用是如何实现的呢?

这些系统层级的逻辑就要靠 Objective-C Runtime 为我们完成了。

比如 Person 类中所定义的属性和方法,在内存中的存储方式就是通过 Runtime 的 struct objc_class 结构来定义了。每一个类的实例在 Runtime 中都会用 objc_class 这个结构来表示,这也就意味着所有的对象也都包含了 objc_class 结构中所定义的属性。

那么我们继续, objc_class 结构包含了很多属性, 其中一个叫做 isa, 它的类型是 Class。 那么继续追根溯源,在 <objc/objc.h> 中找到了 Class 类型的定义:

1
typedef struct objc_class *Class;

实际上 isa 的类型,就是 objc_class 这个结构的类型。 isa 所指向的结构正是这个类的元信息(属性,方法的定义)。

现在,我们对 Runtime 的基础结构有了一个了解。 再回到 objc_msgSend 函数中, 它的第一个参数就是我们要发送消息的实例。首先,objc_msgSend 函数会检测这个实例的 isa 属性,找到 isa 中定义的:

1
struct objc_method_list **methodLists

methodLists 属性表示当前实例的方法列表,它是一个 objc_method_list 类型的结构:

1
2
3
4
5
6
7
8
9
10
struct objc_method_list {
struct objc_method_list *obsolete OBJC2_UNAVAILABLE;
int method_count OBJC2_UNAVAILABLE;
#ifdef __LP64__
int space OBJC2_UNAVAILABLE;
#endif
/* variable length structure */
struct objc_method method_list[1] OBJC2_UNAVAILABLE;
}

这个结构的定义可能大家会有些地方不太明白,比如 space 属性是干什么的。咱们可以暂时抛开这些问题,只关心和消息分发相关的属性 - method_count 属性表示当前这个实例中方法的个数,method_list 结构表示当前实例上面所有方法的列表:

1
struct objc_method method_list[1]

它的每一个元素又是一个 objc_method 类型的结构:

1
2
3
4
5
struct objc_method {
SEL method_name OBJC2_UNAVAILABLE;
char *method_types OBJC2_UNAVAILABLE;
IMP method_imp OBJC2_UNAVAILABLE;
}

这个结构有三个属性,method_name 是 SEL 类型。 就是 @selector(sayHello) 这样的表达式所表示的类型。 也就是我们所说的 Selector。 看到这里是不是有些豁然开朗的感觉呢?

Selector ,它其实是 Runtime 的一个数据结构。它代表一个方法的唯一标识

然后再看第二个属性 method_types, 这个属性用一个字符串表示方法返回值类型以及每个参数的类型。 它使用 @encode 规则对类型信息进行编码,咱们现在只要了解到这就好,细节先不深究。

最后一个参数是 IMP 类型,它表示这个 Selector 对应的函数的地址。 对,是函数没错。 Objective-C 中定义的所有类的方法在底层实现上就是一个函数。

消息分发流程

咱们对 Objective-C Runtime 的消息的底层数据结构已经有了足够的了解。 接下来就探讨一下消息分发机制吧。

说了这么多之后大家还记得是怎么调用 objc_msgSend 函数的么? 咱们再来回顾一下:

1
objc_msgSend(person,@selector(sayHello))

第一个参数是要发送消息的实例,也就是 person 对象。 objc_msgSend 会先查询它的 methodLists 方法列表,使用第二个参数 sayHello 逐个和 personmethodLists 中的每一个方法信息的 SEL 进行对比,如果找到对应的方法,就调用它所对应的函数,也就是 IMP,然后调用这个函数。

消息分发的基本流程用一张图来描绘:

这张流程图最后一步,我们看到这样调用 sayHello 函数:

1
sayHello(person,@selector(sayHello));

是不是觉得有点奇怪? sayHello 方法我们明明是这样定义的:

1
- (void) sayHello;

它不接受任何参数,而我们流程图中的 sayHello 却传入了两个参数。这就引出了 runtime 的另外一个机制,我们继续讨论。

方法实现

Objective-C 中所有的方法调用,其实都会隐式的传递进来两个参数。第一个参数我们比较熟悉了, 就是 self。 只不过我们习以为常的把 self 当成一个关键字,其实它是一个传递进来的参数。

第二个参数叫做 _cmd 用于表示当前函数所对应的 Selector。 这个参数很少会用到,咱们不进一步展开。

这就解释了我们刚才的问题, Objective-C 中即便这个方法声明为不接受任何参数,但在实际调用它的时候,也会至少将这两个隐含的参数传递进来。 这个两个参数的传递过程,我们在应用层开发的时候是完全不用管的,这些工作都由 Objective-C Runtime 替我们完成了。

更进一步的说,我们为 Person 定义的 sayHello 方法,在 runtime 中实际上就是一个函数而已。 它的签名如下:

1
void sayHello(Person person, SEL _cmd) { ... }

有了这个概念后,我们就更加理解 objc_msgSend 消息分发的过程了。 Objective-C Runtime 用 objc_msgSend(person,@selector(sayHello)) 这样的方式将 sayHello 消息发送给 person 实例。 objc_msgSend 函数找到 @selector(sayHello) 在 Person 类中所对应的函数,然后调用这个函数,并传入两个隐含的参数。

从这个流程不难看出, Selector 实际上是函数的一个标识,它不是函数

runtime 通过 SEL 类型的 Selector 标识,在 Person 类的方法列表中找到和这个 Selector 相同的条目, 然后执行这个条目 IMP 属性所指向的函数地址,并传入 self 和 _cmd 两个隐含的参数。

回想一下前面提到的 objc_method 结构的定义:

1
2
3
4
5
struct objc_method {
SEL method_name OBJC2_UNAVAILABLE;
char *method_types OBJC2_UNAVAILABLE;
IMP method_imp OBJC2_UNAVAILABLE;
}

这就更加明确了, SEL 类型的 method_name 属性仅仅作为一个标识。 而 method_imp 才是真正要执行的函数地址。

消息缓存

经过了前面长篇大论的分析,了解了 Objective-C Runtime 的消息分发的整体流程,这样一个简单的方法调用:

1
[person sayHello];

实际上在它的背后,对应着一系列复杂的机制。实际上在 Objective-C 中调用一个方法需要两个过程,首先通过消息分发找到对应的函数

注意这里是函数,在 runtime 中只有函数(Function)

然后再调用这个函数,并传递相应的参数。

实际上消息分发的过程是比较消耗性能的,需要进行一系列的查表操作。 所以 Objective-C Runtime 对消息的分发建立了缓存机制。 这点我们可以回顾一下 objc_class 结构的定义,是否还记得它也定义了一个 cache 属性:

1
2
3
4
5
6
7
8
struct objc_class {
...
struct objc_cache *cache OBJC2_UNAVAILABLE;
...
}

它的类型是 objc_cache, 继续找到这个结构的定义:

1
2
3
4
5
struct objc_cache {
unsigned int mask /* total = mask + 1 */ OBJC2_UNAVAILABLE;
unsigned int occupied OBJC2_UNAVAILABLE;
Method buckets[1] OBJC2_UNAVAILABLE;
};

实际上 objc_cache 维护了一个哈希表,使用 Selector 作为键,存储了缓存的函数列表。

了解了这些,消息分发的时候,首先会在 cache 里面进行匹配,如果我们发送的消息所对应的函数在 cache 中能够找到,就直接执行这个函数了。 如果 cache 中没有,才会真的去查找 methodLists 列表,并且成功匹配一次后,就将它放入缓存中,以后再调用这个方法就不会重新的进行查表操作了。

消息分发的传递机制

我们在发送一个消息时,如果当前实例的方法列表中没找到对应的函数怎么办呢,比如我们发送这样一个消息:

1
[person description]

Person 类中确实没有定义 description 方法。对于这样的情况, Objective-C Runtime 会继续查找它的父类,使用定义在 objc_class 结构中的 super_class 属性。

1
2
3
4
5
6
struct objc_class {
...
Class super_class OBJC2_UNAVAILABLE;
...
}

直到查找到最顶层的根类。 比如在我们上面的例子中, Person 类确实没有定义 description 方法,但它的父类 NSObject 是有这个方法的,所以就会执行定义在 NSObject 的方法列表中的这个函数。 这个流程也很好的解释了 Objective-C 中方法重载的机制。 Runtime 会现在子类的 methodLists 中查找,如果子类有相应的重载,就会优先使用子类的实现。

如果遍历完整个类层级依然找不到对应的方法实现,默认情况下就会抛出类似这样的异常:

1
unrecognized selector sent to instance 0x7fe672452350

相信大家在开发中会不少次遇到这种情况吧。 不过,这只是默认行为,其实这个异常是可以不抛出的。 我们完全可以在发送了一个并没有实现的消息的时候不让程序崩溃。 这就涉及到 runtime 的消息转发机制了。 这次就先不讨论啦,改天帮大家总结一篇单独的文章。

直接发送消息

介绍了这么多,相信大家通过这篇文章的内容,对 Runtime 的消息机制已经有了比较多的了解。 所有的方法调用,在 Runtime 中都会通过 objc_msgSend 来发送。 如果这么说来其实是可以直接调用 objc_msgSend 来发送消息的。 可以验证一下:

1
2
3
4
5
#import <objc/message.h>
。。。
((void (*)(id, SEL)) objc_msgSend)(person,@selector(sayHello));

这段代码是可以在真实环境中编译并运行的。 运行程序后,控制台上会有这样的输出:

1
hello

这说明我们通过直接调用 objc_msgSend 的方式完成了方法调用。 解释一下刚才的代码, 首先需要引入 Runtime 的头文件 #import <objc/message.h>

message.h 中定义的 objc_msgSend 函数,并没有明确参数列表和返回类型, 所以我们需要强制转换一下,否则我们会遇到编译错误:

1
((void (*)(id, SEL)) objc_msgSend)

然后调用这个转换后的函数,并传入相应的参数:

1
((void (*)(id, SEL)) objc_msgSend)(person,@selector(sayHello));

这样,编译顺利通过,验证成功~

直接调用函数

虽然我们可以调用 objc_msgSend 来发送消息,但它还是要经过消息分发的过程。 当然,如果你需要的话,你是可以完全绕过消息分发机制直接调用函数的。 NSObject 中定义了一个 methodForSelector 方法,可以得到 Selector 所对应的函数:

1
2
3
void (*sayHello)(id, SEL);
sayHello = (void (*)(id,SEL))[person methodForSelector:@selector(sayHello)];
sayHello(person, @selector(sayHello));

我们通过 methodForSelector 得到了 sayHello 函数的地址引用,这样我们就可以直接调用 sayHello 函数了,这样就会绕过 Runtime 的消息分发机制。

当然,这两个小例子主要是帮助大家了解 Objective-C Runtime 的机制。我们在实际代码中,如非必要,还是不建议绕过默认的消息分发机制。

总结

Objective-C Runtime 可以说是隐藏在幕后的精英。 我们所写的几乎每一行代码,都和 Objective-C Runtime 形影不离。 比如,传递给 objc_msgSend 的第一个参数是 nil 的话, objc_msgSend 就会判断并进行短路操作。 这也解释了为什么在值为 nil 的引用上面调用方法不会导致程序崩溃。 Objective-C Runtime 中所涉及的内容,以及它定义的函数,很少会再我们的日常开发中用到。但了解 Objective-C Runtime 背后的机制,会让你在写代码的时候更加有把握,有一种内功暴增的感觉。

如果你觉得这篇文章有帮助,还可以关注微信公众号 swift-cafe,会有更多我的原创内容分享给你~

本站文章均为原创内容,如需转载请注明出处,谢谢。




微信公众平台
更多精彩内容,请关注微信公众号


公众号:swift-cafe
邮件订阅
请输入您的邮箱,我们会把最新的内容推送给您: