iOS开发之Runtime

作者: 效宇笑语 | 来源:发表于2017-05-29 00:56 被阅读245次

    在swift这门优雅的语言还没诞生之前,iPhone开发主要使用的是Object-C这门面向对象语言,OC是由C实现的超集(大部分的OC库都有对应的C版本的实现例如Foundation和CoreFoundation),并不需要像JAVA那样运行在虚拟机中,而且可以很好的结合C和C++代码提高程序的性能,除了面向对象的特性外,OC这门语言还具备了smalltalk的消息机制,当我们调用了一个对象的方法或者说函数时,其实是向那个对象发送了一条消息。
    OC是一门动态语言,也就是说在OC运行时,有一个运行时系统,运行时系统的作用就是执行编译后的代码,动态的加载类,向对象发送消息,运行时系统更像是OC的操作系统。
    那么什么是动态呢?我们来看看下面这段代码:

    Person *p = [[Person alloc] initWithName:@"Tom" andAge:15];
    [p performSelector:@selector(sayHello)]; //虽然Person类中并没有这个sayHello方法,依然可以编译通过
    

    这段代码在编译阶段并不能够判断出Person对象是否存在sayHello这个方法(尽管会给出警告,但并不报错),可以通过编译阶段,但是会在运行时崩溃。也就是说OC语言的动态特性使得类型信息在运行时被检查,而不是编译时。同时Class也是动态创建的,也就是说你可以在程序运行的时候为程序新增类、对象、以及方法和方法体等。本文将介绍runtime原理和实际应用:

    1. 消息机制

    2. 消息转发

    3. 属性定义

    4. 实际使用

    在了解Runtime机制之前,先来简单了解一下NSObject这个公共父类(并不是所有的类都继承自NSObject,例如NSProxy):
    
    @interface NSObject <NSObject> {
    Class isa OBJC_ISA_AVAILABILITY;
    }
    + (void)load;
    + (void)initialize;
    - (instancetype)init
    #if NS_ENFORCE_NSOBJECT_DESIGNATED_INITIALIZER
    NS_DESIGNATED_INITIALIZER
    #endif
    .
    .
    .
    

    可以看到,NSObject有一个成员变量叫做isa,它是Class类型的,这个Class其实是一个结构体:typedef struct objc_class *Class,来研究一下这个结构体:

    struct objc_class {
        Class isa OBJC_ISA_AVAILABILITY;
        #if !__OBJC2__
            Class super_class ;
            const char *name ;
            long version ;
            long info ;
            long instance_size ;
            struct objc_ivar_list *ivars ;
            struct objc_method_list **methodLists ;
            struct objc_cache *cache ;
            struct objc_protocol_list *protocols ;
        #endif
    } OBJC2_UNAVAILABLE;
    

    结构体的成员中包含了另一个isa的引用,其它的结构成员在OC2.0之后不可用,但是,依然可以从中获取重要的一些信息,例如父类、类名、对象大小、变量列表、方法列表、该类遵循的协议等都是以列表的形式保存在objc_class中,其中还有一个缓存cache,用于缓存最近使用到的消息,该文件中还包括对其它结构体的定义,例如方法、类目、属性等。(可以通过#import<objc/runtime.h>查看)基础知识先说到这里,来看看消息机制。

    1.消息机制

    什么是消息机制,举例来说:

    Math m = [[Math alloc]init];
    [m sum:5 y:6];
    

    通常会说调用了m对象的sum方法,但编译器会将函数调用转变为向对象发送一条消息,:
    objc_msgSend(m , sum ,5 , 6);
    现在应该说是像m对象发送了一条sum消息更合适。

    首先应该了解SEL和IMP,我们暂且可以这么区分,一个方法有方法名和方法体,SEL指的是方法名(可以这么理解但是实际叫做<b>选择器</b>,下文会提到),而IMP指的是方法体也就是对应的实现,在C语言中例如调用一个方法的话,编译器会将方法调用转换为汇编指令call 并带一个地址操作数,程序计数器会将下一条要执行的指令地址设为这个操作数,并将返回地址压入栈中。在OC中SEL的定义为:typedef struct objc_selector *SEL;而IMP的定义为:typedef void (*IMP)(void /* id, SEL, ... */ );

    在运行时,消息会绑定到对应的实现上:

    • 首先会根据对象m的选择器sum查找对应的方法实现(IMP)

    • 传递参数(包括消息的接受对象、选择器),执行方法

    • 将函数的返回结果返回

      当一个新的对象被创建时,系统要为该对象分配对应的内存,实例变量被初始化,还记得上面说的isa么,isa这个指针变量将被指向该对象的<b>类结构</b>,之后通过super_class可以获取该对象的父类,进而整个继承链的类结构信息就都可以获取了。编译器负责将类、对象构建为具有运行时信息的结构(包括isa、super_class、选择器转发表等)。另一个重要的信息就是转发表,可以看做是一个以SEL为键以IMP地址为值的映射。
      当向一个对象发送消息时,objc_msgSend会去该对象的类结构体(isa指针指向的结构)中查找转发表,如果能够定位指定的选择器的话,就会执行对应地址处的方法体,如果找不到,会沿着继承链一层层去查找每一个父类的转发表直到NSObject类。如下图所示:

    23-22-32.jpg
    这样一层层查找会有损程序效率,于是就有了上面提到的缓存,当第一次调用了某个方法,系统便会将方法和对应的实现地址缓存起来(系统就是如此霸道,一旦第一次使用了某个方法,系统会认为你还想继续使用),在查找转发表之前,会搜一搜这个缓存。
    用上面的Math来说明这个过程,当我们创建m对象时,系统会为m 对象分配内存,将isa 指针指向Math 类结构,并配置转发表,当我们向m 发送sum 消息时,objc_msgSend 会去isa 所指的结构体中查找转发表,去找啥?去找selector 为sum 的地址,如果找到,就执行对应地址的方法体,然后将selector 缓存起来,如果找不到,就沿着superClass 链向上查找转发表,如果到了NSObject 这一层还没有找到,对不起,程序就抛异常了。
    

    objc_msgSend至少需要两个参数:接收消息的对象和选择器,这两个参数是在编译的时候被插入的,在OC中我们定一个方法,并不需要显示的指定这两个参数。
    刚才说了那么多selector和IMP地址,那么如果我们不想通过消息机制来调用一个函数应该怎么办,可以通过methodForSelector:SEL来将方法的实现取出来:

    Student *p = [[Student alloc] initWithName:@"Tom" andAge:15];
    typedef NSInteger(*sum)(id ,SEL , NSInteger , NSInteger);
    sum s = (sum)[p methodForSelector:@selector(sum:y:)];
    NSLog(@"%d",s(p , @selector(sum:y:) , 10 , 5));
    

    输出结果为15。注意methodForSelector返回的是IMP结构体,需要转换为指定函数指针类型,并保证前两个参数依然为接受对象和方法选择器。

    2. 消息转发

    有没有过这样的经历,当我们试图调用一个不存在的方法是,会报以下的错误:unrecognized selector sent to instance 0x1004001a0,很多iOS初学者不知道这句话是什么意思,是说地址为0x1004001a0的对象没有定义相关的方法选择器,因此不能够被识别,通过查看该地址处的对象就可以找到出错的原因,也可以根据debug的crash信息定位出错的对象和信息。那么如果一个对象无法响应某个消息(就是上面说的没有定义某个函数),运行时向该对象发送了这个未定义的消息,程序就一定被判死刑了么,其实不一定,当一个对象无法响应某个message的时候,系统会给你三次机会来动态的为一个对象增加一个处理消息的实现或者实现消息的转发,让我们来看看第一种方式:

    动态决议

    + (BOOL)resolveClassMethod:(SEL)sel
    + (BOOL)resolveInstanceMethod:(SEL)sel
    

    这两个方法都是动态的为方法选择器添加方法实现又叫做<b>动态方法决议</b>,当调用了对象的一个不存在的方法选择器或者该方法选择器没有对应的方法体,消息机制会调用这两个方法来决议(注意:如果消息机制沿着继承连找不到对应的selector和IMP之间的映射时才会调用,也就是说只有调用了类或者对象不存在的方法体时才会尝试决议),例如:

    //Person 类
    #import <Foundation/Foundation.h>
    @interface Person : NSObject
    @property(nonatomic , strong)NSString *name;
    @property(nonatomic , assign)NSInteger age;
    
    - (id)initWithName:(NSString *)name andAge:(NSInteger)age;
    - (void)say;
    - (NSInteger)sum:(NSInteger)x y:(NSInteger)y;
    + (void)sayHello;
    @end
    

    对应的implement为:

    // implement
    #import "Person.h"
    @implementation Person
    - (id)initWithName:(NSString *)name andAge:(NSInteger)age {
        if(self = [super init]){
            self.name = name;
            self.age = age;
        }
        return self;
    }
    + (void)sayHello {} //1
    + (BOOL)resolveClassMethod:(SEL)sel { //2
        NSLog(@"%@",NSStringFromSelector(sel));
        if(sel == @selector(sayHello)) {
                return YES;
        }
        return [Person resolveClassMethod:sel];
    }
    
    + (BOOL)resolveInstanceMethod:(SEL)sel {
        NSLog(@"%@",NSStringFromSelector(sel));
        return [super resolveInstanceMethod:sel];
    }
    @end
    

    以类方法sayHello 为例,此时已经实现了sayHello,所以并不会调用resolve方法,当我们把1处的代码删除后,程序会崩溃,如何动态为类方法sayHello添加方法体呢?
    我们将2处的方法修改为:

    + (BOOL)resolveClassMethod:(SEL)sel {
        if(sel == @selector(sayHello)) {
            class_addMethod([NSObject class], @selector(sayHello),         (IMP)sayHelloDynamic, "v@:");//①
            return YES;
        }
        return [super resolveClassMethod:sel];
    }
    

    然后添加如下代码:

    void sayHelloDynamic(id target , SEL sel) {
        printf("Hello world\n");
    }
    

    执行结果为:Hello world。(此处有疑问:上面代码的①处,必须指定为NSObject的类对象,如果是Person的话,通过class_getClassMethod得到的结果为nil,也就是说添加类方法不成功,这里还需要继续调查,如果有知道的读者可以留言。)
    resolveClassMethod动态添加类方法,而resolveInstanceMethod动态添加实例方法,网上大多数教程都在解释后面这个方法,想必也是因为resolveClassMethod添加类方法失败。

    消息转发

    当通过继承连定位不到对象相关消息的实现,同时resolveInstanceMethod对应的selector返回NO的话,系统会尝试消息转发(按照文档的说法,决议优先消息转发,决议与消息转发正交,也就是如过对应的selector在决议方法中返回true,消息转发不会被调用)。可以将消息转发看做是处理不存在消息的第二层保护。如果向一个对象发送了一个它不能处理的消息时,运行时系统会向forwardInvocation:发送一个消息,并传递NSInvocation对象,该对象可以看做是一个方法调用的包装(消息的响应对象、selector、参数、返回值),通过重写该方法就可以获得一个消息转发的机会。
    还是上面的Person类,如果我们现在调用Person对象的say方法,程序一定崩溃,让我们在implement中加入以下代码:

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

    但是单单是重写了forwardInvocation方法还是不够的,还需要重写methodSignatureForSelector:方法来为forwardInvocation:的anInvocation参数提供必要的信息,代码如下:

    - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
        NSLog(@"----%@",NSStringFromSelector(aSelector));
        if([self respondsToSelector:aSelector]) {
            return [super methodSignatureForSelector:aSelector];
        } else {
            [self noMessage:aSelector];
        return [NSMethodSignature signatureWithObjCTypes:"v@:"]; }
    }
    
    - (void)noMessage:(SEL) sel{
        NSLog(@"No this function %@",NSStringFromSelector(sel));
    }
    

    我们使用signatureWithObjCTypes创建NSMethodSignature对象,这里需要传一个参数,就是函数的编码类型,由返回值类型、参数类型决定,可以参看官方的图解:


    23-35-44.jpg

    例如我们定一个函数void sum(int x , int y)那么这个函数的编码就为"vii",如果是void msgHand(id target , SEL selector)这个函数的编码为"v@:"这种格式是消息实现体IMP常使用的格式。
    以上代码我们并没有转发消息,而是将不能处理的消息打印出来并swallow掉,如果想实现转发的话,可以转换为如下代码,只需要修改forwardInvocation的代码就可以了:

    - (void)forwardInvocation:(NSInvocation *)anInvocation {
        if (anInvocation.selector == @selector(say)){
            Student *stu = [[Student alloc] init];
            [anInvocation invokeWithTarget:stu];
        }
    }
    

    这样就把消息转发给了stu对象了,可以看出来,OC的对象可以作为消息转发的中心,也可作为错误消息的垃圾站,如上述实现。

    3. 属性定义

    当编译器遇到属性声明时(@property),将会生成一个关于此属性的原型数据,我们可以通过系统的api获取一个类、协议中的属性以及其对应的原型。

    typedef struct objc_property *objc_property_t;
    

    属性也是结构体指针,但是该结构体不可见,只能通过相关函数获取内部的信息。
    获取一个类和协议的全部属性:

    objc_property_t *class_copyPropertyList(Class cls, unsigned int *outCount)//获取一个类的全部属性
    objc_property_t *protocol_copyPropertyList(Protocol *proto, unsigned int *outCount)//获取协议中的全部属性
    const char *property_getName(objc_property_t property) //返回属性名
    const char *property_getAttributes(objc_property_t property) //返回属性的编码类型信息
    

    以下函数获取Person类的全部属性和属性的类型信息:

    - (void)demo {
        unsigned int num;
        objc_property_t *properties = class_copyPropertyList([self class], &num); //1
        for (int i = 0 ; i < num; i++) { //2
            objc_property_t one = properties[i];
            NSString *attrName = [NSString stringWithCString:property_getName(one)       encoding:NSUTF8StringEncoding]; //3
            NSString *typeString = [NSString stringWithCString:property_getAttributes(one) encoding:NSUTF8StringEncoding]; //4
            NSLog(@"attr is %@ , type is %@",attrName , typeString);
        }
        free(properties);
    }
    

    1 处获取Person类的全部属性并保存在properties数组中,并将数组长度保存在num中。
    2 循环遍历properties
    3 4 获取属性名和属性的编码类型信息
    输出结果:

    attr is name , type is T@"NSString",&,N,Gname,V_name
    attr is age , type is Tq,N,V_age
    

    当然我们可以在程序运行的时候动态地添加属性:

    BOOL class_addProperty(Class cls, const char *name, const objc_property_attribute_t *attributes, unsigned int attributeCount)
    

    属性的类型信息:
    以T开始后接类型的@类型加一个',',由V_属性名结束,中间就是该属性的描述符,用,号隔开。附赠一张苹果官方的属性类型编码:


    23-28-28.jpg

    4. 实际使用

    Runtime的使用比较多样也比较灵活,但是比较流行的用法就是method swizzling,也就是互换方法体,如下图所示在进行method swizzling前后selector和IMP之间的关系:

    Group 2.png Group 3.png

    交换方法体之后的对应关系,在实际中有什么作用呢,例如项目开发了一大半,突然有一个采集数据的需求,需要每次用户进入页面都要统计每个页面进入的次数,由于项目已经接近尾声,再假如说没有做关于VC的同一调用接口,一个个页面修改起来就会很麻烦,如果我们能够在所有的VC执行viewDidAppear 时做一个其它的事情还不用每个页面都修改这是最好的办法,那么method swizzling就很适合你:

    #import <UIKit/UIKit.h>
    
    @interface UIViewController (Swizzle)
    
    @end
    
    #import "UIViewController+Swizzle.h"
    #import <objc/runtime.h>
    @implementation UIViewController (Swizzle)
    + (void)load{
        SEL selDidAppear = @selector(viewDidAppear:);
        Method impDidAppear = class_getInstanceMethod([self class], selDidAppear);
        SEL selLog = @selector(logVC:);
        Method impLog = class_getInstanceMethod([self class], selLog);
        method_exchangeImplementations(impDidAppear, impLog);
    }
    
    - (void)logVC:(BOOL) nouse {
        NSLog(@"进入了页面 %@", NSStringFromClass([self class]));
        [self logVC:nouse];
    }
    @end
    

    这样VC的viewDidAppear和logVC的方法实现就交换了,当系统调用viewDidAppear实际调用的是logVC的方法体,而调用logVC实际走的是viewDidAppear。在实际中的应用还有很多例如数据统计、动态加载代码、为类目添加属性等。
    项目代码:<a href='https://github.com/ChinaPicture/iOS-Runtime.git'>gitHub-iOSRuntime</a>

    相关文章

      网友评论

        本文标题:iOS开发之Runtime

        本文链接:https://www.haomeiwen.com/subject/jfhkfxtx.html