在 iOS 工程里,Method Swizzling 几乎是所有 Runtime 话题里最容易"会用",却也最容易"踩坑"的技术之一。
很多团队第一次接触它,往往是为了做这些能力:
- 页面埋点
- 点击事件统计
- KVO/KVC 防护
- 容器类防崩
- 网络层监控
- 生命周期统一治理
一开始,大家通常会写出一版经典模板:
objective-c
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 对应的调用关系,已经不再是"原方法 <-> 新方法"的简单互换,而是一个链:
text
自己 Hook -> 第三方 Hook -> 更早的 Hook -> 原始实现
所以,Swizzling 的核心问题不是"我能不能换过去",而是:
- 我能不能知道当前方法已经被谁换过
- 我能不能把当前旧实现保存下来
- 我能不能在自己的逻辑执行完之后,把调用链继续往下传
- 我能不能保证链不断、不乱、不死循环
这才是工程里的重点。
二、为什么最基础的 method_exchangeImplementations 不够用
最常见的基础写法如下:
objective-c
+ (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 过的实现
- 但无论是谁,你都把调用链接住了
下面是一版更适合工程理解的实现。
objective-c
#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 不是"原始实现",而是"当前实现"
这一句是全篇最重要的地方:
objective-c
IMP currentIMP = method_getImplementation(targetMethod);
这里拿到的,不一定是系统最原始的 viewDidAppear:。
如果第三方已经 Swizzle 过,这里拿到的就是第三方的 Hook 实现。
也正因为如此,这套方案的本质不是"保存原方法",而是"保存当前旧实现"。
3. backupSEL 是一个备份入口
这一段代码:
objective-c
class_addMethod(cls, backupSEL, currentIMP, targetTypes);
做的事情是:
- 给类新增一个
mcf_orig_viewDidAppear:方法入口 - 这个入口对应的实现,是 Swizzle 安装时看到的旧
IMP
于是,从此以后,backupSEL 就成了"调用链继续往下传"的出口。
4. targetSEL 被接管成你的 Hook
这一段代码:
objective-c
class_replaceMethod(cls, targetSEL, hookIMP, method_getTypeEncoding(hookMethod));
意味着:
- 外部以后再调
viewDidAppear: - 实际先进入的是
mcf_hook_viewDidAppear:
于是,整个调用流变成:
- 先执行你的逻辑
- 再由你决定是否继续调用旧实现
5. mcf_orig_viewDidAppear: 只是一个"插座"
这个方法体本身是空的:
objective-c
- (void)mcf_orig_viewDidAppear:(BOOL)animated {
}
它的存在主要是:
- 让编译器知道这个 Selector 是合法的
- 让代码更可读
- 给 Runtime 提供一个挂载旧
IMP的位置
真正执行时,跑到这个 Selector 上的不是这段空方法,而是运行时挂上去的旧实现。
五、为什么这套写法能处理第三方冲突
我们用一个例子看清楚调用链。
场景一:没有第三方时
初始状态:
text
viewDidAppear: -> original IMP
你安装后:
text
viewDidAppear: -> my_hook
mcf_orig_viewDidAppear: -> original IMP
执行链:
text
my_hook -> original IMP
场景二:第三方先 Hook 了同一个方法
第三方安装后:
text
viewDidAppear: -> third_hook
third_orig: -> original IMP
你再安装时,读到的 currentIMP 实际上是 third_hook。
于是安装后变成:
text
viewDidAppear: -> my_hook
mcf_orig_viewDidAppear: -> third_hook
third_orig: -> original IMP
最终调用链:
text
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:resumedealloc- KVO/KVC 相关点位
这些方法应该纳入基础架构治理,而不是让业务团队随意交换。
2. 建 Hook 注册表
对于每一个被 Hook 的点,至少记录这些信息:
- Class
- Selector
- 当前旧 IMP
- 新 IMP
- 安装模块
- 安装时间
- 备份 Selector
- IMP 来源 image
这会带来两个直接价值:
- 方便排查"谁 Hook 了谁"
- 方便发现"是不是同一个方法被多方改写过"
3. 安装前识别当前 IMP 来源
可以通过 dladdr 反查 IMP 属于哪个二进制:
objective-c
#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 才不会从解决问题的工具,变成制造问题的源头。