iOS底层原理(一):OC的本质、KVO原理、Category原理、Block原理

一、OC对象的本质
知识点
    1. 我们平时编写的OC代码,底层实现其实都是C\C++代码,OC的对象、类底层都是由C\C++结构体实现的
    1. 可以通过以下命令,可以将OC代码转换成C++代码,这种转换并不是准确的,因为从Clang新版本开始会将iOS代码转换成中间代码,而不是C++代码,所以转成C++的代码仅供参考(PS:大部分情况下还是准确的,想要特别精准就需要通过查看汇编代码来实现了)
将Objective-C代码转换为C\C++代码:
xcrun  -sdk  iphoneos  clang  -arch  arm64  -rewrite-objc  OC源文件  -o  输出的CPP文件
如果需要链接其他框架,使用-framework参数。比如-framework UIKit
    1. 凡是继承自NSObject的对象,都会自带一个类型是Classisa成员变量,将其转成C++,就可以看到NSObject本质上是一个叫做NSObject_IMPL的结构体,其成员变量isa本质上也是一个指向objc_class结构体的指针,如下所示:
      NSObject对象的本质
    1. 一个NSObject对象在内存中的布局如下所示,堆空间不但会存放子类的成员变量,还会存放类对象的isa指针
      image.png
    1. 系统会给一个NSObject对象分配16个字节的内存,而NSObject对象实际只占用了8个字节的内存,占用的这8个字节的就是isa指针,剩下8个字节是系统为了内存对齐而分配的,如下所示:
创建一个实例对象,至少需要多少内存?
#import <objc/runtime.h>
class_getInstanceSize([NSObject class]);

创建一个实例对象,实际上分配了多少内存?
#import <malloc/malloc.h>
malloc_size((__bridge const void *)obj);
image.png
    1. OC对象主要可以分为三类:instance实例对象、class类对象、meta-class元类对象,他们之间的关系,可以用一张经典的图来表示,如下所示:
image.png

上图中的关系用文字表示是这样的:

- instance的isa指向class

- class的isa指向meta-class

- meta-class的isa指向基类的meta-class

- class的superclass指向父类的class,如果没有父类,superclass指针为nil

- meta-class的superclass指向父类的meta-class,基类的meta-class的superclass指向基类的class

- instance调用对象方法的轨迹:isa找到class,方法不存在,就通过superclass找父类

- class调用类方法的轨迹:isa找meta-class,方法不存在,就通过superclass找父类
    1. 64bit开始,也就是采用了ARM64处理器之后,OC的isa被改进成了union共用体,其isa指针并不是直接指向类对象或者元类对象的,而是要通过一个&ISA_MASK的位运算,才能获取到真正的类对象或者元类对象的地址;优化之后的isa指针,每一位都有其含义,虽然这么优化节省的内存不多,但是对于使用频率如此之高的isa指针来说,还是非常有意义的,至于为什么要经过&ISA_MASK位运算才能拿到类对象的真正地址,我们后续再说
      isa & ISA_MASK
    1. isa & ISA_MASK是指向objc_class结构体的,我们从objc4源码摘出来objc_class的结构如下所示,我们可以看出objc_class结构体内部,保存了isa、superclass、方法缓存、class_rw_t可读写的类信息(方法列表 、属性列表 、协议列表) 、class_ro_t只读的类信息(类名、成员变量列表)等信息
      image.png

PS: 这里的方法列表、属性列表、协议列表都归属于class_rw_t可读写的类信息中,而成员变量列表归属于class_ro_t只读的类信息中,所以方法列表、属性列表、协议列表都是可以通过RunTime动态增加的,而成员变量列表就不能动态增加!!!这就是Category为什么只能增加方法,不能增加成员变量的核心原因(通过关联方式增加的成员变量是通过全局变量来存储的)

面试题
    1. 一个NSObject对象占用多少字节的内存?

    答:一个NSObject实际占用8个字节,是用来存放isa指针的,而系统分配了16个字节,额外多的8个字节是为了内存对齐而分配的

    1. 对象的isa指针指向哪里?

    答:实例对象的isa指向类对象,类对象的isa指向元类对象,元类对象的isa指向基类的元类对象

    1. OC对象的类信息存放在哪里?

    答:OC对象的实例方法列表、属性列表、协议列表等信息存放在类对象中;OC对象的类方法列表存放在元类中;成员变量具体的值存放在实例对象中。

二、KVO
知识点
    1. KVO用于监听某个对象属性的值是否改变,未使用KVO监听对象时NSObject对象实例对象和类对象内存布局如下:
实例对象和类对象的内存布局.png
    1. 使用了KVO监听的对象,其实例对象和类对象的内存布局如下所示:
      image.png
    1. KVO本质上会生成一个NSKVONotifying_XXX的派生类,如上图所示,MJPerson的实例对象isa指针会指向该派生类,该派生类的superClass指针会指向MJPerson的类对象,KVO的机制还会在派生类中重写此属性的set方法,在set方法中,先调用willChangeValueForKey方法,再调用原来的setter实现,再调用didChangeValueForKey方法
以监听age属性为例,KVO的触发流程:
[self willChangeValueForKey:@"age"];
super.age = age;  //原来的方法实现
[self didChangeValueForKey:@"age"];  
-----didChangeValueForKey方法内部会调用observer的observeValueForKeyPath:ofObject:change:context:方法
面试题
    1. KVO的本质是什么?

    答:利用RuntimeAPI动态实现了一个子类,并且让实例对象的isa指向这个全新的子类,当修改实例对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数,其内部的调用顺序是这样的:willChangesValueForKey、父类原来的setter、didChangeValueForKey

    1. 如何手动触发KVO?

    答:手动调用willChangeValueForKey:didChangeVableForKey:方法

    1. 直接修改成员变量会触发KVO吗?

    答:不会触发KVO

    1. 通过KVC修改属性会触发KVO吗?

    答:会触发KVO

三、Category
知识点
    1. Category编译之后的底层结构是struct category_t,每一个分类都会对应一个category_t结构体,通过阅读objc4源码我们可以得知,struct category_t里面存储着分类的对象方法、类方法、属性、协议信息,如下所示:
      category_t
    1. 从源码基本可以看出,在category_t结构体中,对象方法,类方法,协议,和属性都可以找到对应的存储方式。并且我们发现分类结构体中是不存在成员变量的,因此分类中是不允许添加成员变量的。分类中添加的属性并不会帮助我们自动生成成员变量,只会生成get set方法的声明,需要我们自己去实现。
    1. 某个类的Category的加载过程是这样的:
    • (1). 首先会通过Runtime加载这个类的所有分类的数据,包括这个类的所有分类中的方法列表、属性列表、协议列表

    • (2). 然后把这个类的所有分类的方法列表、属性列表、协议列表,分别合并到一个各自的大数组中,这里对分类采用了while(i--)倒叙遍历,所以后面参与编译的分类,其数据会放在数组的前面,然后会将合并后的大数组,插入到这个类的class_rw_t结构体中,整个流程的源码如下图所示:

      将分类中的数据合并到大数组中.png

    • (3). 将分类数据(方法列表、属性列表、协议列表),插入到类class_rw_t结构体时,调用了attachLists()方法,在这个方法内部,会将分类的方法,属性,协议列表放在了类对象中原本存储的方法,属性,协议列表的前面,如下面源码所示,这样做的目的是为了保证分类方法优先调用,我们知道当分类重写本类的方法时,会覆盖本类的方法,其实本质上并不是覆盖,而是优先调用,本类原来的方法依然是存在的。

      image.png

    1. 调用 A 对象的 B 方法为例,梳理一下整个调用流程:
    • 首先从A对象的内存中拿到isa指针,进行一次位运算isa & ISA_MASK后,拿到真正的A对象的类地址

    • 然后从方法缓存cache中查找B方法,找到就调用,找不到的话就从class_rw_t的方法列表methods中查找方法,此时Runtime早已经帮我们把分类的方法列表,插入到了方法列表methods中了,所以我们只需要按顺序在methods中查找,找到就调用,并将其放入方法缓存cache

    • 找不到就通过superClass指针找到父类,将上述步骤再走一遍,然后一直重复,直到superclass为空为止,如果一直没找到B方法,就进入动态方法解析和消息转发

    1. +load方法会在runtime加载类、分类时调用,每个类、分类+load方法在程序运行过程中只调用一次,调用顺序如下:
    • (1). 先调用类的+load方法,按照编译顺序调用,先编译的先调用,调用子类的. +load方法之前会先调用父类的+load方法

    • (2). 再调用分类的+load方法,按照编译顺序调用,先编译的先调用

      PS: +load方法是根据方法地址直接调用的,不会走objc_msgSend消息发送流程

    1. +initialize方法会在类第一次接受到消息时调用,调用顺序是:先调用父类的+initialize方法,在调用子类的+initialize方法,需要注意的是:
    • 如果子类没有实现+initialize方法,就会调用父类的+initialize方法(所以父类的+initialize方法可能会被调多次)

    • 如果分类实现+initialize方法,就会覆盖类本身的+initialize方法

    1. 我们知道由于类对象底层结构的限制,不能将成员变量动态插入到类中,但可以通过关联对象来间接实现,关联对象的原理是:将成员变量存储在全局统一的AssociationsManager中,而不是存储在对象本身的内存中,由AssociationsHashMap来管理所有被添加到对象中的关联对象,其流程如下所示:
      image.png
面试题
    1. Category中有load方法嘛?load方法什么时候调用的?load方法能被继承吗?

    答:有load方法,load方法在runtime加载类、分类时调用,load方法可以继承,一般情况下不会主动调用load方法,都是让系统自动调用

    1. load、initialize方法的区别是什么?

(1). 调用时机不同:

1>. load是runtime加载类和分类的时候调用,只会被调用一次
2>. initialize是类第一次接收到消息的时候调用,当子类没有initialize方法时,就会调用父类的,所以可能会被调用多次

(2). 调用方式不同:

1>.load是通过函数地址直接调用的
2>.initialize是通过objc_msgSend调用的

(3). 调用顺序不同:

1>.load:先调用类的load方法,先编译的先调用,在调用load之前会先调用父类的load方法;然后在调用分类的load方法,也是先编译的先调用;分类的load方法不会覆盖本类的load方法
2>.initialize:先初始化父类,之后再初始化子类。如果子类没有实现+initialize,会调用父类的+initialize(所以父类的+initialize可能会被调用多次),如果分类实现了+initialize,就覆盖类本身的+initialize调用。
四、Block
知识点
    1. Block本质上也是一个OC对象,内部也有一个isa指针,Block是封装了函数调用和函数调用环境的OC对象
    1. 为了保证Block内部能够正常访问外部变量,Block有个变量捕获机制,用auto修饰的局部变量是值捕获,用static修饰的局部变量是指针捕获,全局变量不会捕获(局部变量不加修饰符的话,默认是用auto修饰的),规则和示例如下所示:
      Block捕获规则

      Block捕获示例.png
    1. self是调用函数时传进来的参数,也是属于局部变量,所以捕获的时候是值捕获
    1. Block根据存放的内存区域的不同,有三种类型:存放在全局区的叫做NSGlobalBlock-全局Block、存放在栈区的叫做NSStackBlcok-栈Block、存放在堆区的叫做NSMallocBlock-堆Block,如下图所示,可以通过调用Class方法或者isa指针来查看Block的具体类型
      image.png
    1. MRC,如果Block内部没有访问用auto修饰的变量,那么Block就是全局Block;如果Block访问了用auto修饰的变量,那么Block就是栈Block;如果给栈Block使用了Copy,那么就会将栈Block复制到堆上,从而变成了堆Block,如下图所示:
      MRC下Block的类型.png
    1. 由于栈空间的内存随时都会被释放,为了保证数据安全,ARC在以下情况下,会自动将Block拷贝到堆上,当copy到堆上时,会自动调用Block内部的copy函数,copy函数内部会调用_Block_object_assign函数,_Block_object_assign会根据捕获的对象的修饰符(__strong、__weak、__unsafe__unretained)来做出相应的操作,形成强引用或者弱引用
      ARC的这些情况下,栈Block会自动复制到堆上
    1. block堆中移除时,会调用block内部的dispose函数,dispose函数内部会调用_Block_object_disposeh函数,_Block_object_disposeh函数会自动释放捕获的变量
      image.png
    1. Block内部访问了auto修饰的变量时,会根据Block的类型的不同,而出现不同的情况:
    • 当block在栈上时,如果block内部访问了auto变量,将始终不会对auto变量产生强引用

    • block在堆上时,如果内部访问了auto变量将会调用block内部的copy函数,copy函数会根据auto变量的修饰符来决定对auto变量产生强引用还是弱引用(如果auto变量用__strong修饰,就会产生强引用;如果用__weak、__unsafe_unretained修饰就会产生弱引用)

    1. __block修饰符可以解决block内部无法修改auto修饰的变量的值的问题,__block修饰符不能修饰全局变量和static静态变量,编译器会将__block变量包装成一个对象,如下所示:
      __block变量被包装成了对象
    1. Block内部访问了__block修饰的变量时,会根据Block的类型的不同,而出现不同的情况:
    • 当block在栈上时,如果block内部访问了__block变量,将始终不会对__block变量产生强引用

    • block在堆上时,如果内部访问了__block变量,将会调用block内部的copy函数,copy函数会对__block变量形成强引用

    1. 学会了以上知识点之后,再来思考一下经典的循环引用问题,是不是就感觉很简单了呢?我们来一起看一下,所谓循环引用就是对象持有Block,而Block也持有了self,导致双方都无法释放,从而导致内存泄漏,如下面代码所示:
self.block = ^{
     NSLog(@"%@",self);
};
  • 循环引用分析:在ARC环境下,block被赋值给了Block类型的self.block成员变量,所以这个block是堆block;block内部访问了self变量,我们知道self是局部变量,不加修饰符的话默认是用auto和__strong修饰的;既然block在堆上,并且内部访问了auto修饰的变量,那么将会在block内部调用copy函数,copy函数会根据修饰符进行强/弱引用,此处使用__strong修饰的,所以会对self进行一次强引用,而self对block产生的也是强引用,所以产生了循环引用,如下图所示:

    循环引用问题.png

  • 解决方案:解决起来也很简单,只需要将self换成__weak修饰的就可以了,这样在调用block内部的copy方法时,对self产生的就是弱引用了,如以下代码所示:

__weak typeof(self) weakSelf = self;
self.block = ^{
  NSLog(@"%@",weakSelf);
};
面试题

- 1. Block的本质是什么?

答: Block是封装了函数调用以及函数调用环境的OC对象

- 2. __block的作用是什么?

答:__block可以解决block内部无法修改auto修饰的变量的问题,编译器会将__block修饰的变量包装成一个对象

- 3. block的属性修饰词为什么是copy?

答: block使用copy其实是MRC留下来的一个传统,在MRC下,block创建在栈区, 使用copy就能把它放到堆区, 这样在作用域外调用该block程序就不会崩溃;而在ARC下block的属性修饰词是copy和strong都可以,ARC会在需要的时候,自动帮我们把block从栈上拷贝到堆上

- 4. block中修改NSMutableArray中的元素,需不需要添加__block?

答: 不需要,仅仅修改数组中的元素是不需要加__block的,如果是给array重新赋值新的数组,这时候才需要加__block