前言
今天我们一起来聊聊Runtime的那些事。作为一名iOS开发者,如果不知道Runtime,就如同Java工程师不知道JVM、C工程师不知道指针一样尴尬。
当然,掌握Runtime,也是一名UI工程师进阶为高级工程师的必经之路。鲁迅曰,不想当厨师的司机不是一名好工程师(鲁迅:我说的?黑人问号)。哈哈开个玩笑,接下来咱们正式进入主题,跟上步伐,玩的愉快!
切入点
研究一件事情,我比较喜欢先寻找切入点,一方面这样会比较符合人的认知心理,更容易找到问题本源,另一方面,这样也是为了避免盲目探究,最后偏离了方向,忘记了初心。
那么,尖锐的问题来了,Runtime知识看起来是一个非常庞杂的体系,哪里入手呢?
切入之前,我们得先了解下,Runtime是什么?一起来看看官方怎么说:
// 下面一段来自官方文档
The Objective-C runtime is a runtime library that provides support for the dynamic properties of the Objective-C language,
and as such is linked to by all Objective-C apps.
// 下面一段来自官方编程指南
The Objective-C language defers as many decisions as it can from compile time and link time to runtime.
Whenever possible, it does things dynamically.
This means that the language requires not just a compiler,
but also a runtime system to execute the compiled code.
The runtime system acts as a kind of operating system for the Objective-C language; it’s what makes the language work.
上述资料分别摘自官方文档与官方编程指南,文章底部有链接。
按照官方的解释,Runtime是一个运行时库,为OC语言提供动态属性的支持,OC语言为了保证其强大的动态能力,所以把编译期、链接期的尽可能多的决议推迟了到了运行时,所以除了编译器,还需要一个运行时系统。而Runtime就是这个运行时系统,相当于是OC语言的的操作系统,这也是OC语言的运行机制。所以到这里,我们对Runtime的认识明晰了很多。
我们知道,OC语言是由Smalltalk语言演化而来【可以阅读下面的'扩展知识'小节】,是一种消息型的语言,即该语言采用“消息结构”而非“函数调用”来实现的。“消息结构”的语言运行时所执行的代码是由运行环境决定的,而“函数调用”的语言则有编译器决定。而结合前面官方对Runtime的定义,我们可以很明确,是Runtime运行时系统支撑起了OC语言的动态性的。
所以我们的研究切入点一下就找到了,那么就从OC语言的消息机制说起。
OC语言的消息机制
我们可以通过OC语言的消息机制为切入点,窥探一下Runtime的一些细节。
我们通过clang
命令,将OC代码重写为C++,可以看到更底层一些的实现细节。
// clang命令
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
// OC代码
ZKPerson *person = [[ZKPerson alloc] init];
[person run];
// C++代码
ZKPerson *person = ((ZKPerson *(*)(id, SEL))(void *)objc_msgSend)((id)((ZKPerson *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("ZKPerson"), sel_registerName("alloc")), sel_registerName("init"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)person, sel_registerName("run"));
通过上面的探索,我们看到了一个有趣的现象:OC中的方法调用,其实都被转换为了objc_msgSend
的调用。
苹果的Runtime相关代码是开源的,我们可以通过阅读源码,追踪objc_msgSend
的调用流程,一步一步了解消息机制的本质。
当我们在源码工程中搜索objc_msgSend
的时候,发现并不像上次研究RunLoop那么顺利。原来这个方法没有在出现在objc-runtime-new.mm
文件中,而是在汇编文件中。我们选择与真机设备arm64架构匹配的汇编文件研究:objc-msg-arm64.s
。由于汇编语言的函数会默认将高级语言的函数名加上_
前缀,所以我们在这个文件中搜索_objc_msgSend
。找到了函数实现:
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
cmp p0, #0 // nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
b.eq LReturnZero
#endif
ldr p13, [x0] // p13 = isa
GetClassFromIsa_p16 p13 // p16 = class
LGetIsaDone:
CacheLookup NORMAL // calls imp or objc_msgSend_uncached
#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
b.eq LReturnZero // nil check
// tagged
adrp x10, _objc_debug_taggedpointer_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
ubfx x11, x0, #60, #4
ldr x16, [x10, x11, LSL #3]
adrp x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE
add x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF
cmp x10, x16
b.ne LGetIsaDone
// ext tagged
adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
ubfx x11, x0, #52, #8
ldr x16, [x10, x11, LSL #3]
b LGetIsaDone
// SUPPORT_TAGGED_POINTERS
#endif
LReturnZero:
// x0 is already zero
mov x1, #0
movi d0, #0
movi d1, #0
movi d2, #0
movi d3, #0
ret
END_ENTRY _objc_msgSend
这个函数不算长,即使不太懂汇编,我们也大概能明白这个函数的意思:
- 首先做一些边界检测,然后通过
GetClassFromIsa_p16 p13
获取isa
指针,我们知道,通过isa
指针可以找到类对象或元类对象,后面小节会对isa
详细介绍。 - 找到
isa
后,调用CacheLookup NORMAL
,这个方法是个.macro宏,具体代码我就不贴了,毕竟不是重点,我介绍下这个方法中做的事情:- 首先是在这个类的方法缓存列表中查找是否有这个方法,如果命中直接调用。
- 如果没有命中缓存,则调用
CheckMiss
==>__objc_msgSend_uncached
==>MethodTableLookup
==>__class_lookupMethodAndLoadCache3
,最后这个方法的实现是在objc-runtime-new.mm
中,而这个方法直接做个参数透传,调用了lookUpImpOrForward
函数,而这个函数,就是OC语言的消息机制的核心实现。我们一起来看下:
消息发送机制:
这个函数主要做了如下几件事情
- 对该类和其父类的内存结构进行重新修正组装,为
class_rw_t
数据开辟内存空间(因为需要在class_rw_t
这个数据结构中存储分类动态添加的方法、属性和协议等数据),通过调用methodizeClass
对该类的方法列表、属性列表和属性列表进行修正,即把该类的分类中的这些内容添加到该类的内存结构中。
if (!cls->isRealized()) {
realizeClass(cls);
}
经过上一步,该类和其父类的内存结构进行修正,其方法列表、属性列表和协议列表都是最新最全的了。接下来就可以真正进入消息发送流程了。
-
首先在该类中查找。
- 查找该类的方法缓存列表。如果命中则直接done,也即是将IMP返回。
// Try this class's cache. imp = cache_getImp(cls, sel); if (imp) goto done;
- 在该类的方法列表中查找。如果查找到了,则把该方法保存在该类的方法缓存列表中,再将IMP返回给外部。
// 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; } }
-
如果在该类中没有找到,则通过
superClass
指针向父类查找。
查找逻辑同上,先从缓存中查找,没找到再从方法列表中查找。这里有个细节要注意,如果在父类中找到了该方法,不管是在父类的方法缓存列表中找到的还是在父类的方法列表中找到的,找到后都要把该方法缓存在该类cls
中的方法缓存列表中去,方便下次调用。// Try superclass caches and method lists. { unsigned attempts = unreasonableClassCount(); for (Class curClass = cls->superclass; curClass != nil; curClass = curClass->superclass) { // 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; } } }
以上的是常规的消息发送流程。如果最终找到
NSObject
即RootClass
还是没有找到,则启动了消息转发流程。
消息转发机制:
(1)解铃还须系铃人,先征询接收者能不能动态添加方法。即+resolveInstanceMethod
运行期组件会首先问该方法所属的类:是否能动态添加方法来处理当前这个未知的选择子,即动态方法解析
。当这个对象收到无法解读的消息后,会调用其所属类的下列类方法:
+ (BOOL)resolveInstanceMethod:(SEL)selector
其返回值是Boolean类型,表示这个类是否能新增一个实例方法来处理未知消息。如果未知方法为类方法,会询问resolveClassMethod
来处理未知类消息。
使用这个方法的前提是:相关方法的实现代码已经写好,只等着运行的时候动态插在类里面就行了。比如CoreData框架中的NSManagedObjects对象的属性用@dynamic修饰,表示这些属性的存取方法会在编译期自动添加,在编译期会以ORM的方式模型映射到数据库进行数值存取生成Setter和Getter。
这个阶段非常重要,我们在实际编程中,runtime动态添加方法也多是在这个阶段进行的。常见的写法如下:
+ (BOOL)resolveInstanceMethod:(SEL)selector {
if (...) {
class_addMethod(self, selector, IMP, ...);
}
}
(2)如果接收消息的类没有通过阶段1来动态增添方法来处理未知消息,那么运行时组件就会问当前接收者:你既然不想处理这个消息,那能不能把这条消息转发给其他接收者来处理。这时当前接收者灵机一动,哦隔壁老王应该可以响应这个消息,那就转发给他吧。于是,通过下面代码,将未知消息成功甩锅出去:
- (id)forwardingTargetForSelector:(SEL)selector
该方法的返回值即是把未知消息甩向的对象。如果成功甩出去了,那么消息转发至此结束,皆大欢喜。如果没有找到背锅侠(备援响应者),则返回nil。那这时候,运行期组件就很绝望:你自己不想动态添加方法来响应这个未知消息,也不想甩给其他人来响应,有些绝望,于是,运行期组件只好求助消息派发系统,进入了下一步:
(3)运行期组件被消息接收者两次拒绝后,生无可恋,于是向消息派发系统求助。于是把未知消息的所有细节封装包裹在NSInvocation
对象中,包括选择子SEL、接收者Target和参数等,触发后消息派发系统
将亲自出马,把消息指派给目标对象。
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
return [NSMethodSignature signatureWithObjCTypes:@"v@:"];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation{
[anInvocation setTarget:realObject];
[anInvocation invoke];
}
注意,由于这个步骤跟上面的步骤二类似,可以指定新的接收者来处理未知消息。他可以先改变这条未知消息的内容再转发出去,如更换选择子,追加参数等。实现此方法时,若发现某调用操作不应由本类处理,则会调用超类的同名方法,即继承体系中的每个类都有机会处理此请求,直至NSObject。如果最后调用了NSObject类的方法还是没能处理,那么该方法会继而调用老少皆知的doesNotRecognizeSelector
以抛出异常,至此,未知消息以最终未能处理来正式告终。
扩展知识
为了保证上面章节的整体行文的流畅性,下面我把一些我认为比较重要但是跟这篇文章的主题不太强相关的知识写在这个小节中,后面如果有时间的话(研究表明:说出"后面如果有时间的话"这句话的人通常不会有时间,摊~)我可能会单独成一篇文章。
关于 OC 语言的一些历史
OC是Brad Cox和Tom Love在1980年初发明出的一种程序设计语言,跟同时代的C++一样,都是在C的基础上加入面向对象特性扩充而成的。
他是一门面向对象、消息型语言,由消息型语言的鼻祖Smalltalk演进而来,即该语言采用“消息结构”而非“函数调用”来实现的。(事实上,SmallTalk 可以说是世界上第一个真正的面向对象的语言,第一个具备垃圾回收的语言,第一个拥有真正的集成开发环境的语言,第一次引入了MVC的概念来开发软件的语言)。
我们知道,C++一直是如日中天,霸榜编程语言排名前列30年之久,而作为同样是基于C语言扩充而来的语言-OC,为什么表现的不尽如人意,直到2010年才被众多世人知晓呢?
造成这种结果的原因,跟苹果公司有着密切的关系。当年(1988年)乔布斯创办的NeXT公司选用OC作为其御用程序开发编程语言,并买下了版权, 并且扩展了著名的开源编译器GCC,使之支持 Objective-C 的编译。种种的一切,给OC带来了生机,但同时也由于版权的限制和苹果公司的的孤傲,导致OC不能像Java、Swift、C++等发展这么迅速。
当然,OC对苹果公司的软硬一体化的战略部署,起到了举足轻重的作用,想想如今的谷歌深陷跟甲骨文关于Java版权之争难以自拔,不禁对当年老乔的决定心生敬畏。