iOS Method Swizzling 的工程化实践:如何处理多重交换与第三方冲突

12 阅读11分钟

在 iOS 工程里,Method Swizzling 几乎是所有 Runtime 话题里最容易“会用”,却也最容易“踩坑”的技术之一。

很多团队第一次接触它,往往是为了做这些能力:

  • 页面埋点
  • 点击事件统计
  • KVO/KVC 防护
  • 容器类防崩
  • 网络层监控
  • 生命周期统一治理

一开始,大家通常会写出一版经典模板:

Method originalMethod = class_getInstanceMethod(cls, originalSEL);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSEL);
method_exchangeImplementations(originalMethod, swizzledMethod);

这段代码在单一场景下很好用,也足够直观。但真正进入复杂工程环境后,问题马上就会出现:

  • 如果第三方 SDK 也交换了同一个方法怎么办?
  • 如果多个内部模块都想 Hook 同一个 Selector 怎么办?
  • 如果我以为自己调回了“原方法”,其实调到的是别人的 Hook 怎么办?
  • 如果 Swizzling 链断了,会不会出现行为丢失、重复调用甚至死循环?

这时候,Method Swizzling 就不再是一个“技巧”,而是一个需要工程治理的问题。

这篇文章不讨论面试话术,只讨论一件事:在真实项目里,如何正确理解并处理多重交换,以及与第三方库的冲突。


一、先说结论:Swizzling 的难点不在“交换”,而在“链路”

很多文章会把 Swizzling 讲成“把 A 和 B 两个实现互换一下”。这在概念上没有问题,但在工程上不够。

因为真实项目里,方法实现并不是只有两层:

  • 系统原始实现
  • 你的 Hook 实现

更常见的情况是:

  • 系统原始实现
  • 第三方 SDK 的 Hook
  • 公司内部埋点库的 Hook
  • 容器防崩库的 Hook
  • 性能监控库的 Hook

此时,某一个 Selector 对应的调用关系,已经不再是“原方法 <-> 新方法”的简单互换,而是一个链:

自己 Hook -> 第三方 Hook -> 更早的 Hook -> 原始实现

所以,Swizzling 的核心问题不是“我能不能换过去”,而是:

  • 我能不能知道当前方法已经被谁换过
  • 我能不能把当前旧实现保存下来
  • 我能不能在自己的逻辑执行完之后,把调用链继续往下传
  • 我能不能保证链不断、不乱、不死循环

这才是工程里的重点。


二、为什么最基础的 method_exchangeImplementations 不够用

最常见的基础写法如下:

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

        Method originalMethod = class_getInstanceMethod(cls, originalSEL);
        Method swizzledMethod = class_getInstanceMethod(cls, swizzledSEL);

        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

- (void)mcf_viewDidAppear:(BOOL)animated {
    NSLog(@"track page");
    [self mcf_viewDidAppear:animated];
}

在单库、单次交换的场景下,这没有问题。因为交换之后:

  • viewDidAppear: 指向 mcf_viewDidAppear: 的实现
  • mcf_viewDidAppear: 指向原始 viewDidAppear: 的实现

所以在 Hook 方法里再调 [self mcf_viewDidAppear:animated],本质上就是调回原方法。

但一旦出现多方交换,问题就来了。

比如:

  • 埋点 SDK 先交换了 viewDidAppear:
  • 你自己的库又交换了一次 viewDidAppear:

此时你以为 [self mcf_viewDidAppear:animated] 调回的是“系统原始实现”,其实未必。它调回的只是“交换后的另一边”。一旦安装顺序复杂,调用链的可读性和稳定性都会快速下降。

所以,基础版的 method_exchangeImplementations 更适合作为“原理示例”,不适合作为复杂工程中的唯一方案。


三、更稳的写法:保存当前旧实现,而不是假设自己拿到的是原始实现

要解决多重交换问题,思路要从“互换”切换成“备份当前实现 + 接管目标入口”。

也就是说:

  • 先拿到目标方法当前对应的 IMP
  • 把这个 IMP 挂到一个备份 Selector 上
  • 再把目标 Selector 指向自己的 Hook 实现
  • Hook 执行时,再调用备份 Selector 对应的旧实现

这套思路的关键价值在于:

  • 你保存的是“当前旧实现”
  • 这个旧实现可能是系统原始实现
  • 也可能已经是第三方 Hook 过的实现
  • 但无论是谁,你都把调用链接住了

下面是一版更适合工程理解的实现。

#import <UIKit/UIKit.h>
#import <objc/runtime.h>

@interface UIViewController (MCFSafeTrack)
@end

@implementation UIViewController (MCFSafeTrack)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class cls = [self class];
        SEL targetSEL = @selector(viewDidAppear:);
        SEL hookSEL = @selector(mcf_hook_viewDidAppear:);
        SEL backupSEL = @selector(mcf_orig_viewDidAppear:);

        Method targetMethod = class_getInstanceMethod(cls, targetSEL);
        Method hookMethod = class_getInstanceMethod(cls, hookSEL);

        if (!targetMethod || !hookMethod) {
            return;
        }

        const char *targetTypes = method_getTypeEncoding(targetMethod);
        IMP currentIMP = method_getImplementation(targetMethod);

        BOOL addBackupSuccess = class_addMethod(cls, backupSEL, currentIMP, targetTypes);
        if (!addBackupSuccess) {
            class_replaceMethod(cls, backupSEL, currentIMP, targetTypes);
        }

        IMP hookIMP = method_getImplementation(hookMethod);
        class_replaceMethod(cls, targetSEL, hookIMP, method_getTypeEncoding(hookMethod));
    });
}

- (void)mcf_hook_viewDidAppear:(BOOL)animated {
    NSLog(@"[MCF] %@ didAppear", NSStringFromClass(self.class));
    [self mcf_hook_callOriginalViewDidAppear:animated];
}

- (void)mcf_hook_callOriginalViewDidAppear:(BOOL)animated {
    SEL backupSEL = @selector(mcf_orig_viewDidAppear:);

    if ([self respondsToSelector:backupSEL]) {
        void (*func)(id, SEL, BOOL) = (void (*)(id, SEL, BOOL))[self methodForSelector:backupSEL];
        if (func) {
            func(self, backupSEL, animated);
        }
    }
}

- (void)mcf_orig_viewDidAppear:(BOOL)animated {
    // never called directly
}

@end

四、这段代码到底做了什么

如果只看代码,很容易觉得它只是“换了一种 Swizzling 写法”。但它和传统 exchange 的心智模型完全不同。

1. +load 中做安装,而不是做业务逻辑

+load 的时机非常早,在 main 之前执行。它适合做一次性的 Runtime 安装动作,比如:

  • Method Swizzling
  • 注册全局拦截器
  • 建立底层防护钩子

它不适合做这些:

  • 文件 IO
  • 网络初始化
  • 大量对象创建
  • 重逻辑计算

所以,把 Swizzling 安装放在 +load 里是合理的;但把重逻辑塞进 +load,就会把启动链路拖长。

2. currentIMP 不是“原始实现”,而是“当前实现”

这一句是全篇最重要的地方:

IMP currentIMP = method_getImplementation(targetMethod);

这里拿到的,不一定是系统最原始的 viewDidAppear:。

如果第三方已经 Swizzle 过,这里拿到的就是第三方的 Hook 实现。

也正因为如此,这套方案的本质不是“保存原方法”,而是“保存当前旧实现”。

3. backupSEL 是一个备份入口

这一段代码:

class_addMethod(cls, backupSEL, currentIMP, targetTypes);

做的事情是:

  • 给类新增一个 mcf_orig_viewDidAppear: 方法入口
  • 这个入口对应的实现,是 Swizzle 安装时看到的旧 IMP

于是,从此以后,backupSEL 就成了“调用链继续往下传”的出口。

4. targetSEL 被接管成你的 Hook

这一段代码:

class_replaceMethod(cls, targetSEL, hookIMP, method_getTypeEncoding(hookMethod));

意味着:

  • 外部以后再调 viewDidAppear:
  • 实际先进入的是 mcf_hook_viewDidAppear:

于是,整个调用流变成:

  • 先执行你的逻辑
  • 再由你决定是否继续调用旧实现

5. mcf_orig_viewDidAppear: 只是一个“插座”

这个方法体本身是空的:

- (void)mcf_orig_viewDidAppear:(BOOL)animated {
}

它的存在主要是:

  • 让编译器知道这个 Selector 是合法的
  • 让代码更可读
  • 给 Runtime 提供一个挂载旧 IMP 的位置

真正执行时,跑到这个 Selector 上的不是这段空方法,而是运行时挂上去的旧实现。


五、为什么这套写法能处理第三方冲突

我们用一个例子看清楚调用链。

场景一:没有第三方时

初始状态:

viewDidAppear: -> original IMP

你安装后:

viewDidAppear: -> my_hook
mcf_orig_viewDidAppear: -> original IMP

执行链:

my_hook -> original IMP

场景二:第三方先 Hook 了同一个方法

第三方安装后:

viewDidAppear: -> third_hook
third_orig: -> original IMP

你再安装时,读到的 currentIMP 实际上是 third_hook。

于是安装后变成:

viewDidAppear: -> my_hook
mcf_orig_viewDidAppear: -> third_hook
third_orig: -> original IMP

最终调用链:

my_hook -> third_hook -> original IMP

这就是多重交换的本质:

  • 每一层都不假设自己拿到的是系统原始实现
  • 每一层只调用自己保存下来的上一层实现
  • 于是链路自然串起来

六、method_exchangeImplementations、add + replace、保存旧 IMP 三种方案怎么选

如果把工程里常见的 Swizzling 方案做一个分层,大致可以分成三档。

第一档:直接 method_exchangeImplementations

适合:

  • Demo
  • 原理验证
  • 单一模块
  • 简单场景

优点:

  • 简单
  • 代码短
  • 好理解

缺点:

  • 继承链场景不够稳
  • 多方 Hook 时不易维护

第二档:class_addMethod + class_replaceMethod + exchange

适合:

  • 常规工程项目
  • 需要兼容方法继承场景
  • 希望比直接 exchange 更安全

它解决的是“当前类自己是否真的实现了这个方法”的问题,避免直接交换继承来的父类实现。

第三档:保存旧 IMP,按链调用

适合:

  • 高风险系统方法
  • 多方 Hook 可能性高
  • 第三方 SDK 多
  • 需要治理 Hook 冲突

它解决的是调用链问题。

如果说第二档是在解决“交换姿势是否安全”,那么第三档是在解决“交换之后链路是否稳定”。


七、+load 会不会拖慢启动

会,但要说清楚是哪一部分在拖慢。

安装期成本

Swizzling 安装一般发生在 +load:

  • 查找 Method
  • 读取 IMP
  • 添加 backup selector
  • 替换目标 selector

这些都是一次性成本。

如果只 Hook 少数关键方法,通常可控,不会成为启动主瓶颈。

运行期成本

真正更值得关注的是运行期成本。

因为 Hook 安装完成之后,每一次调用被拦截的方法,都会多走一层逻辑:

  • 你的校验
  • 你的日志
  • 你的埋点
  • 再调用旧实现

所以,Swizzling 的主要性能影响并不在“交换那一下”,而在“被 Hook 方法每次执行时额外多做了什么”。

这也是为什么:

  • 高风险高频方法要慎 Hook
  • Hook 内逻辑必须尽量轻量
  • 不能把监控、序列化、复杂判断全塞进生命周期主路径

八、真实工程里怎么预防多重交换和第三方冲突

只靠“写对一段 Swizzling 代码”是不够的。工程里更重要的是治理。

1. 对高风险 Selector 建白名单

不是所有方法都允许随便 Hook。

真正容易冲突的往往是这些全局高频方法:

  • viewDidAppear:
  • viewWillAppear:
  • sendAction:to:forEvent:
  • resume
  • dealloc
  • KVO/KVC 相关点位

这些方法应该纳入基础架构治理,而不是让业务团队随意交换。

2. 建 Hook 注册表

对于每一个被 Hook 的点,至少记录这些信息:

  • Class
  • Selector
  • 当前旧 IMP
  • 新 IMP
  • 安装模块
  • 安装时间
  • 备份 Selector
  • IMP 来源 image

这会带来两个直接价值:

  • 方便排查“谁 Hook 了谁”
  • 方便发现“是不是同一个方法被多方改写过”

3. 安装前识别当前 IMP 来源

可以通过 dladdr 反查 IMP 属于哪个二进制:

#import <dlfcn.h>

Dl_info info;
if (dladdr((void *)currentIMP, &info)) {
    NSLog(@"IMP image: %s", info.dli_fname);
    NSLog(@"IMP symbol: %s", info.dli_sname);
}

这样至少能知道:

  • 当前实现来自系统
  • 来自主工程
  • 来自哪个第三方 SDK

4. Debug 环境强断言,线上环境弱打断

推荐分环境治理:

  • Debug:强断言、强日志
  • 灰度:高优先级告警
  • 线上:不中断业务,但要上报

比如:

  • 重复注册同一个 Hook,Debug 直接 Assert
  • backup selector 被占用,灰度打印错误
  • 线上发现非法链路时,不 Crash,但要把问题带上上下文上报

5. 不让业务团队直接 Swizzle 高风险点

更成熟的工程实践是:

  • 基础架构层提供统一 Hook 平台
  • 业务方通过注册能力接入
  • 不允许业务模块自己写 Runtime 交换

否则很快就会变成“谁都能动系统方法,但没人知道链路长什么样”。


九、一个容易被忽略的事实:防崩不是吞掉异常,而是保住链路

很多团队第一次做 Swizzling,是为了“防崩”。

比如:

  • 数组越界不崩
  • KVC 非法 key 不崩
  • KVO 重复移除不崩

但如果只是“把异常吞掉”,没有链路意识,结果通常是:

  • 崩溃没了
  • 业务逻辑悄悄变脏了
  • 页面行为不一致
  • 排查成本更高

所以,Swizzling 真正该解决的不是“看起来别崩”,而是:

  • 对高风险点做前置拦截
  • 出问题时保住链路
  • 该降级就降级
  • 该上报就上报
  • 不把问题从“显性崩溃”变成“隐性脏状态”

这是两个完全不同的工程目标。


十、最后总结

Method Swizzling 最大的误区,是把它当成一个“很灵巧的黑魔法”。

实际上,一旦项目里出现多个 SDK、多个业务模块、多个基础能力同时介入,Swizzling 就会从“技巧”变成“基础设施”。

真正稳的工程实践,通常有这几个原则:

  • 不假设自己拿到的一定是原始实现
  • 保存当前旧 IMP,而不是想当然地“回原方法”
  • 让每一层 Hook 只调用自己保存的上一层实现
  • 对高风险 Selector 做统一治理
  • 用注册表、扫描、监控把 Hook 链路透明化
  • 让 Runtime 能力平台化,而不是散落在各个模块里

如果要把全文浓缩成一句话,我更愿意这样总结:

  • 当项目规模足够大时,Method Swizzling 不应再被当成一段零散的 Runtime 代码,而应该被当成一项需要注册、可观测、可追踪、可回滚的底层治理能力。只有这样,Hook 才不会从解决问题的工具,变成制造问题的源头。