ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PerformSelector警告与内存泄漏:ARC下动态调用的正确姿势

PerformSelector警告与内存泄漏:ARC下动态调用的正确姿势 如果你的项目是从 Objective-C 时代一路走过来的大概率在 Xcode 的 Issue Navigator 里没少跟这条警告打过照面“PerformSelector may cause a leak because its selector is unknown”。我最早遇到它是在封装一个全局 Target-Action 路由时当时第一反应就是找个编译选项把它关掉后来被线上一个内存增长追了整整两周才真正把这条警告背后的内存管理规则搞明白。这篇文章就把这条警告的来龙去脉、实际触发场景以及几种绕开又不出事的方案一次说清楚。这条警告不是 Xcode 在闹情绪它是 clang 在 ARC 下的保守策略。PerformSelector 把消息发送从编译期“确定”变成了运行期“猜”编译器没法知道你要调的方法签名自然不知道返回值归谁管、要不要在调用后补一个 release。搞清楚这个逻辑你才能在动态派发和内存安全之间找到平衡。内容适合三类人正在维护 Objective-C 老项目的开发者、做 iOS 底层库封装的人以及刚接触运行时机制想弄明白消息发送原理的学习者。下面直接开讲。1. 警告是怎么来的ARC 的保守策略1.1 编译器如何判断返回值归属ARC 能自动管理内存全靠编译期静态分析。它有一套“方法族”规则只要方法名以alloc、new、copy、mutableCopy开头编译器就认为返回值是 1 所有权调用方需要在用完后释放。比如[obj newToken]编译器知道这个调用会产出一个需要平衡引用的对象自动在合适的时机插入 release。但到了 PerformSelector 这里就麻烦了。[target performSelector:selector(newToken)]里的 selector 只是一个运行时字符串编译器根本不知道它指向什么方法。它没法判断newToken到底是不是方法族只能按“返回普通对象”来处理后续的 release 也就无处安放。如果这个 selector 真实对应的方法恰好是newXxx或copyXxx那每一次调用都会让内存上涨一个引用计数这就是警告里说的 leak。你可能会想编译器为什么不在运行时检查一下因为 clang 是静态分析器它不能保证 selector 的解析结果。与其在不确定时帮你乱插入内存管理代码不如在编译期提示你“这里可能有坑”。这就是为什么这条警告是-Warc-performSelector-leaks属于 ARC 专属。1.2 什么情况下“泄漏”是真的不是所有 PerformSelector 都会泄漏。判断方法很简单看目标方法返回值的所有权。如果方法返回值是void或者返回的是并未标记为 1 的普通对象那么 ARC 的默认处理就是正确的不会出问题。真正会泄漏的主要是三类方法名以alloc、new、copy、mutableCopy开头返回对象方法被__attribute__((objc_method_family(new)))等属性强制标记为 1 所有权方法返回值被赋给强引用变量并持续持有但编译器又没有插入配对释放。举个例子。假设一个钱包类里定义interface Wallet : NSObject - (id)newToken; end然后你用performSelector:去调用id token [wallet performSelector:selector(newToken)];编译器看到的是“一个普通对象返回赋值给局部强引用”它认为 ARC 会自动处理token的生命周期不会额外调用 release。而真实语义是newToken返回 1 对象调用方必须负责平衡引用。两个规则错位结果就是每次调用泄漏一次。这种问题在循环里尤其致命。2. 别只会用 performSelector全家桶逐一说清2.1 常用的五个变体和参数限制performSelector:家族并不只有你经常看到的那一个常见的变体至少有七个performSelector:无参调用performSelector:withObject:传一个对象参数performSelector:withObject:withObject:传两个对象参数performSelector:withObject:afterDelay:延迟调用performSelector:withObject:afterDelay:inModes:指定 runloop mode 延迟调用performSelectorOnMainThread:withObject:waitUntilDone:在主线程执行performSelectorInBackground:withObject:在后台线程执行performSelector:onThread:withObject:waitUntilDone:指定线程执行。最大的限制是参数只能传对象而且是“最多两个”。你要是想传NSInteger、CGRect、BOOL这些非对象类型直接往withObject:里塞会编译报错运行时更走不通。就算强行把NSInteger包装进NSNumber也只是绕了一层本质还是对象。真正要传结构体或原生类型得用 NSInvocation后面会专门讲。2.2 延迟执行与 runloop 的关系performSelector:withObject:afterDelay:和普通的performSelector:不是一个东西它的底层是往当前线程的 runloop 里注册一个 timer。如果你当前线程根本没有启动 runloop或者 runloop 处于一个不包含默认 mode 的追踪状态比如正在滑动列表时用了UITrackingRunLoopMode延迟调用就不会执行。另一个很容易忽略的点performSelector:onThread:withObject:waitUntilDone:这一族方法同样依赖目标线程的 runloop。如果目标线程没有活跃的 runloopwaitUntilDone:YES会让当前线程一直等下去直接卡死。我见过不止一次因为后台线程没开 runloop 导致performSelector:onThread:静默失败的事故。2.3 返回值“丢了”和“多出来”的问题performSelector:系列不是没有返回值只是返回值被当作id处理类型信息丢失了。编译器于是无从判断是否需要对返回值做特殊管理。前面说的newToken案例就是典型的返回值“多出来”的归属问题。还有一种是“丢了”的情况如果一个方法返回了一个被标记为ns_returns_retained的强引用对象你用了performSelector:且立刻丢弃返回值编译器不会回收它对象就悬在外面没人管。这种情况在自定义类里极少见但在桥接 Core Foundation 对象时会出现。处理原则只有一个凡是方法族返回 1 对象的都不要走 PerformSelector除非你明确知道自己在做手动管理。3. 怎么绕开警告四种方案的完整对比3.1 NSInvocation把动态调用重构成显式签名NSInvocation 是官方推荐的正规军。它让你把“要调用的方法签名”显式提供给编译器有了NSMethodSignatureARC 就能正确推断参数和返回值的内存管理方式。这是从根上解决未知 selector 问题的方案。看一下标准写法SEL selector NSSelectorFromString(doSomething:withAnother:); if (![target respondsToSelector:selector]) { return; } NSMethodSignature *signature [target methodSignatureForSelector:selector]; if (!signature) { return; } NSInvocation *invocation [NSInvocation invocationWithMethodSignature:signature]; invocation.target target; invocation.selector selector; id arg1 (1); id arg2 hello; [invocation setArgument:arg1 atIndex:2]; [invocation setArgument:arg2 atIndex:3]; [invocation invoke]; id returnValue nil; [invocation getReturnValue:returnValue];两个细节需要强调。第一参数 index 从 2 开始因为 0 号是self1 号是_cmd。第二setArgument:接收的是指针所以这里传的是arg1而不是arg1。返回值如果非对象类型getReturnValue:同样也只做内存拷贝需要你自己声明一个匹配类型去接比如NSInteger result; [invocation getReturnValue:result];。NSInvocation 缺点是慢比直接发消息慢一个量级不适合做高性能热路径上的高频调用。但它是消息转发机制的标准载体也是forwardInvocation:里必经的一环通用性和安全性最高。3.2 IMP 函数指针高性能方案与 arm64 的坑如果你确认目标方法的签名是固定的可以用methodForSelector:取出 IMP然后用函数指针调用。这样绕开了 clang 的警告又保留了动态派发能力性能几乎和直接objc_msgSend一样好。typedef id (*ObjectIMP)(id, SEL, id); SEL selector selector(doSomething:); if ([target respondsToSelector:selector]) { ObjectIMP imp (ObjectIMP)[target methodForSelector:selector]; id result imp(target, selector, (1)); }这段代码能直接通过编译但有一个非常重要的前提函数指针的签名必须和真实方法签名严格一致。返回值类型、参数类型、参数个数都不能错否则在 arm64 架构上会因寄存器分配错乱而崩溃。这也是为什么我会把 IMP 方案的危险级别标得比 NSInvocation 高。如果你要调的方法返回值是void函数指针类型要对应写void (*)(id, SEL, id)不能统一用id (*)(id, SEL, ...)。别抱有侥幸变长参数在 arm64 上的调用约定和固定参数完全不同写错就是崩溃。3.3 直接调用 objc_msgSend性能极限但要谨慎Objective-C 的消息发送本来就会编译成objc_msgSend的调用所以你也可以在代码里直接使用它((void(*)(id, SEL, id))objc_msgSend)(target, selector, arg);这种写法能把动态派发压到极致很多开源库在底层就是这么干的。但你自己写的时候必须把函数指针的每个参数类型都写对尤其是浮点数、结构体这类在寄存器传递规则上和整数不同的类型。objc_msgSend在不同 CPU 架构上对浮点参数的处理不一样写错了会得到完全错误的结果而不是崩溃。我的建议是除非你在写消息转发的底层封装否则不要直接碰objc_msgSend。它的性能优势和 IMP 方案没有本质差别但出错概率高得多。生产环境里能用 IMP 就用 IMPIM P 解决不了再升级到 NSInvocation。3.4 消除警告的“正规姿势”pragma 和 switch在某些场景下你就是能确认对应方法是安全的比如方法返回void、参数类型确定、路由表只接受内部白名单 selector。这时候你可以用 pragma 限定范围关闭警告#pragma clang diagnostic push #pragma clang diagnostic ignored -Warc-performSelector-leaks [target performSelector:selector withObject:arg]; #pragma clang diagnostic pop注意这里用的是 push / pop不是简单的 ignored这样警告只在 push 和 pop 之间被关闭不会影响其他代码。这种做法的前提是你清楚风险并且明确当前 selector 不涉及 1 返回值。它治标不治本但作为临时方案是安全的。另一种更优雅的方式是用 switch 或字典把动态 selector 映射到一套编译器认识的静态消息上switch (actionType) { case ActionOpen: [target open]; break; case ActionClose: [target close]; break; }这是把“动态”回归“静态”的思路牺牲一点扩展性换来编译期类型安全。如果动态方法数量有限这是我推荐的首选。3.5 四种方案的横向对比方案类型安全性能复杂度适用场景NSInvocation高低中路由、消息转发、参数不定IMP 函数指针中高中签名固定、热路径objc_msgSend低极高高底层封装pragma 忽略无高低明确安全的临时方案个人建议的决策顺序是能用静态映射就用静态映射动态方法多就上 NSInvocation真的要做性能优化再考虑 IMP。直接跳过警告会爽一时等泄漏积攒到线上爆发时你会比写 NSInvocation 多花十倍的时间排查。4. 实战动态路由、消息转发与延迟执行4.1 路由表中动态调用的落地很多 App 做模块间通信都会维护一个路由表用字符串映射到 selector。这类场景下目标类是动态的、方法是动态的用 NSInvocation 最稳。比如我手里的简化版本接收一个路由 key从注册表查 selector 并执行- (void)dispatchAction:(NSString *)action payload:(id)payload { NSString *selectorName self.routeMap[action]; if (!selectorName.length) { return; } SEL selector NSSelectorFromString(selectorName); id target self.destination; if (![target respondsToSelector:selector]) { return; } NSMethodSignature *signature [target methodSignatureForSelector:selector]; NSInvocation *invocation [NSInvocation invocationWithMethodSignature:signature]; invocation.target target; invocation.selector selector; NSMethodSignature *sig invocation.methodSignature; NSUInteger argCount sig.numberOfArguments; if (argCount 2) { [invocation setArgument:payload atIndex:2]; } [invocation invoke]; }这套逻辑把“字符串 action”和“对象方法”解耦开路由表只管映射业务方只需要遵循“方法最多一个参数”的约定就能安全完成动态调用。4.2 消息转发配合 NSInvocation 处理未知 selector如果不希望“调用了不存在的方法”直接崩溃可以在消息转发阶段拦截。forwardInvocation:里拿到的不再是裸的 selector而是完整的 NSInvocation可以直接改 target 或者改消息内容- (void)forwardInvocation:(NSInvocation *)invocation { if ([self.fallbackTarget respondsToSelector:invocation.selector]) { [invocation invokeWithTarget:self.fallbackTarget]; } else { [super forwardInvocation:invocation]; } } - (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { NSMethodSignature *signature [super methodSignatureForSelector:selector]; if (!signature) { signature [self.fallbackTarget methodSignatureForSelector:selector]; } return signature; }这里有一段顺带帮你理解了 performSelector 的警告你在代码里写performSelector:时编译器连运行时的消息转发机制都考虑到了风险更不可控所以直接警告。4.3 延迟执行和跨线程执行容易踩的坑前面提到延迟执行依赖 runloop。现实中最大的坑是你在子线程里写了performSelector:withObject:afterDelay:但子线程没起 runloop代码安静地不执行。排查时一眼看上去像 selector 没绑对实际上只是 runloop 没跑。如果必须在线程里延迟执行要么手动启动一个带循环的 runloop要么直接用 GCD 的dispatch_after替代dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [target doSomething]; });跨线程执行时waitUntilDone:YES如果配合一个未启动 runloop 的目标线程会让当前线程永久等待。等你的崩溃日志里出现主线程阻塞监控报警再回头看这里就晚了。我的建议是业务代码里的跨线程调用能换 GCD 就换 GCDperformSelector 的线程变体只留在框架内部。5. 高频问题实录与排查技巧5.1 方法返回 void 为什么还泄漏有个朋友排查了好久没找到原因他的 selector 对应方法确实是- (void)refreshUI返回值是void但 Instruments 里仍然能看到内存增长。后来发现这个类里有一个方法被__attribute__((objc_method_family(new)))标记了而他的路由表在某次重构后把方法名打错了selector 解析到了一个带 1 所有权的新方法上。这就是动态派发的经典风险编译期无法静态校验只能在运行时出错。排查方法是打个断言检查当前方法的签名NSMethodSignature *sig [target methodSignatureForSelector:selector]; const char *returnType sig.methodReturnType; // 打印 returnType 检查是否 或 v判断是否对象类型如果返回类型是对象就要警惕方法族如果是vvoid基本安全。5.2 传非对象参数时崩溃performSelector:withObject:只能接对象传NSInteger或结构体会直接出问题。记得有一次我把CGRect当作NSValue包装传进去虽然在调试时看着正常到了真机上由于 NSValue 的解包时机差异得到的是错位数据。场景上应该直接上 NSInvocation用setArgument:atIndex:传原生态结构体别做无谓的包装。5.3 arm64 上 IMP 函数指针崩溃arm64 架构下函数调用参数寄存器规则与 armv7 差别很大。直接把 IMP 转成id (*)(id, SEL, id)没问题但如果你在转成void (*)(id, SEL, ...)后用变长参数方式调用寄存器分配就会出错。很多老代码一在 arm64 真机上跑就闪退大概率就是这个原因。解决办法是让函数指针签名和消息签名完全对齐参数类型逐个写下不要用...。5.4 用 Instruments 做 leak check 验证如果你改完代码想知道到底还泄不泄漏直接在 Xcode 里跑 Leaks 工具不一定能快速暴露每一条路径毕竟测试场景未必覆盖到动态调用。我的做法是在可疑调用外面套一个大循环比如执行一万次然后观察内存增量for (NSInteger i 0; i 10000; i) { [wallet performSelector:selector(newToken)]; }如果内存从几 MB 涨到几十 MB说明泄漏还在如果一万一循环跑完内存持平说明 ARC 处理正常。这个小技巧比任何静态分析都直观也方便写进回归测试。配合leaks命令行工具在 CI 上跑还能自动化检测动态调用路径的引用失衡。最后现在再看到这条警告我的选择习惯是先判断方法的返回值类型和参数类型能落到静态消息就落静态消息必须用动态 selector 时参数多于两个一概走 NSInvocation参数固定且对性能有要求时用严格匹配签名的 IMP 指针。pragma 关闭警告是最后的选择而且每次都要加注释说明为什么安全。当初追那个线上内存问题的两周我学会的最重要的一件事编译器报的每一条 warning背后都有一个它在替你担忧的运行时场景。把警告当成线索去看比把它当成障碍去掉要省力得多。
返回列表