iOS--RunTime学习笔记(一)

我们都知道OC是一门动态语言,那么什么是动态语言呢?动态语言,是指程序在运行时可以改变其结构:新的函数可以被引进,已有的函数可以被删除,类型的检查在运行时进行。学习了OC的动态特性以后,我们就会知道,为什么会说OC是一门动态语言。

OC具有相当多的动态特性,经常被提到的有:动态类型,动态识别,动态绑定,动态加载。

动态类型

动态类型特性能使程序直到执行时才确定对象所属类型。比如说id类型,id类型即通用的对象类型,可以指向任何对象。结合isKindOfClass方法,在确定对象为某个类的成员后,再安全的进行强制转换,执行对应的代码。

动态绑定

基于动态类型,在某个实例对象的类型被确定后,该对象对应的属性和响应的消息也被完全确定。这样就使得程序直到执行时才确定对象要调用的实际方法。在继续之前,需要明确OC中消息的概念。由于OC的动态特性,在OC中其实很少提及“函数”概念,传统的函数一般在编译时就已经把参数信息和函数实现打包到编译后的源码中了,而在OC中最常用的是消息机制。

OC中的消息发送机制(补充)

在C语言中,我们调用函数时,必须先声明函数(或者自上而下),而实际上,声明函数的过程就是获取函数地址,调用函数的过程就是直接跳到地址执行,代码在被编译器解析、优化后,就成为了一堆汇编代码,然后连接各种库,完了生成可执行的代码(即是静态的)。

在OC中,消息发送机制则是Runtime通过selector快速查找IMP的过程,有了函数指针就可以执行对应的方法实现。所有的[object message]都会转换为objc_msgSend(object, @selector(message))(如果是有参数的方法,可以在后面用“,”分隔对参数进行拼接),objc_msgSend方法又会调用class_getMethodImplementation获取IMP。

这里需要普及一下什么是selector和IMP。
selector:OC在编译时,会根据方法的名字生成一个用来区分这个方法的唯一的一个ID,本质上就是一个字符串。只要方法名称相同,就算参数的类型不同,它们的ID也是相同的。
IMP:实际上就是一个函数指针,指向方法实现的首地址。

运行时类中的方法可能会增加,需要先做读操作加锁,使得方法查找和缓存填充成为原子操作。添加category会刷新缓存,之后如果旧数据又被重新填充到缓存中,category添加操作就会被忽略掉。

获取IMP的逻辑整理和流程:

  1. 检查selector是否需要忽略,如果selector是需要被忽略的垃圾回收用到的方法,则将IMP结果设为_objc_ignored_method,这是个汇编程序入口,可以理解为一个标记。对此种情况进行缓存填充操作后,跳到第8步,否则执行下一步。
  2. 检查target是否为nil,如果是nil就直接cleanup,然后return。
  3. 查找当前类中的缓存,如果命中缓存获取到了IMP,则直接跳到第8步,否则执行下一步。
  4. 在当前类中的方法列表(method list)中进行查找,也就是根据selector查找到Method后,获取Method中的IMP,并填充到缓存中。查找过程比较复杂,会针对已经排序的列表使用二分法查找,未排序的列表则是线性遍历(顺序查找)。如果成功查找到Method对象,则直接跳到第8步,否则执行下一步。
  5. 在继承层级中递归向父类中查找,情况跟上一步类似,也是先查找缓存,缓存中没有就查找方法列表。查找成功,进入第8步,查找失败,进入下一步。
  6. 进入动态方法解析,这是消息转发前的最后一次机会。此时释放读入锁,接着间接的发送+resolveInstanceMethod(如果查找的是实例方法,调用该方法)或+resolveClassMethod(如果查找的是类方法,调用该方法)消息。这相当于告诉程序员,赶紧用runtime给类里这个selector弄个对应的IMP吧,因为此时锁已经unlock了所以不会缓存结果。这些工作都是在非线程安全下进行的,完成后需要回到第1步再次查找IMP。
  7. 此时不仅没查到IMP,动态方法解析也不奏效,即将进入消息转发。
  8. 读操作解锁,并将之前找到的IMP返回。

查找类方法的IMP的流程和查找实例方法的一样,不过实例方法的IMP是在类的缓存和方法列表里面查找,类方法的IMP是在元类的缓存和方法列表里面查找。关于元类的定义,建议大家参考这篇文章,写的很详细。

OC中的消息转发机制(补充)

当发送的消息在类的缓存和方法列表里面查找不到时,为了防止crash,还有3种补救方法:

  1. 动态方法解析,就是上面的第6步操作。对应的具体方法是+(BOOL)resolveInstanceMethod:(SEL)sel和+(BOOL)resolveClassMethod:(SEL)sel,当查找的方法是实例方法时调用前者,当查找的方法是类方法时,调用后者。

    、、、
    void eat(id target, SEL sel, NSString *name) {
    }
    
    + (BOOL)resolveInstanceMethod:(SEL)sel
    {
        if (sel == @selector(name)) {
          class_addMethod(self, sel, (IMP)eat, "v@:@");
          return YES;
        }
        return [super resolveInstanceMethod:sel];
    }
     、、、
    

这里需要说明一下class_addMethod的第四个参数,它是一个const char * _Nullable types类型,Nullable代表这个参数可以为空,char *表示这是一个char型指针,所以不要试图用@“”。看我们传进去的参数v@:@,有人可能有点迷糊,但其实很好理解:v代表这是一个返回值为void的函数,@代表这是一个参数,:代表这是一个SEL类型,所以跟我们的eat方法对应起来就是v@:@。关于这些类型的详细对应关系,大家可以在官方文档中查找。

  1. 转移消息的接受者。通过方法-(id)forwardingTargetForSelector:(SEL)aSelector可以转移消息的接收者,当返回非self/非nil时,消息被转给新对象执行。如果该对象实现了该方法(不管该方法是私有还是公有,其实OC是没有严格意义上的私有方法的,因为有runtime的存在,你想要调用总可以办到的),就直接让那个对象来处理消息。这种方式只是转移消息的接收者,不能对传递的消息做修改。

     、、、
    - (id)forwardingTargetForSelector:(SEL)aSelector{
       if(selector == @selector(name)){
         Student *s = [[Student alloc] init];
         if([s respondsToSelector:@selector(name)]){
           return s;
         }
       }
       return[super forwardingTargetForSelector:aSelector];
    }
    、、、
    
  2. 完整消息转发。如果上述方式没有对转发的消息做处理,那么系统就会走完整的转发流程。系统会先通过-(NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector方法,找到转发的消息的方法签名,然后再通过-(void)forwardInvocation:(NSInvovation *)anInvocation来转发消息。注意,前面一个方法不能返回nil,如果返回nil,第二个方法是不会执行的。通过这种方式转发的消息,我们不仅可以转换消息的接收者,转发给多个对象,还可以对转发的消息做相应的修改。

       - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
           if (aSelector == @selector(eatChicken:)) {
               // 这个方法的参数对应的type和上面提到的TypeEncoding的对应关系是一样的。signatureWithObjCTypes的参数应该与aSelector的返回值和参数的类型和个数相对应,比如eatNothing是一个无返回值无参数的方法,所以type就对应为v@:(可能有的童鞋把type写成v@:@也能成功运行,但是成功运行不代表就正确。如果你在forwardInvocation中对NSInvocation的returnvalue和argument进行操作的话,就会报错。有可能会报参数越界,也有可能会什么错误信息都没有。如果出现这种情况的童鞋不妨检查一下自己的type和selector是否一一对应)。
               NSMethodSignature *sign = [NSMethodSignature signatureWithObjCTypes:"v@:"];
               // 为了防止上面的错误出现,我们也可以采取这种写法,EatFood是声明了aSelector方法的类。
               // sign = [[EatFood alloc] init] methodSignatureForSelector:aSelector];
               return sign;
           }
           NSMethodSignature *sign = [super methodSignatureForSelector:aSelector];
           return sign;
       }
    
       - (void)forwardInvocation:(NSInvocation *)anInvocation{
           NSNumber *number = @3;
    
           // 修改转发消息的参数,注意因为有两个隐藏参数的存在,所以index应该从2开始算起
           [anInvocation setArgument:&number atIndex:2];
    
           // 修改转发的消息
           anInvocation.selector = @selector(eatNothing);
    
           // 把消息转发给多个对象
           if ([EatChicken instancesRespondToSelector:anInvocation.selector]) {
               [anInvocation invokeWithTarget:[[EatChicken alloc] init]];
           }
          if ([EatFood instancesRespondToSelector:anInvocation.selector]) {
               [anInvocation invokeWithTarget:[[EatFood alloc] init]];
           }
       }        
       、、、
    

如果上述3个方法都没有来处理这个消息,就会进入NSObject的-(void)doesNotRecognizeSelector:(SEL)aSelector方法中,抛出异常。
如果想要通过运行时为类添加方法,使用第一种方案;如果想要把消息转发给另外另外一个类的对象时,使用第二种方案;如果想要把消息转发给多个类的对象(OC的多继承也可以通过该方法实现)时,使用第三种方案,该方案还可以对消息的selector和参数进行修改。步骤越往后,处理消息的代价就越大。

那么说了那么多,消息转发在实际开发中究竟有什么应用呢?相信大家在调试接口的时候都遇到过这种问题,后台开发接口的时候,大家说好的没有数据返回空数组,空字典或者空字符串,但是他啪给你返回来一个NSNull,然后在你开开心心去用dic[@"happy"]去取值的时候给你来一个大大的崩溃,很心酸有没有。这时候我们可以怎么做呢,给NSNull添加一个category,然后重写methodSignatureForSelector和forwardInvocation来处理异常情况。 (PS:实现这两个方法的时候会报一个警告:Category is implementing a method which will also be implemented by its primary class。意思是类目里面重写了基类里面的方法,苹果是不建议在类目里面重写类的方法的,因为类目里重写的方法作用域是全局的,有可能会导致一些未知的错误,坑队友啊。而且有一些框架里面也有可能会重写这个方法,这样哪个类目里的方法会被执行就是未知的。在类目里面声明一些方法的时候,尽量带上前缀,以防止跟系统或者三方框架里面的方法重名。另外,重写方法的话尽量使用继承的方式,继承的作用域只在于子类,不会对父类产生影响。
、、、

      @implementation NSNull (Forward)

      - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
      {
           NSMethodSignature *sign = [super methodSignatureForSelector:aSelector];
          if (sign == nil) {
              sign = [NSMethodSignature signatureWithObjCTypes:"@^v^c"];
          }
          return sign;
      }

      - (void)forwardInvocation:(NSInvocation *)anInvocation
      {
          if ([self respondsToSelector:anInvocation.selector]) {
              [anInvocation invokeWithTarget:self];
          }
      }

      @end
      、、、
通过运行时避免动态绑定来优化方法调用的一个小tips

如果需要频繁对一个消息进行多次调用,而且我们希望节省每次调用方法都要发送消息的开销时,我们可以通过取得方法的地址,并且直接像调用函数一样调用该方法来达到目的。

利用NSObject类中的methodForSelector:方法,我们可以获得一个指向方法实现的指针,并且可以使用该指针直接调用方法实现。methodForSelector:返回的指针和赋值的变量类型必须完全一致,包括方法的参数类型和返回值类型都在类型识别的考虑范围中。
下面的例子展示了怎么使用指针来调用setFilled:方法的实现:

 void (*setter) (id, SEL, BOOL);
 int i;
 setter = (void (*) (id, SEL, BOOL))[target methodForSelector:@selector(setFilled)];
 for (int i = 0; i < 1000, i++){
     setter(targetList[i], @selector(setFilled:), YES);
 }
 、、、

方法指针的第一个参数是接收消息的对象,第二个参数是方法的SEL,这两个参数在方法中是隐藏参数,但使用函数的形式来调用方法时必须显式的给出。使用methodForSelector:来避免动态绑定将减少大部分消息的开销,但是这只有在指定的消息被重复发送给多个不同类的对象(如果是多次发送给同一个对象,效果不是很明显,因为通过消息的发送机制我们知道,调用过的method会被添加到类的方法的cache列表里,从cache列表查询和直接通过指针调用区别并不是很大,不过开销肯定是减少了的)很多次时才有意义,例如上面的for循环。

动态识别

动态识别常用的几个方法:
- (BOOL)isKindOfClass:(__unsafe_unretained Class)是否是某个类或者这个类的子类。
- (BOOL)isMemberOfClass::(__unsafe_unretained Class)是否是一个类的实例。
- (BOOL)respondsToSelector:selector类中是否有这个方法。

动态加载

OC程序可以在运行时链接和载入新的类和范畴类。新载入的类和在程序启动时载入的类并没有区别。动态加载可以用在很多地方。例如,系统配置中的模块就是被动态加载的。在Cocoa环境中,动态加载一般被用来对应用程序进行定制。您的程序可以在运行时加载其它程序员编写的模块--和Interface Build载入定制的调色板以及系统配置程序载入定制的模块的类似。这些模块通过您许可的方式扩展了您的程序,而您无需自己来定义或实现。您提供了框架,而其它的程序员提供了实现。

OC可以动态的创建一个类吗?
答案:可以。

  1. 使用objc_allocateClassPair函数为新的类分配存储空间,参数分别是你要继承的类,新的类的名称和你要分配的内存大小。
  2. 使用class_addMethod为类增加方法,使用class_addIvar为类增加实例变量。
  3. 用objc_registerClassPair函数注册这个类,以便它能被别人使用。

推荐阅读更多精彩内容