RunLoop知识总结

1,913 阅读10分钟

1.RunLoop简介

1.1 什么是RunLoop

从字面上来说是运行循环,也可以翻译为跑圈.

  • RunLoop本质上是一个对象,这个对象可以保持程序的持续运行并且处理程序中的各种事件(如触摸事件,定时器时间,selector事件).
  • RunLoop没有事情处理时就会使线程进入睡眠状态.这样可以节省CPU资源,提高程序性能.

1.2 RunLoop和线程

RunLoop和线程是息息相关的,我们都知道线程的作用就是用来执行特定的一个或多个任务,正常情况下,线程执行完当前任务后就会退出,之后若线程又有任务需要执行也无法继续执行了.这时我们就需要一种方式让线程能不断执行任务,即使当前线程没有任务执行,线程也不会退出,而是等待下一个任务的到来.所以我们就有了RunLoop.

  1. 每一条线程都有唯一一个与之对应的RunLoop对象.

  2. 主线程的RunLoop对象系统已经自动帮我们创建好了,并且只有主线程结束时即程序结束时才会销毁.

  3. 子线程的Runloop对象需要我们主动创建并维护,子线程的Runloop对象在第一次获取时就会创建,销毁则是在子线程结束时. 并且创建出来的runLoop对象默认是不开启的,必须手动开启RunLoop.

  4. 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的五个类.

  1. CFRunLoopRef: 代表RunLoop对象
  2. CFRunLoopModeRef: 代表RunLoop的运行模式
  3. CFRunLoopSourceRef: 就是上面RunLoop模型图中的事件源/输入源
  4. 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

系统默认定义了多种运行模式, 如下:

  1. kCFRunLoopDefaultMode: APP的默认运行模式, 通常主线程就是在这个模式下运行的
  2. UITrackingRunLoopMode: 跟踪用户交互事件(用于ScrollView追踪触摸滑动,保证界面滑动时不受其他Mode影响)
  3. UIInitializationRunLoopMode: 在刚启动APP时进入的第一个Mode,启动完成后就不会再使用
  4. CSEventReceiveRunLoopMode: 接受系统内部事件(用于绘图),通常用不到
  5. 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.然后在点击动作中的代码中加入一个输出语句,并打上一个断点

步骤如下:

当我们运行程序后点击按钮后就会来到此断点,然后我们就可以查看当前的函数调用栈.

如下图所示:

所以点击事件是这样来的:

  1. 首先程序启动然后运行到18行的main函数,之后在main函数中调用17行的UIApplicationMain函数,然后一直往上调用函数, 最终调用到点击函数.
  2. 我们可以看到在12行中有CFRunLoopDoSources0,即我们的点击事件属于sourece0函数的,点击事件就是source0中处理的.
  3. 而至于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, 没有直接退出

  1. 通知观察者RunLoop已经启动(系统本身就会为我们添加一个观察者)
  2. 通知观察者即将要处理Timer
  3. 通知观察者即将要处理Sourece0
  4. 启动任何准备好的Source0
  5. 如果Soure1准备好并处于等待状态进入,立即启动,进入步骤9.(source1内部就是由source0和timer组成)
  6. 通知观察者进入休眠状态
  7. 将线程置于休眠状态直到下面任一事件发生
    • 某一事件到达基于端口的源
    • 定时器启动
    • RunLoop设置的时间已经超时
    • RunLoop被外部显示唤醒。
  8. 通知观察者,线程被唤醒
  9. 处理未处理的事件
    • 如果用户定义的定时器启动, 处理定时器事件并重新启动RunLoop,进入步骤2.
    • 如果输入源启动, 传递相应消息。
    • 如果RunLoop被显示唤醒并且时间还没超时,重启RunLoop,进入步骤2
  10. 通知观察者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只要没有超时,任务就会一直执行不完,那么线程就不会进入死亡状态.

具体实现过程:

  1. 首先创建一条子线程并添加要执行的方法
  2. 在执行的方法中开启一个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进入休眠状态前会释放掉旧的释放池,释放池中的变量也一起被销毁了.然后创建出一个新的释放池,用来存放新产生的不用的变量.