iOS技术图谱之runtime

一、Runtime原理

1、Runtime是什么?

runtime 简称运行时,是系统在运行的时候的一些机制,其中最主要的是消息机制。它是一套比较底层的纯 C 语言 API, 属于一个 C 语言库,包含了很多底层的 C 语言 API。我们平时编写的 OC 代码,在程序运行过程时,其实最终都是转成了 runtime 的 C 语言代码。

简单来说, Runtime 是一个库,这个库使我们可以在程序运行时创建对象、检查对象,修改类和对象的方法。

高级编程语言想要成为可执行文件需要先编译为汇编语言再汇编为机器语言,机器语言也是计算机能够识别的唯一语言,但是OC并不能直接编译为汇编语言,而是要先转写为纯C语言再进行编译和汇编的操作,从OC到C语言的过渡就是由runtime来实现的。然而我们使用OC进行面向对象开发,而C语言更多的是面向过程开发,这就需要将面向对象的类转变为面向过程的结构体。

2、Runtime怎么工作?

要了解Runtime怎么工作,我们先要知道类和对象在OC中是怎么定义的。

(1)Class和Object

在oc中,Class被定义为指向objc_class 结构体的指针,定义如下:

typedef struct objc_class *Class;

那objc_class结构体又是什么样子的呢?

struct objc_class {
    Class isa;                                // 实现方法调用的关键
    Class super_class;                        // 父类
    const char * name;                        // 类名
    long version;                             // 类的版本信息,默认为0
    long info;                                // 类信息,供运行期使用的一些位标识
    long instance_size;                       // 该类的实例变量大小
    struct objc_ivar_list * ivars;            // 该类的成员变量列表
    struct objc_method_list * methodLists;   // 方法定义的列表
    struct objc_cache * cache;                // 方法缓存
    struct objc_protocol_list * protocols;    // 协议列表};

可以看到,一个类保存了自身所有的成员变量( ivars )、所有的方法( methodLists )、所有实现的协议( objc_protocol_list )

接下来我们看一下objc_object是如何定义的:

struct objc_object {
    Class isa;
};
typedef struct objc_object *id;

这里看到了我们熟悉的 id ,一般我们用它来实现类似于 C++ 中泛型的一些操作,该类型的对象可以转换为任意一种对象。在这里 id 被定义为一个指向 objc_object 的指针。说明 objc_object 就是我们平时常用的对象的定义,它只包含一个 isa 指针。

也就是说,一个对象唯一保存的信息就是它的 Class 的地址。当我们调用一个对象的方法时,它会通过 isa 去找到对应的 objc_class,然后再在 objc_class 的 methodLists 中找到我们调用的方法,然后执行。

再说说 cache ,因为调用方法的过程是个查找 methodLists 的过程,如果每次调用都去查找,效率会非常低。所以对于调用过的方法,会以 map 的方式保存在 cache 中,下次再调用就会快很多。

2、Meta Class 元类

上一小节讲了 Objective-C 中类和对象的定义,也讲了调用对象方法的实现过程。但还留下了许多问题,比如调用一个对象的类方法的过程是怎么样的?还有 objc_class 中也有一个 isa 指针,它是干嘛用的?

现在划重点,在 Objective-C 中,类也被设计为一个对象。

其实观察 objc_class 和 objc_object 的定义,会发现两者其实本质相同(都包含 isa 指针),只是 objc_class 多了一些额外的字段。相应的,类也是一个对象,只是保存了一些字段。

既然说类也是对象,那么类的类型是什么呢?这里就引出了另外一个概念 —— Meta Class(元类)。

在 Objective-C 中,每一个类都有对应的元类。而在元类的 methodLists 中,保存了类的方法列表,即所谓的「类方法」。并且类的 isa 指针指向对应的元类。因此上面的问题答案就呼之欲出,调用一个对象的类方法的过程如下:
(1)通过对象的isa指针找到对应的类
(2)通过类的isa指针找到对应的元类
(3)在元类的MethodLists中,找到对应的方法,执行

(objc_object结构体中的isa -> objc_class结构体,并通过这个结构体的isa指针 -> 元类中的methodlists)

注意:上面类方法的调用过程不考虑继承的情况,这里只是说明一下类方法的调用原理,完整的调用流程在后面会提到。

这么说来元类也有一个 isa 指针,元类也应该是一个对象。的确是这样。那么元类的 isa 指向哪里呢?为了不让这种结构无限延伸下去, Objective-C 的设计者让所有的元类的 isa 指向基类(比如 NSObject )的元类。而基类的元类的 isa 指向自己。这样就形成了一个完美的闭环。

下面这张图可以清晰地表示出这种关系。


同时注意 super_class 的指向,基类的 super_class 指向 nil 。

3、Method

上面讲到,「找到对应的方法,然后执行」,那么这个「执行」是怎样进行的呢?下面就来介绍一下 Objective-C 中的方法调用。

先来看一下 Method 在头文件中的定义:

typedef struct objc_method *Method;

struct objc_method {
    SEL method_name;    
    char * method_types;
    IMP method_imp;
};

Method 被定义为一个 objc_method 指针,在 objc_method 结构体中,包含一个 SEL 和一个 IMP ,同样来看一下它们的定义:

// SEL
typedef struct objc_selector *SEL;

// IMP
typedef id (*IMP)(id, SEL, ...);

(1)先说一下 SEL 。 SEL 是一个指向 objc_selector 的指针,而 objc_selector 在头文件中找不到明确的定义。

SEL sel = @selector(viewDidLoad);NSLog(@"%s", sel);         
 // 输出:viewDidLoadSEL 
sel1 = @selector(viewDidLoad1);
NSLog(@"%s", sel1);         
// 输出:viewDidLoad1

可以看到, SEL 不过是保存了方法名的一串字符。因此我们可以认为, SEL 就是一个保存方法名的字符串。
由于一个 Method 只保存了方法的方法名,并最终要根据方法名来查找方法的实现,因此在 Objective-C 中不支持下面函数重载。

(2)再来说 IMP 。可以看到它是一个「函数指针」。简单来说,「函数指针」就是用来找到函数地址,然后执行函数。

这里要注意, IMP 指向的函数的前两个参数是默认参数, id 和 SEL 。这里的 SEL 好理解,就是函数名。而 id ,对于实例方法来说, self 保存了当前对象的地址;对于类方法来说, self 保存了当前对应类对象的地址。后面的省略号即是参数列表。

(3)到这里, Method 的结构就很明了了。 Method 建立了 SEL 和 IMP 的关联,当对一个对象发送消息时,会通过给出的 SEL 去找到 IMP ,然后执行。

在 Objective-C 中,所有的方法调用,都会转化成向对象发送消息。发送消息主要是使用 objc_msgSend 函数。看一下头文件定义:

id objc_msgSend(id self, SEL op, ...);

可以看到参数列表和 IMP 指向的函数参数列表是相对应的。 Runtime 会将方法调用做下面的转换,所以一般也称 Objective-C 中的调用方法为「发送消息」

[self doSomething];
objc_msgSend(self, @selector(doSomething));

上面看到 objc_msgSend 会默认传入 id 和 SEL 。这对应了两个隐含参数, self 和 _cmd 。这意味着我们可以在方法的实现过程中拿到它们,并使用它们。下面来看个例子:

- (void)testCmd:(NSNumber *)num {    
    NSLog(@"%ld", (long)num.integerValue);
    
    num = [NSNumber numberWithInteger:num.integerValue-1];    
    if (num.integerValue > 0) {
        [self performSelector:_cmd withObject:num];
    }
}

尝试调用:

[self testCmd:@(5)];

上面会按顺序输出 5, 4, 3, 2, 1 ,然后结束。即我们可以在方法内部用 _cmd 来调用方法自身。

4、继承

上面已经介绍了方法调用的大致过程,下面来讨论类之间继承的情况。重新回去看 objc_class 结构体的定义,当中包含一个指向父类的指针 super_class 。

即当向一个对象发送消息时,会去这个类的 methodLists 中查找相应的 SEL ,如果查不到,则通过 super_class 指针找到父类,再去父类的 methodLists 中查找,层层递进。最后仍然找不到,才走抛异常流程。

下面的图演示了一个基本的消息发送框架:


5、消息转发

当一个方法找不到的时候,会走拦截调用和消息转发流程。我们可以重写 +resolveClassMethod: 和 +resolveInstanceMethod: 方法,在程序崩溃前做一些处理。通常的做法是动态添加一个方法,并返回 YES 告诉程序已经成功处理消息。如果这两个方法返回 NO ,这个流程会继续往下走,完整的流程如下图所示:

6、Category

我们来看一下 Category 在头文件中的定义:

typedef struct objc_category *Category;
struct objc_category {
    char * category_name;    
    char * class_name;    
    struct objc_method_list * instance_methods;
    struct objc_method_list * class_methods;
    struct objc_protocol_list * protocols;
}

Category 是一个指向 objc_category 结构体的指针,在 objc_category 中包含对象方法列表、类方法列表、协议列表。从这里我们也可以看出,Category 支持添加对象方法、类方法、协议,但不能保存成员变量。

如何为Category添加属性呢?通过关联对象技术可以实现。
注意:在 Category 中是可以添加属性的,但不会生成对应的成员变量、 getter 和 setter 。因此,调用 Category 中声明的属性时会报错。

注意:当一个对象被释放后, Runtime 回去查找这个对象是否有关联的对象,有的话,会将它们释放掉。因此不需要我们手动去释放。

二、Runtime的常规操作

上面简单介绍了 Runtime 的原理,接下来介绍下 Runtime 常用的操作。

1. Method Swizzling 方法交换

首先来介绍一下被称为「黑魔法」的 Method Swizzling 。 Method Swizzling 使我们有办法在程序运行的时候,去修改一个方法的实现。包括原生类(比如 UIKit 中的类)的方法。首先来看下通常的写法:

Method originalMethod = class_getInstanceMethod(class, (originalSelector));
Method swizzledMethod = class_getInstanceMethod(class, (swizzledSelector));
if (!class_addMethod((class),(originalSelector),method_getImplementation(swizzledMethod),method_getTypeEncoding(swizzledMethod))) {             
    method_exchangeImplementations(originalMethod, swizzledMethod);         
} else {                                                                    
    class_replaceMethod((class),(swizzledSelector),method_getImplementation(originalMethod),method_getTypeEncoding(originalMethod));            
}

简单描述一下:
(1)先获取 originalMethod 和 swizzledMethod 。
(2)将 originalMethod 加到想要交换方法的类中(注意此时的 IMP 是 swizzledMethod 的 IMP ),如果加入成功,就用 originalMethod 的 IMP 替换掉 swizzledMethod 的 IMP ;如果加入失败,则直接交换 originalMethod 和 swizzledMethod 的 IMP 。

那么问题来了,为什么不直接用 method_exchangeImplementations 来交换就好?

因为可能会影响父类中的方法。比如我们在一个子类中,去交换一个父类中的方法,而这个方法在子类中没有实现,这个时候父类的方法就指向了子类的实现,当这个方法被调用的时候就会出问题。所以先采取添加方法的方式,如果添加失败,证明子类已经实现了这个方法,直接用 method_exchangeImplementations 来交换;如果添加成功,则说明没有实现这个方法,采取先添加后替换的方式。这样就能保证不影响父类了。

如果每次交换都写这么多就太麻烦了,我们可以定义成一个宏,使用起来更方便。

define SwizzleMethod(class, originalSelector, swizzledSelector) { \

Method originalMethod = class_getInstanceMethod(class, (originalSelector)); \
Method swizzledMethod = class_getInstanceMethod(class, (swizzledSelector)); \    if (!class_addMethod((class),                                               \
                     (originalSelector),                                    \
                     method_getImplementation(swizzledMethod),              \
                     method_getTypeEncoding(swizzledMethod))) {             \
    method_exchangeImplementations(originalMethod, swizzledMethod);         \
} else {                                                                    \
    class_replaceMethod((class),                                            \
                        (swizzledSelector),                                 \
                        method_getImplementation(originalMethod),           \
                        method_getTypeEncoding(originalMethod));            \
}                                                                           \

}
在 +load 中调用:

+ (void)load {    
    static dispatch_once_t onceToken;    
    dispatch_once(&onceToken, ^{   
        SwizzleMethod([self class], @selector(viewWillAppear:), @selector(AA_viewWillAppear:));        
    });
}

注意:我们要保证方法只会被交换一次。因为 +load 方法原则上只会被调用一次,所以一般将 Method Swizzling 放在 +load 方法中执行。但 +load 方法也可能被其他类手动调用,这时候就有可能会被交换多次,所以这里用 dispatch_once_t 来保证只执行一次。

2、获取所有属性和方法

Runtime 中提供了一系列 API 来获取 Class 的成员变量( Ivar )、属性( Property )、方法( Method )、协议( Protocol )等。直接看代码:

// 测试 打印属性列表
- (void)testPrintPropertyList {
    unsigned int count;    
    objc_property_t *propertyList = class_copyPropertyList([self class], &count);
    for (unsigned int i=0; i<count; i++) {
        const char *propertyName = property_getName(propertyList[i]);
        NSLog(@"property----="">%@", [NSString stringWithUTF8String:propertyName]);
    }
    free(propertyList);
}
// 测试 打印方法列表
- (void)testPrintMethodList {
    unsigned int count;
    Method *methodList = class_copyMethodList([self class], &count);
    for (unsigned int i=0; i<count; i++) {
        Method method = methodList[i];
        NSLog(@"method----="">%@", NSStringFromSelector(method_getName(method)));
    }
    free(methodList);
}
// 测试 打印成员变量列表
- (void)testPrintIvarList {
    unsigned int count;
    Ivar *ivarList = class_copyIvarList([self class], &count);
    for (unsigned int i=0; i<count; i++) {
        Ivar myIvar = ivarList[i];
        const char *ivarName = ivar_getName(myIvar);
        NSLog(@"ivar----="">%@", [NSString stringWithUTF8String:ivarName]);
    }
    free(ivarList);
}
// 测试 打印协议列表
- (void)testPrintProtocolList {
    unsigned int count;
    __unsafe_unretained Protocol **protocolList = class_copyProtocolList([self class], &count);    for (unsigned int i=0; i<count; i++) {
        Protocol *myProtocal = protocolList[i];
        const char *protocolName = protocol_getName(myProtocal);
        NSLog(@"protocol----="">%@", [NSString stringWithUTF8String:protocolName]);
    }
    free(protocolList);
}

因为这里用到的是 C 语言风格的变量,所以要注意用 free 来释放。至于获取这些属性方法有什么用,在下面的「应用场景」中会提到。

三、Runtime的应用场景

说了这么多, Runtime 到底有什么用,下面就来介绍一下常见的几种应用场景。

1、AOP面向切片编程

在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

画重点,对业务逻辑进行分离,降低耦合度。

假设现在有这样一个需求,我们要对应用中所有按钮的点击事件进行上报,统计每个按钮被点击的次数。

首先我们要明确,统计功能应该与业务无关,即统计代码不应该与业务代码耦合在一起。因此用上面「AOP」的思想来实现是合适的,而 Runtime 给我们提供了这样一条途径。因为当按钮点击时,会调用 sendAction:to:forEvent: 方法,所以我们可以使用 Method Swizzling 来修改该方法,在其中添加上报的逻辑。来看代码:

// UIButton+Swizzling.m
+ (void)load {    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        RSSwizzleInstanceMethod([self class],                                @selector(sendAction:to:forEvent:),
                                RSSWReturnType(void),
                                RSSWArguments(SEL action, id target, UIEvent *event),
                                RSSWReplacement({
            NSString *name = NSStringFromClass([self class]);
            NSLog(@"UIButton+Swizzling:%@ 按钮被点击--上报", name);

            RSSWCallOriginal(action, target, event);

        }), RSSwizzleModeAlways, NULL);

    });
}

注意:尽管上面的需求也可以用继承一个基类的方式来实现,但是如果此时已经有很多类继承自 UIButton ,则修改起来会很麻烦,其次我们也不能保证后续的所有按钮都继承这个基类。另外上面提到,统计逻辑不应该和业务逻辑耦合,如果为了统计的需求去修改业务代码,也是不可取的(除非迫不得已)。因此上面利用 Method Swizzling 的方式更为合适,也更为简洁。

2、字典转模型

我们可以用 KVC 来实现字典转模型,方法是调用 setValuesForKeysWithDictionary: 。但这种方法要求 Model 的属性和 NSDictionary 的 key 一一对应,否则就会报错。这里可以用 Runtime 配合 KVC ,来实现更灵活的字典转模型。

下面为 NSObject 添加一个分类,添加一个初始化方法,来看代码:

// NSObject+JSONExtension.h
- (instancetype)initWithDictionary:(NSDictionary *)dictionary {    
    self = [self init];    
    if (self) {
        unsigned int count;
        objc_property_t *propertyList = class_copyPropertyList([self class], &count);
        for (unsigned int i=0; i<count; i++) {            
            // 获取属性列表
            const char *propertyName = property_getName(propertyList[i]);            
            NSString *name = [NSString stringWithUTF8String:propertyName];
            id value = [dictionary objectForKey:name]; 
           if (value) {               
                 // 注意这里用到 KVC
                [self setValue:value forKey:name];
            }
        }
        free(propertyList);
    }    
    return self;
}

注意:在实际的应用中,会有更多复杂的情况需要考虑,比如字典中包含数组、对象等。这里只是做个简单示例。

3、进行归档和解档

「归档」是将对象序列化存入沙盒文件的过程,会调用 encodeWithCoder: 来序列化。「解档」是将沙盒文件中的数据反序列化读入内存的过程,会调用 initWithCoder: 来反序列化。

通常来说,归解档需要对实例对象的各个属性依次进行归档和解档,十分繁琐且易出错。这里我们参照「字典转模型」的例子,通过获取类的所有属性,实现自动归解档。

触发对象归档可以调用 NSKeyedArchiver 的 + archiveRootObject:toFile: 方法;触发对象解档可以调用 NSKeyedUnarchiver 的 + unarchiveObjectWithFile: 方法。

首先在 NSObject 的分类中添加两个方法:

// NSObject+JSONExtension.m
- (void)initAllPropertiesWithCoder:(NSCoder *)coder {    
    unsigned int count;
    objc_property_t *propertyList = class_copyPropertyList([self class], &count);    for (unsigned int i=0; i<count; i++) {
        const char *propertyName = property_getName(propertyList[i]);
        NSString *name = [NSString stringWithUTF8String:propertyName];        
        id value = [coder decodeObjectForKey:name];
        [self setValue:value forKey:name];
    }
    free(propertyList);
}

- (void)encodeAllPropertiesWithCoder:(NSCoder *)coder {    
    unsigned int count;
    objc_property_t *propertyList = class_copyPropertyList([self class], &count);
    for (unsigned int i=0; i<count; i++) {
        const char *propertyName = property_getName(propertyList[i]);
        NSString *name = [NSString stringWithUTF8String:propertyName];        
        id value = [self valueForKey:name];
        [coder encodeObject:value forKey:name];
    }
    free(propertyList);
}

在 NSObject 的子类中实现归解档方法:

// ObjectA.m- (id)initWithCoder:(NSCoder *)aDecoder{    
    self = [super init];    if (self) {
        [self initAllPropertiesWithCoder:aDecoder];
    }    return self;
}

-(void)encodeWithCoder:(NSCoder *)aCoder{
    
    [self encodeAllPropertiesWithCoder:aCoder];
}

注:上面的代码逻辑并不完善,只是做简单示例用。

4、逆向开发

在「逆向开发」中,会用到一个叫 class-dump 的工具。这个工具可以将已脱壳的 APP 的所有类的头文件导出,为分析 APP 做准备。这里也是利用 Runtime 的特性,将存储在mach-O文件中的 @interface@protocol 信息提取出来,并生成对应的 .h 文件。

5、热修复

「热修复」是一种不需要发布新版本,通过动态下发修复文件来修复 Bug 的方式。比如 JSPatch,就是利用 Runtime 强大的动态能力,对出问题的代码段进

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容