阅读 306

iOS底层学习 - Runtime之Method Swizzling黑魔法

相信大家对于Method Swizzling并不陌生,在平时的开发中多多少少都有些使用,这也是Runtime的开发应用中比较广泛的用法。但是它确确实实是个黑魔法,一有不慎,就是一座天坑。本章就来研究一下它,让他变成白魔法

什么是Method Swizzling?

  1. Method Swizzling(方法交换),顾名思义,就是将两个方法的实现交换,即由原来的A-AImpB-BImp对应关系变成了A-BImpB-AImp

  2. 每个类都维护一个方法Method列表,Method则包含SEL和其对应IMP的信息,方法交换做的事情就是把SELIMP的对应关系断开,并和新的IMP生成对应关系。

  3. Method Swizzing是发生在运行时的,主要用于在运行时将两个Method进行交换,我们可以将Method Swizzling代码写到任何地方,但是只有在这段Method Swilzzling代码执行完毕之后互换才起作用

  4. Method Swizzling是OC动态性的最好诠释,深入地去学习并理解其特性,将有助于我们在业务量不断增大的同时还能保持代码的低耦合度,降低维护的工作量和难度。

可以用图来更好的解释一下

交换前:

交换后:

Method Swizzling相关函数API

//获取通过SEL获取一个方法
class_getInstanceMethod
复制代码
//获取一个方法的实现
method_getImplementation
复制代码
//获取一个OC实现的编码类型
method_getTypeEncoding
复制代码
//給方法添加实现
class_addMethod
复制代码
//用一个方法的实现替换另一个方法的实现
class_replaceMethod
复制代码
//交换两个方法的实现
method_exchangeImplementations
复制代码

Method Swizzling使用注意事项

  • 1.方法交换应该保证唯一性和原子性
    • 唯一性:应该尽可能在+load方法中实现,这样可以保证方法一定会调用且不会出现异常。
    • 原子性:使用dispatch_once来执行方法交换,这样可以保证只运行一次。
  • 2.一定要调用原始实现
    • 由于iOS的内部实现对我们来说是不可见的,使用方法交换可能会导致其代码结构改变,而对系统产生其他影响,因此应该调用原始实现来保证内部操作的正常运行
  • 3.方法名必须不能产生冲突
    • 这个是常识,避免跟其他库产生冲突。
  • 4.做好注释和Log
    • 记录好被影响过的方法,不然时间长了或者其他人debug代码时候可能会对一些输出信息感到困惑。
  • 5.如果非迫不得已,尽量少用方法交换
    • 虽然方法交换可以让我们高效地解决问题,但是如果处理不好,可能会导致一些莫名其妙的bug。

典型坑点-交换方法主动调用load

第一个坑点比较简单,就是我们在load中交换完方法后,不做处理的话,如果再去调用load,方法IMP会又被交换回来,导致交换不成功。

解决的方法也比较简单,在上述的注意事项1中已经说过,使用单例模式来交换方法,保证方法的交换只执行一次

典型坑点-子类无实现,交换父类方法

坑点例子

我们可以通过一个例子来实现,创建父类LGPerson,子类LGStudent和分类LGStudent+LG来进行方法的交换。

@interface LGPerson : NSObject
- (void)personInstanceMethod;
@end

@implementation LGPerson
- (void)personInstanceMethod{
    NSLog(@"person对象方法:%s",__func__);
}
@end
**************************************************************
@interface LGStudent : LGPerson

@end

@implementation LGStudent

@end
**************************************************************
复制代码

首先进行普通的方法交换,并在VC里面正常调用,根据打印结果,可以发现我们在子类交换了父类的方法后,没有产生崩溃,并且子类的分类中交换的方法也正常执行了

@implementation LGStudent (LG)

+ (void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [LGRuntimeTool lg_betterMethodSwizzlingWithClass:self oriSEL:@selector(personInstanceMethod) swizzledSEL:@selector(lg_studentInstanceMethod)];
    });
}

- (void)lg_studentInstanceMethod{
    [self lg_studentInstanceMethod];
    NSLog(@"LGStudent分类添加的lg对象方法:%s",__func__);
}

@end

*******************************调用*******************************
- (void)viewDidLoad {
    [super viewDidLoad];
    LGStudent *s = [[LGStudent alloc] init];
    [s personInstanceMethod];
}
*******************************打印结果*******************************
2020-01-20 10:45:31.809408+0800 006---Method-Swizzling坑[81429:20470219] person对象方法:-[LGPerson personInstanceMethod]
2020-01-20 10:45:31.809568+0800 006---Method-Swizzling坑[81429:20470219] LGStudent分类添加的lg对象方法:-[LGStudent(LG) lg_studentInstanceMethod]

复制代码

但是,如果我们在调用的时候,父类本身再调用一下这个方法的话,就会出现崩溃,原因也比较清楚,就是子类将此方法交换了,父类并没有交换后方法的IMP,所以会出现找不到方法的崩溃

- (void)viewDidLoad {
    [super viewDidLoad];
    
    LGStudent *s = [[LGStudent alloc] init];
    [s personInstanceMethod];
    
    LGPerson *p = [[LGPerson alloc] init];
    [p personInstanceMethod];
}

复制代码

解决方案

一句话总结就是:如果该方法自己没有,则先给自己添加要交换的方法。之后再父类原方法IMP指向交换的方法

在交换方法的时候,先尝试添加一下原方法到类中,并将IMP指向交换的方法

  • 如果成功了,说明该类之前没有,那么需要替换父类原方法的IMP到交换的方法中,这样就行程了上面图中的闭环
  • 如果没有成功,说明该类中本身就有此方法,那么直接进行交换即可
+ (void)lg_betterMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
    
    if (!cls) NSLog(@"传入的交换类不能为空");
    // oriSEL       personInstanceMethod
    // swizzledSEL  lg_studentInstanceMethod
    
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
   
    // 尝试添加
    // ✅对应关系:personInstanceMethod(sel) - lg_studentInstanceMethod(imp)
    BOOL success = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(oriMethod));
    
    if (success) {// 自己没有 - 交换 - 没有父类进行处理 (重写一个)
        //✅lg_studentInstanceMethod (swizzledSEL) - personInstanceMethod(imp)
        class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    }else{ // 自己有
        method_exchangeImplementations(oriMethod, swiMethod);
    }
}
复制代码

典型坑点-方法只有声明,没有实现

还是使用上述例子,如果LGStudent有一个方法- (void)helloword只有生命,没有实现。

就算我们使用了上述的解决方法,添加了方法,但是由于原方法找不到,为nil。所以会造成死循环调用

我们可以通过判断原方法是否存在,并添加一个一个空实现来解决这个坑点。之后再进行判断原方法进行交换,这样就能完美解决了

具体代码如下

+ (void)lg_bestMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
    
    if (!cls) NSLog(@"传入的交换类不能为空");
    
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
    
    if (!oriMethod) {
        ✅// 在oriMethod为nil时,替换后将swizzledSEL复制一个不做任何事的空实现,代码如下:
        class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
        method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){ }));
    }
    
    ✅// 一般交换方法: 交换自己有的方法 -- 走下面 因为自己有意味添加方法失败
    ✅// 交换自己没有实现的方法:
    ✅//   首先第一步:会先尝试给自己添加要交换的方法 :personInstanceMethod (SEL) -> swiMethod(IMP)
    ✅//   然后再将父类的IMP给swizzle  personInstanceMethod(imp) -> swizzledSEL
    //oriSEL:personInstanceMethod

    BOOL didAddMethod = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
    if (didAddMethod) {
        class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    }else{
        method_exchangeImplementations(oriMethod, swiMethod);
    }
    
}
复制代码

Method Swizzling常见应用

以下swizzling方法的具体封装,和上述代码中一样

无侵入埋点

在 iOS 开发中最常见的三种埋点,就是对页面进入次数、页面停留时间、点击事件的埋点。这些都可以通过Method Swizzling来实现。

下面的例子中,我们通过交换UIViewControllerviewWillAppearviewWillDisappear的方法,来实现了进入界面和退出界面的统计,并记录了相关的类名,通过映射的关系,就可以清楚的知道用户的行为了


@implementation UIViewController (logger)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        ✅// 通过 @selector 获得被替换和替换方法的 SEL,作为 SMHook:hookClass:fromeSelector:toSelector 的参数传入 
        SEL fromSelectorAppear = @selector(viewWillAppear:);
        SEL toSelectorAppear = @selector(hook_viewWillAppear:);
        [SMHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
        
        SEL fromSelectorDisappear = @selector(viewWillDisappear:);
        SEL toSelectorDisappear = @selector(hook_viewWillDisappear:);
        
        [SMHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
    });
}

- (void)hook_viewWillAppear:(BOOL)animated {
    ✅// 先执行插入代码,再执行原 viewWillAppear 方法
    [self insertToViewWillAppear];
    [self hook_viewWillAppear:animated];
}
- (void)hook_viewWillDisappear:(BOOL)animated {
    ✅// 执行插入代码,再执行原 viewWillDisappear 方法
    [self insertToViewWillDisappear];
    [self hook_viewWillDisappear:animated];
}

- (void)insertToViewWillAppear {
    ✅// 在 ViewWillAppear 时进行日志的埋点
    [[[[SMLogger create]
       message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]
      classify:ProjectClassifyOperation]
     save];
}
- (void)insertToViewWillDisappear {
    ✅// 在 ViewWillDisappear 时进行日志的埋点
    [[[[SMLogger create]
       message:[NSString stringWithFormat:@"%@ Disappear",NSStringFromClass([self class])]]
      classify:ProjectClassifyOperation]
     save];
}
@end
复制代码

那么点击方法,我们也可以通过运行时方法替换的方式进行无侵入埋点。

这里最主要的工作是,找到这个点击事件的方法 sendAction:to:forEvent:,然后在 +load() 方法替换成为你定义的方法。完整代码实现如下:

UIViewController生命周期埋点不同的是,UIButton在一个视图类中可能有多个不同的继承类,相同 UIButton的子类在不同视图类的埋点也要区别开。所以,我们需要通过 “action 选择器名 NSStringFromSelector(action)” +“视图类名 NSStringFromClass([target class])”组合成一个唯一的标识,来进行埋点记录


+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        ✅// 通过 @selector 获得被替换和替换方法的 SEL,作为 SMHook:hookClass:fromeSelector:toSelector 的参数传入
        SEL fromSelector = @selector(sendAction:to:forEvent:);
        SEL toSelector = @selector(hook_sendAction:to:forEvent:);
        [SMHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
    });
}

- (void)hook_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    [self insertToSendAction:action to:target forEvent:event];
    [self hook_sendAction:action to:target forEvent:event];
}
- (void)insertToSendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    ✅// 日志记录
    if ([[[event allTouches] anyObject] phase] == UITouchPhaseEnded) {
        NSString *actionString = NSStringFromSelector(action);
        NSString *targetName = NSStringFromClass([target class]);
        [[[SMLogger create] message:[NSString stringWithFormat:@"%@ %@",targetName,actionString]] save];
    }
}
复制代码

除了 UIViewControllerUIButton 控件以外,Cocoa 框架的其他控件都可以使用这种方法来进行无侵入埋点。以 Cocoa 框架中最复杂的 UITableView 控件为例,你可以使用 hook setDelegate 方法来实现无侵入埋点。另外,对于 Cocoa 框架中的手势事件(Gesture Event),我们也可以通过 hook initWithTarget:action:方法来实现无侵入埋点。

防止数组,字典等越界崩溃

这个例子我相信平时在开发中,大家都用到过,因为数组越界等是最容易造成crash的一种方式,而且一般崩溃起来比较严重,所以我们一定要避免的

在iOS中NSNumber、NSArray、NSDictionary等这些类都是类簇(Class Clusters),一个NSArray的实现可能由多个类组成。所以如果想对NSArray进行Swizzling,必须获取到其真身进行Swizzling,直接对NSArray进行操作是无效的。这是因为Method Swizzling对NSArray这些的类簇是不起作用的。

因为这些类簇类,其实是一种抽象工厂的设计模式。抽象工厂内部有很多其它继承自当前类的子类,抽象工厂类会根据不同情况,创建不同的抽象对象来进行使用。例如我们调用NSArrayobjectAtIndex:方法,这个类会在方法内部判断,内部创建不同抽象类进行操作。

所以如果我们对NSArray类进行Swizzling操作其实只是对父类进行了操作,在NSArray内部会创建其他子类来执行操作,真正执行Swizzling操作的并不是NSArray自身,所以我们应该对其“真身”进行操作。

下面列举了NSArrayNSDictionary本类的类名,可以通过Runtime函数取出本类:

下面是一个常见的例子

@implementation NSArray (CrashHandle)

✅// 如果下面代码不起作用,造成这个问题的原因大多都是其调用了super load方法。在下面的load方法中,不应该调用父类的load方法。这样会导致方法交换无效
+ (void)load {
    Method originMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(objectAtIndex:));
    Method swizzlingMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(wy_objectAtIndex:));
    method_exchangeImplementations(fromMethod, toMethod);
}

✅// 为了避免和系统的方法冲突,我一般都会在swizzling方法前面加前缀
- (id)wy_objectAtIndex:(NSUInteger)index {
    ✅// 判断下标是否越界,如果越界就进入异常拦截
    if (self.count-1 < index) {
        @try {
            return [self cm_objectAtIndex:index];
        }
        @catch (NSException *exception) {
            ✅// 在崩溃后会打印崩溃信息。如果是线上,可以在这里将崩溃信息发送到服务器
            NSLog(@"---------- %s Crash Because Method %s  ----------\n", class_getName(self.class), __func__);
            NSLog(@"%@", [exception callStackSymbols]);
            return nil;
        }
        @finally {}
    } ✅// 如果没有问题,则正常进行方法调用
    else {
        return [self cm_objectAtIndex:index];
    }
}
**************************调用******************************
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 测试代码
    NSArray *array = @[@0, @1, @2, @3];
    [array objectAtIndex:3];
    //本来要奔溃的,但是没有,打印出了信息
    [array objectAtIndex:4];
}

复制代码

以上的两个例子,只是开发中常用的,还有很多其他的应用,就需要根据需求来不断调整了。这些都属于AOP面向切面编程的一个实际应用,Method Swizzling也是其在iOS开发中应用的最常用的一种AOP思想

参考

iOS runtime实战应用:Method Swizzling

iOS开发·runtime原理与实践

iOS开发高手课