-
Run Loop是什么
RunLoop顾名思义,是运行循环。它跟线程是一一对应的,每一个线程都有一个RunLoop,在需要的时候创建。RunLoop的作用很简单,就是保持线程不会退出,并且处理一些事件。
如果没有RunLoop,线程只要一执行完代码就会退出。RunLoop类似一个while
循环,但是又不像while
循环会占用CPU资源,RunLoop在等待的时候处于休眠状态,只有接收到事件时,才会被唤醒,然后再做相应的处理。
程序启动时,系统会自动为我们开启主线程的RunLoop,这就保证了我们的程序不会退出,并且可以一直响应我们的操作。而子线程的RunLoop并没有开启,需要我们手动开启。
-
Run Loop使用
说到使用RunLoop,其实我们在使用NSTimer的时候就已经使用过它了,只不过那时候并没有对RunLoop深入研究,我们来重新体验一下一个NSTimer的简单使用:
//创建一个Timer
NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer");
}];
//把它加到RunLoop里
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
一个NSTimer
必须和RunLoop一起工作,不然它没办法运作。这里再给RunLoop添加timer
的时候,有一个参数叫Mode
,这是RunLoop模式,不同模式处理不同类型输入源的事件。
-
NSDefaultRunLoopMode
:App的默认Mode,通常主线程是在这个Mode下运行。 -
UITrackingRunLoopMode
:界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。 -
UIInitializationRunLoopMode
: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。 -
GSEventReceiveRunLoopMode
: 接受系统事件的内部 Mode,通常用不到。 -
NSRunLoopCommonModes
: 这是一个占位用的Mode,不是一种真正的Mode,它会同时处理默认模式和UI模式中的事件。
到这里又引发出来了新的问题,为什么NSTimer
必须添加到RunLoop才能使用呢?
这就涉及到了RunLoop所能处理的事件了:
Run loop接收输入事件来自两种不同的来源:输入源(input source)和定时源(timer source)。输入源传递异步事件,通常消息来自于其他线程或程序。定时源则传递同步事件,发生在特定时间或者重复的时间间隔。两种源都使用程序的某一特定的处理例程来处理到达的事件。
这张官方的图简单的描述了RunLoop所能处理的事件来源:
接下来,我们来玩一玩RunLoop。在OC中,有NSRunLoop
和CFRunLoop
两种方式来获取并且操作RunLoop。其中NSRunLoop
是CFRunLoop
的封装。我们以NSRunLoop
为主对RunLoop进行使用。首先,我们创建一个线程,然后开启它的runloop,我们如何证明它的runloop已经开启呢?结合上图,我们只需要找一个runloop能够处理的事件,然后让它去处理就可以了,我这里挑选了- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait ;
方法:
//首先持有一个线程对象,方便我们之后使用它
@property (nonatomic, strong) NSThread *thread;
- (void)viewDidLoad {
[super viewDidLoad];
//初始化并开启,在线程内部开启它的runloop
self.thread = [[NSThread alloc] initWithBlock:^{
NSLog(@"这是一条子线程%@",[NSThread currentThread]);
[[NSRunLoop currentRunLoop] run];
}];
[self.thread start];
}
//点击屏幕时,在线程上执行下面的打印
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
}
//打印出线程,以便我们确认是同一条
- (void)test{
NSLog(@"哈哈哈%@",[NSThread currentThread]);
}
我们可以通过[NSRunLoop currentRunLoop]
获取当前线程的RunLoop,开启RunLoop只需要调用其run
方法。
按照我们预想的结果,运行以后每次点击屏幕都应该有输出,但是实际上我们点击屏幕并没有任何效果。这是因为开启RunLoop之前必须给其指定至少一种输入源或者定时源,不然开启之后会马上退出。说到这里,我们得看一下RunLoop在一次循环的周期内,到底做了什么事情:
每次运行run loop,你线程的run loop对会自动处理之前未处理的消息,并通知相关的观察者。具体的顺序如下:
- 通知观察者run loop已经启动
- 通知观察者任何即将要开始的定时器
- 通知观察者任何即将启动的非基于端口的源
- 启动任何准备好的非基于端口的源
- 如果基于端口的源准备好并处于等待状态,立即启动;并进入步骤9。
- 通知观察者线程进入休眠
- 将线程置于休眠直到任一下面的事件发生:
- 某一事件到达基于端口的源
- 定时器启动
- Run loop设置的时间已经超时
- run loop被显式唤醒
- 通知观察者线程将被唤醒。
- 处理未处理的事件
- 如果用户定义的定时器启动,处理定时器事件并重启run loop。进入步骤2
- 如果输入源启动,传递相应的消息
- 如果run loop被显式唤醒而且时间还没超时,重启run loop。进入步骤2
- 通知观察者run loop结束。
从以上我们可以知道,如果是定时器事件,执行之后会直接重启RunLoop,如果是其它事件,处理完毕后,不会再次唤醒RunLoop,要想它继续监听事件,我们必须得手动唤醒它。之前在我们点击的时候,runloop已经退出了,所以代码并没有执行。
不过这难不倒我们,我们可以给它一个循环,让它不断得开启:
while (true) {
[[NSRunLoop currentRunLoop] run];
}
再次运行,点击屏幕就可以看到打印出的信息:
开启RunLoop还有另外几个方法,我们平时最好不要直接使用run方法,可能会造成无限循环:
//同run方法,增加超时参数limitDate,避免进入无限循环。使用在UI线程(亦即主线程)上,可以达到暂停的效果。
(void)runUntilDate:(NSDate *)limitDate;
//等待消息处理,好比在PC终端窗口上等待键盘输入。一旦有合适事件(mode相当于定义了事件的类型)被处理了,则立刻返回;类同run方法,如果没有事件处理也立刻返回;有否事件处理由返回布尔值判断。同样limitDate为超时参数。
(BOOL)runMode:(NSString )mode beforeDate:(NSDate )limitDate;
以上是一些简单的操作,我们可以利用RunLoop去监测一些事件,当它发生的时候再去做处理。但是用while
循环会让CPU一直在工作,所以我们最好设置一种终止RunLoop循环的条件。
前面提到,RunLoop在开启时,需要给它指定输入源,而输入源是可以自定义的,不过它需要使用CFRunLoop
。接下来我们可以自己自定义一种输入源:
/* Run Loop Source Context的三个回调方法,其实是C语言函数 */
// 当把当前的Run Loop Source添加到Run Loop中时,会回调这个方法。
void runLoopSourceScheduleRoutine (void *info, CFRunLoopRef runLoopRef, CFStringRef mode)
{
NSLog(@"Input source被添加%@",[NSThread currentThread]);
}
// 当前Input source被告知需要处理事件的回调方法
void runLoopSourcePerformRoutine (void *info)
{
NSLog(@"回调方法%@",[NSThread currentThread]);
}
// 如果使用CFRunLoopSourceInvalidate函数把输入源从Run Loop里面移除的话,系统会回调该方法。
void runLoopSourceCancelRoutine (void *info, CFRunLoopRef runLoopRef, CFStringRef mode)
{
NSLog(@"Input source被移除%@",[NSThread currentThread]);
}
//创建两个属性来保存`runLoopSource `和`runLoop `
@implementation ViewController{
CFRunLoopSourceRef runLoopSource;
CFRunLoopRef runLoop;
}
//在之前的线程代码中为RunLoop添加Source
self.thread = [[NSThread alloc] initWithBlock:^{
NSLog(@"这是一条子线程%@",[NSThread currentThread]);
runLoop = CFRunLoopGetCurrent();
CFRunLoopSourceContext context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL,
&runLoopSourceScheduleRoutine,
&runLoopSourceCancelRoutine,
&runLoopSourcePerformRoutine};
//CFAllocatorRef内存分配器,默认NULL,CFIndex优先索引,默认0,CFRunLoopSourceContext上下文
runLoopSource = CFRunLoopSourceCreate(NULL, 0, &context);
CFRunLoopAddSource(runLoop, runLoopSource, kCFRunLoopDefaultMode);
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
}];
[self.thread start];
然后我们需要在点击的时候通知InputSource
,并且唤醒runLoop
:
//通知InputSource
CFRunLoopSourceSignal(InputSource);
//唤醒runLoop
CFRunLoopWakeUp(runLoop);
然后点击测试一下:
因为我们设置了超时时间,所以10秒以后,RunLoop就会退出。同时它的InputSource
被自动移除。
以上,我们简单的自己创建并添加了RunLoop的InputSource
,实际开发中,我们可以对InputSource
进行封装,使用起来更方便。这里就不做这一步了,网上可以找到比较完善的例子。
RunLoop还有一个观察者,可以让我们监听到RunLoop的各种状态,它也需要用CFRunLoop
来实现,接下来,我们在上面的基础上,对RunLoop添加观察者进行监听:
// RunLoop监听回调
void currentRunLoopObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
NSString *activityDescription;
switch (activity) {
case kCFRunLoopEntry:
activityDescription = @"kCFRunLoopEntry";
break;
case kCFRunLoopBeforeTimers:
activityDescription = @"kCFRunLoopBeforeTimers";
break;
case kCFRunLoopBeforeSources:
activityDescription = @"kCFRunLoopBeforeSources";
break;
case kCFRunLoopBeforeWaiting:
activityDescription = @"kCFRunLoopBeforeWaiting";
break;
case kCFRunLoopAfterWaiting:
activityDescription = @"kCFRunLoopAfterWaiting";
break;
case kCFRunLoopExit:
activityDescription = @"kCFRunLoopExit";
break;
default:
break;
}
NSLog(@"Run Loop activity: %@", activityDescription);
}
//为runLoop添加观察者
CFRunLoopObserverContext runLoopObserverContext = {0, NULL, NULL, NULL, NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(NULL,//内存分配器,默认NULL
kCFRunLoopAllActivities,//监听所有状态
YES,//是否循环
0,//优先索引,一般为0
¤tRunLoopObserver,//回调方法
&runLoopObserverContext//上下文
);
if (observer)
{
CFRunLoopAddObserver(runLoop, observer, kCFRunLoopDefaultMode);
}
运行以后:
上面,我们对Runloop的使用做了简单的分析,但是对我们好像还是没什么卵用。接下来,我们通过一个实际的案例来运用RunLoop,让它变成我们的法宝。
-
Run Loop的实际应用
我们在实际开发中经常会遇到TableView中有大量的图片显示,在滑动过程中,能明显得感觉到卡顿。我们这里用TableView显示多张大图简单模拟一下:
self.tableView = [[UITableView alloc] initWithFrame:self.view.frame];
self.tableView.delegate = self;
self.tableView.dataSource = self;
self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero];
[self.view addSubview:self.tableView];
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return 299;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 100;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSString *identifier = @"identifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
}
for (UIView *view in cell.subviews) {
[view removeFromSuperview];
}
UIImageView *imageView1 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView1.frame = CGRectMake(10, 10, 100, 80);
[cell addSubview:imageView1];
UIImageView *imageView2 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView2.frame = CGRectMake(120, 10, 100, 80);
[cell addSubview:imageView2];
UIImageView *imageView3 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView3.frame = CGRectMake(230, 10, 100, 80);
[cell addSubview:imageView3];
return cell;
}
那么怎么做优化呢?利用我们前面了解的RunLoop可以实现这个优化。我们知道卡顿的主要原因是因为加载大量大图是比较耗时的,而在主线程上处理耗时操作时,我们滑动或者点击屏幕就会有卡顿的感觉,因为在同一条线程上的任务只能串行执行。而我们滑动屏幕时,一瞬间要显示很多张图片,这就形成了一个耗时操作。
经过思考,我们可以把这些图片在每一次runLoop循环中添加一张,这样的话,因为每次只添加一张图片,时间大大缩短,就不会有卡顿的感觉了。
我们这里利用runLoop的观察者来监听每一次runloop循环,然后在监听事件里,添加一张图片。我们这里把添加图片当做任务放到一个数组里面,任务就是一个block,这样我们在回调里面只需要拿出任务执行就OK了:
//定义一个任务
typedef void(^RunLoopTask)(void);
//用来存放任务的数组
@property (nonatomic, strong) NSMutableArray<RunLoopTask> *tasks;
//最大任务数量
@property (nonatomic, assign) NSInteger maxTaskCount;
//初始化数据
self.maxTaskCount = 24;
self.tasks = [NSMutableArray array];
//添加任务到数组
- (void)addTask:(RunLoopTask)task{
[self.tasks addObject:task];
//保证之前没来得及显示的图片不会再绘制
if (self.tasks.count > _maxTaskCount) {
[self.tasks removeObjectAtIndex:0];
}
}
//添加任务
[self addTask:^{
UIImageView *imageView1 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView1.frame = CGRectMake(10, 10, 100, 80);
[cell addSubview:imageView1];
}];
[self addTask:^{
UIImageView *imageView2 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView2.frame = CGRectMake(120, 10, 100, 80);
[cell addSubview:imageView2];
}];
[self addTask:^{
UIImageView *imageView3 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
imageView3.frame = CGRectMake(230, 10, 100, 80);
[cell addSubview:imageView3];
}];
- (void)addObserverToMainRunLoop{
//为runLoop添加观察者
CFRunLoopObserverContext runLoopObserverContext = {0, (__bridge void *)(self), NULL, NULL, NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(NULL,//内存分配器,默认NULL
kCFRunLoopBeforeWaiting,//等待之前
YES,//是否循环
0,//优先索引,一般为0
¤tRunLoopObserver,//回调方法
&runLoopObserverContext//上下文
);
if (observer)
{
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
CFRelease(observer);
}
// RunLoop监听回调
static void currentRunLoopObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
MyViewController *vc = (__bridge MyViewController *)info;
if (vc.tasks.count == 0) {
return;
}
RunLoopTask task = vc.tasks.firstObject;
task();
[vc.tasks removeObjectAtIndex:0];
}
然后再运行,就很流畅了。当然代码并不是完整代码,篇幅有限,只能把主要代码贴上来。
总结一下,我们可以把耗时的大量UI操作利用RunLoop分解,使界面保持流畅。
本文参考iOS多线程编程指南(三)Run Loop。
本文所涉及到所有的代码点击前往。