Save&Load

Save The World, Load The Game

0%

iOS底层探索 - runtime与消息发送

前言

探索完对象的创建与销毁,下面我们来看一下Objective-C一个特性,那就是runtime,中文翻译叫做运行时。是指Objective-C编译后的代码不是直接运行的,而是在一个运行时的系统中动态调用的。正是因为这个特性,使得我们可以做到很多有意思的事情。

runtime

按照官方定义,运行时系统更像是一个操作系统,而Objective-C语言就像是运行在其中的程序。运行时有两个版本,本文中讨论的是”modern”版本,即Objective-C 2.0使用的版本,它较于Objective-C 1.0使用的”legacy”版本增加了一些新的特性,详细的可以看官方文档的描述。

消息发送

runtime最重要的功能就是消息发送即方法调用,在Objective-C中,我们调用一个方法一般是这样实现的:

1
[receiver message]

然后编译器在编译的过程中会把上面的代码转换成:

1
objc_msgSend(receiver, selector)

objc_msgSend方法完成了动态发送消息的功能,下面我们就来看下objc_msgSend中都做了什么。

在缓存中查找

在runtime源码中找到了objc_msgSend的定义,但是可惜的是没有看到具体的实现。后面上网查了下,发现为了性能考虑,objc_msgSend的源码使用汇编实现的,然后在源码的汇编文件中发现了objc_msgSend的实现。

篇幅原因这里就不贴源码了,感兴趣的可以自己阅读。这里就用伪代码来描述一下大体实现:

1
2
3
4
5
6
7
8
id objc_msgSend(id self, SEL _cmd, ...) {
if (self == nil) return; \\判空保护
if(CacheHit == CacheLookup) {
return call imp; \\如果查询缓存命中,则直接调用imp
} else {
return __class_lookupMethodAndLoadCache3; \\如果没有命中,则继续查询
}
}

我们可以看到objc_msgSend汇编代码中主要做的就是查询缓存。因为动态消息发送,消息查询发送的逻辑还是比较耗时的,如果每次发送都要查询一次,系统开销比较大。所以每个类中都会有一个方法缓存列表,缓存调用过的方法IMP。当再次有相同的方法调用过来的时候,会先查询缓存,如果缓存命中则直接调用对应的IMP即可。这样的话,调用的效率就和静态编译的差不多了。

在类中查找

如果缓存中没有命中,那么就会调用__class_lookupMethodAndLoadCache3,这个方法就不是汇编实现的了:

1
2
3
4
5
6
7
8
9
10
11
/***********************************************************************
* _class_lookupMethodAndLoadCache.
* Method lookup for dispatchers ONLY. OTHER CODE SHOULD USE lookUpImp().
* This lookup avoids optimistic cache scan because the dispatcher
* already tried that.
**********************************************************************/
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
return lookUpImpOrForward(cls, sel, obj,
YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

这里面只是简单的调用了lookUpImpOrForward,参数传入中需要注意initialize为YES,说明如果调用的类是第一次调用,那么需要先调用+initialize方法;cache为NO,说明不需要再查缓存了,因为之前已经查过了;resolver为YES,说明如果当类中找不到方法的时候,调用一次_class_resolveMethod,进行动态方法解析。下面看下lookUpImpOrForward的具体实现:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
bool initialize, bool cache, bool resolver)
{
IMP imp = nil;
bool triedResolver = NO;

runtimeLock.assertUnlocked();

// Optimistic cache lookup
if (cache) {
imp = cache_getImp(cls, sel);
if (imp) return imp;
}

// runtimeLock is held during isRealized and isInitialized checking
// to prevent races against concurrent realization.

// runtimeLock is held during method search to make
// method-lookup + cache-fill atomic with respect to method addition.
// Otherwise, a category could be added but ignored indefinitely because
// the cache was re-filled with the old value after the cache flush on
// behalf of the category.

runtimeLock.read();

if (!cls->isRealized()) {
// Drop the read-lock and acquire the write-lock.
// realizeClass() checks isRealized() again to prevent
// a race while the lock is down.
runtimeLock.unlockRead();
runtimeLock.write();

realizeClass(cls);

runtimeLock.unlockWrite();
runtimeLock.read();
}

if (initialize && !cls->isInitialized()) {
runtimeLock.unlockRead();
_class_initialize (_class_getNonMetaClass(cls, inst));
runtimeLock.read();
// If sel == initialize, _class_initialize will send +initialize and
// then the messenger will send +initialize again after this
// procedure finishes. Of course, if this is not being called
// from the messenger then it won't happen. 2778172
}


retry:
runtimeLock.assertReading();

// Try this class's cache.

imp = cache_getImp(cls, sel);
if (imp) goto done;

// Try this class's method lists.
{
Method meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, cls);
imp = meth->imp;
goto done;
}
}

// Try superclass caches and method lists.
{
unsigned attempts = unreasonableClassCount();
for (Class curClass = cls->superclass;
curClass != nil;
curClass = curClass->superclass)
{
// Halt if there is a cycle in the superclass chain.
if (--attempts == 0) {
_objc_fatal("Memory corruption in class list.");
}

// Superclass cache.
imp = cache_getImp(curClass, sel);
if (imp) {
if (imp != (IMP)_objc_msgForward_impcache) {
// Found the method in a superclass. Cache it in this class.
log_and_fill_cache(cls, imp, sel, inst, curClass);
goto done;
}
else {
// Found a forward:: entry in a superclass.
// Stop searching, but don't cache yet; call method
// resolver for this class first.
break;
}
}

// Superclass method list.
Method meth = getMethodNoSuper_nolock(curClass, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
imp = meth->imp;
goto done;
}
}
}

// No implementation found. Try method resolver once.

if (resolver && !triedResolver) {
runtimeLock.unlockRead();
_class_resolveMethod(cls, sel, inst);
runtimeLock.read();
// Don't cache the result; we don't hold the lock so it may have
// changed already. Re-do the search from scratch instead.
triedResolver = YES;
goto retry;
}

// No implementation found, and method resolver didn't help.
// Use forwarding.

imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);

done:
runtimeLock.unlockRead();

return imp;
}

这里面的逻辑比较多,所以这里梳理一下:

  1. 判断cache是否为true,如果为true,则查找一次缓存
  2. 如果class还没有初始化,那么先初始化一下
  3. 如果class是第一次调用且initializetrue,则调用一下class的+initialize方法
  4. 搜索当前类的缓存列表,如果有imp则直接返回
  5. 搜索当前类的方法列表,如果有imp则直接返回
  6. 循环遍历类的superclass,依次查询superclass的缓存列表和方法列表,如果有imp则直接返回
  7. 如果resolvertrue,则调用一次_class_resolveMethod方法,然后跳转到第4步执行
  8. 如果还没有找到imp,那么将imp赋值为_objc_msgForward_impcache并缓存
  9. 返回imp

逻辑理清之后,这里要解释一下方法查询的逻辑,为什么要找superclass

每个实例中都有一个isa指针,isa指针指向的是类实例,在Objective-C中每个类都是一个单例对象,这个类中保存的是类的所有实例方法。类的isa指向的是类的元类(metaClass),其中元类保存的是类的所有类方法,而类的superclass指向的是父类实例。

所以在搜索方法的时候,是顺着superclass指针来查找实例或者类方法的。

动态方法解析

前面说到,如果方法查找没有找到imp,那么就会调用_class_resolveMethod方法,也就是动态方法解析的逻辑,下面看下具体是怎么实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void _class_resolveMethod(Class cls, SEL sel, id inst)
{
if (! cls->isMetaClass()) {
// try [cls resolveInstanceMethod:sel]
_class_resolveInstanceMethod(cls, sel, inst);
}
else {
// try [nonMetaClass resolveClassMethod:sel]
// and [cls resolveInstanceMethod:sel]
_class_resolveClassMethod(cls, sel, inst);
if (!lookUpImpOrNil(cls, sel, inst,
NO/*initialize*/, YES/*cache*/, NO/*resolver*/))
{
_class_resolveInstanceMethod(cls, sel, inst);
}
}
}

这个逻辑就比较简单了,如果当前的class不是metaClass,那么就调用[cls resolveInstanceMethod:sel];如果是metaClass,那么就先调用[nonMetaClass resolveClassMethod:sel],然后再查询父类方法中是否有相同sel名字的方法,如果没有,再调用一次[cls resolveInstanceMethod:sel]

可以发现调用类方法的时候,会再次兜底调用一次resolveInstanceMethod方法,也就是说如果调用一个不存在的类方法的时候,最终会调用一次NSObjectresolveInstanceMethod方法(这是因为resolveInstanceMethod调用的是当前对象的isa类中方法,而metaClassisa一般都是NSObject)。所以在NSObjectresolveInstanceMethod中还会有一次补救机会。

消息转发

当动态解析也没有找到IMP的时候,就会返回一个_objc_msgForward_impcacheIMP,这个IMP只是在方法缓存列表中存储的一个指针,指向了_objc_msgForward或者_objc_msgForward_stret。

这之后的实现逻辑如下:

  1. 先调用forwardingTargetForSelector方法获取新的target作为receiver重新执行 selector,如果返回的内容不合法(为 nil 或者跟旧 receiver 一样),那就进入第二步。
  2. 调用methodSignatureForSelector获取方法签名后,判断返回类型信息是否正确,再调用forwardInvocation执行NSInvocatio对象,并将结果返回。如果对象没实现methodSignatureForSelector方法,进入第三步。
  3. 调用doesNotRecognizeSelector方法。

具体是如何实现的,需要用到逆向工程的技术,这里就不详细展开了,推荐阅读下面的文章来了解一下。
Objective-C 消息发送与转发机制原理
Hmmm, What’s that Selector?

forwardingTargetForSelector

forwardingTargetForSelector的类方法和实例方法在NSObject中的默认实现都是直接返回nil,所以需要开发者自己来实现。这里简单给一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+ (id)forwardingTargetForSelector:(SEL)sel {
if(sel == @selector(xxx)) {
return NSClassFromString(@"Class name");
}
return [super forwardingTargetForSelector:sel];
}

- (id)forwardingTargetForSelector:(SEL)sel
{
if(aSelector == @selector(xxx)){
return otherObject;
}
return [super forwardingTargetForSelector:sel];
}

methodSignatureForSelector && forwardInvocation

如果forwardingTargetForSelector返回了nil,那么就会调用methodSignatureForSelector,如果返回不为空,则会继续调用forwardInvocation方法。这里同样是需要开发者自己来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (NSMethodSignature*)methodSignatureForSelector:(SEL)aSelector
{
if (aSelector == @selector(xxx)) {
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}

return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
SEL sel = anInvocation.selector;

otherObject *o = [otherObject new];
if([o respondsToSelector:sel]) {
[anInvocation invokeWithTarget:o];
}
else {
[self doesNotRecognizeSelector:sel];
}

}

应用举例

到这里整个runtime的消息发送的流程就已经走完了,那么了解runtime之后有什么用呢?下面就举几个比较常见的应用:

为Category中的property实现setter和getter

在正常情况下,Category中添加property后,系统是不会生成settergetter方法的。但是有了runtime,我们就可以动态的为property添加settergetter方法。

这里我为自己创建的一个Fruit类的Category中添加一个name属性:

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
@interface Fruit (Name)

@property (nonatomic, copy) NSString *name;

@end

@implementation Fruit (Name)

static const void *Name = &Name;
@dynamic name;

- (void)setName:(NSString *)name {
objc_setAssociatedObject(self, &Name, name, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSString *)name {
return objc_getAssociatedObject(self, &Name);
}

@end

- (void)viewDidLoad {
[super viewDidLoad];

Fruit *fruit = [Fruit new];
fruit.name = @"apple";
NSLog(@"%@", fruit.name);
}

KVO

KVO全称Key-Value Observering,提供了一种属性key被修改的时候能够收到通知的机制。这是使用了isa-swizzling方法来实现的,当观察对象A时,系统会动态创建一个A的子类名为NSKVONofitying_A,并在这个类中重写了你要观察的属性的setter方法。

然后在setter方法中,属性赋值之前调用了willChangeValueForKey:,赋值之后调用了didChangeValueForKey:。这样观察者就能够收到修改通知,这些操作都是在运行时实现的。

1
2
3
4
5
- (void)setObject:(NSString *)newObject { 
[self willChangeValueForKey:@"object"];
_object = newObject;
[self didChangeValueForKey:@"object"];
}

JSPatch

JSPatch 是一个 iOS 动态更新框架,只需在项目中引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 原生接口,获得脚本语言的优势:为项目动态添加模块,或替换项目原生代码动态修复 bug。

JSPatch底层实现的原理就是使用了消息转发的机制,将要替换的方法通过class_replaceMethod()指向_objc_msgForward,这样就会走到forwardInvocation:,然后在forwardInvocation:中替换成新的方法。在这个新方法里取到参数传给JS,调用JS的实现函数。

更多实现细节可以看官方的说明文档

总结

  • 方法调用最终运行的代码是在运行时决定的,而不是在代码编译阶段
  • 消息发送时有使用缓存机制来加速查找
  • 消息发送大致流程:缓存列表查找 -> 类方法列表查找 -> 动态方法解析 -> 消息转发
  • 消息发送过程中有多次补救的机会,灵活使用可以做很多事情

参考