相信大家对于Method Swizzling并不陌生,在平时的开发中多多少少都有些使用,这也是Runtime的开发应用中比较广泛的用法。但是它确确实实是个黑魔法,一有不慎,就是一座天坑。本章就来研究一下它,让他变成白魔法
什么是Method Swizzling?
-
Method Swizzling
(方法交换),顾名思义,就是将两个方法的实现交换,即由原来的A-AImp
、B-BImp
对应关系变成了A-BImp
、B-AImp
。 -
每个类都维护一个方法
Method
列表,Method
则包含SEL
和其对应IMP
的信息,方法交换做的事情就是把SEL
和IMP
的对应关系断开,并和新的IMP
生成对应关系。 -
Method Swizzing
是发生在运行时的,主要用于在运行时将两个Method进行交换,我们可以将Method Swizzling
代码写到任何地方,但是只有在这段Method Swilzzling
代码执行完毕之后互换才起作用。 -
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来实现。
下面的例子中,我们通过交换UIViewController
中viewWillAppear
和viewWillDisappear
的方法,来实现了进入界面和退出界面的统计,并记录了相关的类名,通过映射的关系,就可以清楚的知道用户的行为了
@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];
}
}
除了 UIViewController
、UIButton
控件以外,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这些的类簇是不起作用的。
因为这些类簇类,其实是一种抽象工厂的设计模式。抽象工厂内部有很多其它继承自当前类的子类,抽象工厂类会根据不同情况,创建不同的抽象对象来进行使用。例如我们调用NSArray
的objectAtIndex:
方法,这个类会在方法内部判断,内部创建不同抽象类进行操作。
所以如果我们对NSArray
类进行Swizzling
操作其实只是对父类进行了操作,在NSArray
内部会创建其他子类来执行操作,真正执行Swizzling
操作的并不是NSArray
自身,所以我们应该对其“真身”进行操作。
下面列举了NSArray
和NSDictionary
本类的类名,可以通过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思想