iOS学习之Runtime(二)

时间:2023-03-08 20:44:18
iOS学习之Runtime(二)

 前面已经介绍了Runtime系统的概念、作用、部分技术点和应用场景,这篇将会继续学习Runtime的其他知识。

一、Runtime技术点之类/对象的关联对象

  关联对象不是为类/对象添加属性或者成员变量(因为在设置关联后也无法通过copyIvarList或者copyPropertyList取得),而是为类添加一个相关的对象,通常用于存储类信息,例如存储类的属性列表数组,方便以后字典转模型的操作。

  Runtime为我们提供了三个函数进行关联对象的相关操作:

 /**
* 为某个类关联某个对象
*
* id object,当前对象
* const void *key,关联的key,是C字符串
* id value,被关联的对象
* objc_AssociationPolicy policy,关联引用的规则,取值有以下几种:
* enum {
* OBJC_ASSOCIATION_ASSIGN = 0,
* OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1,
* OBJC_ASSOCIATION_COPY_NONATOMIC = 3,
* OBJC_ASSOCIATION_RETAIN = 01401,
* OBJC_ASSOCIATION_COPY = 01403
* };
*
*/
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)

  我们可以将关联对象的设置与获取封装起来,用于方便获取类的属性列表。

 @implementation Person

 const char *propertiesKey = "propertiesKey";

 + (NSArray *)properties
{
// 通过key取出关联对象
NSArray *pList = objc_getAssociatedObject(self, propertiesKey);
if (pList != nil)
{
return pList;
} // 如果没有关联对象,则取出成员变量和属性,存入数组
unsigned int outCount;
Ivar *ivarList = class_copyIvarList(self, &outCount); NSMutableArray *array = [NSMutableArray arrayWithCapacity:outCount]; for (int i = ; i < outCount; i++)
{
Ivar *ivar = &ivarList[i];
NSString *name = [NSString stringWithUTF8String:ivar_getName(*ivar)];
NSString *key = [name substringFromIndex:];
[array addObject:key];
} // 释放ivarList
free(ivarList); // 设置关联对象
objc_setAssociatedObject(self, propertiesKey, array, OBJC_ASSOCIATION_COPY_NONATOMIC); return array.copy;
} - (NSString *)description
{
NSLog(@"name: %@---------age: %d---------height: %f", self.name, self.age, _height);
return nil;
} @end

  这样的话,我们只需在外部调用这个类方法,即可获得该类的所有属性列表。

  不过我在网上也看到有人将关联对象应用到分类中,目前来说我还不能确定这种做法是否恰当,不过倒是可以提供一种思路。这种用法的初衷是在不使用继承的方式下给系统类添加一个公共变量。我们都知道,分类只能为类添加方法,而延展里面为类添加的变量或方法都是私有的(这里简单介绍一下延展的作用,延展其实就是C语言中的前向声明,不过现在苹果已经弥补了这个缺陷,所以这里不再细述)。那么怎样才能在不使用继承的方式下给系统类添加一个公共变量呢?这里就用的了关联对象。

  我们可以在NSDictionary的分类MyDict.h中新增一个属性property1,一般情况下如果我们只声明了这些变量,在外面使用的时候就会报错,因为分类是不允许你这么做的。那么我们就需要通过设置关联对象来实现property1的set、get方法,其实原理很简单,就是在set方法中,通过一个key将property1的值关联到类中;在get方法中,再通过这个key将property1的值取出即可。

 1 const char *property1Key = "property1Key";
2
3 - (void)setProperty1:(NSString *)property1
4 {
5 // 通过key设置关联对象
6 objc_setAssociatedObject(self, property1Key, property1, OBJC_ASSOCIATION_COPY_NONATOMIC);
7 }
8
9 - (NSString *)property1
10 {
11 // 通过key获取关联对象
12 return objc_getAssociatedObject(self, property1Key);
13 }

  这样我们就可以在外部使用这个分类的新增属性了。同样的,我们也可以为其设置block,原理都是一样的,这里就不再累述了。

二、Runtime技术点之消息转发

 在学习消息转发知识之前,我们需要知道几个概念:

 1、OC中调用方法就是向对象发送消息。比如[person walk];实际上是给person对象发送了walk这个消息。调用类方法也一样,类实际上也是一个对象,是元类的实例。方法调用的流程如下:

  (1)系统会查看这个对象能否接收这个消息(查看这个类有没有这个方法,或者有没有实现这个方法);

  (2)如果不能接收这个消息,就会调用下面这几个方法,给出“补救”的机会;

  (3)如果在这几个方法中都没有做处理,那么程序就会报错;

  需要注意的是,下面这几个方法调用是有先后顺序的,并且如果前一个方法做出相应处理了,就不会再调用后面的方法了。

     + resolveInstanceMethod:(SEL)sel  // 实例方法没有实现时会调用这个方法
+ resolveClassMethod:(SEL)sel // 类方法没有实现时会调用这个方法
- (id)forwardingTargetForSelector:(SEL)aSelector
- (void)forwardInvocation:(NSInvocation *)anInvocation

  其中:- (void)forwardInvocation:(NSInvocation *)anInvocation; 需要跟 - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector; 结合使用才能实现消息转发,methodSignatureForSelector的作用是为方法创建一个有效的签名。如果没有找到方法的对应实现,则会返回一个空的方法签名,最终导致程序崩溃。关于怎样使用它们实现消息转发,下面会介绍。

 2、SEL的概念。

  SEL就是对方法的一种包装。包装的SEL类型数据,它对应相应的方法地址,找到方法地址就可以调用方法。在内存中每个类的方法都存储在类对象中,每个方法都有一个与之对应的SEL类型的数据,根据一个SEL数据就可以找到对应的方法地址,进而调用方法。

  每个类都有一个包含SEL和对应的IMP的Method列表,也就是说一个Method包含着一个SEL和一个对应的IMP,而消息转发就是将原本的SEL和IMP的这种对应关系给分开,跟其他的Method重新组合。

  SEL类型的定义:typedef struct objc_selector *SEL

 3、OC中的方法默认被隐藏了两个参数:self和_cmd。self指向对象本身,_cmd指向方法本身。比如- (void)walk; 这个方法实际有两个参数:self和_cmd。再比如- (void)walk:(NSString *)address; 这个方法实际有三个参数:self、_cmd和address。而且self的类型必须是id,_cmd的类型必须是SEL,这也就解释了为什么_cmd能够指向方法本身了,因为_cmd的类型就是SEL,而SEL就是对方法的一种包装。

 有了对上面这些概念的认知,我们才能更好的理解消息转发的原理与实现。

 (一)、动态添加方法实现消息转发

 根据概念1我们知道,假如一个方法没有对应的实现,那么系统首先会调用+ (BOOL)resolveInstanceMethod:(SEL)sel; 来进行“补救”,那我们是否可以在这里手动添加一个该方法对应的实现呢?答案是肯定的,这也就是有些文档中提到的动态添加方法。现在假设Person.h中有一个- (void)walk; 方法,但是Person.m中并没有实现它,现在我们需要重写+ (BOOL)resolveInstanceMethod:(SEL)sel; 来实现动态添加方法。

 @implementation Person

 + (BOOL)resolveInstanceMethod:(SEL)sel
{
NSString *selString = NSStringFromSelector(sel);
if ([selString isEqualToString:@"walk"])
{
class_addMethod(self, @selector(walk), (IMP)goTo, "v@:");
}
return [super resolveInstanceMethod:sel];
} // 这是C语言的语法
void goTo(id self, SEL sel)
{
NSLog(@"Person walk.");
} @end

 这里有几点需要解释:

  (1)根据概念2我们知道,SEL是对方法的封装,那么通过SEL我们可以获取到方法名,只有在walk被调用时,我们才动态添加这个方法的实现;

  (2)我们再来分析一下class_addMethod。官方的解释是这样的:Adds a new method to a class with a given name and implementation. 直接可以理解为给类添加一个新的方法。

    第一个参数:The class to which to add a method. 要添加方法的类。

    第二个参数:A selector that specifies the name of the method being added. 可以理解为没有实现的方法名称。

    第三个参数:A function which is the implementation of the new method. The function must take at least two arguments—self and _cmd. 要添加的方法实现。注意,这个方法最少要有两个参数:self和_cmd。

    第四个参数:An array of characters that describe the types of the arguments to the method. 描述要添加的方法的参数类型的数组。Since the function must take at least two arguments—self and _cmd, the second and third characters must be “@:” (the first character is the return type). 因此这个方法最少要有两个参数:self和_cmd,并且第二个字符和第三个字符必须是“@:”,第一个字符是这个方法的返回值类型。

  (3)根据概念3我们知道,OC中的方法默认被隐藏了两个参数,但是C语言并非如此,而Runtime又是基于C语言和汇编的,所以也就很好理解为什么这个方法的实现必须要有self和_cmd这两个参数了。但是“@:”又是什么东西?还记得上一篇中我们提到的类型编码吗?具体可以看这里。“@”代表的就是对象,也就是对应这里的self;“:”代表的就是SEL,也就是对应这里的_cmd;而上面的“v”则是代表这个方法的返回值是void类型。

 这样一来,当我们调用[person walk]; 时,实际上调用的就是goTo方法,所以最终打印结果为:

 -- ::54.073 RunTimeTest[:] Person walk.

 (二)、切换消息接收者实现消息转发

 消息转发的另一种形式相比起来更容易理解,直接将消息转发给其他对象,相当于调用其他对象的同名方法,这就用到了- (id)forwardingTargetForSelector:(SEL)aSelector;

 现在我们再创建一个Car类,同样在Car.h中声明一个方法- (void)walk; 并且在Car.m中实现它,然后重写Person类的- (id)forwardingTargetForSelector:(SEL)aSelector; 注意,此时不要在+ (BOOL)resolveInstanceMethod:(SEL)sel 做任何处理。

 - (id)forwardingTargetForSelector:(SEL)aSelector
{
NSString *selString = NSStringFromSelector(aSelector);
if ([selString isEqualToString:@"walk"])
{
// 将消息转发给Car
return [[Car alloc] init];
} return [super forwardingTargetForSelector:aSelector];
}

 外部同样是调用[person walk]; 这样就实现了将消息由Person转发到Car中了。

 我们还可以利用- (void)forwardInvocation:(NSInvocation *)anInvocation; 和- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector; 结合来实现消息转发。如果一个对象收到一条无法处理的消息,运行时系统会在抛出错误前,给该对象发送一条forwardInvocation:消息,该消息的唯一参数是个NSInvocation类型的对象,该对象封装了原始的消息和消息的参数。

 - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
{
NSMethodSignature *methodSign = [super methodSignatureForSelector:aSelector];
if (!methodSign)
{
// 手动设置方法的有效签名
methodSign = [Car instanceMethodSignatureForSelector:aSelector];
} return methodSign;
} - (void)forwardInvocation:(NSInvocation *)anInvocation
{
SEL selector = [anInvocation selector];
NSString *selString = NSStringFromSelector(selector);
if ([selString isEqualToString:@"walk"])
{
if ([Car instancesRespondToSelector:selector])
{
// 消息调用
[anInvocation invokeWithTarget:[[Car alloc] init]];
}
}
}

三、Runtime技术点之交换方法实现

 交换方法实现的需求场景还是比较多的,假设我们写了一个功能性的方法,该方法在整个项目中被多次调用,当需求更改时,要求使用另一种功能代替现有的这个功能,这个时候我们通常有以下几种做法:

 (1)将这个方法的现有实现删掉,重新实现新的功能;

 (2)重新实现一个方法,将项目中所有调用现有方法的地方,都改成调用新的方法;

 ......

 这两种做法无疑都存在一定的缺陷,第(1)中方案,假设需求又要再改成之前的功能呢?这种现象是很常见的。第(2)种方案,耗时耗力,实施起来太麻烦。

 那利用Runtime该怎么操作呢?我们确实还是需要重新实现一个方法的,因为是一个新的功能需求嘛,但是原来的方法我们不去动它,只需在Runtime时将它们的实现交换一下即可,听起来是不是很简单呢?那就直接上代码吧。

 @implementation Person

 - (void)walk
{
NSLog(@"Person walk.");
} - (void)eat
{
NSLog(@"Person eat.");
} + (void)load
{
Method methodOne = class_getInstanceMethod(self, @selector(walk));
Method methodTwo = class_getInstanceMethod(self, @selector(eat)); method_exchangeImplementations(methodOne, methodTwo);
} @end

 交换两个方法的实现一般写在类的load方法里面,因为load方法会在程序运行前加载一次,而initialize方法会在类或者子类在第一次使用的时候调用,当有分类的时候会调用多次。通过method_exchangeImplementations我们将walk和eat方法的实现进行了交换,这样在外边调用[person walk]; 时,实际上执行的是eat中的实现。

 有两点是需要注意一下的:

  (1)如果两个方法都是有参数的,那么参数的类型必须是匹配的,也即参数的类型必须一致;但是如果一个有参数,一个没有参数,经过测试,也是可以执行成功的。

  (2)如果方法一调用了方法二,就像这样:

 - (void)walk
{
NSLog(@"Person walk.");
} - (void)eat
{
NSLog(@"Person eat."); [self walk];
}

    那么在执行交换方法实现之后,需要将调用方法二的地方改成调用方法一,就像这样:

 - (void)walk
{
NSLog(@"Person walk.");
} - (void)eat
{
NSLog(@"Person eat."); [self eat];
}

    否则会造成死循环。其实很好理解,交换之后,walk方法的实现实际已经变成了eat的实现,再去调用walk时相当于调用的eat,所以会一直调用下去。

  如果明白了下面这个原理,上面的这个技术点很好理解:

    任何一个方法都有两个重要的属性:SEL是方法的编号,IMP是方法的实现,方法的调用过程实际上去根据SEL去寻找IMP。

ps:好了,关于iOS的Runtime学习,就先整理到这吧,有一些东西只是停留在原理上,还没有实际应用到具体场景,所以还是有些地方是不太透彻的,欢迎大家评论交流,共同进步。

  代码地址仍然是上一篇中的地址:GitHub,依然是每一个知识点对应一个版本,需要的小伙伴可以下载查看。