blog

探索 Method Swizzling 的正确使用方式

2019-03-01
iOS 技术

背景

初次接触 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_setFrameframe。但实际上,应该找到的是原始的 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。