什么是RunLoop?
可以简单理解为,让程序保持运行的一个while
循环,这个循环内监听各种事件(如触摸事件、performSelector
、定时器NSTimer
等),没有事件的时候睡眠,从而有效的利用CPU(只有在有事件的时候才用CPU,没事件的时候睡眠)
不管RunLoop有多复杂,其本质就是上面所说的:一个循环,有事件的时候处理事件,无事件的时候休眠(这里的睡眠是指用户态切换到内核态,这样的休眠线程是被挂起的,不会再占用cpu资源)。
RumLoop与线程有如下关系:
- 一个线程只有一个RunLoop对象
- 主线程的RunLoop默认已经创建好了,而子线程的需要手动创建。
- RunLoop在第一次获取时创建,在线程结束时销毁。
我们验证一下,在main
函数返回之前,打印一下:
int main(int argc, char *argv[])
{
NSString *appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
int ret = UIApplicationMain(argc, argv, nil, appDelegateClassName);
NSLog(@"after ret");
return ret;
}
结果没有打印,这说明主进程已经进入了一个RunLoop主了,主进程不结束,就跳不出RunLoop,也就执行不了之后的打印。
我们打印一下主线程的RunLoop试试:
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"%@", [NSRunLoop currentRunLoop]);
}
// 打印结果(只取关键信息):
// CFRunLoop 0x600001704700
// current mode = kCFRunLoopDefaultMode,
这说明主线程在一个RunLoop中,并且当前的运行模式是kCFRunLoopDefaultMode
这样感觉RunLoop很简单,但它又很复杂,因为要考虑的因素有很多,比如各种事件的处理顺序,定时器、多线程等等
对于一个复杂问题,解决方法之一就是抽象,苹果为解决上面的问题,抽象出了RunLoop对象,RunLoop中包含多个Mode类,每个mode类中包含若干个 Source,Observer和Timer类,关系如下:
Mode是RunLoop的运行模式,有五类:
kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
UIInitializationRunLoopMode // 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
GSEventReceiveRunLoopMode // 接受系统事件的内部 Mode,通常用不到
kCFRunLoopCommonModes // 这是一个占位用的Mode,不是一种真正的Mode,可以简单理解为kCFRunLoopDefaultMode和UITrackingRunLoopMode的结合
这里的Source是事件源,比如触摸事件。
Observer是观察者,监听事件源的事件,可以简单理解为线程,比如主线程RunLoop的的Observer是主线程。
还有一些规定:
- RunLoop虽然有多个Mode,但RunLoop函数执行的时候,只能指定一个Mode
- 如果要切换Mode,需要等到一个Loop循环结束,再让新的Mode进入
上面说一个RunLoop只有一个Mode在执行,下面做个试验看看:
@interface ViewController ()
@property (weak, nonatomic) IBOutlet UITextView *textView;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}
- (void)timerTest {
NSLog(@"%s", __func__);
}
@end
这里我们在ViewControlller
里面创建了一个timer
,把他加到NSDefaultRunLoopMode
中,这个ViewControlller
有个可以滚动的UITextView
(继承UIScrollView
,UIScrollView
默认的Mode是UITrackingRunLoopMode
)
当我们滑动UITextView
的时候,timer
停止触发事件了,说明RunLoop的Mode从Default切换到了UITrackingRunLoopMode
解决方法就是把timer
放入kCFRunLoopCommonModes
中,这个Mode相当于同时是kCFRunLoopDefaultMode
和UITrackingRunLoopMode:
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
上面是一个经典的例子,可以解决在UIScrollView
(包括其子类)中有NSTimer
定时的场景。
受此启发,我们可以用RunLoop解决卡顿问题,有一种卡顿问题就是UITableView
中有很多高清大图需要载入,在滑动屏幕的时候卡顿。
我们先分析一下卡顿的原因:最根本的原因是RunLoop转一圈的时间太长了,因为一次RunLoop循环需要解析很多张高清大图,系统渲染每一张高清大图都需要一定的时间,这样需要等到渲染的RunLoop结束之后,才能切换滑动屏幕RunLoop的Mode(UITrackingRunLoopMode),解决方法就是:
- 创建一个定时器:每间隔一定时间(可以是0.01s)执行一个空方法来唤醒RunLoop
- 将加载图片的方法装入block,将block加入一个有数量限制的数组,当block超过最大数量限制,移除最早添加的block
- 监听RunLoop的苏醒,苏醒回掉就执行一次就从数组中取出一个block事件执行,执行完的事件从数组中删除
这样设计让RunLoop的每次循环只执行一个加载图片的block(减少RunLoop单次循环的时间)。给数组设置一个最大数量限制,可以防止同一时间需要渲染的图片过多(减少RunLoop渲染图片的总时间)。
下面我们可以看看RunLoop里面长什么样了:
RunLoop内部逻辑
这里引入了新概念:source0是触摸事件和所有执行
performSelector
方法,source1是基于port的线程间的通信。
这里我们可以大概看出RunLoop中处理事件的顺序,可以简要的总结为:
- 先通知Timer,Sources要处理事件了
- 处理source0
- 看看有没有source1,没有就休眠,有就不休眠
- 休眠状态下sources,timer,dispatch,手动都可以唤醒
- 3结束或者4唤醒后,就开始处理各种其他事件(timer,source1,dispatch)
- 如果第五步处理了至少一个事件,则开始新一轮的RunLoop,否则退出RunLoop
以上逻辑可以推出,在RunLoop中,只要有任何一个事件,RunLoop就不会退出,除非是RunLoop在休眠超时被唤醒或者外部强制停止,才会退出。
下面用一个例子感受一下RunLoop里的逻辑:
- (void)viewDidLoad {
[super viewDidLoad];
[self createObserver];
[NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerFired) userInfo:nil repeats:YES];
}
- (void)timerFired
{
NSLog(@"---- timer fired ----");
}
- (void)createObserver
{
//创建监听者
/*
第一个参数 CFAllocatorRef allocator:分配存储空间 CFAllocatorGetDefault()默认分配
第二个参数 CFOptionFlags activities:要监听的状态 kCFRunLoopAllActivities 监听所有状态
第三个参数 Boolean repeats:YES:持续监听 NO:不持续
第四个参数 CFIndex order:优先级,一般填0即可
第五个参数 :回调 两个参数observer:监听者 activity:监听的事件
*/
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
switch (activity) {
case kCFRunLoopEntry:
NSLog(@"RunLoop进入");
break;
case kCFRunLoopBeforeTimers:
NSLog(@"RunLoop要处理Timers了");
break;
case kCFRunLoopBeforeSources:
NSLog(@"RunLoop要处理Sources了");
break;
case kCFRunLoopBeforeWaiting:
NSLog(@"RunLoop要休息了");
break;
case kCFRunLoopAfterWaiting:
NSLog(@"RunLoop醒来了");
break;
case kCFRunLoopExit:
NSLog(@"RunLoop退出了");
break;
default:
break;
}
});
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode); // 添加监听者,关键!
CFRelease(observer); // 释放
}
这里给RunLoop创建了一个观察者,观察者的回调打印RunLoop里的逻辑,另外有一个Timer每隔1.0秒触发一下。结果如下:
// 23:26:30 RunLoop醒来了
// 23:26:30 ---- timer fired ----
// 23:26:30 RunLoop要处理Timers了
// 23:26:30 RunLoop要处理Sources了
// 23:26:30 RunLoop要休息了
// 23:26:31 RunLoop醒来了
// 23:26:31 ---- timer fired ----
// 23:26:31 RunLoop要处理Timers了
// 23:26:31 RunLoop要处理Sources了
// 23:26:31 RunLoop要休息了
可以看到,Timer要触发的时候,唤醒了RunLoop,RunLoop醒来后去处理Timer,执行了Timer的方法(打印---- timer fired ----
),然后RunLoop回到循环的开头,通知观察者要处理Timers和Sources了,结果发现没有要处理的,然后就去休息了,如此循环。。。基本和上面的逻辑一致。
这里介绍一个RunLoop的应用:
创建一个常驻线程
首先我们创建一个继承自NSThread
的类BZThread
,用来打印销毁时候的信息,然后在viewDidLoad
中创建一个线程:
@interface BZThread : NSThread
@end
@implementation BZThread
- (void)dealloc {
NSLog(@"BZThread is dealloced");
}
@end
@interface ViewController ()
@property NSThread *thread;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
NSThread *thread = [[BZThread alloc] initWithTarget:self selector:@selector(threadTest) object:nil];
self.thread = thread;
[self.thread start];
}
- (void)threadTest {
NSLog(@"thread is created");
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self performSelector:@selector(doSomethingInThread) onThread:self.thread withObject:nil waitUntilDone:NO];
}
- (void)doSomethingInThread {
NSLog(@"doSomethingInThread is fired");
}
@end
// BZThread is created
我们发现,线程是被创建了,也被ViewControlelr
持有了(没有马上被销毁),但是我们在这个线程里执行方法没有反应,这说明这个线程的RunLoop没有运行起来。
解决方法是在这个线程方法里,给这个线程的RunLoop创建一个Mode:
- (void)threadTest {
[[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] run];
NSLog(@"thread is created");
}
点击屏幕,我们就执行了线程的方法了:
// doSomethingInThread is fired
这是因为,虽然一个线程对应一个RunLoop,但一个RunLoop至少需要一个Mode,才能跑起来,主线程默认就有Mode了,而新的线程需要我们手动去创建新的Mode。
最后介绍一个RunLoop的应用:
检测卡顿:
如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。
如何检查卡顿呢?需要创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。一旦发现进入睡眠前的 状态,或者唤醒后的状态,在设置的时间阈值内一直没有变化,即可判定为卡顿。接下来,我们就可以 dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。