【iOS】底层原理:方法交换

文章目录

前言

上一篇 消息流程探索我们把 objc_msgSend 从快速查找 cache -> 慢速查找继承链 -> 动态方法决议 -> 消息转发都过了一遍。分类那篇也讲了分类的加载时机。

这篇我们聊这两个东西的交汇点------Method Swizzling,也就是传说中的 iOS 黑魔法。

Method Swizzling 是什么

Method Swizzling 就是在运行时把两个方法的实现互换。

每个类都有一张方法列表(methodList),里面每个 Method 包含 SEL(方法名)和 IMP(函数指针)。方法交换,就是把 SEL 和 IMP 的对应关系改掉

如图,交换前后的sel和IMP的对应关系:

这就相当于给方法搞了个"别名"------你想在 viewWillAppear: 里加点日志,不用去改每个控制器的代码,直接交换就行。

这种思想叫 AOP(面向切面编程),跟 OOP 的区别简单来说:

  • OOP:把业务逻辑封装成各种类(UserManager、OrderManager)
  • AOP:把日志、埋点、权限这些跟业务无关的东西抽出来,统一织入

在 OC 里做 AOP,主要手段就是 Runtime 的方法交换和消息转发。实际场景比如:

  1. 日志/埋点:统一记录页面访问,不用每个 VC 写一遍 NSLog
  2. 防崩溃 :Hook 掉 NSArray 的 objectAtIndex:,越界时返回 nil 而不是 Crash
  3. 性能监控:统计方法耗时
  4. 权限检查:调用前统一校验

前置知识:SEL、IMP、Method

SEL(方法选择器)

c 复制代码
typedef struct objc_selector *SEL;

SEL 就是方法名的唯一标识。编译时生成,Runtime 有个全局哈希表来存,保证同样的方法名 SEL 唯一。这也是 OC 不能像 C++ 那样重载的原因------同名方法 SEL 一样,没法区分。

可以理解为"书名"

IMP(方法实现指针)

c 复制代码
typedef void (*IMP)(void /* id, SEL, ... */ );

IMP 就是个函数指针,指向方法实现代码在内存里的地址。objc_msgSend 最终就是跳转到这个地址去执行。

理解为"书翻到的页码"

Method(方法描述)

c 复制代码
typedef struct objc_method *Method;

struct objc_method {
    SEL _Nonnull method_name;
    char * _Nullable method_types;
    IMP _Nonnull method_imp;
};

Method 就是把 SEL 和 IMP 打包在一起的结构体,相当于"书的某一章节的完整内容"。

方法交换的本质就是互换 Method 结构体里的 method_imp 指针------SEL 本身不变,IMP 换了个指向

核心源码分析

method_exchangeImplementations 的作用

下面这段是 源码里 method_exchangeImplementations 的实现:

cpp 复制代码
void method_exchangeImplementations(Method m1Signed, Method m2Signed)
{
    // 1. nil 保护
    if (!m1Signed  ||  !m2Signed) return;

    method_t *m1 = _method_auth(m1Signed);
    method_t *m2 = _method_auth(m2Signed);

    // 2. 加锁
    mutex_locker_t lock(runtimeLock);

    // 3. 取出两个 Method 的 IMP 和 SEL
    IMP imp1 = m1->imp(false);
    IMP imp2 = m2->imp(false);
    SEL sel1 = m1->name();
    SEL sel2 = m2->name();

    // 4. 核心:交换 IMP
    m1->setImp(imp2);
    m2->setImp(imp1);

    // 5. 刷新所有类的缓存
    flushCaches(nil, __func__, [sel1, sel2, imp1, imp2](Class c){
        return c->cache.shouldFlush(sel1, imp1) || c->cache.shouldFlush(sel2, imp2);
    });

    // 6. 调整 retain/release/alloc 等特殊标记
    adjustCustomFlagsForMethodChange(nil, m1);
    adjustCustomFlagsForMethodChange(nil, m2);
}

核心就四件事:

步骤 干啥的
nil 保护 传空直接 return,别崩
加锁 runtimeLock 保证线程安全
交换 IMP 就是 m1->setImp(imp2) + m2->setImp(imp1),指针互换
flushCaches 交换完必须刷缓存,不然 objc_msgSend 快速查找可能命中旧的 IMP

文档里说这个函数等价于以下操作的原子版本

objc 复制代码
IMP imp1 = method_getImplementation(m1);
IMP imp2 = method_getImplementation(m2);
method_setImplementation(m1, imp2);
method_setImplementation(m2, imp1);

class_replaceMethod 的区别

cpp 复制代码
IMP class_replaceMethod(Class cls, SEL name, IMP imp, const char *types)
{
    if (!cls) return nil;
    mutex_locker_t lock(runtimeLock);
    return addMethod(cls, name, imp, types ?: "", YES);
}

这个跟 method_exchangeImplementations 最大的区别是------

API 方向 效果
class_replaceMethod 单向 A 的 IMP 换成 B 的,但 B 的 IMP 不变,原来的 IMP 丢了
method_exchangeImplementations 双向 A 和 B 的 IMP 互换,通过别名还能调到原来的实现

在做 Swizzling 的时候我们想要的是双向交换------这样在自定义方法里 [self ypp_xxx] 才能调到原始实现。所以最常用的还是 method_exchangeImplementations

class_addMethod 的用途

objc 复制代码
OBJC_EXPORT BOOL
class_addMethod(Class cls, SEL name, IMP imp, const char *types);

这个返回值很有意思:

  • 返回 YES:说明加成功了 -> 当前类没有这个方法(从父类继承来的),现在给它新加了一个
  • 返回 NO:说明加失败了 -> 当前类自己已经有这个方法了

这个特性就是后面解决"子类没实现父类实现了"这个坑的关键

一个最简单的例子

objc 复制代码
@implementation UIViewController (Swizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method originalMethod = class_getInstanceMethod(self, @selector(viewWillAppear:));
        Method swizzledMethod = class_getInstanceMethod(self, @selector(ypp_viewWillAppear:));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

- (void)ypp_viewWillAppear:(BOOL)animated {
    // 这里实际上调的是原始的 viewWillAppear:(因为 IMP 已经交换了)
    [self ypp_viewWillAppear:animated];
    NSLog(@" %@ 页面出现了", NSStringFromClass([self class]));
}

@end

调用链路是这样的:

复制代码
[someVC viewWillAppear:] 调用
  → objc_msgSend 找 viewWillAppear: 的 IMP
  → 找到的 IMP 是 ypp_viewWillAppear:(已交换)
  → 执行 ypp_viewWillAppear: 里的代码
  → 里面调 [self ypp_viewWillAppear:]
  → objc_msgSend 找 ypp_viewWillAppear: 的 IMP
  → 找到的 IMP 是原始的 viewWillAppear:(已交换)
  → 执行原始实现

所以不会递归------交换是双向的,新的方法名指向旧的实现,旧的实现又能通过新代码包装

三大坑点

下面有三个比较容易出现的坑

一:重复交换

假设某个方法被交换了两次:

复制代码
第一次交换:viewWillAppear: → ypp_xxx  //没问题
第二次交换:viewWillAppear: → 原始实现  //又变回到原始实现

为什么会出现二次交换?最常见的原因是没有用 dispatch_once 保护

objc 复制代码
//  正确做法
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 交换代码
    });
}

二:子类没实现,父类实现了

这是最经典的崩溃场景

objc 复制代码
@interface LGPerson : NSObject
- (void)personInstanceMethod;
@end

@interface LGStudent : LGPerson  // 没有重写 personInstanceMethod
@end

// LGStudent 的分类
+ (void)load {
    Method oriMethod = class_getInstanceMethod([LGStudent class], @selector(personInstanceMethod));
    Method swiMethod = class_getInstanceMethod([LGStudent class], @selector(lg_studentInstanceMethod));
    method_exchangeImplementations(oriMethod, swiMethod);
}

乍一看没问题,但运行会崩

原因:class_getInstanceMethod 会沿继承链查找。LGStudent 自己没实现,所以返回的是 LGPerson 的 Method 结构体 。交换后,父类 LGPersonpersonInstanceMethod 的 IMP 指向了 LGStudent 分类里的方法。当父类对象调用这个方法时,里面如果调了 [self lg_studentInstanceMethod],父类去哪找这个方法?找不到,直接 unrecognized selector 崩溃。

解决方案:用 class_addMethod 判断方法归属。

objc 复制代码
+ (void)safeSwizzleWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL {
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
    if (!oriMethod || !swiMethod) return;

    // 尝试给当前类添加 oriSEL,IMP 指向 swiMethod
    BOOL success = class_addMethod(cls, oriSEL,
                                   method_getImplementation(swiMethod),
                                   method_getTypeEncoding(oriMethod));
    if (success) {
        // 添加成功 → 说明 cls 没有这个方法(来自父类)
        // 把 swizzledSEL 指向原 IMP
        class_replaceMethod(cls, swizzledSEL,
                           method_getImplementation(oriMethod),
                           method_getTypeEncoding(oriMethod));
    } else {
        // 添加失败 → 说明 cls 自己实现了,直接交换
        method_exchangeImplementations(oriMethod, swiMethod);
    }
}

这里的逻辑其实挺巧的:

情况 class_addMethod 返回值 说明
类自己实现了该方法 NO 直接 method_exchangeImplementations 安全
类没有该方法(从父类继承) YES class_replaceMethod 处理新 SEL 的指向

三:两个方法都没实现

父类只有声明没有实现,子类也没有:

objc 复制代码
@interface LGPerson : NSObject
- (void)personInstanceMethod;  // 只声明,没写实现
@end

这时候 oriMethod 就是 nil。method_exchangeImplementations 源码里虽然有 nil 保护不会崩,但交换也没生效。调用这个方法时走完整个消息转发流程,没人处理,最终 unrecognized selector 崩溃。

还有一种更隐蔽的情况:oriMethod 拿到了,但 IMP 指向的是 _objc_msgForward_impcache(消息转发标记),交换后调用新方法会触发转发,万一转发也没处理,就递归死循环栈溢出。

解决方案:加 nil 兜底

objc 复制代码
if (!oriMethod) {
    // 给 oriSEL 加一个空实现
    class_addMethod(cls, oriSEL,
                   method_getImplementation(swiMethod),
                   method_getTypeEncoding(swiMethod));
    // 把 swiMethod 的 IMP 设为空 block
    method_setImplementation(swiMethod,
                            imp_implementationWithBlock(^(id self, SEL _cmd) {
                                NSLog(@"[Warning] %s 没有实现", sel_getName(oriSEL));
                            }));
    return;
}

额外注意:_cmd 依赖问题

还有一个容易被忽略的点:如果我们的方法实现依赖 _cmd(那个隐式的 SEL 参数),交换后可能会出问题

_cmd 是每个 OC 方法都有的隐式参数,代表当前方法的 SEL。交换之后,别人调用原始方法名时,实际执行的是你自定义方法的代码,但此时 _cmd 的值仍然是原始方法的 SEL,而不是你自定义方法的 SEL

objc 复制代码
- (void)originalMethod {
    // 交换前:_cmd == @selector(originalMethod)
    // 交换后:如果有人调 originalMethod,实际执行下面的代码,
    //        但 _cmd 仍然是 @selector(originalMethod)
}
- (void)ypp_swizzledMethod {
    // 交换前:_cmd == @selector(ypp_swizzledMethod)
    // 交换后:调 ypp_swizzledMethod 时,_cmd 是 @selector(originalMethod)
}

如果你的自定义方法内部用 _cmd 做判断(比如根据方法名走不同逻辑),这种行为就不符合预期了。不过大多数日常埋点、日志场景不依赖 _cmd,所以这个坑遇到的不多

类方法怎么交换

实例方法存在类的方法列表里,类方法存在元类(metaclass) 的方法列表里。所以交换类方法时需要操作元类。

objc 复制代码
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class metaClass = object_getClass(self);  // 获取元类

        Method oriMethod = class_getClassMethod(self, @selector(originalClassMethod));
        Method swiMethod = class_getClassMethod(self, @selector(swizzledClassMethod));

        // class_addMethod/class_replaceMethod 要传元类
        BOOL success = class_addMethod(metaClass, @selector(originalClassMethod), ...);
        if (success) {
            class_replaceMethod(metaClass, @selector(swizzledClassMethod), ...);
        } else {
            method_exchangeImplementations(oriMethod, swiMethod);
        }
    });
}

object_getClass(self)[self class] 的区别:

  • [self class] 在类方法里返回类对象本身
  • object_getClass(self) 返回 self 的 isa 指向------在类方法里就是元类

因为要操作元类的方法列表,所以必须用 object_getClass

这是怎么跟消息发送流程串起来的

我们在 消息流程探索讲过 objc_msgSend 的完整路径:

复制代码
objc_msgSend(receiver, sel)
  ├─ 快速查找:cache_getImp
  ├─ 慢速查找:lookUpImpOrForward
  │   ├─ 本类 method list
  │   ├─ 父类 cache → 父类 method list
  │   └─ ... 沿继承链向上
  ├─ 动态方法决议
  └─ 消息转发

Swizzling 到底改了什么? 它改的是 method_list 里 Method 的 imp 指针

交换之后,方法列表里 SEL 对应的 IMP 已经变了。下次 objc_msgSend 走慢速查找时,拿到的就是新 IMP。

但是------如果旧的 IMP 已经被缓存到 cache 里了怎么办? 这就是 flushCaches 的作用。method_exchangeImplementations 源码里调了 flushCaches(nil, ...) 来清空所有类的缓存,迫使下次走慢速查找,拿到最新的 IMP。

一条理解:Swizzling 在"源头"(method_list)改了 IMP,然后通过刷 cache 让"缓存"(cache_t)也更新,最终 objc_msgSend 无论怎么查都是新的 IMP。

为什么写在 +load 里

结合 分类的加载时机来看。

分类的方法是在 attachLists 阶段合并到本类的。+load 方法执行的时候,所有分类数据已经合并完毕了,所以这时候做交换是安全的。

整个时间线:

复制代码
App 启动 → dyld 加载 Mach-O
  → _objc_init(注册回调)
  → map_images / _read_images(读类、处理分类元数据)
  → realizeClass + methodizeClass(分类合并到本类)
  → load_images(执行 +load 方法) ← 这时候分类已经合并完了

那为什么是 +load 而不是 +initialize

+load +initialize
调用时机 App 启动时,main 之前 第一次给类发消息时
线程安全 单线程串行 多线程环境
适不适合 Swizzling 最早最确定 时机不确定

另外注意:不要在 +load 里调 [super load]

Runtime 已经在 load_images 里调过父类的 +load 了,你再手动调一次,父类的交换代码就执行了两遍------可能造成重复交换

实际应用:防数组越界

这是 Swizzling 最经典的实战场景。但有个坑:NSArray 是个类簇(Class Cluster)

什么叫类簇?就是 NSArray 只是个抽象父类,真正的实现是它的私有子类:

objc 复制代码
NSArray *a = @[@"a"];        // 实际是 __NSArrayI
NSArray *b = @[];            // 实际是 __NSArray0
NSMutableArray *m = @[].mutableCopy; // 实际是 __NSArrayM

NSArray 做 Swizzling 没有用,必须找到真身类:

objc 复制代码
@implementation NSArray (SafeAccess)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class arrayCls = objc_getClass("__NSArrayI");
        Method from = class_getInstanceMethod(arrayCls, @selector(objectAtIndex:));
        Method to   = class_getInstanceMethod(arrayCls, @selector(ypp_safe_objectAtIndex:));
        method_exchangeImplementations(from, to);
    });
}

- (id)ypp_safe_objectAtIndex:(NSUInteger)index {
    if (index >= self.count) {
        NSLog(@" 越界: %lu >= %lu", (unsigned long)index, (unsigned long)self.count);
        return nil;
    }
    return [self ypp_safe_objectAtIndex:index]; // 调的是原始的 objectAtIndex:
}

@end

字面量 arr[0] 走的是 objectAtIndexedSubscript:,所以也需要单独 Hook。

常见类簇真身表:

公开类 真身类
NSArray __NSArrayI
NSArray(空) __NSArray0
NSArray(单元素) __NSSingleObjectArrayI
NSMutableArray __NSArrayM
NSDictionary __NSDictionaryI
NSMutableDictionary __NSDictionaryM

可以直接用的安全封装

把前面三个坑的解决方案合在一起:

objc 复制代码
+ (void)ypp_safeSwizzleWithClass:(Class)cls
                          oriSEL:(SEL)oriSEL
                     swizzledSEL:(SEL)swizzledSEL {
    if (!cls || !oriSEL || !swizzledSEL) return;

    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);

    // 情况1:原方法不存在 → 添加空实现兜底
    if (!oriMethod) {
        class_addMethod(cls, oriSEL,
                       method_getImplementation(swiMethod),
                       method_getTypeEncoding(swiMethod));
        method_setImplementation(swiMethod,
                                imp_implementationWithBlock(^(id self, SEL _cmd) {
            NSLog(@"[空实现] %s", sel_getName(oriSEL));
        }));
        return;
    }

    // 情况2:新方法不存在 → 同样兜底
    if (!swiMethod) {
        class_addMethod(cls, swizzledSEL,
                       method_getImplementation(oriMethod),
                       method_getTypeEncoding(oriMethod));
        return;
    }

    // 情况3:判断方法归属
    BOOL success = class_addMethod(cls, oriSEL,
                                   method_getImplementation(swiMethod),
                                   method_getTypeEncoding(oriMethod));
    if (success) {
        class_replaceMethod(cls, swizzledSEL,
                           method_getImplementation(oriMethod),
                           method_getTypeEncoding(oriMethod));
    } else {
        method_exchangeImplementations(oriMethod, swiMethod);
    }
}

使用:

objc 复制代码
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self ypp_safeSwizzleWithClass:self
                                oriSEL:@selector(viewWillAppear:)
                           swizzledSEL:@selector(ypp_viewWillAppear:)];
    });
}

常见问题

1. Method Swizzling 的原理是什么?

方法列表里每个 Method 有 SEL 和 IMP,交换就是把两个 Method 的 IMP 指针互换。调用 method_exchangeImplementations 后,内核就是 m1->setImp(imp2) + m2->setImp(imp1),然后 flushCaches 刷缓存保证下次查找拿到新 IMP。

2. 为什么写在 +load 里?为什么用 dispatch_once?

+load 是 App 启动时最早调用的,且分类数据已经合并完毕,时机最确定。dispatch_once 保证交换代码只执行一次,防止重复交换导致失效。

3. 子类没实现、父类实现了,直接交换会怎样?

会崩。class_getInstanceMethod 返回的是父类的 Method,交换后父类方法的 IMP 指向了子类分类的方法,父类对象调用时找不到子类分类的方法。用 class_addMethod 判断归属后分情况处理。

4. class_replaceMethod 和 method_exchangeImplementations 的区别?

前者是单向替换,原来的 IMP 就丢了;后者是双向交换,通过别名可以访问原实现。

5. 给 NSArray 做 Swizzling 要注意什么?

NSArray 是类簇,要对真身类(__NSArrayI)做交换,而不是 NSArray 本身。

6. 交换后调用自定义方法为什么不递归?

因为交换是双向的。自定义方法的 SEL 指向的是原始实现,所以 [self ypp_xxx] 实际上调的是原始方法,不是自己。

7. Swizzling 有什么风险?

  1. 没加 dispatch_once -> 重复交换
  2. 没做归属判断 -> 影响父类
  3. 方法名没加前缀 -> 跟别人的库冲突
  4. 类簇直接 Swizzling -> 无效
  5. 多个库 Hook 同一个方法 -> 互相覆盖

总结

Method Swizzling 的本质一句话:运行时交换 SEL 和 IMP 的对应关系。

回头看 method_exchangeImplementations 那几十行源码,核心其实就两件事:m1->setImp(imp2) + m2->setImp(imp1) 把指针互换,再调 flushCaches 清掉旧缓存。没有魔法。

安全使用的核心三点:

  1. dispatch_once 防重复交换
  2. class_addMethod 判断归属,防父类污染
  3. nil 兜底,防递归崩溃

搞懂了消息发送流程和分类加载时机,Swizzling 就没那么神秘了------它只是在 method_list 这一层改了改指针指向,再通过 flushCaches 让下次 objc_msgSend 拿到新的 IMP 而已

相关推荐
暗冰ཏོ2 小时前
2026 App 开发完整指南:Android、iOS、跨平台开发与安卓应用上线全流程
android·ios·uni-app·web app·app开发
你疯了抱抱我13 小时前
【Mac】vscode 配置 GD32E230CXXX 开发环境
ide·vscode·macos·嵌入式
m0_5358175516 小时前
macOS下Claude Code从0到1配置教程(附API密钥获取+常见报错修复)
gpt·macos·node.js·api·claude·claudecode·88api
一个人旅程~19 小时前
Windows的6月份安全启动证书过期如何查看是否过期是否需要更新如何操作
windows·经验分享·macos·电脑
Gh0stX19 小时前
macOS Burp Suite Professional 激活指南
macos
人月神话-Lee19 小时前
【图像处理】图像直方图——从“频率分布“到“智能决策“
图像处理·人工智能·ios·ai编程·swift
会Tk矩阵群控的小木19 小时前
imessage虚拟机群发系统搭建:基于UTM+Frida的完整实现与海外社媒集成
macos·ios·objective-c·cocoa·开源软件·个人开发·tk矩阵
风吹夏回20 小时前
保姆级教程:Dify 本地一键部署(Windows/Mac 通用)
windows·macos
灰鲸广告联盟21 小时前
新老用户广告价值不同?差异化策略如何实现收益最大化
android·开发语言·flutter·ios