一、从一道面试题说起:方法调用和函数调用到底差在哪
做iOS开发的朋友大概率都经历过这样一个场景:面试官笑眯眯地问"OC的方法调用本质上是什么?",然后你背出"消息发送"四个字,他再追问"消息发送具体是怎么走的?"空气就安静了。我当年也是从这个地方开始真正啃起Objective-C的运行时机制,后来在工作中调试KVO、处理performSelector崩溃、实现AOP埋点,才发现这个东西不是面试用的死知识,而是实实在在影响你写代码姿势的底层逻辑。
先说一个最常见的对比:C语言的函数调用和OC的方法调用,编译器处理方式完全不同。C语言里写func(10, 20),编译阶段就会确定调用哪个函数地址,这叫静态绑定,也叫早绑定。整个过程在编译期就锁死了,运行时不参与任何决策。OC里写[obj doSomething],编译器会把它改写成objc_msgSend(obj, @selector(doSomething))这个函数调用,真正的查找和跳转发生在运行时。这就是动态绑定,也叫晚绑定。
这个差别带来的影响非常深远。静态绑定的函数调用,性能上几乎零开销,CPU直接跳到固定地址执行。而OC的方法调用,每次都要经过复杂的查找流程,哪怕有缓存机制,理论上也比C函数调用慢一个量级。但是这种"慢"换来了极致的灵活性:方法可以在运行时动态添加、替换、转发,目标对象甚至可以临时决定把消息转给别人处理。这正是C语言给不了你的能力。
很多新手刚接触OC时会困惑:[obj method]这行代码,为什么反汇编以后变成了objc_msgSend?为什么给nil发送消息不崩溃,而是返回0或者nil?这些问题的答案全都藏在这套动态机制里。把方法调用的本质吃透,等于拿到了理解整个runtime体系的钥匙,后面再看KVO的实现原理、block的底层结构、Category为什么能"覆盖"方法,都会顺畅很多。
这篇文章不讲那种浅尝辄止的科普,把从objc_msgSend入口到方法查找、缓存、动态决议、消息转发的完整链路拆开揉碎,用代码验证每一步,最后聊几个日常开发中和这套机制强相关的实战场景。适合对OC有一定基础、想深入理解runtime的读者,也适合打算系统复习iOS底层知识的面试冲刺者。
二、objc_msgSend是总入口:编译器把方法调用翻译成了什么
写OC代码时,你用的方法是中括号语法[obj methodName]。但在编译阶段,LLVM会将这段代码翻译成一条C函数调用:
c复制objc_msgSend(obj, @selector(methodName))
如果你调用的方法带参数,比如[obj eatWithFood:rice count:2],翻译结果大概是:
c复制objc_msgSend(obj, @selector(eatWithFood:count:), rice, 2)
注意,objc_msgSend是可变参数函数,消息接收者、方法选择器、后续参数全都原封不动地传进去。这也就是说,你在代码里写得再优美的方法调用,到了汇编层次就是一个普通的C函数调用,只不过这个函数恰好是runtime做消息派发的入口。
2.1 为什么需要对空nil发送消息不崩溃
OC里面有个经典说法:"向nil对象发送消息不会崩溃"。日常开发中大量代码依赖这个特性,你可能已经习惯了,但它背后的逻辑其实很朴素。看objc_msgSend的汇编实现,第一步就是判断接收者是否为nil。如果是nil,直接返回一个空结果(整型返回0,对象类型返回nil,结构体返回全零),后续查找流程根本不会触发。
这个设计在底层实现上很优雅,也解释了为什么OC允许你写这种看起来有隐患的代码:
objc复制Person *p = nil;
[p walk]; // 不会崩溃,只是什么都不发生
NSString *s = [p getName]; // s为nil
对比C++或者Java,指针为null时调用成员函数大概率直接段错误或者抛出空指针异常。OC用"消息接收者可以不响应"的思路,从语言层面把空指针调用的风险降到了最低。不过要注意,消息是发出去了,但没人处理,如果你写的是[p walk]期望有个副作用执行,这段代码会静默失败,排查时容易让你找半天。这也是很多团队禁用"向nil发送消息"依赖的原因之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2.2 方法选择器SEL到底是什么
聊objc_msgSend之前还得先明确一个概念:SEL方法选择器。每次写@selector(walk),编译器会把这个方法名注册到一个全局字符串表里,生成一个唯一的SEL结构体指针。你可以把SEL理解成方法名的哈希化代表,它只关心方法叫什么,不关心实现在哪里。
@selector(walk)和@selector(walk:)是两个完全不同的SEL,因为冒号也是方法名的一部分,代表参数。这也是为什么OC方法名要把参数名也拼进去:initWithFrame:和initWithName:是不同方法,编译器靠SEL区分,而这个SEL是基于完整方法名生成的字符串。底层实现里,SEL本质是指向objc_selector结构体的指针,但在日常调试中,你可以简单把它当作一个方法名的唯一标识。
2.3 汇编实现的快速路径:第一道查缓存
objc_msgSend并不是一个普通的C函数,在苹果的runtime源码里,它的大部分逻辑是用汇编实现的,尤其是快速路径部分。为什么用汇编?因为方法调用是OC世界最高频的操作,每一个[obj method]都会经过这里,必须压榨出极致性能。
快速路径的核心逻辑如下:
- 检查接收者是否为nil,是则返回零值。
- 取出接收者的isa指针,找到所属类(或者元类)。
- 从类的缓存中查找方法对应的IMP。
- 如果找到IMP,直接跳转执行。
- 如果没有找到,跳转到C函数
lookUpImpOrForward走慢速查找流程。
也就是说,方法调用并不是每次都遍历方法列表,而是先查缓存。这个缓存在后面专门开一章讲。总之从汇编代码的优化力度就能看出,苹果在消息发送这条链路上下足了功夫。
三、方法查找的完整路径:从快速查找到慢速查找
快速路径找不到IMP时,runtime会进入慢速查找流程,由lookUpImpOrForward这个C函数处理。这里做的事情比快速路径复杂得多,涉及类继承链、方法列表、动态决议。把这一节搞明白,你就掌握了OC方法查找的完整地图。
3.1 类对象、元类对象与isa走位
先补一个基础点:OC的类在运行时是一个对象,叫类对象(Class对象),它也有isa指针。类对象的isa指向元类对象(MetaClass),元类对象里存储的是类方法的列表。这个设计让"类方法"和"实例方法"在查找逻辑上完全统一:实例方法去类对象的methodList里找,类方法去元类对象的methodList里找。
你写[Person sharedInstance]这种类方法调用,编译后依然是objc_msgSend(Person类对象, @selector(sharedInstance)),只不过接收者变成了类对象,查表走的是类对象的isa指针到元类对象的链路。所以类的继承体系中,实例对象的isa指向类,类对象的isa指向元类,元类的isa指向根元类,最终形成一个闭环。这个闭环对理解消息转发也至关重要,因为类方法找不到时,动态决议走的是+resolveClassMethod:,转发阶段也能一路往父类找。
3.2 两个关键查表动作:按继承链遍历方法列表
lookUpImpOrForward拿到接收者的Class之后,会做这样几件事:
- 如果类没有实现(class还未初始化),先完成初始化。
- 在当前类的方法列表中查找SEL对应的Method。
- 没找到就沿着superclass指针往上找父类,逐级查找。
- 找到后缓存IMP并返回;找不到则进入动态方法解析流程。
方法列表的存储形式通常是有序数组,runtime会按SEL的地址做二分查找。当某个方法被归类后(通过class_addMethod动态添加的),方法列表会变成无序状态,此时改为线性查找。所以二分查找是正常情况下的高概率路径,性能是有保障的。
这里有个经常被忽略的细节:方法查找是按"类优先、再父类"的顺序进行的。也就是说,子类实现的方法会覆盖父类的同名方法,这个覆盖面在运行时天然存在,Category也是在类的方法列表末尾追加方法(如果是有序列表还可能被插入到前面),所以Category方法会"覆盖"原类同名方法,其实不是真正覆盖,而是查找顺序靠前。
3.3 方法结构体Method里藏了什么
方法列表里存储的是Method结构体,它的组成是:
c复制struct method_t {
SEL name; // 方法名
const char *types; // 类型编码
IMP imp; // 函数指针
};
IMP本质上就是普通的C函数指针,指向方法的实际实现。到这一步就通了:方法调用最终跳转到的,其实还是C函数。理解这一点很重要,因为所有动态添加方法、方法交换(Swizzling)、消息转发,最终操作的对象都是IMP这个函数指针。
举个例子,Method Swizzling就是把两个方法的IMP互换:
objc复制Method old = class_getInstanceMethod(cls, @selector(originalMethod));
Method new = class_getInstanceMethod(cls, @selector(swizzledMethod));
method_exchangeImplementations(old, new);
交换的是IMP指针,而不影响SEL本身。调用方依然用@selector(originalMethod)发消息,但实际执行的代码变成了swizzledMethod的实现。这也是为什么swizzling之后,两个方法名对应的行为会发生互换,因为在底层,SEL和IMP的对应关系已经被改写了。
四、方法缓存cache_t:为什么热方法会越调越快
讲缓存之前先做个实验,打开Instruments的Time Profiler跑一个循环次数很大的方法调用,你会发现同一个对象连续调用同一个方法,耗时曲线几乎是平的。这得益于runtime内部一套高效的缓存机制,它让热路径上的方法查找只需要几次内存访问。
4.1 缓存存储在类对象里,而不是实例对象里
很多初学者容易误解,以为方法缓存存在实例对象中。实际上,缓存存在类对象(Class)内部的一个结构体cache_t中。同一个类的所有实例共享这个缓存。这是什么意思?就是说A实例调用了walk方法之后,B实例再调用walk方法,命中的是同一份缓存。
c复制struct cache_t {
bucket_t *_buckets; // 散列表
mask_t _mask; // 散列表长度 - 1
mask_t _occupied; // 已缓存的方法条数
};
bucket_t是缓存的基本单元,包含两个关键成员:SEL key和IMP imp。每个缓存项就是把SEL映射到IMP的一条记录。
4.2 散列表索引与哈希冲突的处理
cache_t的底层是一个哈希表,长度通常是2的幂。哈希函数很简单,就是把SEL的地址值和_mask做位与运算,得到bucket的索引。因为SEL是通过全局字符串表生成的唯一指针,地址本身已经足够散列。
c复制static inline mask_t cache_hash(cache_key_t key, mask_t mask) {
return (mask_t)(key & mask);
}
哈希表一定会出现冲突,runtime采用的冲突处理策略是线性探测(开放地址法):如果计算出的bucket已经被占用,就顺移到下一个位置继续尝试,直到找到空位。查找过程也是同理,沿着探测序列一个个对比key,匹配成功就返回IMP。
c复制static inline mask_t cache_next(uintptr_t mask, uintptr_t bucket) {
return (bucket + 1) & mask;
}
因为探测序列可能回到起点,所以_mask必须保证始终是存储桶数减1,而存储桶数一直是2的幂,这样bucket + 1跟mask做位与后,索引越界时能自动回绕到0。
这套机制实践起来效果很好,但需要注意一个边界情况:当缓存快速增长时,散列表会频繁扩容,扩容会导致所有bucket重新散列。如果一个方法调用刚好赶上扩容,性能会有短暂波动,但之后就稳了。苹果的runtime对这种情况做了优化:扩容阈值是已占用槽位达到容量的3/4左右,并且扩容时直接用新的扩容后的mask重新散列旧数据,保证大部分时候缓存都能命中。
4.3 为什么方法查找不是每次都很慢
有了缓存,方法调用链路的真实路径变成了:
- 汇编快速路径:直接从
cache_t中查找bucket。 - 命中:读取IMP并跳转执行。
- 未命中:调用
lookUpImpOrForward慢速查找,找到后把IMP写入缓存。
所以一个方法被调用过后,第二次调用开始就只走"哈希计算一次 + 读一次bucket",耗时基本是个位数纳秒级别。这也是为什么即使OC是动态派发,在大多数业务场景下性能也不会成为瓶颈。苹果花了大量功夫把这条链路的常数因子压到极低。
在实际开发中,你很少需要手动和缓存打交道,但理解它有一个额外的实际价值:调试时遇到性能问题,可以判断是否因为方法列表过于庞大或缓存频繁失效,而不要下意识把所有问题都归结到"OC动态派发慢"上。曾经在一个直播App的项目里,一段处理视频帧的代码中频繁调用了某个动态方法,排查后发现它的查找链路走了很多层父类,虽然最终命中缓存,但初始化时缓存填充也是开销。优化方案是直接缓存IMP指针,性能提升非常明显,这个操作同样受益于对缓存机制的理解。
五、找不到方法时的三级救援:动态决议与消息转发
方法查找到底,如果当前类、父类、根类的方法列表里都找不到对应SEL,runtime并不会立刻崩溃,而是触发一套完整的"救援流程"。这套流程分三个级别,每一级你都可以接管,让程序在运行时决定该怎么办。
5.1 第一次救援:动态方法解析
runtime首先调用+resolveInstanceMethod:(实例方法)或者+resolveClassMethod:(类方法),给你一次机会,让你可以在这个类里"补上"这个方法。
objc复制@implementation Person
// 当实例方法找不到时,会先回调这里
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(speak)) {
// 动态给Person类添加一个方法实现
class_addMethod(self, sel, (IMP)speakImp, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
void speakImp(id self, SEL _cmd) {
NSLog(@"这个方法是动态添加的,方法名: %@", NSStringFromSelector(_cmd));
}
@end
class_addMethod把speakImp这个C函数绑定到了@selector(speak)上,然后runtime会重新走一遍方法查找流程。注意,动态决议阶段返回YES后,runtime会再次查找方法列表,现在能找到刚刚添加的方法了,于是调用成功。
这个机制在很多底层库中非常常用。比如CoreData的@dynamic属性,就是靠动态方法解析在运行时生成getter和setter的。用过@dynamic声明的属性,编译期不会生成存取方法,运行时第一次调用时会触发resolveInstanceMethod:,由CoreData框架动态生成IMP并绑定到SEL上。
5.2 第二次救援:快速消息转发
如果resolveInstanceMethod:返回NO,runtime会进入快速消息转发阶段,调用-forwardingTargetForSelector:。这个方法的返回值是id类型,意思就是"你要不要把这个消息转给别人处理"。
objc复制@interface Person : NSObject
@property (nonatomic, strong) SportsMan *sportsMan;
@end
@implementation Person
- (id)forwardingTargetForSelector:(SEL)aSelector {
if (aSelector == @selector(playBasketball)) {
return self.sportsMan;
}
return [super forwardingTargetForSelector:aSelector];
}
@end
调用[person playBasketball]时,person自己并没有实现这个方法,它的forwardingTargetForSelector:返回了sportsMan对象,runtime会在目标对象上重新发起一次消息发送。这次的"转发"非常轻量,没有生成NSInvocation对象,性能开销极小,所以能够用来实现简单的消息多继承效果。很多代理模式下也会利用这个机制,把一个类无法处理的消息无缝转发给另一个类。
5.3 第三次救援:完整的消息转发
快速转发如果返回nil,接下来会进入最完整的消息转发流程。runtime会先调用-methodSignatureForSelector:获取方法签名,然后根据签名构造NSInvocation对象,再调用-forwardInvocation:,把完整的调用信息交由你处理。
objc复制- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(run:)) {
return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
}
return [super methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
if (anInvocation.selector == @selector(run:)) {
[anInvocation invokeWithTarget:self.helper];
}
}
到了这个阶段,你能拿到完整的NSInvocation,可以修改参数、修改返回值、选择任意一个目标对象来调用,甚至把消息吞掉什么都不干。这种力度比快速转发大得多。常见的使用场景包括:NSProxy做AOP切面、网络层等重试机制、模拟多继承。比如第三方埋点SDK经常用NSProxy包装业务对象,在不改动业务代码的前提下,通过forwardInvocation:偷偷插入埋点逻辑。
5.4 以崩溃收场:unrecognized selector
三级救援全部失败之后,runtime会调用-doesNotRecognizeSelector:,这个方法的默认实现是抛异常、崩溃,报错信息就是那句经典的:
code复制*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '-[Person speak]: unrecognized selector sent to instance 0x...'
通过这个报错,你能反推出一次完整的方法查找和救援过程已经经历完了。如果你在resolveInstanceMethod:、forwardingTargetForSelector:、forwardInvocation:中加入了日志,可以在控制台清楚看到预警链路。把这三级救援当做一个闭环,下次排查"unrecognized selector"崩溃时就不会一头雾水了。
六、用runtime亲手验证这套机制:三个小实验
光看理论容易忘,动手验证一遍,记忆才扎实。这一章分享三个我实际在Playground和Demo工程里跑过的实验,代码不多,但每一步都能直观看到方法调用的底层行为。
6.1 实验一:动态添加方法,验证resolveInstanceMethod
新建一个Person类,声明一个speak方法但故意不实现。调用之前先看一下方法列表中是否存在:
objc复制Person *p = [Person new];
unsigned int count = 0;
Method *methods = class_copyMethodList([Person class], &count);
for (unsigned int i = 0; i < count; i++) {
NSLog(@"方法: %@", NSStringFromSelector(method_getName(methods[i])));
}
free(methods);
可以看到方法列表里没有speak,然后调用[p speak],配合上一节写的resolveInstanceMethod:动态添加。运行后控制台输出"这个方法是动态添加的",再打印一次方法列表,这次能看到speak已经出现了。这个实验直观证明:方法列表不是编译期封闭的,运行期可以往里面塞新东西。
如果你的类里实现了resolveInstanceMethod:但没有调用class_addMethod而是直接返回YES,运行时会再次走查找逻辑,依然找不到方法,随后进入消息转发。所以动态解析阶段必须真的把方法加上,否则无法达成目的。
6.2 实验二:通过forwardingTargetForSelector实现消息转移
创建两个类:Person和SportsMan,SportsMan实现了playBasketball。在Person里实现forwardingTargetForSelector:返回SportsMan对象:
objc复制SportsMan *sportsMan = [SportsMan new];
Person *p = [Person new];
p.sportsMan = sportsMan;
[p playBasketball]; // 实际上会执行SportsMan的playBasketball
在forwardingTargetForSelector:里加一句NSLog,能清楚看到每一次"转移"的过程。这个实验还可以延伸一下:如果Person也实现了playBasketball,那么方法查找会先在Person自己的方法列表里命中,根本不会走到转发。这就是为什么子类或分类的同名方法可以"覆盖"父类方法,因为查找链上根本没有给父类方法出场的机会。
6.3 实验三:利用method_exchangeImplementations观察IMP交换后的行为
最后做一个Swizzling实验,直观理解SEL和IMP的映射:
objc复制@implementation Person
- (void)originalMethod {
NSLog(@"原始实现");
}
- (void)swizzledMethod {
NSLog(@"交换后的实现, 当前方法: %@", NSStringFromSelector(_cmd));
}
@end
// 调用处
Method old = class_getInstanceMethod([Person class], @selector(originalMethod));
Method new = class_getInstanceMethod([Person class], @selector(swizzledMethod));
method_exchangeImplementations(old, new);
Person *p = [Person new];
[p originalMethod]; // 输出"交换后的实现"
[p swizzledMethod]; // 输出"原始实现"
注意第二行输出,swizzledMethod执行时_cmd是swizzledMethod,但实际执行的是原始实现代码。这让你能看出:_cmd始终是发送消息时的SEL,而执行的代码是交换后的IMP。这个实验把SEL和IMP的松耦合关系展示得很清楚。
调试这类底层代码时,强烈建议打开反汇编窗口,在objc_msgSend入口下个符号断点,然后单步执行,能亲眼看到缓存命中后那条jmp指令的跳转。我第一次单步跟踪时,对OC运行时"查表跳转"这个机制的感受一下子从"背概念"变成了"真懂"。
七、理解本质之后,日常开发里的几个顿悟
掌握了方法调用的本质,很多事情回头看都会有一种"原来是这么回事"的感觉。这一章聊聊这套底层逻辑在真实开发中的几处映射,不空谈理论,全是能直接落地的认知。
7.1 performSelector到底坑在哪
performSelector:系列方法是利用消息发送机制做动态调用的典型代表:
objc复制if ([self respondsToSelector:@selector(handleAction:)]) {
[self performSelector:@selector(handleAction:) withObject:data];
}
它的内部实现本质上就是objc_msgSend的封装,先查respondsToSelector:,确认当前对象(包括父类和转发链路)能响应这个方法,然后再发消息。注意respondsToSelector:返回YES并不代表方法一定在方法列表里,它也会触发消息转发流程,所以哪怕对象自己没实现,只要forwardingTargetForSelector:能转发出去,respondsToSelector:同样返回YES。
用performSelector:时要留意ARC的警告,涉及对象参数和返回值的传递时,编译器无法自动管理内存,容易踩坑。从底层原理看,performSelector:只是把objc_msgSend包装了一层,真正常用的场景其实是动态拼接SEL,比如从字符串构造选择器回调。
7.2 target-action模式为什么能工作
UIButton点击事件的target-action机制,底层依赖的也是消息发送。当你执行:
objc复制[button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];
点击发生时,UIKit内部向target发送buttonClicked:消息。这里的target是需要强持有的,如果target被提前释放,按钮事件触发时会向野指针发消息,大概率崩溃。理解了消息发送的本质,就能理解为什么官方文档反复强调target不能是weak:因为objc_msgSend不会做引用管理,它只负责发消息,如果接收者已经不存在,查找和跳转都没有意义。
这也解释了为什么某些动态化方案里,target-action可以配合消息转发实现"事件远程分发":把按钮的事件转发给一个统一处理器,由它再转发到具体业务模块,本质就是利用三级救援中的快速转发通道。
7.3 类方法和实例方法的消息统一
掌握了isa走位,你会发现类方法调用的本质和实例方法完全一致。[Person sharedInstance]消息接收者是Person类对象,走的查找链路是类对象的isa到元类对象,再到根元类。这意味着:类方法也可以被动态添加、动态转发。+resolveClassMethod:就是为类方法准备的动态解析入口。很多框架实现单例或懒加载时,会动态生成类方法,利用的就是这套机制。
这个统一性带来一个巧妙的能力:你可以像操作实例方法一样操作类方法。写网络层时,我见过一个挺取巧的方案:定义一个ServiceProxy元类代理,所有类方法请求都转发给统一的网络中层处理。这套方案能跑通,靠的完全是消息发送机制对类方法的一视同仁。
7.4 方法与JavaScript互操作时的底层关联
说到动态性,OC的运行时机制和JavaScript的运行时设计有异曲同工之处:JS调用对象方法时,也是在原型链上查找属性,找不到就顺着__proto__继续走,直到根对象。OC的superclass链路和JS的原型链,本质都是"逐级查找 + 动态绑定"。所以OC与JavaScript互相调用时,比如通过JavaScriptCore把OC对象暴露给JS环境,JS里执行obj.method(),最后会通过runtime的消息机制,把调用转换为objc_msgSend发送给OC对象。理解这套本质,调试JSPatch那一类热修复框架的源码时,会特别有方向感。
7.5 缓存失效对性能的隐性影响
方法缓存机制还有个容易忽略的问题:当类的方法列表被大量修改时,比如加载了很多Category、运行期频繁class_addMethod,缓存会反复重建。在启动阶段如果做了大量swizzling,方法缓存被清空重建的次数增多,可能轻微拖慢首个界面的加载速度。我的经验是:不要在App启动的关键路径上集中做大量方法交换,可以分散到各个模块的懒加载阶段去处理。
另外,如果你在性能敏感代码中反复执行动态查找,可以考虑直接缓存IMP:
objc复制IMP imp = class_getMethodImplementation([Person class], @selector(walk));
if (imp) {
((void (*)(id, SEL))imp)(self, @selector(walk));
}
这等于手动绕过了一部分消息发送查找逻辑,直接把IMP取出来调用。在视频帧处理、音频缓冲等高频场景中,这种优化能省去每次缓存命中的哈希查找开销。但要注意,如果类结构在运行期发生变化(比如插入Category或动态添加方法),缓存的IMP可能失效,所以这个优化适合方法实现完全稳定的内部代码。
说实话,真正把OC方法调用的本质吃透,靠的并不是记住objc_msgSend这个函数名,而是亲手走一遍从汇编快速路径到慢速查找到三级救援的完整链路,再用实验验证每一步行为。我刚开始研究时,一遍遍在调试器里看方法执行的跳转逻辑,看了整整一个周末,后来在工作中处理KVO异常、实现AOP、优化视频处理性能时,那些底层细节总会跳出来帮我定位问题。
如果你正在学OC或准备iOS面试,别只背结论,把objc_msgSend的源码翻出来,把类对象、元类对象、方法缓存、消息转发这条线自己画一遍,再写几个验证小实验,这比看十篇博客都管用。如果这篇文章里哪一步你还想深入,强烈建议去看苹果开源的objc4源码,配合调试器单步跟踪,收获比任何二手资料都大。
