1. 撕开 RunLoop 的伪装:它不仅仅是一个死循环
很多兄弟在面试时把 RunLoop 背得滚瓜烂熟:“它是管理事件循环的对象,让线程有事做事,没事休眠...” 听起来没毛病,但你在写代码时真的看见过它吗?
在main.m那个不起眼的入口文件里,UIApplicationMain函数就像一个黑洞,一旦进去,主线程这辈子就别想出来了。
int main(int argc, char * argv[]) { @autoreleasepool { // 这一行下去,你的 App 才算真正活了 return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } }这背后所谓的“死循环”,并不是写个while(1)那么简单。如果你真在代码里写个while(1),你的 CPU 占用率瞬间飙升到 100%,手机发烫得能煎鸡蛋,过不了多久 iOS 的看门狗(Watchdog)就会因为主线程卡死把你杀掉。
RunLoop 的高明之处在于用户态和内核态的切换。
关键在于mach_msg()。当 RunLoop 发现没任务(Source0/Source1/Timer/Observer 都处理完了),它不是在空转,而是调用了内核函数,告诉系统:“哥们,我累了,有消息再叫醒我”。这时候,线程进入Trap 状态,CPU 资源被完全释放。这才是“没事休眠”的真相——它不是在循环里发呆,而是直接挂起了。
你在 Crash 堆栈里经常看到的__CFRunLoopServiceMachPort,就是在等这个内核消息。
线程保活的误区
早些年 AFNetworking 2.x 为了在后台接收 Delegate 回调,强行搞了个“常驻线程”。现在的代码里如果我还看到有人这么写,通常会直接在 Code Review 里打回:
+ (void)networkThreadEntryPoint:(id)__unused object { @autoreleasepool { [[NSThread currentThread] setName:@"AFNetworking"]; NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; // 这里的操作很骚 [runLoop run]; } }注意那个addPort。如果你不加这个 Port,RunLoop 启动后发现“卧槽,没 Source 也没 Timer,玩个蛋”,然后直接退出,线程销毁。添加一个空的 MachPort 就像是给驴挂了个胡萝卜,虽然它永远吃不到(因为没有真正的消息发给这个 port),但它会为了这个目标一直跑下去。
但在今天,这种做法是过时的。现在的 GCD 和NSURLSession已经管理得足够好,除非你在做极度复杂的 Socket 长连接或者需要精细控制生命周期的后台任务,否则尽量别自己手动去 Run 一个 RunLoop,维护成本极高,容易搞出僵尸线程。
2. 模式(Mode)的博弈:为什么你的 Timer 总是“装死”?
这是个老生常谈的问题,但 90% 的人只知道解法,不知道因果。
当你滑动UITableView或者UIScrollView时,原本定好的NSTimer突然就不走了。面试官问你为啥,你答“因为 Mode 切换了”,面试官点点头。但如果在生产环境,这还不够。
RunLoop 同一时间只能运行在一个 Mode 下。
kCFRunLoopDefaultMode: App 平时溜达的状态。
UITrackingRunLoopMode: 手指头按在屏幕上搓动时的状态。
当你滑动列表,主线程切换到TrackingMode。你的 Timer 默认是加在DefaultMode里的。RunLoop 就像个势利的管家,它说:“我现在只服务 Tracking 模式下的贵宾,Default 模式下的穷亲戚(Timer)先在门口等着。”
于是,界面停了,Timer 里的倒计时才通过“跳秒”的方式补回来,或者干脆就丢了。
常见的错误解法与代价
很多人知道要用NSRunLoopCommonModes。
// 大家都这么写,觉得自己很机智 [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];这行代码其实是个标记位的魔法。CommonModes不是一个真正的 Mode,它是一个集合(Set)。默认情况下,它包含了 Default 和 Tracking。把 Timer 加到 CommonModes,等于告诉 RunLoop:“不管你在哪个山头唱戏,这个 Timer 你都得给我带着。”
但是!这里有个巨大的坑,尤其是在涉及繁重计算的时候。
假设你的 Timer 每 0.1秒触发一次,做一些动画计算或者图片处理。如果你把它加到了CommonModes,意味着在用户疯狂滑动列表(TrackingMode)的时候,这个 Timer 依然在主线程抢占 CPU 资源。
结果就是:列表滑动变卡了。
因为人眼对 60FPS 的滑动流畅度非常敏感,任何在 TrackingMode 下抢夺主线程资源的行为都是犯罪。所以,在做性能优化时,有时候我们故意不把 Timer 加到 CommonModes,而是让它在滑动时暂停,等停下来再刷新 UI。这取决于你的业务是“数据实时性优先”还是“交互流畅度优先”。
干货建议:如果你的 Timer 只是为了更新一个不会影响核心体验的小倒计时(比如 cell 上的秒杀倒计时),用CommonModes没问题。但如果是为了计算复杂的粒子效果,请考虑用 CADisplayLink 并扔到子线程,或者直接在滑动时暂停动画。
3. 刨根问底:Source0 和 Source1 到底在吵什么?
打开一段卡顿日志的堆栈,你总能看到CFRunLoopDoSources0或CFRunLoopDoSources1。这俩货是 RunLoop 处理事件的核心,搞不清它们,你就看不懂卡顿监控的日志。
Source0:由于你自己作死产生的事件
它只包含应用层面的回调。也就是非基于 Port 的。 举个最直白的例子:[performSelector:onThread:...]。 当你在主线程或者子线程调用这个方法时,你其实是像 RunLoop 里的 Source0 集合扔了一个任务,并标记为signaled(待处理)。
关键点来了:Source0 是被动的。它需要被标记,并且 RunLoop 醒着的时候才会去处理。如果 RunLoop 正在休眠,你仅仅扔个 Source0 它是不会立马醒的(通常需要配合 wakeup)。
触摸事件(Touch Begin/Move/End)在这一层其实是个特例。虽然硬件中断是 Source1 也就是 mach port 传过来的,但系统内部会把它包装处理,最终经常表现为 Source0 的回调分发到UIApplication。所以在卡顿堆栈里,点击按钮的响应往往在DoSources0下面。
Source1:系统大佬的直通车
它是基于 Port 的,这玩意儿直接和内核打交道。 物理按键、传感器数据、进程间通信(IPC),这些都是通过 Mach Port 也就是 Source1 进来的。
Source1 有个特权:它可以主动唤醒休眠的 RunLoop。
生产环境的“灵异现象”
有一次排查线上 Bug,发现主线程莫名其妙卡顿,堆栈停留在CFRunLoopDoSources0。查了半天代码,发现是有个二货开发写了个巨大的数组遍历逻辑,放在了performSelector里执行。
因为它是 Source0,RunLoop 在一次循环(Loop)中,处理完所有的 Source0 才会进入休眠或处理 Source1。如果你的 Source0 任务太重,就会直接导致这一帧的时间被撑爆,掉帧就产生了。
优化策略:如果你有大量的任务需要分包处理,不要试图在一个 Source0 回调里干完。可以将大任务拆分成多个小的 Source0,或者利用CFRunLoopObserver在 RunLoop 的BeforeWaiting(准备休眠)时机去执行低优先级的任务(比如预排版、图片预解码)。这也就是 AsyncDisplayKit (Texture) 的核心原理之一。
4. 自动释放池(AutoreleasePool)的幽灵:它什么时候干活?
你可能背过:“AutoreleasePool 在 RunLoop 开始时创建,在休眠前销毁。”
这话对,也不对。
我们在主线程里产生的autorelease对象,如果不手动加池子,确实是依赖 RunLoop 来清理的。系统在 RunLoop 中注册了两个高优先级的 Observer(观察者):
第一个 Observer: 监听
Entry(进入 Loop)。它会调用_objc_autoreleasePoolPush()。这就像是进门前拿了个垃圾袋。第二个 Observer: 监听
BeforeWaiting(准备休眠)和Exit(退出)。在
BeforeWaiting时,它会先_objc_autoreleasePoolPop()(把垃圾袋扔了,清理内存),然后紧接着_objc_autoreleasePoolPush()(再拿个新袋子,为下一次醒来做准备)。在
Exit时,直接 Pop,打扫战场走人。
为什么知道这个很重要?
内存峰值(High Water Mark)优化。
在处理大图加载或者复杂的 JSON 转 Model 列表时,如果你的逻辑全在一次 RunLoop 循环里跑完,中间产生了成千上万个临时的NSString、NSDictionary,它们都得等到 RunLoop 准备休眠或者跑完这一圈时才释放。
在这个时间点之前,你的 App 内存会像过山车一样飙升。如果这时候内存报警,你的 App 就 Crash 了。
实战操作:在for循环里,或者处理密集型任务时,显式地套一个@autoreleasepool {}。
// 错误示范:坐等 RunLoop 给你擦屁股 for (int i = 0; i < 10000; i++) { NSString *log = [NSString stringWithFormat:@"Log info: %d", i]; // ... 产生大量临时对象 } // 正确示范:自己拉屎自己冲 for (int i = 0; i < 10000; i++) { @autoreleasepool { NSString *log = [NSString stringWithFormat:@"Log info: %d", i]; // ... 出了这个花括号,内存立马释放 } }这看起来是内存管理的基础,但本质上是你对 RunLoop 释放时机的不信任票。在高性能场景下,不要指望 RunLoop 的那个默认 Observer,它的粒度太粗了。
5. 深入心脏:RunLoop 内部逻辑的伪代码重构
为了彻底理解,我们别看那些晦涩的 C 源码了,我用大白话伪代码把 RunLoop 的核心逻辑写一遍。看完这个,你就知道卡顿监控的代码该插在哪了。
/// 这是一个极其简化的 RunLoop 逻辑模型 void CFRunLoopRun() { // 1. 告诉 Observer:这班车我要发车了 (kCFRunLoopEntry) __CFRunLoopDoObservers(kCFRunLoopEntry); do { // 2. 告诉 Observer:我要处理 Timer 了 (kCFRunLoopBeforeTimers) __CFRunLoopDoObservers(kCFRunLoopBeforeTimers); // 3. 告诉 Observer:我要处理 Source0 了 (kCFRunLoopBeforeSources) __CFRunLoopDoObservers(kCFRunLoopBeforeSources); // 4. 处理 Source0 (这一步可能会消耗大量 CPU,如果是卡顿,大概率卡在这) __CFRunLoopDoSources0(); // 5. 关键点:如果有 Source1 (基于端口的消息) 已经到了,那就别睡了,直接跳到第9步去处理 if (CheckIfExistMessagesInMainDispatchQueue() || CheckIfSource1Fired()) { goto handle_msg; } // 6. 告诉 Observer:没啥事,我要睡了 (kCFRunLoopBeforeWaiting) // **注意:卡顿监控通常在这里记录时间戳,因为下面就进入系统内核态了** __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting); // 7. 调用 mach_msg 等待唤醒。线程在此挂起,不占用 CPU。 // 等待被 Source1、Timer、或者 GCD 主线程任务唤醒 mach_msg_trap(); // 8. 告诉 Observer:我醒了 (kCFRunLoopAfterWaiting) // **注意:卡顿监控在这里再次记录时间戳。如果 8 - 6 的时间极短,说明只是睡了一觉; // 如果 8 - 2 的时间差减去 睡眠时间 很大,说明 Loop 执行超时了** __CFRunLoopDoObservers(kCFRunLoopAfterWaiting); handle_msg: // 9. 被什么唤醒的?处理对应的东西 if (TimerFired) { __CFRunLoopDoTimers(); } else if (GCDMainQueue) { // 处理 dispatch_async(dispatch_get_main_queue(), block) __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(); } else { // 处理 Source1 __CFRunLoopDoSource1(); } // 10. 处理完一波,看看要不要退出循环 } while (!stop); // 11. 告诉 Observer:我要下班了 (kCFRunLoopExit) __CFRunLoopDoObservers(kCFRunLoopExit); }看懂了这个流程,下一章讲卡顿监控时,你就明白为什么我们要创建一个子线程,专门盯着主线程的 RunLoop 状态变迁了。我们实际上是在监控kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting这两个状态之间的停留时长。
6. 滑动优化的降维打击:利用 Mode 玩“时停”
你在写UITableView的cellForRow时,是不是习惯把图片设置、头像圆角裁剪、文字计算全堆在一起?
// 菜鸟写法 cell.imageView.image = [UIImage imageNamed:@"heavy_image"]; cell.avatar.layer.cornerRadius = 25; // 离屏渲染警告 cell.avatar.clipsToBounds = YES;当用户手指在屏幕上狂搓(TrackingMode)时,主线程既要处理触摸事件,又要计算布局,还要去解码那张该死的大图。CPU 就像个被两个老板同时催工的社畜,结果就是——掉帧。
怎么破?利用 RunLoop 的 Mode 切换机制,我们可以玩一招“时间停止”。
核心逻辑:当用户在滑动时(TrackingMode),我们什么重活都不干,只显示占位图或文字。一旦用户手指离开屏幕,滑动停止(回到 DefaultMode),我们再把高清图和复杂的圆角切好放上去。
这代码写起来比你想的要简单得多,核心就是performSelector的modes参数:
// 在 cellForRowAtIndexPath 中 // 1. 先设置默认图,保证界面不白板 [cell.avatarImageView setImage:[UIImage imageNamed:@"placeholder"]]; // 2. 将耗时的设置任务推迟到 DefaultMode 下执行 [self performSelector:@selector(setHeavyImageForCell:) withObject:cell afterDelay:0 inModes:@[NSDefaultRunLoopMode]];看懂了吗?afterDelay:0并不是立即执行,而是“尽快执行”。但inModes:@[NSDefaultRunLoopMode]这句才是灵魂。它告诉 RunLoop:“只要还在滑动(TrackingMode),这行代码就别跑;等停下来(DefaultMode),立马给我执行。”
生产环境的坑(必看):这招有个致命的问题——Cell 重用。 如果用户滑得飞快,Cell A 刚准备加载图片,结果还没停下来就被回收给 Cell B 用了。等停下来时,那个延迟的任务执行了,把 Cell A 的图片贴到了 Cell B 脸上。这就是经典的“图片错乱”。
修正方案:你必须在prepareForReuse里,或者在设置图片前,取消掉之前挂在这个 Cell 上的延迟任务。
+ (void)cancelPreviousPerformRequestsWithTarget:(id)aTarget selector:(SEL)aSelector object:(id)anArgument;虽然现在 SDWebImage 等库已经处理得很好了,但在处理复杂的富文本排版计算时,这个技巧依然是低成本换取高 FPS的杀手锏。
7. 大任务拆解:RunLoop 分发中心 (Work Distribution)
假设你要在主线程加载一张 4000x4000 的大图并渲染到视图上,或者你要一次性从数据库读 50 条数据转成 Model。不管你多快,只要这一个任务耗时超过16.7ms(1/60秒),这一帧就丢了。
传统的并发编程会告诉你:扔到后台线程去! 但 UI 必须在主线程更新,你算出数据总得回来吧?回来的那一瞬间 commit 也是耗时的。
这里介绍一个非常高级的技巧,曾被用于 AsyncDisplayKit (Texture) 的早期版本:RunLoop 任务分片。
原理:既然一个大任务会卡死,那我就把它切成 100 个小任务。每次 RunLoop 循环唤醒时,我只做一个小任务,做完立马把控制权交还给系统去渲染 UI。这样,虽然总耗时没变,但每一帧都有空闲去响应触摸和绘制,用户感觉不到卡。
如何实现?
我们需要一个单例,用来管理这些“碎片任务”。
定义任务队列:用一个
NSMutableArray存 Block。监听 RunLoop:注册一个
CFRunLoopObserver,监听kCFRunLoopBeforeWaiting(准备休眠前)或者kCFRunLoopAfterWaiting(刚醒来)。通常选BeforeWaiting比较安全,因为那时该处理的都处理了,偷点时间干活。消费任务:回调触发时,从数组里
pop一个任务执行。
关键代码逻辑(伪代码):
// 定义回调函数 static void RunLoopWorkDistributionCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { // 检查是否有任务 if (tasks.count == 0) return; // 取出一个任务 RunLoopTask task = tasks.firstObject; [tasks removeObjectAtIndex:0]; // 执行它! task(); // 这一步非常重要: // 执行完一个任务后,如果还有任务,必须显式唤醒 RunLoop。 // 否则 RunLoop 处理完这个 Callback 可能会直接进入休眠,导致剩下的任务要等下一次触摸才能触发。 if (tasks.count > 0) { CFRunLoopWakeUp(CFRunLoopGetCurrent()); } } // 注册监听 - (void)registerObserver { CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL, NULL}; CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, // 也就是干完所有杂活准备睡觉前 YES, // 重复监听 0, // 优先级 &RunLoopWorkDistributionCallback, &context); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); }实战场景:你在cellForRow里要加载 3 张高清头像。如果你直接[imgView setImage:...]三次,肯定卡。 用这个分发器:
[[RunLoopWorkDistribution shared] addTask:^{ cell.img1.image = ...; }]; [[RunLoopWorkDistribution shared] addTask:^{ cell.img2.image = ...; }]; // ...此时,这三个头像会在接下来的 3 次 RunLoop 循环(也就是接下来的 3 帧)里分别显示。视觉上几乎是同时的,但主线程的压力被摊平了。
8. 猎犬计划:基于 RunLoop 的卡顿监控系统
现在市面上最准的卡顿监控(比如微信的 Matrix,阿里的 BlockCanary),都不是靠算 FPS 的。
FPS 只是表象。FPS 低可能是因为 GPU 渲染压力大,也可能是 CPU 满载。而作为 iOS 开发,我们主要解决的是主线程 CPU 卡死的问题。
RunLoop 才是卡顿的根源。如果主线程在执行某个方法时卡住了,一定意味着 RunLoop 停在了__CFRunLoopDoSources0或者__CFRunLoopDoSource1之后,迟迟没有进入BeforeWaiting状态。
我们可以搞一个子线程监控者(Watchdog),像一条猎犬一样死死盯着主线程的 RunLoop 状态。
监控架构设计
创建一个子线程:并在子线程里开启一个
while(true)循环。主线程埋点:给主线程 RunLoop 添加 Observer,记录状态变更的时间戳。
信号量机制:这是核心。
代码逻辑推演(这是价值百万的监控核心):
// 主线程 Observer 回调 static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { MyLagMonitor *monitor = (__bridge MyLagMonitor*)info; monitor->activity = activity; // 记录当前状态 // 发送信号,告诉子线程的看门狗:“我主线程还活着,状态变了!” dispatch_semaphore_signal(monitor->semaphore); } // 子线程监控逻辑 - (void)startMonitoring { dispatch_async(monitoringQueue, ^{ while (isMonitoring) { // 等待信号。设置超时时间为阈值,比如 250ms (0.25秒) long waitResult = dispatch_semaphore_wait(self->semaphore, dispatch_time(DISPATCH_TIME_NOW, 250 * NSEC_PER_MSEC)); if (waitResult != 0) { // !!!超时了!!! // 说明主线程在 250ms 内没有更新状态,没有发信号过来。 if (!self->observer) { self->activity = 0; return; } // 必须过滤掉 "休眠" 状态。 // 如果主线程是在 BeforeWaiting (准备睡觉) 之后超时的,那是正常休眠,不是卡顿。 if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting) { // 抓到了!主线程正在忙着处理 Source0 或者刚醒来处理 Timer/GCD, // 结果忙了超过 250ms 还没干完。 // TODO: 此时立马抓取主线程的堆栈 (PLCrashReporter / BSBacktraceLogger) [self logStacktrace]; } } } }); }为什么是这两个状态?
kCFRunLoopAfterWaiting: 说明 RunLoop 刚被叫醒(比如定时器响了,或者主队列有 Block),正在处理这些唤醒它的任务。如果这就卡了,说明你的
timer回调或者dispatch_async里的代码太烂。kCFRunLoopBeforeSources: 说明 RunLoop 正在处理 Source0(主要是你的 UI 点击事件、方法调用)。绝大多数 UI 卡顿都发生在这里。
误报剔除技巧:如果 App 处于后台,RunLoop 可能会休眠很久,这时候不要报警。 如果 CPU 负载整体很高(全系统卡),你的子线程可能也抢不到时间片,导致误差,这时候要结合 CPU 使用率来判断。
9. 濒死体验:利用 RunLoop 在 Crash 后“续命”
这可能是 RunLoop 最“黑魔法”的应用场景了。
当 App 崩溃时(比如数组越界 Uncaught Exception),系统会杀死进程。但在用户看来,就是“闪退”。这对体验是毁灭性的,尤其是用户正在编辑长文,还没保存就崩了。
我们能不能让 App 在崩溃后,不要立马死,而是弹个窗提示用户“程序出错了”,然后让他有机会点个“保存”再死?
答案是:可以,接管 RunLoop。
在UncaughtExceptionHandler里,我们可以强行重启一个 RunLoop。
void UncaughtExceptionHandler(NSException *exception) { // 1. 获取当前 RunLoop CFRunLoopRef runLoop = CFRunLoopGetCurrent(); NSArray *allModes = CFRunLoopCopyAllModes(runLoop); // 2. 弹窗提示 (必须在主线程) // 这里其实很危险,因为 UI 系统可能已经乱了,但在 desperate times,值得一试 UIAlertController *alert = ...; [rootVC presentViewController:alert animated:YES completion:nil]; // 3. 强行续命循环 while (!isUserDismissedAlert) { // 让 RunLoop 继续跑,每次跑 0.001 秒就停,类似空转, // 这样可以维持 UI 的响应,处理点击事件 for (NSString *mode in allModes) { CFRunLoopRunInMode((CFStringRef)mode, 0.001, false); } } // 4. 用户点完保存后,手动杀掉进程 NSSetUncaughtExceptionHandler(NULL); abort(); }这招就像是给心脏骤停的人打一针肾上腺素。警告:此时 App 的内存状态可能已经脏了(Corrupted),继续运行可能会导致逻辑错误。所以这个“续命”模式只能用来做紧急数据保存,千万别让用户继续正常用,否则会产生更严重的数据污染。
这也是为什么有些 App 崩了之后,界面卡了一下,然后弹出了一个“抱歉,程序异常”的弹窗,还能让你点确定的原因。他们就是在 Crash Handler 里强行 Run 了一个 Loop。
10. 世纪之问:GCD 和 RunLoop 到底是什么关系?
这是一个在面试中能杀掉 90% 候选人的问题:“你在子线程dispatch_async到主线程更新 UI,主线程的 RunLoop 到底知不知道?”
很多人的理解是模糊的,觉得它俩是两套并行的系统。错。在主线程上,它们是穿一条裤子的。
当你写下这行代码时:
dispatch_async(dispatch_get_main_queue(), ^{ self.label.text = @"Hello"; });发生了什么?
GCD 会把这个 Block 扔进 Main Queue 的结构体里。
然后,GCD 发现这是主队列,它需要“叫醒”主线程。
它会向主线程 RunLoop 注册的那个特殊的Port(还记得 Source1 吗?)发送一个信号。
RunLoop 正在休眠(Trap 状态),被内核唤醒。
RunLoop 醒来后,检测到是被 GCD 唤醒的,它甚至不会走标准的 Source0/Source1 处理流程,而是直接执行一个特定的函数:
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__。
这个函数的名字长得像个玩笑,但它真实存在。你如果不信,下次在dispatch_async的 Block 里打个断点,看左边的调用堆栈,这行大字赫然在目。
结论很惊悚:RunLoop 是主线程的心脏,而 GCD 的主队列任务,只是寄生在 RunLoop 心跳间隙中的寄生虫。 这意味着,如果你在dispatch_async到主线程的任务里写了死循环,不仅 GCD 瘫痪,整个 RunLoop 也会直接暴毙,App 彻底失去响应。
生产环境启示:在做性能分析时,如果看到CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE占用时间过长,说明有人把太多的业务逻辑塞进了dispatch_async,导致 RunLoop 这一圈跑得太累,没时间去响应触摸事件(Source0)。这时候的优化策略是:拆。把大 Block 拆成小的,分多次 dispatch,或者把非 UI 逻辑扔回后台队列。
11. 线程保活的“现代”启示录
我们在第一章提到了 AFNetworking 早期的线程保活。现在,我们深入聊聊为什么你要(或者不要)这么做。
如果你在开发一个IM(即时通讯) SDK或者监控 SDK,你需要一个线程,它必须:
永远不死:随时准备接收服务器推过来的消息。
随叫随到:有消息立马处理。
没事别占 CPU:没消息时必须挂起,不能空转耗电。
这时候,普通的NSThread跑完任务就销毁了,不符合要求。GCD 的并发队列又不受你精确控制。你必须手动搭建一个带有 RunLoop 的线程。
标准模版(可以直接抄进你的 SDK):
@interface MyWorkerThread : NSThread @end @implementation MyWorkerThread - (void)main { @autoreleasepool { // 1. 获取当前 RunLoop NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; // 2. 添加一个 Port。 // 这里的 [NSMachPort port] 是关键。 // 我们并不真的通过这个 Port 发消息,它只是为了告诉 RunLoop: // "我有 input source,别退出,给我等着!" [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; // 3. 启动 Loop。 // 注意:不要用 [runLoop run]。 // 因为 run 方法是无法停止的,它会一直跑在 DefaultMode。 // 我们通常自己写个 while 循环,这样可以在外部通过标志位控制线程停止。 while (!self.isCancelled) { [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; } } }为什么不要用[runLoop run]?文档里写得很隐晦:run方法本质上是一个无限循环调用runMode:beforeDate:的封装。一旦开启,你很难在线程内部优雅地停止它。而上面的while写法,允许你在外部调用[thread cancel]后,下一次 Loop 醒来时检测到isCancelled从而优雅退出,释放内存。
警惕僵尸线程:很多初级架构师写完这个就以为万事大吉了。结果 App 运行久了,内存里堆积了十几个这样的线程,因为他们忘记了退出条件。 如果你的 SDK 只是在某个模块使用(比如进入直播间),一定要在退出模块时,显式地给这个线程发一个 performSelector,让它执行退出逻辑,否则这个线程就是内存泄漏。
12. 那些你没注意到的“RunLoop 细节”
在多年的踩坑经验中,还有几个极其隐蔽的细节,值得你记在小本本上。
A. 定时器的误差会累积吗?
NSTimer是不准的,大家都知道。如果 RunLoop 忙,它会推迟执行。 关键是:推迟后,下一次触发时间是按原计划,还是按推迟后的时间顺延?
答案是:RunLoop 只有在“错过”太久的情况下才会重置时间线。如果你的 Timer 设定是 00:00, 00:01, 00:02。 结果 00:01 的时候 RunLoop 卡住了,直到 00:01:50 才醒过来处理。 那么 RunLoop 会立即执行 00:01 的回调。 然后,等到 00:02:00,它会继续执行下一次。它会试图追赶时间线,但不会把中间错过的 00:01:10, 00:01:20... 全部补执行一遍(会发生合并)。所以,不要用NSTimer做这种需要精确计数的秒表,用CADisplayLink或者 GCD Timer。
B. 界面更新的“集结号”
为什么你改了label.text = @"A", 下一行label.text = @"B", 屏幕不会闪一下 A?
因为 UI 渲染也是 RunLoop 的一个 Observer。 RunLoop 在BeforeWaiting(准备休眠)或者Exit时,会执行一个系统注册的回调。这个回调会遍历所有被标记为setNeedsDisplay或setNeedsLayout的 View,然后一把梭,提交给 GPU 去渲染。
这就是为什么你在代码里疯狂改 Frame 没关系,只有最后一次修改才会被提交。这也解释了为什么在循环里改 UI 不会立即生效,除非你强行调用[CATransaction flush](警告:别乱用这个,会破坏系统的渲染节奏)。
C. 触摸事件的传递黑箱
当你的手指点击屏幕,系统进程(SpringBoard)接收到硬件信号,通过 Mach Port 发送给你的 App 进程。 App 的主线程 RunLoop 被 Source1 唤醒。 然后 Source1 回调会触发__IOHIDEventSystemClientQueueCallback。 这个函数会把事件包装一下,分发给 Source0。 最后 Source0 调用UIApplication的sendEvent:。
实战价值:如果你想做无侵入的埋点系统(AOP),HooksendEvent:是最上层的做法。但如果你想做全局的触摸防抖或者特殊手势拦截,你需要理解这个 Source1 -> Source0 的过程。有些极端的黑客防守技术,甚至会去监控 RunLoop 的 Source1 来源,防止脚本模拟点击。