1. 这个警告不是吓唬人先弄清楚它的来龙去脉如果你的开发经历里有几年 Objective-C 时光大概率在 Xcode 里见过这行黄色警告PerformSelector may cause a leak because its selector is unknown我第一次看到它是在一个用performSelector做动态调用的工具类里。当时第一反应是编译器是不是过于敏感了我明明传的是个老老实实的方法名怎么就跟泄漏扯上关系了后来翻了文档、读了源码、用 Instruments 实测过几轮才彻底明白这条警告背后的逻辑链。这篇文章就把这条链完整讲清楚。先说结论这条警告不是无的放矢。它出现在 ARCAutomatic Reference Counting自动引用计数环境下的一个特殊场景——当你用performSelector:系列方法调用一个编译器无法在编译期确定的选择器时ARC 无法判断这个方法的返回值是否需要释放于是选择用警告提醒你这段代码存在潜在的内存泄漏风险。哪个版本开始有的iOS 5 / macOS 10.7 引入 ARC 那会儿这条警告就跟着来了。Xcode 的编译器LLVM/Clang里它对应-Warc-performselector-leaks这个编译选项归属于 ARC 诊断组。如果你想在某个文件里单独关掉它我一般不建议全局关但某些第三方库的头文件里确实会看到#pragma clang diagnostic ignored -Warc-performselector-leaks知道这个选项名就方便了。但知道怎么关和知道为什么是两回事。真正的问题是为什么 ARC 会认为一次普通的运行时方法调用可能泄漏这就要说到 Objective-C 的内存管理规则了。2. ARC 到底在怕什么内存所有权语义的盲区2.1 所有权不是玄学是编译器赖以生存的命名约定ARC 之所以能在编译期自动插入retain、release、autorelease靠的是一套严格的约定方法名以alloc、new、copy、mutableCopy开头时返回的对象是 1 的调用方持有需要负责释放否则返回的对象被认为是自动释放的autoreleased调用方不需要也不能手动释放。打个比方你在餐厅点菜服务员默认所有菜都是吃完了你自己收拾但如果菜名以宫保麻婆开头——不对这个比喻不精准。换个说法ARC 就像一位严格按照菜单备注行事的管家他看到菜名里写着打包就认为你要自己负责带走没写就认为餐厅会处理。这套规则绝大多数时候有效因为 Objective-C 的命名规范几乎是被所有开发者共同遵守的。问题来了。performSelector:接收的是一个运行时才知道的SEL。编译器不认识这个 SEL 背后对应的方法名它只知道你在一段不知道内容的代码里去调了一个未知的方法。它没法判断这个方法返回的对象到底是需要你 release还是已经 autorelease于是陷入两难。2.2 为什么是may而不是will警告里的 may 非常精准。看两段代码// 场景A调一个返回 void 的方法没有任何泄漏 [self performSelector:selector(doSomething)]; // 场景B调一个返回 1 对象的方法就会泄漏 SEL sel NSSelectorFromString(copyString); NSString *result [self performSelector:sel];场景 A 里方法不返回对象或者返回 autorelease 对象ARC 的担忧不会兑现。场景 B 里如果copyString按命名规范返回了一个 1 的NSStringARC 无法在调用点自动补上release——因为它根本不知道返回值是否需要释放——于是这个对象就永远漂浮在内存里泄漏了。你可能想问编译器不能保守一点在performSelector:之后统一插入一个 release 吗不行。插入 release 意味着对返回 autorelease 对象的方法过度释放直接导致崩溃。ARC 的原则是宁可警告不可乱来。所以它把这个两难的选择交给你要么你自己保证这是安全的要么换一种编译器能看透的写法。2.3 为什么系统框架里用 performSelector 的代码很少报这个警告细心的读者会注意到UIControl的addTarget:action:forControlEvents:、NSTimer的scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:这些 API 内部肯定也用了类似动态调用的机制但它们的调用方并没有出现这条警告。原因有两层。第一层这些 API 的回调方法签名是明确的目标方法在文档里有清晰约定编译器对框架内部的具体实现不产生警告。第二层也是更本质的这些机制内部在发送消息时对返回值并不做内存管理假设。它们拿到 SEL 后的调用路径往往走的是消息转发或objc_msgSend返回值要么被忽略要么被明确处理不会像performSelector:那样把返回值所有权变成一个悬而未决的问题。理解了这两层你就知道警告并不可怕可怕的是不知道它为什么出现然后在代码里盲目加#pragma关掉。3. 可行的替代方案从最简单到最讲究3.1 分支直接调用selector 集合已知时的首选如果你的动态调用只在少数几个已知方法之间切换最简单的做法是彻底避开performSelector写显式分支if (action selector(foo:)) { [self foo:param]; } else if (action selector(bar:)) { [self bar:param]; }这段代码毫无内存管理歧义编译器对每个调用路径都门儿清。缺点是选择器一多代码显得冗长。所以这个方案适合两三个分支的场景比如一个简化版的命令分发器。别嫌丑它是最稳的。3.2 方法指针 IMP适合参数和返回值都明确的情况如果你要调的方法签名是确定的可以用methodForSelector:拿到 IMP直接通过函数指针调用typedef NSString *(*MyMethodIMP)(id, SEL, NSString *); SEL sel selector(appendString:); MyMethodIMP imp (MyMethodIMP)[self methodForSelector:sel]; NSString *result imp(self, sel, hello);消息发送的路径最短性能最好而且返回值内存管理规则在 ARC 下会被编译器正确处理。要注意的是函数指针的类型必须严格匹配实际方法签名SEL作为第二个参数必须传。这个方案对参数个数固定、类型固定的场景非常合适。3.3 NSInvocation参数最多、最通用的动态调用在performSelector之外苹果官方推荐的动态调用方案其实是NSInvocation。它可以封装任意 selector、任意参数个数、任意类型返回值并且支持返回值的内存管理语义自动处理SEL sel NSSelectorFromString(doWorkWithString:andNumber:); NSMethodSignature *sig [self methodSignatureForSelector:sel]; NSInvocation *invocation [NSInvocation invocationWithMethodSignature:sig]; invocation.target self; invocation.selector sel; NSString *arg1 hello; NSUInteger arg2 42; [invocation setArgument:arg1 atIndex:2]; [invocation setArgument:arg2 atIndex:3]; [invocation invoke]; // 如果返回值是对象可以这样安全取出 NSString *returnValue nil; if (sig.methodReturnLength 0) { [invocation getReturnValue:returnValue]; }注意setArgument:atIndex:的 index 从 2 开始0 和 1 分别是 self 和 _cmd这是NSInvocation设计里的老规矩新手容易在这踩坑。getReturnValue:拿到的是void *指针的值对于对象类型你需要把它赋给一个合适的类型变量再在 ARC 下使用——这个赋值过程 ARC 会帮你处理 retain/release所以比performSelector安全得多。NSInvocation的性能比performSelector略慢但绝大多数业务场景里这个差距可以忽略。真正在意性能的地方比如高频消息分发应该用 IMP 方案。3.4 Block面向未来的动态行为Objective-C 的老代码里回调机制被target-action占据新代码里Block 几乎成了默认选择。如果你只是想让某个行为能被延迟到运行时再决定Block 比 selector 更类型安全、更灵活typedef void (^WorkBlock)(NSString *); WorkBlock block ^(NSString *name) { NSLog(Hello %, name); }; [self executeBlock:block]; - (void)executeBlock:(void (^)(NSString *))block { block(world); }Block 捕获外部变量的能力、内联定义的直观性是 selector 无法比拟的。团队协作时Block 的签名清晰比去另一个类里找方法名少很多沟通成本。所以我个人有一条经验凡是方法名在编译期就是写死的就不要用performSelector凡是行为需要在运行时动态注入的优先考虑 Block。3.5 Protocol 与依赖注入从架构上消灭动态调用有些场景里动态调用本身就是一个设计气味——你其实想要的是多态而不是运行时方法名解析。这种情况下定义个协议让具体对象实现它然后直接走接口调用protocol DataSource NSObject - (NSString *)displayName; end // 调用方 NSString *name self.dataSource.displayName;这个方案彻底绕开了 selector 的问题还拿到编译期类型检查是架构层面最优的解法。缺点是需要额外的抽象成本适合模块间通信不适合某个业务函数内的临时调用。4. 在必须动态调用的场景中如何跟警告和平共处4.1 插件化架构里的运行时方法路由我实际遇到过的场景是一个 SDK 允许外部注入一段配置配置里写明了要调用哪个方法名。这是典型的运行时才知道 selector 的情况没法写成静态分支也没法在编译期把所有方法都列出来做协议。这时候不能简单关掉警告而是必须认真对待返回值所有权。我的做法分三步明确约定动态调用的方法统一返回 void。这个约定写在头文件的文档注释里也写进代码评审规范。返回值一旦强制为 void泄漏问题就不存在了。调用前必须做respondsToSelector:检查避免方法不存在导致的崩溃。用-Warc-performselector-leaks在文件级别单独抑制警告并加注释说明原因。#pragma clang diagnostic push #pragma clang diagnostic ignored -Warc-performselector-leaks SEL sel NSSelectorFromString(config.actionName); if ([self respondsToSelector:sel]) { [self performSelector:sel]; } #pragma clang diagnostic pop这个写法保留了动态能力又用返回 void的约定把内存管理风险关进了笼子里。顺便说一句有些团队直接把警告全局关掉这是我不推荐的做法——它会掩盖未来某位同事写返回 1 对象被泄漏的隐患。抑制警告应该是局部、有注释、有约束的而不是全局、沉默的。4.2 消息转发机制比 performSelector 更底层的动态调用在 Objective-C 的运行时里消息发送的完整路径是objc_msgSend找不到 IMP 时会依次触发以下方法resolveInstanceMethod:/resolveClassMethod:—— 给你机会动态添加方法实现。-forwardingTargetForSelector:—— 把消息转发给另一个对象。-methodSignatureForSelector:-forwardInvocation:—— 用NSInvocation完整地处理消息包括参数和返回值。这套机制其实是更可控的动态调用。如果你需要完全动态地处理某个 selector可以覆写methodSignatureForSelector:和forwardInvocation:在forwardInvocation:里对NSInvocation做完完整处理后再 invoke。这样做的好处是接收方明确知道自己处理了什么内存管理语义由你自己掌握。坑提醒覆写消息转发时一定要保证methodSignatureForSelector:返回的 signature 与实际方法签名一致。如果只覆写forwardInvocation:而漏了 signature运行时拿不到方法签名会直接崩溃而且崩溃栈往往看不出原因非常难排查。5. 用 Instruments 把可能泄漏验证成确实泄漏5.1 搭建一个演示工程我在开发中验证内存问题有个习惯不靠猜直接拿 Instruments 打一炮。下面用一个最小 demo 演示泄漏如何发生、如何消失。创建一个空工程在ViewController里加一个按钮点击后触发两段代码- (void)testLeak { // 注意这个前缀 copy按约定返回值是 1 的 SEL sel NSSelectorFromString(copyString); NSString *result [self performSelector:sel]; NSLog(%, result); } - (id)copyString { NSString *str [NSString stringWithFormat:static-%d, arc4random() % 100]; return [str copy]; // 返回 1 对象 }copyString严格遵循命名约定返回 1 对象而调用方用performSelector拿到它ARC 却不敢在performSelector:之后插入 release —— 于是这个对象没人负责释放泄漏。5.2 Instruments 泄漏检测的实战操作按 Command I 打开 Instruments选 Leaks 模板。运行 App多点击几次按钮让分配积累然后观察 Leaks 工具的红色柱状图。注意几个细节Leaks 检测不是即时的它会周期性地扫描内存。你刚点完按钮可能看不到红色柱等几秒扫描一轮就出来了。如果要定位泄漏调用栈需要让 App 在分配时记录 malloc 栈Instruments 里打开Malloc Stack记录Leaks 模板的配置项里可以勾选Record reference counts、开启 stack tracking泄漏列表里就能看到调用performSelector:对应的堆栈。如果是模拟器上测 autorelease 相关的问题最好把Leaks和Allocations两个工具一起跑因为某些对象在 AutoreleasePool 里会延迟释放Leaks 的扫描时机可能错过瞬时高水位。5.3 修复后的对照实验把调用改成 IMP 方式typedef id (*CopyStringIMP)(id, SEL); SEL sel NSSelectorFromString(copyString); CopyStringIMP imp (CopyStringIMP)[self methodForSelector:sel]; id result imp(self, sel);再跑一轮 Leaks红色柱消失说明 ARC 正确释放了返回值。有对比才有结论这两轮实验能帮你把警告为什么出现刻进肌肉记忆。6. 从 SEL 到 #selectorSwift 世界里这个问题去哪了6.1 Swift 的编译期检查堵住了大部分坑Swift 里调用 selector 的标准写法是#selector编译器会检查你引用的方法是否存在、是否对 Objective-C 可见objc func handleTap(_ sender: UITapGestureRecognizer) { // ... } let selector #selector(handleTap(_:))如果你写了一个不存在的方法名编译直接报错。这个就是本质区别performSelector:的问题在 Objective-C 里是运行时才知道 selector 是否存在、是否存在内存管理风险而 Swift 把它提到了编译期。但有个细节值得注意#selector要求方法标注objc也就是说 Swift 类里没暴露给 Objective-C 运行时的普通方法没法这样引用。这时候你可以把逻辑放进一个私有方法再用一个objc包装方法转发过去或者干脆改用 Swift 的闭包/函数类型语言本身支持得更好。6.2 纯 Swift 的替代路径在 Swift 项目里真正面向未来的做法是函数类型作为参数func register(action: (String) - Void)调用方传闭包。Selector 协议 泛型定义协议并让类型遵循拿具体类型调用。基于Any的注册表字典用字符串做 key值是弱引用的闭包。这些方案的优势是类型安全、编译器能帮忙检查逻辑错误。劣势是一旦用到Selector相关的 Objective-C 能力仍然绕不开objc。所以我的建议是除非在桥接 UIKit/AppKit 的 target-action API比如 UIControl、NSTimer、UIBarButtonItem否则少用#selector多用闭包。6.3 iOS 13 之后的 Selector 表示法iOS 13 引入过一个新变化Selector(methodName)的构造方式变得更有存在感但本质上它跟老的NSSelectorFromString一样都是运行时字符串解析。Swift 代码里如果真的需要这种灵活性一定要配合肯定存在的判断逻辑或者做好失败兜底。运行时字符串解析这条路在 Swift 里比在 Objective-C 里更危险——因为 Swift 编译器默认不做运行时动态派发一旦拼错方法名崩溃是必然的。7. 搜索热词带来的额外思考selector 概念的全景漫游写这篇文章时我不小心看到几个跟selector相关的搜索热词no section matches selector、simulink bus selector 没有可选信号、leak check。这很有意思——selector这个词汇在不同领域反复出现背后是同一个哲学用字符串或符号去选择某样东西必然带来选择不到怎么办的问题。在 iOS 里NSPredicate的MATCHES、querySelector、CSS 选择器、正则表达式都是selector的亲戚。你写document.querySelector(#app)返回 null和performSelector:找不到方法名导致崩溃本质都是运行时选择未知目标的风险。Matlab/Simulink 里的 Bus Selector 选不到信号时也会提示没有可选信号跟 iOS 里respondsToSelector:返回 NO 之后我们不得不做的兜底判断是同一种工程直觉。这也是为什么我一直强调动态调用的核心不是如何调用而是调用之前如何验证、调用之后如何兜底。把respondsToSelector:当作前置闸门把内存管理约定当作后置护栏把 Instruments 当作验证工具这套方法论放在任何语言、任何框架里都通用。最后分享一个我自己的习惯写完任何一段涉及动态调用的代码我会在提交前多问一句这个 selector 在编译期能不能确定。能就改成静态调用不能就明确返回值约定并做运行时检查。这个习惯救过我很多次比任何编译器的警告都可靠。
