OC底层原理15-Method Swizzling

iOS--OC底层原理文章汇总

Method Swizzling

方法交换,这是发生在Runtime中的一种处理两个方法交换的手段,它处理的是什么?怎么处理的?今天就来探究一下。
method swizzling在runtime将一个方法的实现替换为另一个方法的实现。在前面的篇章中,我们知道,一个方法的实现是需要sel、imp对应的,通过sel就能找到impmethod swizzling正是通过改变sel、imp指向来实现方法的交换。这也是Runtime的一种应用。
下面是sel、imp交换前后示意图

交换前

经过method swizzling,也可以理解为imp swizzling,将二者的sel、imp,通过更改实现

交换后

我们是在什么时间去处理这个调用转换呢?我们可以选择在+load调用时(也有用在initialize)。由前面的篇章我们了解到,如果类实现+load,使得该类成为非懒加载类,这个方法系统调用的时间会很早,所以在runtime过程中,放在此处是很合适的。
先来看看处理方法交换的相关API,苹果文档显示右如下的方法是处理方法可能用到的:

method_invoke 调用指定方法的实现。
method_invoke_stret 调用返回数据结构的指定方法的实现。
method_getName 返回方法的名称。
method_getTypeEncoding 返回描述方法参数和返回类型的字符串。
method_copyReturnType 返回描述方法返回类型的字符串。
method_copyArgumentType 返回描述方法的单个参数类型的字符串。
method_getReturnType 通过引用返回描述方法返回类型的字符串。
method_getNumberOfArguments 返回方法接受的参数数量。
method_getArgumentType 通过引用返回描述方法的单个参数类型的字符串。
method_getDescription 返回指定方法的方法描述结构。
method_setImplementation 设置方法的实现。
method_exchangeImplementations 交换两种方法的实现。
method_getImplementation 返回方法的实现。

实操

由于选择在+load中处理方法交换,+load可能被多次调用,那就得保证方法交换仅仅执行一次,所以采用单例模式处理:

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{  
        /** 保证执行一次交换
         *  定义一个Runtime工具类,在其中实现交换
         * animalInstanceMethod:原始待替换方法
         * smallCatInstanceMethod:替换之后的方法
         */
        [TLRuntimeTool runtimeMethodSwizzlingWithClass:self originSEL:@selector(animalInstanceMethod) swizzlingSEL:@selector(smallCatInstanceMethod)];
    });
}

以下是TLRuntimeTool的实现

//--------TLRuntimeTool.h----------
@interface TLRuntimeTool : NSObject

+(void)runtimeMethodSwizzlingWithClass:(Class)targetCls originSEL:(SEL)oriSel swizzlingSEL:(SEL)swizzlingSel;

@end
//--------TLRuntimeTool.m ----------
#import "TLRuntimeTool.h"
#import <objc/runtime.h>

@implementation TLRuntimeTool

+(void)runtimeMethodSwizzlingWithClass:(Class)targetCls originSEL:(SEL)oriSel swizzlingSEL:(SEL)swizzlingSel
{
    if (!targetCls) {
        NSLog(@"传入的类不能为空");
        return;
    }
    Method originMethod = class_getInstanceMethod(targetCls, oriSel);
    Method swizzlingMethod = class_getInstanceMethod(targetCls, swizzlingSel);
    method_exchangeImplementations(originMethod, swizzlingMethod);   
}

现在对方法替换做一个测试,定义一个Animal类、Cat类、Cat+small分类,并在VC页面对其调用

测试工程结构

以下是各类实现

  • Animal
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface Animal : NSObject
-(void)animalInstanceMethod;

+(void)animalClassMethod;

@end

NS_ASSUME_NONNULL_END
// -----------------------
#import "Animal.h"
@implementation Animal
- (void)animalInstanceMethod{
    NSLog(@"animal instance method:%s",__func__);
}
+ (void)animalClassMethod{
    NSLog(@"animal class metohd: %s",__func__);
}
@end
  • Cat
#import "Animal.h"

NS_ASSUME_NONNULL_BEGIN

@interface Cat : Animal

@end

NS_ASSUME_NONNULL_END

#import "Cat.h"

@implementation Cat
- (void)animalInstanceMethod{
    NSLog(@"cat instance method:%s",__func__);
}
@end
  • Cat+small
#import "Cat.h"

NS_ASSUME_NONNULL_BEGIN

@interface Cat (small)

@end

NS_ASSUME_NONNULL_END
//----------------
#import "Cat+small.h"
#import "TLRuntimeTool.h"
@implementation Cat (small)

+ (void)load
{
        /** 保证执行一次交换
         *  定义一个Runtime工具类,在其中实现交换
         * animalInstanceMethod:原始待替换方法
         * smallCatInstanceMethod:替换之后的方法
         */
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [TLRuntimeTool runtimeMethodSwizzlingWithClass:self originSEL:@selector(animalInstanceMethod) swizzlingSEL:@selector(smallCatInstanceMethod)];
    });
}
- (void)smallCatInstanceMethod
{
    NSLog(@"给 small cat 分类添加了实例方法:%s",__func__);
  [self smallCatInstanceMethod]; 
}
@end

正常情况下,这样就能处理运行时对方法的替换,不会有什么问题。但会有一些坑点,这样是不完善的,下面来看一看

坑点1:[self smallCatInstanceMethod]是否会产生递归

这里可能会有一个面试点,这样是否会产生递归呢?答案是不会,因为经过方法替换,自己调用自己的 smallCatInstanceMethod,其实该originSel指向的是swizzlingIMP,而swizzlingIMP指向的又会是originIMP,不会产生递归。

循环调用是否递归?

方法替换结果

坑点2:父类的方法,父类实现了,子类未实现

Animal类实现了一个方法animalInstanceMethod(),继承它的一个子类Cat未实现该方法animalInstanceMethod(),然后需要替换的就是该子类Cat的animalInstanceMethod(),而子类就会因为未实现而奔溃。

子类未实现父类方法奔溃

奔溃提示-[Animal smallCatInstanceMethod]找不到,就是因为在TLRuntimTool在方法替换过程中,分类替换方法中去执行[self smallCatInstanceMethod],Animal 的animalInstanceMethod的实现已经是指向Cat类的smallCatInstanceMethod,而分类中未实现animalInstanceMethod,当Animal去查找对应IMP时,是查找不到smallCatInstanceMethod,所以就崩溃了。为了解决该情况,我们就需要在TLRuntimTool中对特殊情况做处理:

//--------TLRuntimeTool.h----------
@interface TLRuntimeTool : NSObject

+(void)runtimeMethodSwizzlingWithClass:(Class)targetCls originSEL:(SEL)oriSel swizzlingSEL:(SEL)swizzlingSel;

@end
//--------TLRuntimeTool.m ----------
#import "TLRuntimeTool.h"
#import <objc/runtime.h>

@implementation TLRuntimeTool

+(void)runtimeMethodSwizzlingWithClass:(Class)targetCls originSEL:(SEL)oriSel swizzlingSEL:(SEL)swizzlingSel
{
    if (!targetCls) {
        NSLog(@"传入的类不能为空");
        return;
    }
    
    Method originMethod = class_getInstanceMethod(targetCls, oriSel);
    Method swizzlingMethod = class_getInstanceMethod(targetCls, swizzlingSel);
    
    BOOL swizzlingResult = class_addMethod(targetCls,
                                           oriSel,
                                           method_getImplementation(swizzlingMethod),
                                           method_getTypeEncoding(originMethod));
    /**
     * 判断是否能添加成功;YES->表明对象类没有方法,重写一个实现方法
     */
    if (swizzlingResult) {
        class_replaceMethod(targetCls,
                            swizzlingSel,
                            method_getImplementation(originMethod),
                            method_getTypeEncoding(originMethod));
    }else{
        // 原有类有实现方法
        method_exchangeImplementations(originMethod, swizzlingMethod);
    }
}

先获取到原有方法和将替换方法,对二者做一个操作,向具有给定名称和实现的目标类中添加新方法.
1.如果返回YES,表明目标类没有方法,重写一个实现方法,替换给定类的方法的实现。即originSEL_A -> originIMP_B -> swizzlingSEL - > originIMP_B.
2.如果返回NO,则该类有给定方法的实现,则替换两个方法的实现;即originSEL_B -> oringIMP_A.
这样就处理之后优化了

坑点3:父类子类都不曾实现需要替换的方法

假设需要替换Cat类的一个animalInstanceMethod,父类声明了方法animalInstanceMethod,子类父类都未实现,按坑点2优化后的结果依然是有问题的。
按照

- (void)smallCatInstanceMethod
{
    NSLog(@"给 small cat 分类添加了实例方法:%s",__func__);
  [self smallCatInstanceMethod]; 
}

这个时候就会出现递归

递归

原因是,子类父类都未实现animalInstanceMethod,在Cat交换smallCatInstanceMethod时再次调用自身就会产生递归。为了避免这个情况,那就还需要优化:


@implementation TLRuntimeTool

+(void)runtimeMethodSwizzlingWithClass:(Class)targetCls originSEL:(SEL)oriSel swizzlingSEL:(SEL)swizzlingSel
{
    if (!targetCls) {
        NSLog(@"传入的类不能为空");
        return;
    }
    
    Method originMethod = class_getInstanceMethod(targetCls, oriSel);
    Method swizzlingMethod = class_getInstanceMethod(targetCls, swizzlingSel);
    
    if (!originMethod) {
        // 在oriMethod为nil时,替换后将swizzledSEL复制一个不做任何事的空实现
        class_addMethod(targetCls, oriSel, method_getImplementation(swizzlingMethod), method_getTypeEncoding(swizzlingMethod));
        method_setImplementation(swizzlingMethod, imp_implementationWithBlock(^(id self, SEL _cmd){ }));
    }
    
    BOOL swizzlingResult = class_addMethod(targetCls,
                                           oriSel,
                                           method_getImplementation(swizzlingMethod),
                                           method_getTypeEncoding(originMethod));
    /**
     * 判断是否能添加成功;如果成功,表明对象类没有方法,重写一个实现方法
     */
    if (swizzlingResult) {
        class_replaceMethod(targetCls,
                            swizzlingSel,
                            method_getImplementation(originMethod),
                            method_getTypeEncoding(originMethod));
    }else{
        // 原有类有实现方法
        method_exchangeImplementations(originMethod, swizzlingMethod);
    }
}

添加了一个对原有方法的判断,避免对没有实现的方法进行替换而出错,在oriMethod为nil时,替换后将swizzledSEL复制一个不做任何事的空实现。

这样就非常棒的解决了几个特殊情况下导致的错误。

方法替换的应用

method-swizzling最常用的应用是防止数组越界奔溃、字典取值崩溃等情况。
在iOS中NSNumber、NSArray、NSDictionary等这些类都是类簇,NSArray的实现时可能会由多个类组成。所以如果想对NSArray进行替换,必须获取到其本类(__NSArrayM)进行替换,直接对NSArray进行操作是无效的。类簇详情请参看Apple文档class cluster的内容.

// 开发过程中断点时留意下就知道其底层的类,如 mutA __NSArrayM * @"2 elements" 0x00006000036fd770
类簇                              “真身”
NSArray               --->    __NSArrayI
NSMutableArray        --->    __NSArrayM
NSDictionary          --->    __NSDictionaryI
NSMutableDictionary   --->    __NSDictionaryM
NSNumber              --->    __NSCFNumber

NSArray为例,新建一个NSArray分类,在里面添加以下方法,区分开发模式发布模式


#import "NSArray+AvoidCrash.h"
#import "TLRuntimeTool.h"
#import <objc/runtime.h>
@implementation NSArray (AvoidCrash)
+ (void)load
{
        /** 保证执行一次交换
         *  定义一个Runtime工具类,在其中实现交换
         * animalInstanceMethod:原始待替换方法
         * smallCatInstanceMethod:替换之后的方法
         */
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        [TLRuntimeTool runtimeMethodSwizzlingWithClass:NSClassFromString(@"__NSArrayI")
                                             originSEL:@selector(objectAtIndex:)
                                          swizzlingSEL:@selector(avoidCrashObjectsAtIndexes:)];

    });
}

//__NSArrayI  objectAtIndex:
- (id)avoidCrashObjectsAtIndexes:(NSUInteger)index {

#ifdef DEBUG  // 开发模式
      return  [self avoidCrashObjectsAtIndexes:index];
#else // 发布模式
    id object = nil;
    @try {
       object = [self avoidCrashObjectsAtIndexes:index];
   }
   @catch (NSException *exception) {
       // 捕捉到的错误
       
       NSLog(@"** Exception class :%s ** Exception Method: %s \n", class_getName(self.class), __func__);
       
       NSLog(@"Uncaught exception description: %@", exception);
       
       NSLog(@"%@", [exception callStackSymbols]);
       
   }
   @finally {
       return object;
   }
#endif
}
@end

写一个数组,取值测试如下

    NSArray * mutA = @[@"3",@"2"];
    NSLog(@"%@",[mutA objectAtIndex:3]);
  • 开发模式


    该崩还是让它崩
  • 模拟发布模式


    读取崩溃错误

    打印了错误日志,但程序不会崩溃

GitHub工程地址