探索 Method Swizzling 的正确使用方式
背景
初次接触 Objective-C 的运行时机制时,很多人会被其“黑魔法”所吸引。特别是当使用方法交换(Method Swizzling)来钩住一个方法并改变其实现时,这使得 AOP 统计跟踪、APM 检测等功能成为可能。
风险
然而,能力越大,责任越大。
在 Stackoverflow 上有一篇文章:“Objective-C 中方法交换的危险是什么?”,清晰地描述了 Objective-C 中方法交换的风险。以下是简要总结。
1. 方法交换不是原子操作
在大多数情况下,方法交换是安全的。这是因为我们通常希望方法替换在整个 APP 生命周期内有效,所以会在 +(void)load
方法中进行操作,这样不会有并发问题。但如果不小心在 +(void)initialize
中进行操作,可能会出现非常奇怪的情况。
实际上,应尽量减少在
+(void)initialize
中的操作,以避免影响启动速度。
2. 它可能会改变不属于我们代码的实现
这是一个显而易见的问题。如果在不了解情况的情况下进行方法交换,可能会影响到别人的代码。尤其是如果覆盖了一个类中的方法却没有调用父类的方法,可能会出现问题。因此,为了避免潜在的不确定性,最好在交换的方法中调用原始实现。
3. 命名冲突的潜在风险
在进行方法交换时,我们通常会为新方法加上前缀。
例如:
- (void)my_setFrame:(NSRect)frame {
// do custom work
[self my_setFrame:frame];
}
然而,这里存在一个问题:如果某处也定义了 - (void)my_setFrame:(NSRect)frame
,就可能导致问题。
因此,最佳解决方案是使用函数指针(尽管这使代码看起来不像 Objective-C)。
@implementation NSView (MyViewAdditions)
static void MySetFrame(id self, SEL _cmd, NSRect frame);
static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame);
static void MySetFrame(id self, SEL _cmd, NSRect frame) {
// 执行自定义操作
SetFrameIMP(self, _cmd, frame);
}
+ (void)load {
[self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];
}
@end
作者还提供了一种更理想的方法交换定义:
typedef IMP *IMPPointer;
BOOL class_swizzleMethodAndStore(Class class, SEL original, IMP replacement, IMPPointer store) {
IMP imp = NULL;
Method method = class_getInstanceMethod(class, original);
if (method) {
const char *type = method_getTypeEncoding(method);
imp = class_replaceMethod(class, original, replacement, type);
if (!imp) {
imp = method_getImplementation(method);
}
}
if (imp && store) { *store = imp; }
return (imp != NULL);
}
@implementation NSObject (FRRuntimeAdditions)
+ (BOOL)swizzle:(SEL)original with:(IMP)replacement store:(IMPPointer)store {
return class_swizzleMethodAndStore(self, original, replacement, store);
}
@end
4. 改变方法参数的问题
作者认为这是最大的问题。当你替换一个方法时,你也替换了传递给原始方法实现的参数。
[self my_setFrame:frame];
这行代码实际上做的是:
objc_msgSend(self, @selector(my_setFrame:), frame);
运行时查找 my_setFrame:
的实现,一旦找到,就会传递 my_setFrame
和 frame
。但实际上,应该找到的是原始的 setFrame:
,所以当它被调用时,_cmd 参数不是预期的 setFrame:
,而是 my_setFrame
,接收到一个意外参数。
最好的办法还是使用上述定义。
5. 方法交换带来的顺序问题
在对多个类进行方法交换时,要注意顺序,尤其是在存在父子类关系时。 例如:
[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
在上述实现中,当你调用 NSButton 的 setFrame 时,它将调用你替换后的 my_buttonSetFrame 方法和 NSView 的原始 setFrame 方法。
相反,如果顺序是这样的:
[NSView swizzle:@selector(setFrame:) with:@selector(my_viewSetFrame:)];
[NSControl swizzle:@selector(setFrame:) with:@selector(my_controlSetFrame:)];
[NSButton swizzle:@selector(setFrame:) with:@selector(my_buttonSetFrame:)];
它将调用替换后的 NSButton、NSControl 和 NSView 的方法,这是正确的顺序。
所以,仍然推荐在 +(void)load
方法中进行方法交换,因为这样可以确保父类的 load 方法先于子类被调用,从而避免错误。
6. 带来理解和调试上的复杂性
这一点无需多言,尤其是在没有文档记录的情况下。有时候,如果你遇到同事写的一些运行时操作,而这些操作藏在某个角落无人知晓,就可能导致不可预测的问题,使调试变得极其麻烦。
正确的方法
那么什么是正确的方法呢?
1.
如作者强调的那样,在 load
方法中进行方法替换
2.
上述作者给出的“完美定义”已经相当正确。但这里还有一点需要注意。
网上有些文章谈到通过 Category 实现方法交换,如下所示:
#import <objc/runtime.h>
@implementation UIViewController (Tracking)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(viewWillAppear:);
SEL swizzledSelector = @selector(xxx_viewWillAppear:);
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
// When swizzling a class method, use the following:
// Class class = object_getClass((id)self);
// ...
// Method originalMethod = class_getClassMethod(class, originalSelector);
// Method swizzledMethod = class_getClassMethod(class, swizzledSelector);
BOOL didAddMethod =
class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}
#pragma mark - Method Swizzling
- (void)xxx_viewWillAppear:(BOOL)animated {
[self xxx_viewWillAppear:animated];
NSLog(@"viewWillAppear: %@", self);
}
@end
然而,这并不够严谨,并且包含了一些之前提到的风险。如果原始实现使用了 _cmd 参数,那么交换后 _cmd 并不符合预期。
假设现在我们要钩住 - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
那么,如果按照上面的方式实现,[self xxx_touchesBegan:touches withEvent:event]; 会崩溃。原因是这个函数包含 forwardTouchMethod,并且反汇编后实现类似如下:
static void forwardTouchMethod(id self, SEL _cmd, NSSet *touches, UIEvent *event) {
// The responder chain is used to figure out where to send the next touch
UIResponder *nextResponder = [self nextResponder];
if (nextResponder && nextResponder != self) {
// Not all touches are forwarded - so we filter here.
NSMutableSet *filteredTouches = [NSMutableSet set];
[touches enumerateObjectsUsingBlock:^(UITouch *touch, BOOL *stop) {
// Checks every touch for forwarding requirements.
if ([touch _wantsForwardingFromResponder:self toNextResponder:nextResponder withEvent:event]) {
[filteredTouches addObject:touch];
} else {
// This is interesting legacy behavior. Before iOS 5, all touches are forwarded (and this is logged)
if (!_UIApplicationLinkedOnOrAfter(12)) {
[filteredTouches addObject:touch];
// Log old behavior
static BOOL didLog = 0;
if (!didLog) {
NSLog(@"Pre-iOS 5.0 touch delivery method forwarding relied upon. Forwarding -%@ to %@.", NSStringFromSelector(_cmd), nextResponder);
}
}
}
}];
// here we basically call [nextResponder touchesBegan:filteredTouches event:event];
[nextResponder performSelector:_cmd withObject:filteredTouches withObject:event];
}
}
如果我们交换了 IMP,[nextResponder performSelector:_cmd withObject:filteredTouches withObject:event]; 将不会有对应的实现,并且 _cmd 将变成我们替换后的 SEL。显然,nextResponder 没有实现相应的方法,导致崩溃。
在这种情况下,您可以这样写:
static IMP __original_TouchesBegan_Method_Imp;
@implementation UIView (Debug)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(touchesBegan:withEvent:);
SEL swizzledSelector = @selector(dae_touchesBegan:withEvent:);
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
__original_TouchesBegan_Method_Imp = method_getImplementation(originalMethod);
BOOL didAddMethod =
class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}
- (void)dae_touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// custom
void (*functionPointer)(id, SEL, NSSet<UITouch *> *, UIEvent *) = (void(*)(id, SEL, NSSet<UITouch *> *, UIEvent*))__original_TouchesBegan_Method_Imp;
functionPointer(self, _cmd, touches, event);
}
这样,就可以找到正确的 IMP。