1.RunLoop简介
1.1 什么是RunLoop
从字面上来说是运行循环,也可以翻译为跑圈.
- RunLoop本质上是一个对象,这个对象可以保持程序的持续运行并且处理程序中的各种事件(如触摸事件,定时器时间,selector事件).
- RunLoop没有事情处理时就会使线程进入睡眠状态.这样可以节省CPU资源,提高程序性能.
1.2 RunLoop和线程
RunLoop和线程是息息相关的,我们都知道线程的作用就是用来执行特定的一个或多个任务,正常情况下,线程执行完当前任务后就会退出,之后若线程又有任务需要执行也无法继续执行了.这时我们就需要一种方式让线程能不断执行任务,即使当前线程没有任务执行,线程也不会退出,而是等待下一个任务的到来.所以我们就有了RunLoop.
-
每一条线程都有唯一一个与之对应的RunLoop对象.
-
主线程的RunLoop对象系统已经自动帮我们创建好了,并且只有主线程结束时即程序结束时才会销毁.
-
子线程的Runloop对象需要我们主动创建并维护,子线程的Runloop对象在第一次获取时就会创建,销毁则是在子线程结束时. 并且创建出来的runLoop对象默认是不开启的,必须手动开启RunLoop.
-
Runloop并不保证线程安全,我们只能在当前线程内部操作当前线程的Runloop对象,而不能在当前线程中去操作其他线程的RunLoop对象.
相关代码如下:
NSRunLoop *currentRunLoop = [NSRunloop currentRunloop] //获取当前线程的RunLoop对象,在子线程中调用时如果是第一次获取内部会帮我们创建RunLoop对象 [currentRunLoop run]; [NSRunLooop mainRunLoop] //获取主线程的RunLoop对象
1.3 默认情况下主线程的RunLoop原理
我们在启动一个程序时,系统会自动调用创建项目时自动创建的main.m 的文件.main.m文件如下所示:
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
其中UIApplicationMain函数中内部帮我们开启了主线程的RunLoop,这个RunLoop使得程序只要不退出或者崩溃,UIApplicationMain函数就一直不会返回,保持了程序的持续运行.上边的代码中主线程开启RunLoop的过程可以简单理解为以下代码:
int main(int argc, char * argv[]) {
BOOL isRunning = YES;
do {
//执行各种任务,处理各种事件
} while(isRunning);
return 0;
}
下图是苹果官方的RunLoop模型图
从上图可以看出RunLoop就是线程中的一个循环,RunLoop会在循环中通过 Input sources(输入源) 和 Timer sources(定时源)不断检测是否有事件需要执行.然后对接收到的事件通知线程去处理,并且在没有事件的时候让线程去休息.
2.RunLoop的相关类
iOS为我们提供了两套API来访问RunLoop, 一套是Foundation框架的NSRunLoop, 一套是Core Foundation框架的CFRunLoop. NSRunloop本质是基于CFRunLoop的oc对象封装,所以我们在这里就讲解Core Foundation框架下有关RunLoop的五个类.
- CFRunLoopRef: 代表RunLoop对象
- CFRunLoopModeRef: 代表RunLoop的运行模式
- CFRunLoopSourceRef: 就是上面RunLoop模型图中的事件源/输入源
- CFRunLoopTimerRef: 就是上面RunLoop模型图中的定时源 5 CFRunLoopObserverRef: 观察者,能够监听RunLoop的状态改变
下面详细讲解几种类的具体含义相互关系.
先来看看一张能表示五个类关系的图:
接着来讲解这五个类的相互关系:
一个RunLoop对象(CFRunLoopRef)包含若干个运行模式(CFRunLoopModeRef)。而每个运行模式下又有若干个输入源(CFRunLoopSourceRef),定时源(CFRunLoopTimerRef),观察者(CFRunLoopObserverRef)
- 每次RunLoop启动时只能指定其中的一种运行模式, 这个运行模式被称作当前的运行模式(CurrentMode).
- 在每个运行模式中至少需要一个输入源或者一个定时源.
- 如果需要切换运行模式, 必须退出当前RunLoop, 再重新指定一个运行模式进入,
- 这样做主要是为了区别不同组之前的Source/Timer/Observer,让其互不影响
下面我们来详细讲解一下这五个类:
2.1 CFRunLoopRef类
CFRunLoop类是Core Foundation框架下的RunLoop对象类.我们可以通过以下方式获取RunLoop对象
- Core Foundation
CFRunLoopGetCurrent(); //获取当前线程的RunLoop对象,在子线程中调用时如果是第一次获取内部会帮我们创建RunLoop对象CFRunLoopGetMain(); //获取主线程的RunLoop对象
2.2 CFRunLoopModeRef
系统默认定义了多种运行模式, 如下:
- kCFRunLoopDefaultMode: APP的默认运行模式, 通常主线程就是在这个模式下运行的
- UITrackingRunLoopMode: 跟踪用户交互事件(用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响)
- UIInitializationRunLoopMode: 在刚启动APP时进入的第一个Mode,启动完成后就不会再使用
- CSEventReceiveRunLoopMode: 接受系统内部事件(用于绘图),通常用不到
- kCFRunLoopCommonMode:这是一种占位模式,并不是一种真正的运行模式(后边会用到) 其中kCFRunLoopDefaultMode, UITrackingRunLoopMode,kCFRunLoopCommonModes是我们开发中需要用到的模式.具体使用方法我们在2.3 CFRunLoopTimerRef中结合CFRunLoopTimerRef来演示说明
2.3 CFRunLoopTimerRef
CFRunLoopTimerRef是定时源, 理解为基于时间的触发器, 基本上就是NSTimer. 下面我们来演示一下CFRunLoopModeRef和CFRunLoopTimerRef结合的使用方法.
在Main.Storyboard中拖入一个textView. 然后尝试执行以下代码:
- (void)viewDidLoad {
[super viewDidLoad];
[self timer1];
}
- (void)timer1 {
//1.创建定时器
NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
//2.将定时器添加到当前的RunLoop,指定RunLoop的运行模式为默认运行模式
[[NSRunLoop currentRunLoop]addTimer:timer forMode:NSDefaultRunLoopMode];
}
- (void)run {
NSLog(@"run --- %@ --- %@", [NSThread currentThread], [NSRunLoop currentRunLoop]);
}
当程序运行时, run方法每隔两秒就会执行一次, 但是若拖动textView,run方法就不会执行.这是因为什么呢?
我们创建的timer是加入到RunLoop的NSDefaultRunLoopMode运行模式中, 但是当我们拖动textView,当前RunLoop会退出当前运行模式,并进入到UITrackingRunLoopMode运行模式,我们创建的timer并没有添加到并到UITrackingRunLoopMode运行模式中,所以run方法就不会执行.
那么有什么解决方法呢?
-
解决方法一:
把timer也添加到UITrackingRunLoopMode运行模式中.这样就可以在两种运行模式下都执行run方法.
增加代码如下:[[NSRunLoop currentRunLoop]addTimer:timer forMode:UITrackingRunLoopMode]; -
解决方法二:
把timer加入到kCFRunLoopCommonMode运行模式中.前面2.2中已经提到这种模式其实知识一种占位模式,并不是真正的运行模式.若是将timer添加到这个模式中,那么timer会被添加到打上common标签的运行模式中.
那么那些运行模式会被打上common标签呢?
NSDefaultRunLoopMode 和 UITrackingRunLoopMode所以只要添加到kCFRunLoopCommonMode运行模式也就等价于把timer加入到NSDefaultRunLoopMode和UITrackingRunLoopMode这两种运行模式中.
将代码替换成如下代码:
[[NSRunLoop currentRunLoop]addTimer:timer forMode:NSRunLoopCommonModes];除了上面代码中使用的timer的创建方法,还有一种常用的timer创建方法
[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];这种方法创建出来的timer会被默认添加到NSDefaultRunLoopMode运行模式,若想添加到UITrackingRunLoopMode中,只要拿到timer对象然后选择上面的其中一种解决方法即可.
注意点
刚才提到了例子都是在主线程中创建timer并加入到RunLoop中特定的运行模式中,那么要是在子线程中创建timer有什么区别呢?
请尝试执行下面的代码:
- (void)viewDidLoad {
[super viewDidLoad];
[NSThread detachNewThreadSelector:@selector(timer2) toTarget:self withObject:nil];
}
- (void)timer2 {
[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
}
- (void)run {
NSLog(@"run --- %@ --- %@", [NSThread currentThread], [NSRunLoop currentRunLoop]);
}
你会发现run方法根本不会调用,这是为什么呢?
这其实就要和上面提到的runLoop的的创建和管理有关了.
子线程的Runloop对象需要我们主动创建并维护,子线程的Runloop对象在第一次获取时就会创建,销毁则是在子线程结束时. 并且创建出来的runLoop对象默认是不开启的,必须手动开启RunLoop.
所以我们应该修改代码为如下:
- (void)timer2 {
//1.获取RunLoop并创建
NSRunLoop *currentRunLoop = [NSRunLoop currentRunLoop];
//2.创建timer
[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
//3.启动子线程的RunLoop
[currentRunLoop run];
}
2.4 CFRunLoopSourceRef
CFRunLoopSourceRef是事件源(RunLoop模型图中提到过的)
- 以前的分法:
- Port-Based Sources(基于端口的)
- Custiom Input Sources(自定义)
- Cocoa Peform Selector Sources(peform selector 方法)
- 现在的分法:
- Source0: 非基于Port(端口)的(用户事件)
- Source1: 基于Port的, 通过内核和其他线程通信,接收,分发系统事件(系统事件) 第一种是通过官方理论来分的, 第二种是在实际应用中通过调用函数来分的.
下面我们举个例子通过函数调用栈中的source
1.首先我们在main.storyboard中拖入一个按钮,并添加动作
2.然后在点击动作中的代码中加入一个输出语句,并打上一个断点
步骤如下:
当我们运行程序后点击按钮后就会来到此断点,然后我们就可以查看当前的函数调用栈.
如下图所示:
所以点击事件是这样来的:
- 首先程序启动然后运行到18行的main函数,之后在main函数中调用17行的UIApplicationMain函数,然后一直往上调用函数, 最终调用到点击函数.
- 我们可以看到在12行中有CFRunLoopDoSources0,即我们的点击事件属于sourece0函数的,点击事件就是source0中处理的.
- 而至于source1就是用来接收和分发系统的事件,然后再分发到Source0中处理.
2.5 CFRunLoopObserver
CFRunLoopObserver是监听者, 能够监听RunLoop的状态改变.
可以监听的时间点有以下:
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), //即将进入RunLoop
kCFRunLoopBeforeTimers = (1UL << 1), //即将处理Timer
kCFRunLoopBeforeSources = (1UL << 2), //即将处理Source
kCFRunLoopBeforeWaiting = (1UL << 5), //即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), //刚从休眠中被唤醒
kCFRunLoopExit = (1UL << 7), //即将退出RunLoop
kCFRunLoopAllActivities = 0x0FFFFFFFU //监听所有事件
};
具体使用方法如下:
- (void)viewDidLoad {
[super viewDidLoad];
[self observer];
}
- (void)observer {
/**
@param1:怎么分配空间(一般传入默认分配方式)
@param2:要监听的RunLoop的什么状态
@param3:是否要持续监听
@param4:优先级 总是传0
@param5:当状态改变时的回调
*/
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
switch (activity) {
case kCFRunLoopEntry:
NSLog(@"即将进入RunLoop");
break;
case kCFRunLoopBeforeTimers:
NSLog(@"即将处理Timer");
break;
case kCFRunLoopBeforeSources:
NSLog(@"即将处理Source");
break;
case kCFRunLoopBeforeWaiting:
NSLog(@"即将进入休眠");
break;
case kCFRunLoopAfterWaiting:
NSLog(@"刚从休眠中被唤醒");
break;
case kCFRunLoopExit:
NSLog(@"即将退出RunLoop");
break;
default:
break;
}
});
/**
@param1:要监听的RunLoop对象
@param2:观察者
@param3:运行模式
*/
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
}
打印台信息如下:
可以看到RunLoop在程序运行后就会处理大量的Source和Timer事件,当没有事情需要做的时候就会进入休眠状态,即让线程休眠,当有事件需要处理时就会唤醒RunLoop再次处理事件,
3. RunLoop原理
五个类都理解完之后我们就来具体说明RunLoop的运行原理.
其中我们借助下面这张网友的逻辑图进行说明
结合上面这个逻辑图我们来说明一个苹果官方文档给出的RunLoop运行逻辑
具体顺序如下:
首先RunLoop会去检查Mode里是否有source/timer, 没有直接退出
- 通知观察者RunLoop已经启动(系统本身就会为我们添加一个观察者)
- 通知观察者即将要处理Timer
- 通知观察者即将要处理Sourece0
- 启动任何准备好的Source0
- 如果Soure1准备好并处于等待状态进入,立即启动,进入步骤9.(source1内部就是由source0和timer组成)
- 通知观察者进入休眠状态
- 将线程置于休眠状态直到下面任一事件发生
- 某一事件到达基于端口的源
- 定时器启动
- RunLoop设置的时间已经超时
- RunLoop被外部显示唤醒。
- 通知观察者,线程被唤醒
- 处理未处理的事件
- 如果用户定义的定时器启动, 处理定时器事件并重新启动RunLoop,进入步骤2.
- 如果输入源启动, 传递相应消息。
- 如果RunLoop被显示唤醒并且时间还没超时,重启RunLoop,进入步骤2
- 通知观察者RunLoop结束
4. RunLoop的实战运用
前面都是一些理论知识的讲解,接下来我们我们就讲讲在实战中如何使用RunLoop.
4.1 NSTimer的使用
刚刚在前面的2.3中我们已经讲解了把Timer加入到RunLoop的不同运行模式的作用和区别.大家如果忘了可以回去再看看如何使用.
4.2 ImageView推迟显示
我们可能有时会遇到一种情况,就是我们的界面有tableView,每个tableView的cell中都有许多图片.然后当我们滚动tableView,需要显示很多图片,这时候可能就会出现卡顿现象.
那么这时我们就可以使用RunLoop来解决这个问题.具体方法为利用performSelector方法调用UIImageView的setImage:方法,然后指定在RunLoop下的NSDefaultRunLoopMode运行模式.代码如下:
[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"男孩"] afterDelay:5.0 inModes:@[NSDefaultRunLoopMode]];
我们设置显示图片的时间为五秒之后,但是程序运行后我们拖动textView,发现五秒后图片并没有出现,而是当我们拖动结束时候才显示出来.
这是因为我们设置显示图片的操作是在RunLoop的NSDefaultRunLoopMode模式中,当我们拖动textView时,RunLoop会切换到UITrackingRunLoopMode模式,这时即使设定的操作执行时间也不会执行,而是要等到我们结束完拖动后才会切换回NSDefaultRunLoopMode模式执行设置图片的操作.
注意点
在上面推迟显示图片的程序中,我们可以发现当我们切换到UITrackingRunLoopMode中,设定的执行操作的时间并没有停止计时,所以当我们一停止拖动时就会马上执行操作.
那么我们要是在RunLoop的NSDefaultRunLoopMode模式下添加了一个timer,拖动textView一段时间后,许多本该执行的操作在停止拖动之后会怎样执行呢.让我们运行下面代码来看看效果吧.
- (void)viewDidLoad {
[super viewDidLoad];
//[self observer];
NSLog(@"%s", __func__);
[self test2];
}
- (void)test2 {
NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"test2");
}];
[[NSRunLoop currentRunLoop]addTimer:timer forMode:NSDefaultRunLoopMode];
}
效果如下图:
我是在13:02:50进行拖动textView,然后13:03:14结束拖动,可以发现如果当时期间24秒间隔本应该要执行12次打印,最后只执行了两次,而且这两次执行是基本紧接着执行的,期间没有间隔.然后又开始了正常的两秒种执行一次打印.
所以我们可以得出RunLoop的逻辑,当timer添加到RunLoop的NSDefaultRunLoopMode模式时,在切换到UITrackingRunLoopMode模式后,RunLoop会最多暂存两次操作,然后等到RunLoop切换回NSDefaultRunLoopMode模式下,再紧挨着执行两次操作.
结论:
所以当NSTimer添加到NSDefaultRunLoopMode模式并不是绝对精准的,当我们滚动一些视图时,执行操作就会变得不按时.解决方法就是把timer也添加到UITrackingRunLoopMode模式中,或者使用其他定时器如GCD定时器.
4.3 后台常驻线程
线程有关知识
-
[NSThread detachNewThreadSelector:@selector(run1) toTarget:self withObject:nil]会创建并自动开启一条线程执行任务,不需要手动启动 -
我们之前创建线程都是为了执行特定任务,执行问特定任务后,线程会自动进入死亡状态.线程进入死亡状态后,是无法再次启动线程,让线程继续执行任务的.
若线程进入死亡状态再次调用start方法会报错
利用RunLoop实现后台常驻线程
我们在做项目时可能会在后台执行频繁操作,在子线程中执行耗时操作(如下载文件,后台播放音乐,后台记录用户信息),那么我最好能让线程不进入死亡状态因此可以持续的执行任务,而不是频繁的创建和销毁线程.
那么我们应该怎么做呢?
添加一条指向常驻内存的线程强引用,然后在这条线程中创建一个RunLoop,并添加一个Sources,然后开启RunLoop.原因是RunLoop只要没有超时,任务就会一直执行不完,那么线程就不会进入死亡状态.
具体实现过程:
- 首先创建一条子线程并添加要执行的方法
- 在执行的方法中开启一个RunLoop,并添加一个Source或Timer,若不添加RunLoop循环会直接退出.一般做法是添加一个port即端口,因为port并不需要指定需要做什么任务,而timer需要指定,我们这里添加Source或Timer只是为了保证循环不退出,所以不需要指定任务,所以一般选择port. 实现代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"%s", __func__);
[self residentThread];
}
- (void)residentThread {
NSThread *thread = [[NSThread alloc]initWithTarget:self selector:@selector(run1) object:nil];
self.thread = thread;
[self.thread start];
}
- (void)run1 {
//这里写需要执行的代码
NSLog(@"run1 -- %@", [NSThread currentThread]);
//一个RunLoop至少需要一个Source或者Timer,在这里添加一个Source1
[[NSRunLoop currentRunLoop]addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop]run];
NSLog(@"未开启RunLoop -- %@", [NSThread currentThread]);
}
3.运行后会发现 未开启RunLoop 并不打印,因为RunLoop循环一直没有返回.
为了线程是否还可以继续执行其他任务即没有进入死亡状态,我们在touchesBegan中调用PerformSelector方法,看看是否会打印.
代码如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:nil];
}
- (void)run2 {
NSLog(@"run2 -- %@", [NSThread currentThread]);
}
运行代码后点击屏幕,发现可以打印,即线程能够继续执行任务.这样常驻线程就完成了.
5.RunLoop有关知识注意点
1 只有子线程的RunLoop设置退出时间才有用,主线程的RunLoop是无法退出的.即下面这句代码是不会起到使RunLoop退出的作用.
[[NSRunLoop mainRunLoop]runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
2 RunLoop什么时候创建和销毁自动释放池
首先我们要知道RunLoop为什么要创建自动释放池?
因为在一个RunLoop运行循环过程中会产生大量变量和对象,而且大多数变量是不会再使用的.那么若不清理掉这些不用的变量,内存就可能会被堆满.所以RunLoop会定期创建一个自动释放池,并且在特地时间释放掉释放池,并重新再创建一个.
第一次创建:
启动RunLoop的时候
最后一次销毁:
退出RunLoop之前
其他时候的创建和销毁:
在RunLoop进入休眠状态前会释放掉旧的释放池,释放池中的变量也一起被销毁了.然后创建出一个新的释放池,用来存放新产生的不用的变量.