先从一个真实场景说起。去年我在做老项目的崩溃治理,后台日志里出现了一条特别扎眼的报错:-[__NSCFConstantString count]: unrecognized selector sent to instance 0x...。看到“unrecognized selector”这几个字,做过 iOS 开发的朋友都会心头一紧。凡是被这类崩溃折磨过的人都知道,这背后不是一个简单的类型用错问题,而是 Objective-C 运行时的方法调用机制在提醒你:某个对象收到了一条它根本没有能力响应的消息。
不少初学者把 [obj doSomething] 理解成“调用 obj 的 doSomething 方法”,这个理解不算错,但不完整。要真正掌握 Objective-C,就必须把这句话翻译成另一种更准确的说法:“向 obj 发送一条叫做 doSomething 的消息”。这两者之间的差别,就是理解 OC 方法调用本质的钥匙。
这篇文章我会把这条链路彻底讲清楚:从编译器拿到一行代码之后做了什么,到运行时如何一步步找到方法实现,再到找不到实现时系统给了哪些“救火”机会,以及这套动态机制在实际项目里的高频应用。不管你刚接触 OC 不久,还是已经被 runtime 折磨过几轮,这篇文章应该都能帮你把脑子里那团模模糊糊的线索理顺。
1. 从一行代码说起:OC方法调用到底做了什么
1.1 你写的是“发消息”,不是“调用”
先看最基础的一行代码:
objc复制[self.person eat];
在 C 语言里,如果一个函数编译成 call 0x1000,那这个地址在编译期就确定下来了,运行到这一句就直接跳过去,没有回旋余地。Objective-C 保留了 C 的这种底层能力,但在这之上叠加了自己的一套动态派发机制。编译器在处理 [self.person eat] 时,并不会直接生成“跳到 eat 实现”的指令,而是把它转换为一次消息发送:
objc复制objc_msgSend(self.person, @selector(eat));
objc_msgSend 是 runtime 底层的核心函数,它的作用,就是拿着“接收者”(self.person)和“消息编号”(@selector(eat)),去找到真正要执行的函数指针,然后跳过去执行。
这里的关键差异在于:消息发送方并不需要提前知道接收者是否能响应这条消息。方法实现能不能找到,是在运行时才决定的。所以才会有“向对象发消息”这种说法,而不是“调用对象的方法”。
我第一次认识到这个差别,是在一个很尴尬的 bug 里。当时我把一个其实是 NSString 的对象当成了自定义类来用,代码能编译通过,跑起来就崩。C 语言里编译器早就拦住这种类型不匹配了,OC 里却把错误留到了运行期。这就是动态发消息的代价:编译器相信你,runtime 替你扛。
1.2 为什么不是C语言那样的直接调用:动态绑定的底层设计逻辑
既然直接调用看起来更快、更安全,为什么 OC 要绕这么大一圈?这就要回到 OC 的历史定位。
Objective-C 从诞生那天起,就被设计成一种“在 C 之上动态化”的语言。它要解决的核心问题,是让程序在运行过程中还能改变行为。C 语言的函数调用,编译完就焊死了;而 OC 的消息发送,则给了程序员一个操作空间:程序跑起来之后,我还能干预“这条消息到底由谁处理、怎么处理”。
这种动态绑定带来了几个非常实际的价值:
- 暴露接口可以非常灵活:你可以在运行时动态地给一个类添加方法,动态地替换已有方法的实现,甚至把一个消息转给另一个对象去处理。
- 通过消息名做解耦:两个类之间不需要在编译期强关联,只要一方能理解消息名,另一方就能把消息发过去。这在插件化架构、事件总线、路由这一类场景里极其有用。
- 为上层框架提供了土壤:KVO、CoreData、AOP、热修复、埋点统计,这些东西之所以能在 OC 生态里大规模出现,底层靠的都是动态消息机制。
有人会质疑:动态派发比静态调用慢吧?确实,多了查表这一步,但 runtime 做了大量优化。现代 CPU 分支预测、方法缓存命中之后,消息发送的开销已经被压缩得很小。后面我会专门讲缓存机制,它才是保证性能的关键角色。
1.3 SEL、IMP、Method:先分清三个核心概念
理解方法调用,绕不开三个词:SEL、IMP、Method。很多人一开始在这里被绕晕,其实它们的分工非常清晰。
- SEL:方法编号,可以理解为“方法的名字”在 runtime 里的内部表示。
@selector(eat)返回的就是一个SEL。同一个方法名,在整个进程里只对应一个SEL,不管它在多少个类里被实现。底层可以看到它内部是一个映射到字符串的 ID,但这个 ID 的赋值由 runtime 统一管理,你不用关心具体数字是什么。 - IMP:真正的函数指针,指向方法实现的那段代码。它是 runtime 最终要找到并跳转的目标。
- Method:把
SEL和IMP以及其他信息(比如参数类型、返回类型的编码)打包在一起的结构体。类的方法列表里存放的就是这个。
打个比方:SEL 是门牌号,IMP 是房间里住的人,Method 是门口挂着的住户登记表。你要找人,先看门牌号,再查登记表,最后敲开房间,叫某人干一件事。
objc_msgSend 的职责,就是拿着 SEL,在接收者的方法集合里找到对应的 Method,取出 IMP,然后跳过去执行。找不到?进入消息转发流程,这个我们后面展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方法查找是怎么进行的:沿着类对象和继承链一路找过去
2.1 isa指针、类对象、元类:你必须搞懂这张“类地图”
objc_msgSend 拿到接收者对象之后,首先要回答一个问题:这个对象属于哪个类?要回答这个问题,就得看对象的 isa 指针。
在 Objective-C 里,每一个对象内部第一个字段就是 isa,它指向这个对象的类对象(Class)。类对象里存放着这个类的实例方法列表、协议列表、属性信息,还有指向父类的 superclass 指针。
这里有个容易搞混的点:类方法存在哪里?很多人直觉以为“类方法存在类对象里”,其实不对。类方法存在元类里。当你向一个类发送消息,比如 [Person eatClassMethod],接收者实际是 Person 这个类对象,查找方法时用的就是 Person 这个类对象的 isa 指针,也就是它的元类。
简单整理一下这张“类地图”:
- 实例对象的
isa指向类对象,类对象里存实例方法。 - 类对象的
isa指向元类,元类里存类方法。 - 类对象的
superclass指向父类的类对象,元类的superclass指向父类的元类。 - 根元类的
superclass最终指向根类对象,整个继承链形成一个闭环。
这个设计初看有点绕,但它的目的是让“类方法”和“实例方法”在查找逻辑上完全统一:无论发送消息的对象是普通实例还是类本身,runtime 都可以用同一套 isa + superclass 的逻辑去找实现。理解这一点之后,你就能明白为什么很多 runtime 操作对实例方法和类方法要分别处理,比如 class_addMethod 要区分 resolveInstanceMethod 和 resolveClassMethod。
2.2 查找过程:从方法缓存到类方法列表再到父类
objc_msgSend 查找一个方法,并不是直接粗暴地遍历整个继承链,它有自己的路径。
第一步,查缓存。每个类对象内部都有一个 cache_t 缓存,里面存着最近调用过的方法。objc_msgSend 先用 SEL 的哈希值去缓存里找,如果能命中,直接拿到 IMP 跳过去。这一步速度极快,也是 OC 消息发送性能的关键保障。你在循环里调用一万次同一个方法,第一次可能慢一点,后面每一次都会因为缓存命中而快得接近直接函数调用。
第二步,缓存没命中,查当前类的方法列表。方法列表在运行时会被整合成 method_array_t,runtime 会通过二分查找或者遍历方式找到匹配的 Method,取出 IMP,并把它写入缓存供后续使用。
第三步,当前类找不到,就沿 superclass 指针向上找父类。父类有缓存就看缓存,没缓存就看方法列表,再找不到继续向上,一直找到 NSObject 为止。如果 NSObject 也没有,就会进入消息转发流程。
这个过程其实很像翻通讯录:手上有一个名字,先看手机里最近联系人里有没有,没有就去全部联系人里翻,再没有就问问身边朋友认不认识这个人,所有人都说不认识,那就得想想别的办法了。
2.3 一个小例子:自定义类和继承场景下,消息是如何一步步找到实现的
看一段常见的继承代码:
objc复制@interface Animal : NSObject
- (void)eat;
@end
@implementation Animal
- (void)eat {
NSLog(@"Animal eating");
}
@end
@interface Dog : Animal
@end
@implementation Dog
@end
然后执行:
objc复制Dog *dog = [[Dog alloc] init];
[dog eat];
这条消息的查找路径是这样的:
objc_msgSend(dog, @selector(eat)),通过dog->isa找到Dog类对象。- 查
Dog的方法缓存,没有。 - 查
Dog的方法列表,没有eat。 - 通过
Dog->superclass找到Animal类对象。 - 查
Animal的缓存,没有;查Animal的方法列表,找到了eat对应的IMP。 - 把
SEL和IMP写入Dog的缓存,然后跳转到实现执行。
这个过程里最值得注意的一点是:Dog 自己没有实现 eat,但它依然能正常响应。这种“向上查找”的继承机制,和 C++ 那种编译期的继承解析在思路上有本质区别。C++ 的虚函数表也能实现动态联编,但 OC 的灵活性更强——继承链上的方法解析完全发生在运行时,连“这个类有没有实现这个协议”都可以在运行期动态询问。
3. 找不到方法时,系统给了你三次“救火”机会:消息转发机制
如果整条继承链都找不到方法实现,按说就可以直接抛异常了。但 runtime 没有立刻放弃,它给了开发者三次抢救机会。这是 OC 方法调用中最有魅力的一部分,也是 runtime 动态性的核心体现。
3.1 动态方法解析:最快的一层救火
第一次机会发生在消息发送流程的早期,叫动态方法解析。runtime 在得知“接收者没有这个方法”之后,会给接收者所属的类发送一条询问消息:这个 SEL 你能不能现场补一个实现?
对应的方法是:
objc复制+ (BOOL)resolveInstanceMethod:(SEL)sel;
+ (BOOL)resolveClassMethod:(SEL)sel;
前者处理实例方法,后者处理类方法。如果你在这个方法里通过 class_addMethod 动态补上了实现,并返回 YES,runtime 就会重新查找一次,这次能找到刚加进去的方法,然后正常执行。
一个典型场景是:你有一批方法名是运行时拼接出来的,不方便在编译期全部实现,就可以在 resolveInstanceMethod 里统一处理。
objc复制void dynamicMethodIMP(id self, SEL _cmd) {
NSLog(@"动态添加的方法被调用了");
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(dynamicMethod)) {
class_addMethod(self, sel, (IMP)dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
注意 class_addMethod 的最后一个参数是类型编码,"v@:" 表示返回值为 void,带 self 和 _cmd 两个隐藏参数。写错类型编码可能导致运行时参数解析错乱,这是新手比较容易踩的坑。
3.2 快速转发:把消息转给别人处理
第二次机会是快速转发。如果你没有在动态方法解析阶段解决问题,runtime 会询问接收者:这条消息你能不能转交给别的对象处理?
对应的方法是:
objc复制- (id)forwardingTargetForSelector:(SEL)aSelector;
在这个方法里,你可以返回任意一个对象。只要返回的对象非空、非 self,runtime 就会把消息完整地转发给那个对象,然后重新执行一次消息发送流程。如果返回的对象也处理不了,系统不会无限递归下去,而是继续进入慢速转发,或者直接抛异常。
这个机制最常见的用途是做代理或者 Decorator 模式。比如某个类不想自己实现所有接口,可以在初始化时持有另一个功能完整的对象,在转发方法里把消息转出去。还有一种玩法是模拟多继承:一个类可以把不属于它的方法转发给另一个类处理,从外部看就像它自己实现了这些方法一样。
使用快速转发时一个容易忽略的点:这个方法不仅会被“未知”消息触发,如果你的对象已经被某个父类实现了 forwardingTargetForSelector,而父类希望某些消息由子类处理也走这条路,逻辑上要理清楚。不然会出现“本来应该子类自己实现的方法,结果被静默转走了”,调试起来比较抓狂。
3.3 慢速转发:最完整的绕行方案
第三次机会是慢速转发。如果快速转发也没有对象认领,runtime 会尝试走完整的消息转发流程。这里有两个方法要配合使用:
objc复制- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
- (void)forwardInvocation:(NSInvocation *)anInvocation;
第一个方法负责提供方法签名。方法签名描述了这个方法的参数类型和返回类型,runtime 靠它来构造一个完整的 NSInvocation 对象。如果你返回 nil,runtime 会认为这条消息彻底无人接管,直接调用 doesNotRecognizeSelector: 抛出异常。因此这个阶段也是判断“方法到底存不存在”的一个关键节点。
第二个方法负责处理这个调用。NSInvocation 把消息的所有信息都打包了:target、selector、参数列表、返回值。你在 forwardInvocation: 里可以任意改写这些内容,比如换一个 target、替换参数、拦截调用不执行,甚至可以什么都不做只当一条消息被吞掉。
objc复制- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(doSomethingWithString:)) {
return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
}
return [super methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
if (anInvocation.selector == @selector(doSomethingWithString:)) {
NSString *arg;
[anInvocation getArgument:&arg atIndex:2];
NSLog(@"慢速转发拦截到参数:%@", arg);
return;
}
[super forwardInvocation:anInvocation];
}
慢速转发比快速转发的安全边界更严格,它能保留完整的调用上下文,所以做统一拦截、日志记录、参数校验这类需求时,慢速转发是更好的选择。但代价是性能损耗更大,对象会被归档成 NSInvocation,每一次参数读取都要经过类型解码。如果要在循环热点里用,建议谨慎评估。
3.4 一条完整的转发流程时间线
把三次转发流程串起来看,整体脉络非常清晰:
objc_msgSend执行常规查找,继承链上没找到。- 触发
resolveInstanceMethod:动态方法解析。如果在这里补上实现,流程重新走查找,执行成功。 - 如果返回
NO,触发forwardingTargetForSelector:快速转发。如果返回一个非空对象,消息被转给该对象,流程重新走查找。 - 如果返回
nil,触发methodSignatureForSelector:。返回nil就崩,返回有效签名就进入下一步。 - 触发
forwardInvocation:,调用方可以在这一步自定义处理。 - 如果
forwardInvocation:默认不做事,最终会触发doesNotRecognizeSelector:,抛出unrecognized selector异常。
这套流程设计得其实很用人性化:从“最轻的补刀”到“最重的接管”,每多一层,开发者拿到的控制权就越大,但同时要付出的性能成本也越高。绝大多数情况下,你都不需要把转发流程走到第 4 步;真走到那一步,往往是架构设计上出了些问题,而不是单纯的方法没实现。
4. 动态性的三大实战技巧:方法交换、动态加方法、关联对象
4.1 Method Swizzling:用起来简单,坑也不少
Method Swizzling 是 OC 动态特性中最出名的一个应用,手段是交换两个方法的 IMP,让一个 SEL 指向原本属于另一个 SEL 的实现。
最常见的写法是在分类里做:
objc复制@implementation UIViewController (Swizzle)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(viewDidLoad);
SEL swizzledSelector = @selector(swizzled_viewDidLoad);
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
BOOL didAdd = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAdd) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}
- (void)swizzled_viewDidLoad {
[self swizzled_viewDidLoad];
NSLog(@"Swizzled viewDidLoad called on %@", NSStringFromClass([self class]));
}
@end
这段代码有几个细节要解释:
- 为什么要用
load?因为load在动态库加载阶段就会调用一次,时机足够早,不容易受其他代码执行顺序影响。 - 为什么套
dispatch_once?防止这个分类被多次加载时重复交换。重复交换会让两个方法的IMP换回去,甚至换乱,导致诡异崩溃。 class_addMethod在这里的作用是什么?如果父类实现了originalSelector,但当前类没有实现,直接method_exchangeImplementations会交换父类实现,可能破坏父类行为。先class_addMethod判断当前类是否已经有实现,如果没有,就先把要交换的方法加到当前类,再替换。这样能避免误伤父类。
Swizzling 最常见的用途是 AOP:给所有 UIViewController 加页面停留统计、给网络层加公共参数、给按钮点击加防抖。用得好,侵入性极小;用得不好,会出现“方法名冲突”“调用顺序错乱”等问题。我的建议是:Swizzling 应该在框架内部封好,不要在业务代码里随意写,交换的 SEL 必须有固定的命名前缀,避免和业务方法重名。
4.2 动态添加方法:给类补上缺失的实现
动态方法解析本质上也是动态添加方法,但 class_addMethod 本身也可以被单独调用,用来在运行时给类实例动态安装方法。
使用场景之一是实现“可插拔”的组件化架构:某个类在加载时根据配置决定是否注册某一个方法。另一种常见场景是在序列化或协议处理时,通过反射的方式来动态补齐能力,比如一套瘦客户端把协议的处理器全部注册到中央调度器上。
objc复制void pingImp(id self, SEL _cmd) {
NSLog(@"ping 方法动态添加成功");
}
Class cls = objc_allocateClassPair([NSObject class], "DynamicClass", 0);
class_addMethod(cls, @selector(ping), (IMP)pingImp, "v@:");
objc_registerClassPair(cls);
id dynamicInstance = [[cls alloc] init];
[dynamicInstance ping];
objc_allocateClassPair 可以让你在运行时创建一个全新类,并注册进 runtime。这套能力非常底层,一般业务代码里不常用,但在实现动态化框架时会非常有用。要注意的是,只有 objc_registerClassPair 之后,这个类才能被正常实例化;而且如果类里有实例变量,必须通过 class_addIvar 在 objc_allocateClassPair 之后、objc_registerClassPair 之前添加,顺序不能反。
4.3 关联对象:给分类加属性的原理
分类默认不能添加实例变量,因为分类的结构体里没有 ivars 字段。但现实需求中,经常需要在分类里“存点东西”。苹果给出的方案是关联对象。
objc复制static char kNameKey;
@implementation UIView (Name)
- (void)setMyName:(NSString *)myName {
objc_setAssociatedObject(self, &kNameKey, myName, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (NSString *)myName {
return objc_getAssociatedObject(self, &kNameKey);
}
@end
关联对象的底层,其实是一个全局的哈希表,以“对象地址 + key”为键,把对象和关联值绑定起来。它本质是外部维护的一张映射表,而不是真实地给这个类添加了存储空间。
使用关联对象有几个注意事项:
- 释放时机不是
dealloc,而是在对象开始销毁时,runtime 会清理所有关联对象。清理时机和dealloc时序要小心,不要在dealloc之后再访问关联对象。 OBJC_ASSOCIATION_ASSIGN和OBJC_ASSOCIATION_RETAIN的选择要慎重。用ASSIGN时,如果关联的是一个对象,释放后可能留下悬垂指针,访问会崩溃。- 关联对象不是为“添加属性存储”设计的优先方案,如果分类里需要大量存储,更推荐通过组合或者持有辅助对象的方式来做,性能更好,语义也更清晰。
5. 把方法调用用到真实场景中:OC与JavaScript互调的原理
聊完纯 runtime 视角的方法调用,再来看一个实际项目里非常高频的场景:JavaScript 和 Objective-C 互相调用。很多人以为 JS 调 OC 是 WebView 的“特殊功能”,实际上,它背后的核心仍然是 OC 方法调用机制,只不过多了一层“桥”。
5.1 为什么JS能调用OC:JSBridge底层是怎么串起来的
JSBridge 要解决的问题是:JS 运行在 JavaScriptCore 里,OC 代码运行在 Objective-C 运行时里,两边是两套完全不同的世界。要让 JS 调用到一个 OC 方法,最常见的一种做法是把 OC 的方法(或者 block)以闭包形式传入 JS 上下文。
以 JavaScriptCore 框架为例,你可以把一个 block 导出为 JS 全局函数。这个 block 被 JS 调用时,实际上会发生一次跨语言的函数调用,而这个 block 内部往往就会通过 OC 对象发送消息,去执行真正的原生逻辑。
objc复制JSContext *context = [[JSContext alloc] init];
context[@"nativeCall"] = ^(JSValue *message) {
NSString *msg = [message toString];
// 这里把调用转发给原生的某个对象
[[NativeHandler sharedHandler] handleMessage:msg];
};
// JS 侧执行
// nativeCall('hello');
当 JS 执行 nativeCall('hello') 时,JavaScriptCore 会把这个调用映射到 OC block 上,block 执行后,内部又通过 [NativeHandler sharedHandler] 发消息给原生对象。整条链路里,最核心的“发消息”环节,走的正是 objc_msgSend 的机制。
在 WKWebView 场景下更常见的方式是 WKScriptMessageHandler。原生注册一个名为 nativeBridge 的 handler,JS 通过 window.webkit.messageHandlers.nativeBridge.postMessage(...) 发消息。WebKit 框架会把这个调用桥接到原生侧的 didReceiveScriptMessage,原生代码在这里接到消息,解析参数,再对对应对象发送 OC 方法。这本质上还是一种“通过消息名查找响应对象”的动态过程,和 runtime 消息转发在思路上有异曲同工之妙。
5.2 一个最小互调示例
我实际在项目里封装过一个很薄的 bridge,核心代码如下:
objc复制// OC 侧注册
- (void)setupJSBridgeForContext:(JSContext *)context {
__weak typeof(self) weakSelf = self;
context[@"callNative"] = ^(JSValue *methodName, JSValue *params) {
__strong typeof(self) strongSelf = weakSelf;
NSString *method = [methodName toString];
NSDictionary *paramDict = [params toDictionary];
[strongSelf dispatchToNative:method params:paramDict];
};
}
- (void)dispatchToNative:(NSString *)method params:(NSDictionary *)params {
NSString *selectorString = [NSString stringWithFormat:@"js_%@:", method];
SEL selector = NSSelectorFromString(selectorString);
if ([self respondsToSelector:selector]) {
[self performSelector:selector withObject:params];
} else {
NSLog(@"JS 调用的原生方法 %@ 未实现", method);
}
}
JS 侧调用:
javascript复制callNative('openPage', { url: 'https://example.com' });
原生侧处理:
objc复制- (void)js_openPage:(NSDictionary *)params {
NSString *url = params[@"url"];
// 跳转逻辑
}
这套方案用 NSSelectorFromString 把字符串变成 SEL,再用 respondsToSelector 做一次能力检查,最后通过 performSelector 触发消息发送。每一步都踩在 OC 方法调用机制上,只是把“编译期写死方法名”变成了“运行期从字符串拼接方法名”。这也是动态消息机制在实际工程里最有代表性的用法之一。
5.3 工程里关于方法命名和接口暴露的注意事项
实际做 JSBridge 时,我踩过几个坑,值得说一句:
- 方法名命名要统一前缀。所有暴露给 JS 的方法都必须有固定前缀,比如
js_,这样能避免 JS 恶意调用到不相关的内部方法,也能在dispatchToNative里快速做白名单校验。 - 参数校验不能省。来自 JS 的参数不可信,类型不对、字段缺失、数组越界都会导致原生崩溃。我在
dispatchToNative里会对params做一次类型检查,不是字典就直接丢弃。 - 线程问题。
WKScriptMessageHandler的回调不一定在主线程,如果原生方法里要操作 UI,必须切回主线程。我习惯在dispatchToNative里统一dispatch_async到主队列,然后在主线程执行 selector。切线程之后再去performSelector,注意performSelector本身不会自动帮你切线程,逻辑要显式写。
6. 一个真实项目片段:UIStackView中的方法调用,验证消息机制的价值
6.1 属性设置的背后其实都是方法调用
UIStackView 在 iOS 开发里是高频组件,很多同学天天写 stackView.axis = UILayoutConstraintAxisVertical、stackView.spacing = 8、stackView.distribution = UIStackViewDistributionFill,但极少有人会想到,这些赋值操作本质上是一连串 OC 方法调用。
OC 的属性 @property 会生成对应的 getter 和 setter。当你写 stackView.axis = UILayoutConstraintAxisVertical 时,编译器实际执行的是 [stackView setAxis:UILayoutConstraintAxisVertical],就是向 stackView 发了一条名为 setAxis: 的消息。这条消息的查找路径和前面讲的一模一样:先查缓存、再查类方法列表、最后沿继承链向上找,找到 UIStackView 或者它父类里的 setAxis: 实现,然后执行。
这听起来好像只是在“复习”前面的知识,但理解这一点有个实际作用:如果你想在属性被设置时做点额外的事,比如自己写一个 StackView 的子类,重写 setAxis:,你会发现能在里面加一点点自己的逻辑。这比你去监听赋值高效得多。重写 setter 时记得调用 super,否则属性存储没有更新,界面表现会混乱。
6.2 从UIStackView看OC的setter机制和KVO
说到了 setter,就不得不提 KVO。KVO 的全称是 Key-Value Observing,它的底层实现叫 isa-swizzling。当你对一个对象添加观察者时,runtime 会动态创建一个该类的子类,并把对象的 isa 指针指向这个子类,同时重写被观察属性的 setter 方法。这样一来,属性赋值发生时,走的还是消息发送流程,但实际执行的方法已经被替换成一个“通知观察者 + 调用原 setter”的实现。
这个机制和前面讲的 Method Swizzling 几乎是一个思路:都是利用了“消息发送是运行时的,可以动态替换实现”这个特性。你在项目里用 UIStackView 的时候,也许不会主动去想 KVO,但如果你将来要对布局属性做响应式处理、或者写一套数据绑定框架,理解了 setter 就是消息调用,KVO 的原理就再也不神秘了。
6.3 实际开发中UIStackView常见的两个坑
顺带分享两个 UIStackView 在实际开发中容易踩的坑,都和“属性设置顺序”有关。
第一个是 addArrangedSubview: 之后设置 hidden 的坑。很多人用 arrangedSubview.hidden = YES 来隐藏一个子视图,以为它就不占位置了。但 UIStackView 对 arranged subview 的 hidden 处理是会自动调整布局的,这个机制本身没有问题。问题是如果你在 addArrangedSubview: 之前把 hidden 设置好,StackView 内部可能因为还没建立约束关系而出现奇怪的布局结果。稳妥的做法是:先 addArrangedSubview:,完成布局配置,再根据业务设置 hidden 状态。
第二个是 spacing 在不同 distribution 下表现不一致。比如 distribution = .fillEqually 时,spacing 设置在所有子视图之间是等距的;但如果你用 .fillProportionally,spacing 的实际表现可能受内容尺寸影响而有细微差异。我曾因为没注意这个差异,在不同机型上看到了不同的间距表现,排查了半天才发现是 distribution 和 spacing 的组合问题。建议在设计动态布局时,提前画清楚不同模式下的约束变化,而不是完全依赖默认表现。
7. 常见问题与排查技巧实录:当方法调不到时怎么办
7.1 unrecognized selector崩溃的排查思路
unrecognized selector 报错是最典型的“消息发不出去”的表现。遇到这个崩溃,我一般按下面的顺序排查:
- 看崩溃栈里
objc_msgSend的接收者类型是什么。系统日志通常会打印类似-[ClassName methodName:]的格式,ClassName是接收者的真实类名,methodName:是找不到的 selector。 - 确认这个类是否真的实现了该方法。去头文件和实现文件里搜
methodName,注意是否拼写错误、参数个数是否匹配。OC 允许一个方法的参数个数不同但编译不报错,很容易埋雷。 - 检查接收者对象是否为
nil。给nil发消息在 OC 里不会崩溃,会静默忽略。但如果对象是一个已经被释放的悬垂指针,你看到的报错会伪装成unrecognized selector,这时候要用EXC_BAD_ACCESS模式来看崩溃栈。 - 检查类型是否被强转。比如把一个
NSString强转成自定义对象,实际发消息时接收者还是NSString,方法自然找不到。这个我在前面提到过,是初学者最容易踩的。
如果是僵尸对象导致的问题,可以在 Xcode 的 Scheme 里打开 Zombie Objects,排查效率会高很多。不过要注意,Zombie Objects 只适合调试环境,线上不能开。
7.2 方法明明实现了,却提示找不到
这种情况比“没实现”要隐蔽得多。我遇到过几次,原因大体分几类:
- 方法放在了
@private或者某个内部类里,声明和实现不一致,调用方走了父类声明,实际查找时走的是父类的方法列表,找不到。 - 方法被分类重名覆盖。OC 的分类方法会和主类方法合并,如果两个分类都实现了同一个方法,合并顺序靠后的分类实现会覆盖前面那个。你看到源码里有方法,但运行时执行的可能是另一个实现。
- 方法被
resolveInstanceMethod或者forwardingTargetForSelector拦截,本该执行的方法根本没机会被查找。 - 并发或动态加载导致方法列表生成时机不对。某些场景下,一个类的方法列表是在第一次被使用时才由 runtime 整理成可查询的结构,如果在这之前通过
method_exchangeImplementations做了交换,有可能出现找不到的情况。
遇到“方法明明存在但找不到”,我的建议是先别改代码,先通过断点或者符号断点看 objc_msgSend 实际收到的 SEL 是什么,再反查这个 SEL 在哪个类里被注册。用 class_copyMethodList 打印一下当前类的方法列表,往往一抓一个准。
7.3 几个真实踩坑记录
最后分享两个我印象比较深的实际案例。
第一个是 Swizzling 重复执行的坑。之前有一个统计 SDK,在 +load 里对 UIViewController 做了 viewWillAppear: 的交换。后来因为 SDK 被集成到一个动态库和一个静态库里,+load 执行了两次,又没有 dispatch_once,导致方法实现被交换了两次,等于换回去了。结果统计偶尔有、偶尔没有,定位花了两天。教训就是:凡是做 Swizzling,dispatch_once 是最低保障,还要注意宿主工程的动态库加载方式。
第二个是 performSelector 调了一个没有声明的方法,但接收对象实际实现了。当时是在一个路由模块里,用 NSSelectorFromString 拼接方法名,结果字符串里少了个冒号。performSelector 依然执行了,但执行的是另一个方法,入参完全错位,导致数据异常。排查到最后,发现是方法名和入参不匹配。教训是:拼 selector 字符串时必须严格遵守 OC 的冒号规则,比如方法带一个参数 doThing:,字符串就要带上末尾冒号,缺了冒号就是一个完全不同的方法名。
我个人在实际项目里的体会是:OC 方法调用这套机制,本质上是在“预先写死的调用”和“运行时临时决定”之间留了一条极宽的弹性带。你可以在消息发送之前拦截它、在查找时修改它的目标、在找不到时给它临时安一个实现,甚至把它彻底转交给另一个对象。这种能力放到今天看,依然是很多语言难以企及的灵活度。但灵活性永远伴随责任。滥用 Method Swizzling、无限拼接 selector、过度依赖消息转发,都会让代码变得难以追踪。我现在的做法是:业务代码里尽量写清楚、写直接,把动态能力收敛到基础组件里;一旦进入 runtime 的黑魔法区域,必须留下足够清晰的注释和 API 边界,避免后来人接手时一脸茫然。
如果你刚开始接触这部分内容,建议不要一上来就研究复杂的 Swizzling,先老老实实把 objc_msgSend 的查找流程吃透。你可以写一个小工具类,用 class_getInstanceMethod、method_getImplementation 打印几个类的方法列表,手动模拟一遍消息查找的过程。把这个过程亲手做过一遍,你对 unrecognized selector、消息转发、动态方法解析这些概念的理解,会比看十篇文章都扎实。
