RunLoop 学习及常见问题
什么是 RunLoop
通常在终端中输入命令,执行任务的线程执行完就退出了,等我们再次输入命令,终端再开始执行任务。但在我们的 app 中,要保持一直运行(除非app被挂起),不断接受用户的输入,循环的接受、处理事件,类似于这样:
while(AppIsRunning){ //只要 app 处于运行状态,就要不断等待着处理事件
id whoWakesMe = SleepForWakingUp();
id event = GetEvent(whoWakesMe);
HandleEvent(event);
}
RunLoop 来帮助线程管理一个或多个事件或消息,接受用户输入等事件源,在事件到达时,RunLoop 立刻唤醒线程来处理事件;没有事件需要处理时,RunLoop 帮助线程休眠,避免其占用资源,这里是帮助其休眠,而不是直接退出。
RunLoop 还决定了程序在何时应该处理那些事件,并且为被调用的对象维护一个消息队列,被调用方从这个消息队列中取出需要他处理的事件。
主线程的 RunLoop 默认开启,而子线程需要调用[NSRunLoop currentRunLoop]
创建和获取 RunLoop,RunLoop 的销毁发生在线程结束时。
RunLoop 与线程的关系
每个线程创建的时候,都有一个 RunLoop 循环,与线程一一对应。
RunLoop 构成
RunLoop构成如图可以看到 RunLoop 的大致构成,它与线程一一对应,而拥有多个CFRunLoopMode,mode 是一系列输入事件源、计时器、runLoop 观察者的集合。
RunLoop Mode
RunLoop 只能选择一个 Mode 启动,同时在“跑”的时候,总是在特定的唯一的 mode 下,每次运行 RunLoop 都要显式或隐式的指定运行 mode。这个 mode 包含了当前需要处理的 Source/Timer/Observer,所以 RunLoop 在时刻内,仅能处理与当前 mode 相关联的事件,只有和模式相关的源才会被监视,并允许他们传递事件消息。
为了保证其中的 Source/Timer/Observer 与其他 mode 的相隔离,切换 mode 时,只能先退出当前RunLoop,再以要切换的 mode 重新进入RunLoop。
开发中,通常会遇到这几种Mode:
- kCFRunLoopDefaultMode:app的默认 Mode,通常主线程在这个 Mode 下运行。
- UITrackingRunLoopMode:界面跟踪 Mode,ScrollView 的触摸滑动 mode (在iOS中,触摸滑动很流畅的原因是在滑动时,只处理此 mode 下的事件且不受其他mode影响)。
- UIInitializationRunLoopMode:刚启动 app 进入的第一个 mode,起到过渡的作用,启动完成后不再使用。
- GSEventReceiveRunLoopMode: Graphic 相关事件的 mode,通常用不到。
- kCFRunLoopCommonModes:将 mode 标记为"common"属性,当 RunLoop 运行在标记为"common"属性的任一 mode 下,发生事件时,里面的 mode 都会被触发。
RunLoop Source
线程的异步事件源,数据源。有两种Source,可以用是否基于Mach Port(进程间通讯接口)区分:
- source0:不基于Mach Port,处理app内部事件,用户自定义的thread发出。当我们使用 NSObject 中的 performSelector 系列方法时,都是source0 事件源。
- source1:基于Mach Port,是由RunLoop和内核管理的。
RunLoop Timer
线程的同步事件源,在预设的时间点到了之后同步的发给线程处理此事件。
RunLoop Observer
Observer 可对 RunLoop 的状态变化进行观察,可观察的变化:
- 刚进入此 RunLoop 中
- RunLoop 准备处理一个 Timer
- RunLoop 准备处理一个 Input Source
- RunLoop 准备进入睡眠
- RunLoop 将被唤醒处理事件之前
- RunLoop 准备退出
因为Observer可对这些事件进行观察追踪,所以也可被看作是一种事件源。
RunLoop处理的流程
RunLoop_1.png第7步中,当线程进入休眠,发生下列事件,线程将被唤醒:
- 基于 Port 的事件发生
- 计时器到时
- 被代码显式唤醒
第9步中,处理唤醒时收到的消息,并且:
- 如果是用户定义的计时器到时,处理事件并重启 RunLoop
- 如果有input 事件源,传递这个消息
- 如果runloop显式被唤醒,且没有超时,重启RunLoop
之后,跳回第2步
RunLoop应用举例
在漫长长长长的理论说明后,让我们看看实际开发中,有哪些地方会用到 RunLoop 呢?
解决 NSTimer "不准"的问题
我们有时候会发现 NSTimer "不太准",明明时间已经到了,该执行的回调却未发生,这是因为我们常常将 NSTimer 默认设置为default mode,如果这时屏幕滚动,mode切换为TrackingMode,时间到了,但是 TrackingMode 无法处理 defaultMode下的回调,造成"不准"。
在 SVProgressHUD 中,我们可以设置转圈的提示框自动消失,可开启一个定时器,在到了设定的时间点后消失,如下
strongSelf.fadeOutTimer = [NSTimer timerWithTimeInterval:duration target:strongSelf selector:@selector(dismiss) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:strongSelf.fadeOutTimer forMode:NSRunLoopCommonModes];
strongSelf 即为提示框,将它消失的定时器添加在 RunLoop 的common 模式下,不管时间点到了的那一时刻 RunLoop 运行在哪个mode下,都会处理消失的回调,"准点消失"。
用 dispatch_after 定时,就准了吗
我发现有很多博客写,NSTimer 造成定时不准的问题可以通过 GCD 中的 dispatch_after 来解决,但是 dispatch_after 并不是说在指定时间后执行处理,而只是在指定时间将操作追加到 Dispatch Queue 中。如果指定时间到了,需要加入的队列正在进行耗时操作,定时操作并不能立即执行,也会造成不准。
验证如下:
//获取主队列
dispatch_queue_t mainQueue = dispatch_get_main_queue();
//定时时间
int64_t delay = 5 * NSEC_PER_SEC;
//定时时间,即从现在到定时的时间
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, delay);
NSLog(@"开始计时: %@", [NSDate date]);
dispatch_after(delayTime, mainQueue, ^{
NSLog(@"时间到: %@", [NSDate date]);
});
//在这里设置一些复杂操作,比方来10000次网络请求
可以看到虽然我们只设置延迟5秒进行,但事实上,在10秒才进行了延迟操作。但是日常的开发中,碰到这么这么复杂的情况应该是比较少的,所以 dispatch_after 也可以一用~~~
GCD 中除了主要的 Dispatch Queue 之外,还对 BSD 系内核惯有功能 kqueue 进行包装,可处理内核中发生的各种事件及方法。
其中的 DISPATCH_SOURCE_TYPE_TIMER 可作为定时器,帮助我们延迟调用:
//获取主队列
dispatch_queue_t mainQueue = dispatch_get_main_queue();
//新生成一个定时器,且此定时器不能为局部变量,否则方法执行完就被销毁了,还怎么做定时后的回调呢?
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, mainQueue);
//定时时间
int64_t delay = 5 * NSEC_PER_SEC;
//一定容差范围时间
int64_t leeway = 0.1 * NSEC_PER_SEC;
//定时时间,即从现在到定时的时间
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, delay);
//设置定时器
//下一次回调为DISPATCH_TIMER_FOREVER,表示不需要重复
dispatch_source_set_timer(self.timer, delayTime,DISPATCH_TIMER_FOREVER, leeway);
//设置时间到了后的回调
__weak typeof(self) weakSelf = self;
dispatch_source_set_event_handler(self.timer, ^{
typeof(self) strongSelf = weakSelf;
NSLog(@"计时结束: %@", [NSDate date]);
dispatch_source_cancel(strongSelf.timer);
});
//启动定时器
dispatch_resume(self.timer);
保证线程的持续运行
在 AFNetworking 2.3 中,需要一个自定义线程接受 connection 回调,一开始初始化线程时,没有需要执行的操作,线程会退出(RunLoop中没有source/timer/observer 会立即退出)。为其添加一个MachPort,为了保证线程的存活。
+ (NSThread *)networkRequestThread {
static NSThread *_networkRequestThread = nil;
static dispatch_once_t oncePredicate;
dispatch_once(&oncePredicate, ^{
//初始化线程时,调用networkRequestThreadEntryPoint方法
_networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
[_networkRequestThread start];
});
return _networkRequestThread;
}
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
[[NSThread currentThread] setName:@"AFNetworking"];
//为线程创建RunLoop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
//为RunLoop添加事件,保证其持续运行
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
解决TableView加载图片时,滑动很卡
TableView 需要加载大量图片时,滑动后,界面会卡,这是因为此时RunLoop 运行在 UITrackingRunLoopMode 下,图片加载在当前mode下,cpu 又要处理加载图片事件,又要处理滑动事件,造成卡顿。
可以显式地将图片的加载设置在 NSDefaultRunLoopMode 下,滑动时的 UITrackingRunLoopMode 并不会去加载图片,解决卡顿问题。
[self.imageView performSelector:@selector(setImage:) withObject:downloadImage afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
自动释放池到底在何时释放?
我们知道,手动指定 autoreleasepool 中的对象,会在作用域结束时释放掉。而设置为 autorelease 的对象是在出了作用域之后,被自动添加到最近创建的自动释放池中。那么这个自动释放池迟早有被撑满需要释放的时刻,这个自动释放池具体是什么时候被释放呢?
在Cocoa框架中,相当于程序主循环的NSRunLoop或者在其他程序可运行的地方,对 NSAutoreleasePool 对象进行生成、持有和废弃处理。
---引自《Objective-C 高级编程》
而它能够释放的原因是系统在每个 runloop 迭代中都加入了自动释放池 Push 和 Pop
下面我们举例讨论下:
@property (nonatomic,weak)NSString * weakStr;
- (void)viewDidLoad {
[super viewDidLoad];
NSString *string = [NSString stringWithFormat:@"这个string要设置的很长长长长长长长长长长长长长长长长"];
//因为苹果引用Tagged Pointer专门存储小的对象,直接存储其值,而不是存储地址
//如果string很短,用Tagged Pointer存储,无法验证其自动释放,地址被收回的过程
weakStr = string;
NSLog(@"viewDidLoad:%@",weakStr);
NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
NSLog(@"viewWillAppear:%@",weakStr);
NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
NSLog(@"viewDidAppear:%@",weakStr);
NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}
输出如图:
在mode改变,RunLoop一次循环结束后,autorelease对象被销毁 观察 weakStr 设置方法何时被调用 在viewWillAppear调用结束后,左边的堆栈中出现了一次AutoreleasePoolPage pop操作
我们在viewDidLoad方法中,用stringWithFormat类方法生成一个字符串,这种方法生成的字符串默认被添加进 autoreleasepool 中。
viewDidLoad 和 viewWillAppear 还在app初始化的 UIInitializationRunLoopMode 下,而 viewDidAppear 已经进入了默认mode下了。期间,autoreleasepool 出现了一次销毁,其中的对象也就被销毁了。
所以说,在没有手加Autorelease Pool的情况下,Autorelease对象是在当前的runloop迭代结束时释放的,而它能够释放的原因是系统在每个runloop迭代中都加入了自动释放池Push和Pop。
关于RunLoop的一道题
NSRunLoop 的描述正确的是( )
A. RunLoop 决定程序在何时应该处理哪些 Event
B. Cocoa 中的 NSRunLoop 类并不是线程安全的
C. RunLoop 可以使程序一直运行接受用户输入
D. RunLoop 起到了调用解耦的作用
我怎么觉得 ABCD 四个选项都对嘞……
参考文章:
RunLoops 官方文档
深入理解RunLoop
黑幕背后的Autorelease
Objective-C Autorelease Pool 的实现原理
RunLoop个人小结