Cx330

「重学iOS」分类(Category)的本质及其与类扩展(Extension) / 继承(Inherit)的区别

2020-12-17 · 22 min read
Objective-C iOS

分类的概念

分类是为了扩展系统类的方法而产生的一种方式,其作用就是在不修改原有类的基础上,为一个类扩展方法,最主要的是可以给系统类扩展我们自己定义的方法。

  • 如何创建一个分类?
    1. Cmd+N,iOS-->Objective-C File,Next;
    2. File Type选择category,class选择需要的类,分类名,Next。

比如我们为Person创建了一个Student的分类:

https://i.loli.net/2020/12/17/e4coPJsRTgUZXE2.jpg

其实分类的作用还是挺大的,比如我们有一个类的功能很复杂 如果只在这个类中实现的话不够清晰,这个时候我们可以给这个类按照功能多建几个分类,可以条理清晰的完成相应功能,比如person类,老师 / 学生/ 工人等等都有自己的特性,可以通过分类来合理管理。

分类的底层实现

我们可以通过一个例子来引出分类的本质,比如现在有一个person类,而person类现在又有两个分类,分别是PersonClass+Kid和PersonClass+sutdent,如果这三个类中都有一个test的对象方法(方法中打印对应的文件名),那么当我创建person对象后调用test方法,究竟会是个什么结果呢?出现这个结果的原因又是什么?

我们通过打印发现,调用set的方法后控制台上打印的是PersonClass-kid,也就是实际上是调用PersonClass-kid分类的test方法。

这个时候我们可能就有疑惑了,我们在之前讲到对象的本质的时候说当调用方法时,其底层都是通过消息机制来实现的,也就是objc_msgSend(objc,selector(msg))。

消息机制会通过isa找对应的对象找到对应的方法,比如实例对象调用对象方法,就会根据实例对象的isa去类对象中遍历对象方法列表,找到合适的方法就return,没有的话就根据supperclass去父类中查找 一级级查找。

按理说应该是person调用test方法,person实例对象根据其isa指针跑到person的类对象中找到对象方法列表,也就是person类的test方法进行调用。

但实际并非如此,我们都知道一个类只有一个类对象,只有一个元类对象,所以出现这个结果的原因只可能是分类的方法被添加到了类对象的方法列表中,并处在主类自身方法的前面。

那么分类的方法是什么时候被添加到类对象的方法中去的呢?是编译的时候还是运行的时候呢?

答案是在运行时通过runtime动态添加到类对象中去的。

首先在编译阶段,系统是先将各个分类转换为category_t结构体,这个结构体里面存储着分类中的各种信息(类方法/对象方法/属性/协议等等)

https://i.loli.net/2020/12/17/g6PkUehaq3ijfDH.png

然后在运行时通过runtime加载各个category_t结构体(PersonClass+Kid和PersonClass+sutdent),通过while循环【①】遍历所有的分类,把每一个Category的方法添加到一个新的方法大数组中,属性添加到一个新的方法大数组中,协议数据添加到一个新的方法大数组中;

最后,将合并后的分类数据(方法、属性、协议),插入到类原来数据(也就是主类的数据)的前面,我们再调用方法时,通过消息机制遍历方法列表,优先找到了分类方法

这个流程我们可以在阅读源码中找到依据:

https://i.loli.net/2020/12/17/aJZgptHq8UM4cs1.png

上面的流程可以解释为什么调用同名方法时有限调用了分类中的实现方法。

但是我们这里有两个分类方法,那为什么是调用的PersonClass-kid的方法呢?分类间的优先级又是什么?

分类的优先级其实我们在上面的流程中有提到,也就是①的位置,就是通过while循环遍历所有的分类,添加到数组中,也就是优先调用哪个分类取决于哪个分类被添加到数组的前面,

因为是while循环,所以越先编译的反倒是放到了数组后面,后面参与编译的Category数据,会在数组的前面。

https://i.loli.net/2020/12/17/xnl4QN6GSbgaokR.jpg

我们看到PersonClass-kid在最后,也就是最晚编译的,根据while的取值规则,反倒被添加到了数组的最前面,消息机制在方法列表中找到了对应方法后就直接return了,所以调用了了PersonClass-kid的方法,当我们手动调整编译顺序后,比如把PersonClass-student.m调到了最后,发现最终打印的结果是:PersonClass-sutdent。

如果当出现继承关系呢?方法又会怎么调用呢?

我们继续创建一个Teacher类,继承自Person类,同时Teacher类有两个分类,分别是Teacher+Chinese和Teacher+English,结构如下:

https://i.loli.net/2020/12/17/IWnCdRJuqYXvh2L.jpg

同样在teacher类及其分类中实现test方法,打印自己的文件名,然后创建一个Teacher类,调用Teacher实例对象的对象方法,打印结果是 Teacher-Chinese

这个流程和刚才说到的一样,Teacher实例对象调用方法,首先根据isa去Teacher的类对象中查找方法,而分类中的方法在运行时也被添加到了方法列表,且在主类自己的方法之前,所以会调用分类的方法,而究竟先调用哪个分类的方法取决于编译顺序,又因为Teacher-Chinese是Teacher分类中最晚被编译的,所以结果是 Teacher-Chinese。

假如teacher及其分类没有实现test方法呢?

打印结果是PersonClass-sutdent.

这是因为teacher实例变量根绝isa去类对象方法列表中没有找到对应的方法(即分类和主类都没实现此方法)那么类对象将根据自己的superclass指针去父类(person)中去寻找对应的方法,而上面也分析到了,person的分类方法加载到方法列表且处在主类方法前面,所以调用的是最晚编译的分类的方法,即PersonClass-sutdent。

  • 所以当调用某个方法时,流程应该是这样的:
    1. 先去该类的分类中查看有无此方法,有的话调用分类的方法(多个分类都有此方法的话就调用最晚编译的分类的方法)。
    2. 没有分类的话或者分类中没有此方法的话,就查看主类中有无实现此方法,有的话调用。
    3. 主类在也没有实现对应方法的话就根据superclass指针去父类中查找,一级级查找,找到调用。
    4. 找到最顶部的基类也没找到对应方法的话,报方法找不到的错误,项目crash。

分类的load方法和initialize方法

在面试过程中涉及到分类时经常会问道,

category有load方法吗?

loda方法什么时候加载?

load方法与initialize方法有什么区别?

再出现继承与分类情况时,各个load方法或者initialize方法是按什么顺序调用的?

我们在查看苹果官方关于load方法的介绍文档中,可以看出:

当类被引用进项目的时候就会执行load函数(在main函数开始执行之前),与这个类是否被用到无关,每个类的load函数只会自动调用一次.也就是load函数是系统自动加载的,load方法会在runtime加载类、分类时调用。

比如我们在项目中创建了几个类及分类,发现没有做任何处理运行项目,发现load方法被自动调用了:

https://i.loli.net/2020/12/17/KYgEcVlSnxHjMd9.jpg
https://i.loli.net/2020/12/17/KYgEcVlSnxHjMd9.jpg
https://i.loli.net/2020/12/17/KYgEcVlSnxHjMd9.jpg
  • 一个项目中有很多类,那么这些类的调用顺序是什么?
    1. 先调用类的+load
      a. 按照编译先后顺序调用(先编译,先调用)
      b. 用子类的+load之前会先调用父类的+load
    2. 再调用分类的+load
      a. 按照编译先后顺序调用(先编译,先调用)

主要流程就是这样

https://i.loli.net/2020/12/17/JUyjhn34b9L7E2m.jpg

这个顺序在源码中有体现:

https://i.loli.net/2020/12/17/qogGO5R91sI3dzF.png
https://i.loli.net/2020/12/17/YA7VfTiEU2D1js6.png

源码阅读指引:

objc4源码解读过程:objc-os.mm
_objc_init

load_images

prepare_load_methods
schedule_class_load
add_class_to_loadable_list
add_category_to_loadable_list

call_load_methods
call_class_loads
call_category_loads
(*load_method)(cls, SEL_load)

比如,现在有一个person类,person类有两个子类student和teacher,编译顺序是student/person/teacher 那么load调用顺序应该是这样的:

  1. 系统按照编译顺序,先找到student类,然后查看student有没有父类且这个父类没有执行过
    loda方法,发现有(pserson类),然后再查看person类有没有没调用过load方法的父类,
    发现有一个NSObject,在遍历NSObject有没有没调用过load方法的父类,发现其是基类,
    没有父类了所以,就先调用NSObject的load方法,然后接下来调用person的load方法,然后
    再调用student的load方法。

  2. 接下来找到person类,发现其不存在没有调用过load方法的父类且其自己的load方法也被调用
    过了,所以直接跳过了,没有调用任何的load方法。

  3. 最后来到了teacher类,查找其父类时,发现父类及更高级别的父类都实现了load方法,而自
    己的load方法还没有调用过,所以调用了teacher的load方法。

    所以调用顺序是:NSObject的load方法->Person的load方法->student的load方法->teacher的load方法
    因为我们无法修改NSObject的load方法实现,所以无法查看到它的方法打印。

当所有类的都调用完load方法后,接下来开始调用分类的load方法:

分类的load方法调用顺序和分类的主类没有任何关系,分类的调用顺序很简单:就是完全按照编译顺序调用load方法,比如A有两个分类a1,a2,B有两个分类b1,b2,分类的编译顺序是b1,a2,b2,a1,那么分类的load方法调用顺序就是:b1的load方法->a2的load方法->b2的load方法->a1的load方法。

这个时候我们又会产生一个新的困惑?我们之前在调用方法时,比如我们调用一个对象的test方法,是根据isa指针去方法列表中查找,找到后就return不在向上或者向下继续查找执行了,但是为什么load方法却不这样呢?为什么load方法在执行完父类的load方法后还继续向下执行子类的load方法?

这是因为load方法并不是通过消息机制实现的,也就是不是通过objc_msgSend(obj,@selector(方法))来实现的,消息机制是找到对应的方法就return,而load方法是直接通过方法地址直接调用。

https://i.loli.net/2020/12/17/cMjYZkgpNCLlwo9.png

以上就是有继承和分类情况下类的load方法调用顺序问题。

接下来来看initialize方法:

initialize方法是在一个类或其子类第一次接收到消息之前进行调用的,用来初始化,一个类只会被初始化一次。

initialize在类或者其子类的第一个方法被调用前调用。即使类文件被引用进项目,但是没有使用,initialize不会被调用。

load方法是无论类有没有被用到,只要添加被引入到项目就会被调用,而initialize则是在这个类或者其子类第一次调用方法的时候就会进行调用。

某个类调用方法时,是通过消息机制,也就是runtime的objc_msgSend方法实现的,所以initialize方法其实是在objc_msgSend进行判断调用的。

也就是当我们调用[teacher alloc],实际上是转化为了objc_msgSend([teacher class],@selector(alloc))方法,而objc_msgSend([teacher class],@selector(alloc))的内部结构有对teacher的initialize进行了判断,内部结构如下:

objc_msgSend([teacher class],@selector(alloc)){
  if([teacher class]没有初始化){
      //对teacher进行初始化  当然初始化并没有这么简单还涉及到了父类的初始化
        objc_msgSend([teacher class],@selector(initialize));
  }  
        objc_msgSend([teacher class],@selector(alloc))
}

同样在上面的项目中,我们重写每个类及分类的initialize方法,调用Teacher的alloc方法,

https://i.loli.net/2020/12/17/cdIRTfEKbZWhNsY.jpg

我们发现是先调用父类Person分类的initialize方法  然后在调用自己分类的initialize方法,上面提到了,objc_msgSend方法会判断类是否进行了初始化,没有的话就进行初始化,而对类的初始化过程,是优先对类的父类进行初始化的,也就是如下的结构:

objc_msgSend([teacher class],@selector(initialize)){
  if(teacher有父类 && teacher的父类没有初始化){
      //递归 有限初始化最顶级的父类
        objc_msgSend([teacher父类 class],@selector(initialize));
  }  
    //标记
    类已初始化 = yes;
}

又因为initialize不同于load通过地址调用方法 ,而是通过消息机制来进行调用的,所以会遍历类对象的方法列表,找到对应的方法就return了,而分类的方法位于主类方法前,后编译的分类排序更靠前,所以先调用了父类person分类Kid的方法,然后调用了teacher分类english的方法。

https://i.loli.net/2020/12/17/TbWZKRYUDjsnuEf.jpg

上面流程我们可以在源码中找到依据:

首先调用方法是查看有没有初始化,没有的话就调用初始化操作,

https://i.loli.net/2020/12/17/4WqYjlGukMmdDBf.png

而初始化操作中先初始化父类,

https://i.loli.net/2020/12/17/pjHA7zQrRyisdaZ.png

因为initialize是通过消息机制来实现的,所以当子类没事实现initialize方法是,会根据supertclass指针去调用父类中的同名方法(对象本质中有讲到),也就是当我们注释掉teacher类及其分类中initialize方法的实现再调用[teacher alloc]方法时发现,调用了两次person分类的initialize方法。

2019-04-15 13:44:26.323779+0800 test[68408:9131102] PersonClass-Kid // 第一次打印是因为初始化teacher时会先初始化父类person
2019-04-15 13:44:26.324083+0800 test[68408:9131102] PersonClass-Kid // 第二次打印是因为初始化teacher时没有找到它的initialize方法,所以去父类中查找了

**虽然调用了两次person的initialize方法,但person只初始化了一次,第二次是初始化teacher。**所以,initialize是当类第一次用到时就对调用,先调用父类的+initialize,再调用子类的initialize。

load方法和initialize方法都可以用来做什么操作?

  • 首先 load方法和initialize方法有几个相同点:
    1. 在不考虑开发者主动调用的情况下,系统最多会调用一次。
    2. 如果父类和子类都被调用,父类的调用一定在子类之前。

+load

由于调用load方法时的环境很不安全,我们应该尽量减少load方法的逻辑,load很常见的一个使用场景,交换两个方法的实现:

//摘自MJRefresh
+ (void)load
{
    [self exchangeInstanceMethod1:@selector(reloadData) method2:@selector(mj_reloadData)];
    [self exchangeInstanceMethod1:@selector(reloadRowsAtIndexPaths:withRowAnimation:) method2:@selector(mj_reloadRowsAtIndexPaths:withRowAnimation:)];
    [self exchangeInstanceMethod1:@selector(deleteRowsAtIndexPaths:withRowAnimation:) method2:@selector(mj_deleteRowsAtIndexPaths:withRowAnimation:)];
    [self exchangeInstanceMethod1:@selector(insertRowsAtIndexPaths:withRowAnimation:) method2:@selector(mj_insertRowsAtIndexPaths:withRowAnimation:)];
    [self exchangeInstanceMethod1:@selector(reloadSections:withRowAnimation:) method2:@selector(mj_reloadSections:withRowAnimation:)];
    [self exchangeInstanceMethod1:@selector(deleteSections:withRowAnimation:) method2:@selector(mj_deleteSections:withRowAnimation:)];
    [self exchangeInstanceMethod1:@selector(insertSections:withRowAnimation:) method2:@selector(mj_insertSections:withRowAnimation:)];
}

+ (void)exchangeInstanceMethod1:(SEL)method1 method2:(SEL)method2
{
    method_exchangeImplementations(class_getInstanceMethod(self, method1), class_getInstanceMethod(self, method2));
}

+initialize

initialize方法一般只应该用来设置内部数据,比如,某个全局状态无法在编译期初始化,可以放在initialize里面。比如NSMutableArray这种类型的实例化依赖于runtime的消息发送,所以显然无法在编译器初始化:

// int类型可以在编译期赋值
static int someNumber = 0; 
static NSMutableArray *someArray;
+ (void)initialize {
    if (self == [Person class]) {
        // 不方便编译期复制的对象在这里赋值
        someArray = [[NSMutableArray alloc] init];
    }
}
  • 还有几个注意点:
    1. load调用时机比较早,运行环境不安全,所以在load方法中尽量不要涉及到其他的类。因为不同的类加载顺序不同,当load调用时,其他类可能还没加载完成,可能会导致使用到还没加载的类从而出现问题;
    2. load方法是线程安全的,它使用了锁,我们应该避免线程阻塞在load方法(因为整个应用程序在执行load方法时会阻塞,即,程序会阻塞直到所有类的load方法执行完毕,才会继续);initialize内部也使用了锁,所以是线程安全的(即只有执行initialize的那个线程可以操作类或类实例。其他线程都要先阻塞,等待initialize执行完)。但同时要避免阻塞线程,不要再使用锁。
    3. iOS会在应用程序启动的时候调用load方法,在main函数之前调用。
    4. 在首次使用某个类之前,系统会向其发送initialize消息,通常应该在里面判断当前要初始化的类,防止子类未覆写initialize的情况下调用两次

关联对象

分类中是可以使用属性的,但不能创建成员变量的,而主类中是可以使用属性与成员变量的。

01.png

原因我们可以通过比较类与分类的底层结构可以看出,

分类的结构:

02.png

类对象的结构:

03.png

因为分类的实际结构中并没有存放成员变量的数组,所以其是无法创建和使用成员变量的。而当我们在创建属性时,其实这个属性实际上是执行了一下操作:

@property(nonatomic,assign)double height;
/**
   //1.声明成员变量
   {
      double _height;
   }
   2.实现set方法和get方法
   - (void)setHeight:(double)height{
      _height = height;
   }
 
   - (double)height{
      return _height;
   }
 */

因为分类中没有成员变量,所以分类中的属性也就没有自动去实现set方法和get方法,这也就导致了我们在使用分类属性时出现crash。

04.png

所以我们如果想让分类中的属性或成员变量能跟主类中一样使用的话,需要通过运行时建立关联引用。

使用方法:重写分类属性的set/get方法:

//首先需要导入runtime的头文件 #import <objc/runtime.h>
- (void)setHeight:(double)height{
    /**
     id  _Nonnull object:这个参数是指属性与哪个对象产生关联?一般写self即可
     const void * _Nonnull key:这个是关联属性名  我一般都是直接写属性名 即@"height"
     id  _Nullable value:关联属性的属性值  也就是height
     objc_AssociationPolicy policy:这个参数一般是值属性的修饰符 比如我们经常用copy来字符串  assign修饰基本数据类型  还有就是原子锁,我们常用的就是不加锁nonatomic
     */
    //这就相当于把height这个属性与self进行绑定,可以看成是相当于把@{@"height":@(height)}这个键值对存放在全局中的某个位置,可以读取与设置
    //height是基本类型 需要包装成NSNumber
    objc_setAssociatedObject(self, @"height", @(height), OBJC_ASSOCIATION_ASSIGN);
    
}

- (double)height{
    //这个是指根据key去取出对应的属性值  这个需要注意的点事key一定要和set方法中的key一致
   return  [objc_getAssociatedObject(self, @"height") doubleValue];
}
// 关联对象提供了以下API

// 添加关联对象
void objc_setAssociatedObject(id object, const void * key,
                                id value, objc_AssociationPolicy policy)

// 获得关联对象
id objc_getAssociatedObject(id object, const void * key)

// 移除所有的关联对象
void objc_removeAssociatedObjects(id object)

关于objc_setAssociatedObject中objc_AssociationPolicy参数的使用:

05.png

关联对象的原理我们可以通过源码来查看 [objc4源码解读:objc-references.mm]

06.png
  • 其中有几个点需要注意:
    1. 关联对象并不是存储在被关联对象本身内存中。主类的属性是存储到类对象自己的内存中的,但是通过关联方式并不会把属性添加到类对象内存中,而是将关联对象存储在全局的统一的一个AssociationsManager中。
    2. 设置关联对象为nil,就相当于是移除关联对象。
    3. 当关联对象被销毁时,AssociationsManager中存在所有与关联对象绑定的信息都会被释放。

按照个人理解的方式应该是这样:

07.png

这个Map我们就可以理解为一个字典,里面存放着一个个键值对。

所以通过上面的分析,我们可以回答一个经常被问道 的关于category的面试题:

Category能否添加成员变量?如果可以,如何给Category添加成员变量?

答:不能直接给Category添加成员变量,但是可以间接实现Category有成员变量的效果。
我们可以使用runtime的API,objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)和objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)这两个来实现。[重写分类属性点的set方法和get方法]


分类(Category)与类扩展(Extension) / 继承(Inherit)的区别

很多面试题经常会比较分类和类扩展的区别,首先我们要看一下什么是分类,什么是类扩展?

分类和扩展

分类的格式:

@interface 待扩展的类(分类的名称)

@end

@implementation 待扩展的名称(分类的名称)

@end

分类的创建:

08.png

类扩展的格式:

@interface XXX()

//属性

//方法(如果不实现,编译时会报警,Method definition for 'XXX' not found)

@end

类扩展的创建:

  1. 直接在类文件中添加interface代码块。
  2. 如图:
09.png
  • 关于类扩展和分类的区别:
    1. 上面提到的分类不能添加成员变量【虽然可以添加属性 但是一旦调用就会报方法找不到的错误】 (可以通过runtime给分类间接添加成员变量),而类扩展可以添加成员变量;

    2. 分类中的属性不会自动实现set方法和get方法,而类扩展中的属性再转为底层时是可以自动实现set、get方法。

    3. 类扩展中添加的新方法,不实现会报警告。分类中定义了方法不实现则没有这个问题。

      10.png
    4. 类扩展可以定义在.m文件中,这种扩展方式中定义的变量都是私有的,也可以定义在.h文件中,这样定义的代码就是共有的,类扩展在.m文件中声明私有方法是非常好的方式。

    5. 类扩展不能像分类那样拥有独立的实现部分(@implementation部分),也就是说,类扩展所声明的方法必须依托对应类的实现部分来实现。

分类和继承

可能有时候可以实现相同的功能,但其实两个存在较大的差异,简单介绍一下两者的异同。

比如刚才上面的情况,我们调用一个方法是,系统的查找顺序是先查找分类,分类没有查找主类,主类没有查找父类(分类没有查找主类是因为分类,主类没有查找父类是因为继承)。

有人可能会有疑问,既然是先查找分类再查找主类,这不是和继承中的先查找子类方法,没有的话再去父类查找是一样的么,能否用集成来代替分类呢?

  • 其实是不行的,虽然先查找分类再查找主类这个流程很像继承(看着像是分类是继承自主类的子类),但是两者有很大区别,主要表现在两点:
    1. 逻辑方面:两者代表的层级关系不一样,继承代表父子关系,分类代表同级关系

      比如dog与animal是继承关系,dog与cat是同级关系(dog是animal的子类,dog和cat是同级,都是animal的子类)。如果我们用继承来代替分类,也就是cat继承自dog,那么无论是可读性还是逻辑表达上都是难以理解的。

    2. 方法调用上:分类这种方式中,主类可以调用分类的方法,分类也可以调用主类的方法,可以相互调用,而继承则不行,子类可以调用父类的方法,但是父类却不能调用子类的方法。

参考原文地址