Swift 与 Objective-C

Objective-C 方法交换(Method Swizzling)完整总结

方法交换是一种在运行时互换连接选择器与 IMP 的映射表的技术。本文整理了惯用的完整实现、_cmd 说谎这一副作用,以及可以使用到什么程度的边界。

5 分钟阅读
Objective-C 方法交换(Method Swizzling)完整总结 封面图

接入 Firebase Analytics 后,进入页面的事件会自动记录。明明我们在 viewDidAppear 中一行代码都没加。

之所以能做到这一点,是因为方法交换(Method Swizzling)。它会在运行时整体替换方法的实现,既是 Objective-C 运行时灵活性的典型案例,也是误用时会打开调试地狱的双刃剑。

正如 objc_msgSend 一文中所述,Objective-C 方法调用的结构是“通过选择器找到 IMP(函数指针)并跳转”。方法交换就是在运行时修改这张选择器→IMP 映射表


原理:互换映射表中的两行

类的方法列表就是“选择器 → IMP”映射表。method_exchangeImplementations会互换两个条目的 IMP。

选择器 交换前的 IMP 交换后的 IMP
viewDidAppear: 原始实现 我的实现
swz_viewDidAppear: 我的实现 原始实现

交换后,系统调用viewDidAppear:时就会执行我的实现。原始实现并没有消失,只是搬到了swz_viewDidAppear:这个名字后面。

展示方法交换前后选择器与 IMP 连接变化的示意图
原始实现不会消失,而是搬到另一个选择器后面

惯用完整实现

实际开发中常用的安全措施全部包含在内时,代码如下。

@implementation UIViewController (Tracking)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class cls = [self class];
        SEL originalSEL = @selector(viewDidAppear:);
        SEL swizzledSEL = @selector(swz_viewDidAppear:);
        Method original = class_getInstanceMethod(cls, originalSEL);
        Method swizzled = class_getInstanceMethod(cls, swizzledSEL);

        BOOL added = class_addMethod(cls, originalSEL,
                                     method_getImplementation(swizzled),
                                     method_getTypeEncoding(swizzled));
        if (added) {
            class_replaceMethod(cls, swizzledSEL,
                                method_getImplementation(original),
                                method_getTypeEncoding(original));
        } else {
            method_exchangeImplementations(original, swizzled);
        }
    });
}

- (void)swz_viewDidAppear:(BOOL)animated {
    [self swz_viewDidAppear:animated]; // 不是递归!下文解释
    NSLog(@"进入页面: %@", NSStringFromClass([self class]));
}

@end

这段代码中有两个容易混淆的要点。

**第一,[self swz_viewDidAppear:animated]不是递归。**这段代码执行时,IMP 已经完成互换,因此swz_选择器连接的是原始实现。也就是说,这一行就是“调用原始实现”。如果漏掉原始调用,整个页面跳转逻辑都会消失,所以它实际上是必需的。

第二,为什么不直接调用method_exchangeImplementations,而要先尝试class_addMethod因为目标方法可能没有实现在该类中,而只实现在父类中。此时直接 exchange 会修改父类的方法,导致所有继承该父类的其他子类都受到影响。如果class_addMethod成功,就等于“向该类新增了原始实现”,因此可以安全地只在自身类的范围内完成交换。

使用+loaddispatch_once的原因很简单。方法交换会改变全局状态,因此必须在 App 生命周期中尽早且只执行一次。+load的准确调用时机将在下一篇文章(+load vs +initialize)中单独讨论。


隐蔽的副作用:_cmd 会说谎

如果在交换后的方法中输出_cmd(当前选择器),得到的会是swz_viewDidAppear:,而不是viewDidAppear:。这是因为实现已经改变,但调用路径中的选择器仍然不变。

平时没有问题,但如果和使用_cmd作为键的代码混用(例如使用_cmd作为 Associated Objects 的键),就会产生隐蔽的 bug。如果原始实现内部依赖_cmd,行为可能会发生变化。


可以使用到什么程度

方法交换有理由使用的范围相当有限。

  • 所有页面通用的埋点:Analytics 或日志 SDK 自动采集进入页面和点击按钮的事件时
  • 规避第三方或系统 bug:临时修正没有源代码的框架行为时
  • 开发调试工具:想跟踪某个方法的全部调用时

反过来,出现以下情况就要提高警惕。

  • 如果多个库都交换同一个方法,执行顺序就取决于加载顺序;只要其中一个漏掉原始调用,其余部分就会全部崩溃
  • swz_方法混入堆栈跟踪后,崩溃报告会变得难以解读
  • 依赖系统内部实现的方法交换,可能一次操作系统更新就会失效

因此,如果可以通过子类化、代理委托或组合来解决问题,就应该优先采用这些方式。方法交换应当只保留为“其他方法在结构上不可行”时的最后手段。


Swift 中呢?

纯 Swift 方法采用静态分发(或 vtable),因此这项技术不起作用。要进行方法交换,目标方法必须暴露给 Objective-C 运行时。

class Tracker: NSObject {
    @objc dynamic func fire() { }
}

必须添加@objc dynamic,调用才会沿着 objc_msgSend 路径流转,也才能替换映射表。UIKit 类基于 Objective-C,所以仍然支持方法交换;但越接近 SwiftUI 世界,这项技术的用武之地就越小。

展示从代码卡片堆顶部替换一张卡片、说明方法交换风险的插图
抽错一张卡片,整体就会摇晃

总结

  • 方法交换是在运行时互换选择器→IMP 映射表的技术——原始实现不会消失,而是搬到另一个选择器后面
  • swizzled 实现中的[self swz_...]调用不是递归,而是原始调用
  • 先尝试class_addMethod是为了避免修改父类方法
  • 使用+load + dispatch_once,让它在 App 生命周期中只执行一次
  • 由于_cmd不一致、库之间的冲突以及操作系统更新风险确实存在,因此只能把它当作最后手段
  • 在 Swift 中,只有带有@objc dynamic的方法才会被交换

下一篇文章将深入解析另一个容易与方法交换混淆的运行时魔法:KVO(Key-Value Observing)的 isa-swizzling。这讲的是替换类本身,而不是方法。

延伸阅读