1. RunLoop 概念
当我们打开 iPhone 手机进入一款 APP 时,这款 APP 会一直在当前屏幕运行,直到我们 kill 进程或切换到后台。联想到我们刚学习 C 语言时写的一些 C 程序,运行结束后会显示结果,程序就退出了。为什么 iPhone 上的 APP 会一直响应用户的请求?背后的机制是如何实现的呢?
这个小节从 What-How-Why 三个方面解释一下,RunLoop 是什么?是如何运行的?为何采用这种设计?
RunLoop是事件驱动的一个大循环
,从 APP 启动开始进入循环直到 APP 退出。它是与线程
相关的底层架构,一个run loop 就是一个事件处理循环,我们用它来安排任务和协调事件。runloop 的目的是为了让有任务的时候保持线程忙碌,无任务时让线程休眠。
RunLoop不是一直处于唤醒状态,它有三种状态:
- 睡眠
- 唤醒
- 处理事件
这三种状态
的执行顺序是 『睡眠-唤醒-处理事件-睡眠-...』。当有事件需要处理时,RunLoop 被唤醒开始处理事件,事件处理结束后,RunLoop 重新进入睡眠状态。
因为一个线程一次只能执行一个任务,执行完成后线程就会退出。如果需要线程一直可以响应不退出, EventLoop 模型(事件循环)可以满足这样的需求。使用事件循环,需要考虑这样几个问题:
- 如何管理事件、消息?
- 如何在无任务处理时避免资源占用?
- 如何在有任务时被唤醒?
RunLoop对象负责管理事件和消息,并提供一个函数(名副其实—— runloop
内部是一个 do-while 循环)来进行Event Loop,该函数负责处理上述的问题2\3,当线程执行该函数后,会一直处于『睡眠-唤醒-处理事件-睡眠-...』的状态循环中,直到循环结束,函数返回。
在 iOS 中是通过CFRunLoop实现的,CFRunLoop 是CoreFoundation框架中的 C 语言API,是线程安全的。在CFRunLoop的基础上封装了NSRunLoop,NSRunLoop是面向对象的,但不是线程安全的。
CFRunLoop 的开源代码可下载,查看 CFRunLoop.c.
不需要去创建 runloop 对象,每个线程(包括主线程)都有一个相关联的 runloop 对象。APP 启动时就会在主线程自动创建和运行一个 runloop。
哪些事件会触发 RunLoop 的状态变化呢? 有以下6种:
-
Observer事件
,runloop 状态变化时通知。
static void CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION(); -
Block事件
:非延迟
的NSObject PerformSelector\dispatch_after, 和 block 回调。
static void CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK(); -
main dispatch queue事件
:一次RunLoop有两个机会执行GCD dispatch main queue中的任务,分别在休眠前和被唤醒后。
static void CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE(); -
timer事件
:延迟
的 NSObject PerformSelector\dispatch_after\timer。
static void CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION(); -
source0
事件:UIEvent\CFSocket
static void CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION(); -
source1
事件:系统内核 mach_msg、CADisplayLink 事件。
static void CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION();
上面的这些方法名可以通过打断点查看调用栈信息看到。
2. RunLoop 剖析
runloop 有两个事件源:input sources(输入源) 传送异步
事件,通常是来自其他线程或其他应用的信息。Timer sources (定时源)传送同步
事件,定时或在某个时间间隔重复发生。
input source 给相应的处理方法发送异步事件,并导致 runUntileDate:方法退出。Timer sources 发送事件给它的处理方法,但不会导致 runloop 退出。
CF 框架中关于 RunLoop几个概念的对应关系:
2.1 RunLoop Mode
Run loop模式是一个集合,其中包括所有要监视的输入源和定时源以及要通知的注册观察者。mode 和源是相对应的,一个 runloop 运行时需要指定一个 mode,与该 Mode 对应的源在本次 runloop 可被监测;非对应的源处于暂停状态,等待与其匹配的 mode 的 runloop 启动时才可被监测。换言之,一次 runloop 只关注一类感兴趣的源。
cocoa和 CF 框架定义了一些默认的通用 mode,是字符串形式:
- default :用于启动 runloop 并配置输入源。
- reply:NSConnection, 通常不会自己配置这个 mode.
- modal panel: modal
- event tracking:用户交互事件
- common modes:是一个数组,可以配置很多 mode 进去,默认包含 除 replay 之外的其他 mode.
CFRunLoop与线程是一对一的关系,每条线程中可开启一个 runloop,但 runloop 是可以嵌套的(一个 runloop 中嵌套一个)。CFRunLoop 与 CFRunLoopMode 是一对多的关系,一个 runloop 可以有多种 mode。
2.2 Input Sources(输入源)
2.3 Timer Sources(定时源)
2.4 RunLoop Observer(观察者)
2.5 消息的 runloop 顺序
3. 何时使用 RunLoop?
只有在创建次线程
时才需要运行 runloop,对于主线程会自动创建并运行。用次线程进行一个耗时很长的任务时不需要启动 runloop,只有在需要与线程进行交互的时候才启动:
- 使用端口或自定义输入源和其他线程通信
- 使用定时器
- cocoa中使用任何performSelector
- 使线程履行周期性任务
当需要在次线程中使用 RunLoop时,需要创建 RunLoop对象并进行配置。
4. 使用 RunLoop 对象
4.1 获取 RunLoop 对象
- cocoa:[NSRunLoop currentRunLoop]
- 使用CFRunLoopGetCurrent函数
4.2 配置
在次线程启动run loop前,你必须至少添加一类源
。因为如果run loop没有任何源需要监视的话,它会在你启动之际立马退出。
5. 设置 RunLoop 源
6. RunLoop 在 iOS 中的应用
6.1 系统中的应用
-
autoreleasepool
自动释放池中的对象在当前 runloop 进入睡眠之前统一释放。 -
事件响应
- 手势识别
- 界面更新
- 定时器
- performSelector
runloop mode
滑动列表时,default-tracking-default
6.2 其他应用
- AFNetworking源码(待研究)
- TableView 延迟加载图片
UIImage *img = ...;
[self.xxxImageView performSelector:@selector(setImg:) withObject:obj afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
因为 default 类型的 Mode的一次 runloop 不接收滑动事件(见2.1中的图,UIEvent对应的mode 是 tracking),因此在 tableview 滑动时,不加载图片。
- crash 后重启 runloop (待尝试)
CFRunLoopRef runloop = CFRunLoopGetCurrent();
NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runloop));
while(1){
for(NSString *mode in allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
7. 一些面试题
考察对 mode 的理解:
1)为何NSTimer在界面滚动时无响应?
当用户触摸界面时,主线程的run loop不再对timer事件进行处理。解决办法如下:
[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
2)NSURLConnection或NSStream指定RunLoop Mode的原因?
如果是在主线程,那么在滚动ScrollView或者TableView时,主线程的Run Loop会运行在UITrackingRunLoopMode模式,那么NSURLConnection或者NSStream的回调就无法运行,设置为NSRunLoopCommonModes,都可以保证NSURLConnection或者NSStream的回调可以被调用。
8. 一些坑
1)创建NSTimer
在线程中添加Timer时,肯定需要先生成Timer对象啦,有类方法也有实例方法,如果是使用scheduledTimerWithXXX 接口生成的Timer对象,会自动添加到当前线程的NSDefaultRunLoopMode
中;如果是其他接口生成的Timer对象,则需要用 -addTimer:forMode
添加Timer,这样做的好处是可以指定添加Timer的Run Loop以及模式。
2) timer 事件不会导致 runloop 退出,需要删除。
如果Run Loop中添加的是Timer而没有其他Input Source,而这个Timer只运行一次,那么Timer事件触发后Timer事件源就会从Run Loop删除,那么再运行Run Loop就会立刻返回;同时Timer事件触发是不会让Run Loop返回的,即使使用CF层的CFRunLoopRef运行接口 SInt32 CFRunLoopRunInMode (mode, seconds, returnAfterSourceHandled);运行Run Loop,其第三个参数为YES,Timer事件触发仍然不会导致当前Run Loop的运行返回。
3)运行 NSRunLoop 的接口选择。
如果是使用NSRunLoop,有三个运行的接口:
//运行 NSRunLoop,运行模式为默认的NSDefaultRunLoopMode模式,没有超时限制
- (void)run;
//运行 NSRunLoop: 参数为运行模式、时间期限,返回值为YES表示是处理事件后返回的,NO表示是超时或者停止运行导致返回的
- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;
//运行 NSRunLoop: 参数为运时间期限,运行模式为默认的NSDefaultRunLoopMode模式
- (void)runUntilDate:(NSDate *)limitDate;
建议是使用第二个接口来运行,因为它能够设置Run Loop的运行参数最多,而且最重要的是可以使用CFRunLoopStop(runLoopRef)来停止Run Loop的运行,而第一个和第三个接口无法使用CFRunLoopStop(runLoopRef)来停止Run Loop的运行。
4)NSURLConnection
在使用NSURLConnection或者NSStream时,需要考虑到Run Loop的问题,默认情况下这两个对象生成后都是运行在当前线程的NSDefaultRunLoopMode模式的,如果是在主线程,那么在滚动ScrollView或者TableView时,主线程的Run Loop会运行在UITrackingRunLoopMode模式,那么NSURLConnection或者NSStream的回调就无法运行。因此最好是指定NSURLConnection或NSStream在Run Loop中的运行模式,两者有相同的接口 - (void)scheduleInRunLoop:( NSRunLoop )aRunLoop forMode:(NSString )mode;来设置NSURLConnection和NSStream的Run Loop以及模式,而且设置的Mode要设置为NSRunLoopCommonModes,因为NSRunLoopCommonModes默认会包含NSDefaultRunLoopMode和UITrackingRunLoopMode,这样无论Run Loop运行在哪个模式,都可以保证NSURLConnection或者NSStream的回调可以被调用。另外如果是在子线程中你设置了自定义的Run Loop模式,还可以用接口 CFRunLoopAddCommonMode(runLoopRef, mode)添加到NSRunLoopCommonModes。
不过NSURLConnection的使用有点特殊,必须使用它的designated initializer - (id)initWithRequest:(NSURLRequest )request delegate:(id)delegate startImmediately:(BOOL)startImmediately生成NSURLConnection对象,而且第三个参数是否立刻启动NSURLConnection要设置为NO,之后再用 - (void)scheduleInRunLoop:( NSRunLoop )aRunLoop forMode:(NSString )mode;设置Run Loop与模式,再调用 [NSURLConnectionObject start]启动。如果是其他接口生成NSURLConnection,用 - (void)scheduleInRunLoop:( NSRunLoop )aRunLoop forMode:(NSString *)mode;设置Run Loop和mode都不会起作用。