接入 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:这个名字后面。
惯用完整实现
实际开发中常用的安全措施全部包含在内时,代码如下。
@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成功,就等于“向该类新增了原始实现”,因此可以安全地只在自身类的范围内完成交换。
使用+load和dispatch_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。这讲的是替换类本身,而不是方法。

