本文授权转载,作者:Sindri的小巢(简书)
从异常说起
我们都知道,在iOS中存在这么一个通用类类型id,它可以用来表示任何对象的类型 —— 这意味着我们使用id类型的对象调用任何一个方法,编译器都不会进行报错。比如下面这段代码:
1 2 |
|
不出意外的,编译器会给你这么一个信息然后华丽丽的崩溃了。相信几乎所有的开发者们在开发生涯中都遇到过这种崩溃信息:
1 |
|
很简单,我们朝着一个地址为0x10675c060的实例对象发送了不属于这个对象的方法。这句话不是instance 0x10675c060 called unrecognized selector,而是消息发送错误。实际上,我们每一次对OC对象的方法调用都是一次消息的发送
消息发送异常
关于静态语言和动态语言
这里要先介绍计算机的开发语言的一个专业名词:动态语言和静态语言。确切的说,OC是一门动态语言。动态语言和静态语言两者的区别如下:
-
静态语言: 静态语言在运行前会进行类型判断,类的所有成员、方法都会在编译阶段确定好内存地址。类成员只能访问属于自己的方法和变量,像上面的调用代码无法通过编译,会直接引起编译器报错。但因为如此,静态语言结构规范、便于调试、且可以进行多样的性能优化。常见的静态语言包括java/C++/C等
-
动态语言:大部分的判断工作被推迟到运行时进行,类的成员变量、方法地址都在运行时确认。可以在运行时动态的添加类成员、方法等。具有较高的灵活性和可定制性、便于阅读,但方法通常无法进行内联等优化
两种语言孰优孰略本人不在这里做判断,但是要知道的是smalltalk是动态语言的鼻祖,更是OC发展的最大推动力。在smalltalk中,所有的东西都是对象(或者都应该被当做对象),例如表达式2 + 3被理解成向对象2发送了消息+,其中接收的参数是 3
消息发送
在前篇runtime-属性与变量中我们导入过runtime的头文件实现了一键归档功能,今天我们要导入另外一个文件:
在OC中,调用一个方法的格式如下:
1 |
|
在方法调用的时候,runtime会将上面的方法调用转换成一个C语言的函数调用,表示朝着davin发送了一个playWith:消息,并传入了friend这个参数:
1 |
|
那么在这个C语言函数中发生了什么事情?编译器是如何找到这个类的方法的呢?苹果开源了runtime的实现代码,其中为了高度优化性能,苹果使用汇编实现了这个函数(源码处于Source/objc-msg-arm.s文件下):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
即使不懂汇编,上面的代码通过注释后也足以让各位一窥究竟。从上述代码中我们可以看到一个方法调用过程中发生的事情,包括:
-
判断接收者是否为nil,如果为nil,清空寄存器,消息发送返回nil
-
到类缓存中查找方法,如果存在直接返回方法
-
没有找到缓存,到类的方法列表中依次寻找
查找方法实现是通过_class_lookupMethodAndLoadCache3这个奇怪的函数完成的:
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 |
|
上面就是一个方法调用的全部过程。主要分为三个部分:
-
查找是否存在对应的方法缓存,如果存在直接返回调用
为了优化性能,方法的缓存使用了散列表的方式,在下一部分会进行比较详细的讲述
-
未找到缓存,到类本身或顺着类结构向上查找方法实现,返回的method_t *类型也被命名为Method
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 |
|
如果在这个步骤中找到了方法的实现,那么将它加入到方法缓存中以便下次调用能快速找到:
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 |
|
如果在类自身中没有找到方法实现,那么循环获取父类,重复上面的查找动作,找到后再将方法缓存到本类而非父类的缓存中
-
未找到任何方法实现,触发消息转发机制进行最后补救
其中消息转发分为两个阶段,第一个阶段我们可以通过动态添加方法之后让编译器再次执行查找方法实现的过程;第二个阶段称作备援的接收者,就是找到一个接盘侠来处理这个事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
方法缓存
在上一篇runtime文章中笔者已经说过对于OC的每一个对象来说,本质上都是一个objc_class的结构体封装,在最新的runtime源码的objc-runtime-new.h中,objc_class的结构如下(笔者已经略去了大部分的函数):
1 2 3 4 5 6 7 8 9 10 11 12 |
|
结构一目了然,很明显cache存储着我们在方法调用中需要查找的方法缓存。作为缓存方法的cache采用了散列表,以此来大幅度提高检索的速度:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
在每次调用完未被缓存的方法时,下面的那段缓存方法的代码就会调用。苹果利用了sel的指针地址和mask做了一个简单的位运算,然后找到一个空槽存储起来。 以此我们可以推出从缓存中查找sel实现的代码CacheLookup,但是为了高度优化性能,苹果同样丧心病狂的使用汇编完成了查找的步骤,官方给出的注释足够我们大致看明白这段代码:
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 |
|
具体的源码可以从苹果开源这里下载,这个方法苹果已经注释的足够清晰了。
上面所有的操作都是对方法的缓存、查找操作,那么方法究竟是什么?在OC中方法被抽象成的数据类型是Method,如果了解并且使用过runtime的读者们可能了解这个类型,其结构如下:
1 2 3 4 5 6 |
|
-
method_imp方法的实现代码,你可以把它看做一个block。事实上,后者确实可以转换成一个IMP类型来实现某些黑魔法。
-
method_types方法的参数编码,什么意思?在属性与变量中我说过每一种数据类型有着自己对应的字符编码,这个表示方法返回值、参数的字符编码,比如-(void)playWith:(id)的字符编码为v@:@
-
method_name顾名思义,方法的名字。通常我们使用@selector()的方式获取一个方法的sel地址,这个被用来进行散列计算存储方法的imp实现。由于SEL类型采用了散列的算法,因此如果同一个类中存在同样名字的方法,那么就会导致方法的imp地址无法唯一化。这也是苹果不允许同名不同参数类型的方法存在的原因
消息转发
通常情况下,在我们调用不属于某个对象的方法的时候,我们的应用就会崩溃crash,比如笔者经历过好几次因为后台返回的NSNull类型导致了测试反馈应用闪退。通过上面的方法调用源码我们可以看到并不是没有找到方法实现就直接发生了崩溃,在崩溃之前编译器会进行消息转发机制,总共给了我们三次机会来避免这样的崩溃并尽可能的找到方法的响应者。
消息转发阶段
首先先看第一阶段。我们都知道,在iOS开发当中我们需要非常的注意用户体验。单纯的是因为数据类型错误而导致应用出现闪退,这样的处理会极大的影响使用app的用户。因此,我们可以通过class_addMethod这个函数来动态的添加这种错误的处理(类可以在objc_registerClassPair完成类的注册之后动态的添加方法,但不允许动态添加属性,参考category机制)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
在第二阶段最开始的时候,这时候已经默许了你并不想使用消息接收者来响应这个方法,所以我们需要找到消息接盘侠 —— 这并不是一件坏事。在iOS中不支持多继承,尽管我们可以通过协议和组合模式实现伪多继承。伪多继承和多继承的区别在于:多继承是将多个类的功能组合到一个对象当中,而伪多继承多个类的功能依旧分布在不同对象当中,但是对象彼此对消息发送者透明。那么,如果我们消息转发给另一个对象可以用来实现这种伪多继承。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
如果你依旧没有为这个方法找到另外一个调用者,那么阻止你app闪退的最后时刻到来了。runtime需要生成一个methodSignature变量来组装,这将通过调用消息接收者的-(NSMethodSignature *)methodSignatureForSelector:获取,这个变量包含了方法的参数类型、参数个数以及消息接收者等信息。接着把这个变量组装成一个NSInvocation对象进行最后一次的消息转发,调用接收者的-forwardInvocation:来进行最后的挽救机会。这意味着我们可以尽情的对invocation做任何事情,包括随意修改参数值、消息接收者等。我最常拿来干的事情就是减少数组的遍历工作:
1 2 3 4 5 6 7 8 9 10 |
|
总的来说整个消息发送的过程可以归纳成下面这张图:
消息发送全过程
虽然消息转发可以帮助我们显著的减少app的闪退率,但是在开发阶段千万不要加入这些特性。最好是在app申请上架的那个阶段再加,这样不至于app其他消息发送异常被我们忽略了。
消息机制黑魔法
上面笔者讲解了关于一个调用方法之中发生的事情,确实非常的复杂。同样的这些特性也非常值得我们去学习使用,runtime提供了一系列关于Method的方法给我们实现面向切面编程的工作。这些工作包括了替换原有方法实现,交换方法实现等等工作。
假设现在我需要一个圆角按钮,并且保证点击触发事件的范围必须要这个圆之内,那么通过一个UIButton+LXDRuntime的扩展来替换旧有-pointInside:withEvent:方法
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 |
|
那么当我需要我的按钮只响应圆形点击区域的时候,只需要设置button.roundTouchEnable = YES,就会自动实现了圆形点击的判断。除了上面的上面的方法替换,还有另一个常用的黑魔法是交换两个方法的实现。归功于Method的特殊结构,将方法名字sel跟代码实现imp分隔开来。你可以把imp当做是一个block代码块,而交换实现的操作就相当于把这两个block交换了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
上面的代码交换了name和age的实现,用图示来表示:
方法交换
应该不难看出,method_exchangeImplementations之所以被推崇的原因在于这种方式交换实现的时候不会导致原有的方法实现发生改变(从头到尾,age的IMP跟name的IMP都没有进行任何的修改),当然了,它的缺点也是非常明显的:
-
多人开发对同一个方法都进行方法替换/交换时,会使得业务逻辑复杂,非常的不利于调试
-
被交换的方法实现会直接的影响到所有该类的实例对象以及子类,不适用于单个对象的实现
可以说runtime提供的这些黑魔法都是双刃剑,合理的运用能让我们更加的强大。另外,除了Method的黑魔法,还要提到一个IMP相关的使用陷阱。上文说过,IMP跟block是非常相似的东西,前者可以跟函数指针强制转换,因此可以看做是一个特殊的block,同样的系统提供了两者相互转换的方法:imp_implementationWithBlock和imp_getBlock。按照上面说的,当调用方法转换成消息转发的时候,objc_msgSend自身已经存在了两个参数id object以及SEL aSelector,那么按照这种思路IMP和block的切换应该是这样的:
1 2 3 4 5 6 7 8 9 10 |
|
上面这段代码会crash的非常无厘头,提示你EXC_BAD_ACCESS错误。重要的事情说三遍:
block参数不存在SEL!!
block参数不存在SEL!!
block参数不存在SEL!!
上面的代码只要去掉了SEL aSelector这个参数,这段代码就能正常执行。
尾话
runtime对于每一个iOS开发者来说,都应该去了解。通过runtime的源码实现,我们可以看到苹果为了性能优化武装到牙齿的行为,也能看到我们书写代码深层之中不为人知的实现。作为runtime系列第二篇(第一篇),纠结了我很久才开始动工,期间看源码看的头都大了,但是确实对我在开发的认识以及代码的结构上有了更多的了解。最后,本文无代码,还是奉上苹果的runtime源码地址。