iOS 多线程:『RunLoop』详尽总结

1. RunLoop 简介

1.1 什么是 RunLoop?

能够理解为字面意思:Run 表示运行,Loop 表示循环。结合在一块儿就是运行的循环的意思。哈哈,我更愿意翻译为『跑圈』。直观理解就像是不停的跑圈。html

  • RunLoop 其实是一个对象,这个对象在循环中用来处理程序运行过程当中出现的各类事件(好比说触摸事件、UI刷新事件、定时器事件、Selector事件),从而保持程序的持续运行。
  • RunLoop 在没有事件处理的时候,会使线程进入睡眠模式,从而节省 CPU 资源,提升程序性能。

1.2 RunLoop 和线程

RunLoop 和线程是息息相关的,咱们知道线程的做用是用来执行特定的一个或多个任务,在默认状况下,线程执行完以后就会退出,就不能再执行任务了。这时咱们就须要采用一种方式来让线程可以不断地处理任务,并不退出。因此,咱们就有了 RunLoop。git

  1. 一条线程对应一个RunLoop对象,每条线程都有惟一一个与之对应的 RunLoop 对象。
  2. RunLoop 并不保证线程安全。咱们只能在当前线程内部操做当前线程的 RunLoop 对象,而不能在当前线程内部去操做其余线程的 RunLoop 对象方法。
  3. RunLoop 对象在第一次获取 RunLoop 时建立,销毁则是在线程结束的时候。
  4. 主线程的 RunLoop 对象系统自动帮助咱们建立好了(原理如 1.3 所示),而子线程的 RunLoop对象须要咱们主动建立和维护。

1.3 默认状况下主线程的 RunLoop 原理

咱们在启动一个iOS程序的时候,系统会调用建立项目时自动生成的 main.m 的文件。main.m文件以下所示:github

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
复制代码

其中 UIApplicationMain 函数内部帮咱们开启了主线程的 RunLoop,UIApplicationMain 内部拥有一个无限循环的代码,只要程序不退出/崩溃,它就一直循环。上边的代码中主线程开启 RunLoop 的过程能够简单的理解为以下代码:web

int main(int argc, char * argv[]) {        
    BOOL running = YES;
    do {
        // 执行各类任务,处理各类事件
        // ......
    } while (running);  // 判断是否须要退出

    return 0;
}
复制代码

从上边可看出,程序一直在 do-while 循环中执行,因此 UIApplicationMain 函数一直没有返回,咱们在运行程序以后程序不会立刻退出,会保持持续运行状态。安全

下图是苹果官方给出的 RunLoop 模型图。bash

img

官方 RunLoop 模型图框架

从上图中能够看出,RunLoop 就是线程中的一个循环,RunLoop 会在循环中会不断检测,经过 Input sources(输入源)和 Timer sources(定时源)两种来源等待接受事件;而后对接受到的事件通知线程进行处理,并在没有事件的时候让线程进行休息。函数

2. RunLoop 相关类

下面咱们来了解一下Core Foundation框架下关于 RunLoop 的 5 个类,只有弄懂这几个类的含义,咱们才能深刻了解 RunLoop 的运行机制。oop

  1. CFRunLoopRef:表明 RunLoop 的对象
  2. CFRunLoopModeRef:表明 RunLoop 的运行模式
  3. CFRunLoopSourceRef:就是 RunLoop 模型图中提到的输入源 / 事件源
  4. CFRunLoopTimerRef:就是 RunLoop 模型图中提到的定时源
  5. CFRunLoopObserverRef:观察者,可以监听 RunLoop 的状态改变

下边详细讲解下几种类的具体含义和关系。性能

先来看一张表示这 5 个类的关系图帮助理解(来源:blog.ibireme.com/2015/05/18/…)。

img

RunLoop相关类关系图.png

接着来说解这 5 个类的相互关系:

一个RunLoop对象(CFRunLoopRef)中包含若干个运行模式(CFRunLoopModeRef)。而每个运行模式下又包含若干个输入源(CFRunLoopSourceRef)、定时源(CFRunLoopTimerRef)、观察者(CFRunLoopObserverRef)。

  • 每次 RunLoop 启动时,只能指定其中一个运行模式(CFRunLoopModeRef),这个运行模式(CFRunLoopModeRef)被称做当前运行模式(CurrentMode)。
  • 若是须要切换运行模式(CFRunLoopModeRef),只能退出当前 Loop,再从新指定一个运行模式(CFRunLoopModeRef)进入。
  • 这样作主要是为了分隔开不一样组的输入源(CFRunLoopSourceRef)、定时源(CFRunLoopTimerRef)、观察者(CFRunLoopObserverRef),让其互不影响 。

下边咱们来详细讲解下这五个类:

2.1 CFRunLoopRef 类

CFRunLoopRef 是 Core Foundation 框架下 RunLoop 对象类。咱们可经过如下方式来获取 RunLoop 对象:

  • Core Foundation
    • CFRunLoopGetCurrent(); // 得到当前线程的 RunLoop 对象
    • CFRunLoopGetMain(); // 得到主线程的 RunLoop 对象

固然,在Foundation 框架下获取 RunLoop 对象类的方法以下:

  • Foundation
    • [NSRunLoop currentRunLoop]; // 得到当前线程的 RunLoop 对象
    • [NSRunLoop mainRunLoop]; // 得到主线程的 RunLoop 对象

2.2 CFRunLoopModeRef

系统默认定义了多种运行模式(CFRunLoopModeRef),以下:

  1. kCFRunLoopDefaultMode:App的默认运行模式,一般主线程是在这个运行模式下运行
  2. UITrackingRunLoopMode:跟踪用户交互事件(用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其余Mode影响)
  3. UIInitializationRunLoopMode:在刚启动App时第进入的第一个 Mode,启动完成后就再也不使用
  4. GSEventReceiveRunLoopMode:接受系统内部事件,一般用不到
  5. kCFRunLoopCommonModes:伪模式,不是一种真正的运行模式(后边会用到)

其中kCFRunLoopDefaultModeUITrackingRunLoopModekCFRunLoopCommonModes是咱们开发中须要用到的模式,具体使用方法咱们在 2.3 CFRunLoopTimerRef 中结合CFRunLoopTimerRef来演示说明。

2.3 CFRunLoopTimerRef

CFRunLoopTimerRef是定时源(RunLoop模型图中提到过),理解为基于时间的触发器,基本上就是NSTimer(哈哈,这个理解就简单了吧)。

下面咱们来演示下CFRunLoopModeRef和CFRunLoopTimerRef结合的使用用法,从而加深理解。

  1. 首先咱们新建一个iOS项目,在Main.storyboard中拖入一个Text View。
  2. 在ViewController.m文件中加入如下代码,Demo中请调用[self ShowDemo1];来演示。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 定义一个定时器,约定两秒以后调用self的run方法
    NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];

    // 将定时器添加到当前RunLoop的NSDefaultRunLoopMode下
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)run
{
    NSLog(@"---run");
}
复制代码
  1. 而后运行,这时候咱们发现若是咱们不对模拟器进行任何操做的话,定时器会稳定的每隔2秒调用run方法打印。
  2. 可是当咱们拖动Text View滚动时,咱们发现:run方法不打印了,也就是说NSTimer不工做了。而当咱们松开鼠标的时候,NSTimer就又开始正常工做了。

这是由于:

  • 当咱们不作任何操做的时候,RunLoop处于NSDefaultRunLoopMode下。
  • 而当咱们拖动Text View的时候,RunLoop就结束NSDefaultRunLoopMode,切换到了UITrackingRunLoopMode模式下,这个模式下没有添加NSTimer,因此咱们的NSTimer就不工做了。
  • 但当咱们松开鼠标的时候,RunLoop就结束UITrackingRunLoopMode模式,又切换回NSDefaultRunLoopMode模式,因此NSTimer就又开始正常工做了。

你能够试着将上述代码中的[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];语句换为[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];,也就是将定时器添加到当前RunLoop的UITrackingRunLoopMode下,你就会发现定时器只会在拖动Text View的模式下工做,而不作操做的时候定时器就不工做。

那难道咱们就不能在这两种模式下让NSTimer都能正常工做吗?

固然能够,这就用到了咱们以前说过的伪模式(kCFRunLoopCommonModes),这其实不是一种真实的模式,而是一种标记模式,意思就是能够在打上Common Modes标记的模式下运行。

那么哪些模式被标记上了Common Modes呢?

NSDefaultRunLoopModeUITrackingRunLoopMode

因此咱们只要咱们将NSTimer添加到当前RunLoop的kCFRunLoopCommonModes(Foundation框架下为NSRunLoopCommonModes)下,咱们就可让NSTimer在不作操做和拖动Text View两种状况下愉快的正常工做了。

具体作法就是讲添加语句改成[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

既然讲到了NSTimer,这里顺便讲下NSTimer中的scheduledTimerWithTimeInterval方法和RunLoop的关系。添加下面的代码:

[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
复制代码

这句代码调用了scheduledTimer返回的定时器,NSTimer会自动被加入到了RunLoop的NSDefaultRunLoopMode模式下。这句代码至关于下面两句代码:

NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
复制代码

2.4 CFRunLoopSourceRef

CFRunLoopSourceRef是事件源(RunLoop模型图中提到过),CFRunLoopSourceRef有两种分类方法。

  • 第一种按照官方文档来分类(就像RunLoop模型图中那样):
    • Port-Based Sources(基于端口)
    • Custom Input Sources(自定义)
    • Cocoa Perform Selector Sources
  • 第二种按照函数调用栈来分类:
    • Source0 :非基于Port
    • Source1:基于Port,经过内核和其余线程通讯,接收、分发系统事件

这两种分类方式其实没有区别,只不过第一种是经过官方理论来分类,第二种是在实际应用中经过调用函数来分类。

下边咱们举个例子大体来了解一下函数调用栈和Source。

  1. 在咱们的项目中的Main.storyboard中添加一个Button按钮,并添加点击动做。
  2. 而后在点击动做的代码中加入一句输出语句,并打上断点,以下图所示:

img

添加Button.png

  1. 而后运行程序,并点击按钮。
  2. 而后在项目中单击下下图红色部分。

img

函数调用栈展现图

  1. 能够看到以下图所示就是点击事件产生的函数调用栈。

img

函数调用栈

因此点击事件是这样来的:

  1. 首先程序启动,调用16行的main函数,main函数调用15行UIApplicationMain函数,而后一直往上调用函数,最终调用到0行的BtnClick函数,即点击函数。
  2. 同时咱们能够看到11行中有Sources0,也就是说咱们点击事件是属于Sources0函数的,点击事件就是在Sources0中处理的。
  3. 而至于Sources1,则是用来接收、分发系统事件,而后再分发到Sources0中处理的。

2.5 CFRunLoopObserverRef

CFRunLoopObserverRef是观察者,用来监听RunLoop的状态改变

CFRunLoopObserverRef能够监听的状态改变有如下几种:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),               // 即将进入Loop:1
    kCFRunLoopBeforeTimers = (1UL << 1),        // 即将处理Timer:2    
    kCFRunLoopBeforeSources = (1UL << 2),       // 即将处理Source:4
    kCFRunLoopBeforeWaiting = (1UL << 5),       // 即将进入休眠:32
    kCFRunLoopAfterWaiting = (1UL << 6),        // 即将从休眠中唤醒:64
    kCFRunLoopExit = (1UL << 7),                // 即将从Loop中退出:128
    kCFRunLoopAllActivities = 0x0FFFFFFFU       // 监听所有状态改变  
};
复制代码

下边咱们经过代码来监听下RunLoop中的状态改变。

  1. 在ViewController.m中添加以下代码,Demo中请调用[self showDemo2];方法。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 建立观察者
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"监听到RunLoop发生改变---%zd",activity);
    });

    // 添加观察者到当前RunLoop中
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

    // 释放observer,最后添加完须要释放掉
    CFRelease(observer);
}
复制代码
  1. 而后运行,看下打印结果,以下图。

img

打印结果

能够看到RunLoop的状态在不断的改变,最终变成了状态 32,也就是即将进入睡眠状态,说明RunLoop以后就会进入睡眠状态。

3. RunLoop原理

好了,五个类都讲解完了,下边开始放大招了。这下咱们就能够来理解RunLoop的运行逻辑了。

下边上一张以前提到的文章中博主提供的运行逻辑图(来源:blog.ibireme.com/2015/05/18/…

img

RunLoop运行逻辑图

这张图对于咱们理解RunLoop来讲太有帮助了,下边咱们能够来讲下官方文档给咱们的RunLoop逻辑。

在每次运行开启RunLoop的时候,所在线程的RunLoop会自动处理以前未处理的事件,而且通知相关的观察者。

具体的顺序以下:

  1. 通知观察者RunLoop已经启动
  2. 通知观察者即将要开始的定时器
  3. 通知观察者任何即将启动的非基于端口的源
  4. 启动任何准备好的非基于端口的源
  5. 若是基于端口的源准备好并处于等待状态,当即启动;并进入步骤9
  6. 通知观察者线程进入休眠状态
  7. 将线程置于休眠知道任一下面的事件发生:
    • 某一事件到达基于端口的源
    • 定时器启动
    • RunLoop设置的时间已经超时
    • RunLoop被显示唤醒
  8. 通知观察者线程将被唤醒
  9. 处理未处理的事件
    • 若是用户定义的定时器启动,处理定时器事件并重启RunLoop。进入步骤2
    • 若是输入源启动,传递相应的消息
    • 若是RunLoop被显示唤醒并且时间还没超时,重启RunLoop。进入步骤2
  10. 通知观察者RunLoop结束。

4. RunLoop实战应用

哈哈,讲了这么多云里雾里的原理知识,下边终于到了实战应用环节。

光弄懂是没啥用的,可以实战应用才是硬道理。下面讲解一下RunLoop的几种应用。

4.1 NSTimer的使用

NSTimer的使用方法在讲解CFRunLoopTimerRef类的时候详细讲解过,具体参考上边 2.3 CFRunLoopTimerRef

4.2 ImageView推迟显示

有时候,咱们会遇到这种状况: 当界面中含有UITableView,并且每一个UITableViewCell里边都有图片。这时候当咱们滚动UITableView的时候,若是有一堆的图片须要显示,那么可能会出现卡顿的现象。

怎么解决这个问题呢?

这时候,咱们应该推迟图片的显示,也就是ImageView推迟显示图片。有两种方法:

1. 监听UIScrollView的滚动

由于UITableView继承自UIScrollView,因此咱们能够经过监听UIScrollView的滚动,实现UIScrollView相关delegate便可。

2. 利用PerformSelector设置当前线程的RunLoop的运行模式

利用performSelector方法为UIImageView调用setImage:方法,并利用inModes将其设置为RunLoop下NSDefaultRunLoopMode运行模式。代码以下:

[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:NSDefaultRunLoopMode];
复制代码

下边利用Demo演示一下该方法。

  1. 在项目中的Main.storyboard中添加一个UIImageView,并添加属性,并简单添加一下约束(否则没法显示)以下图所示。

img

添加UIImageView

  1. 在项目中拖入一张图片,好比下图。

img

tupian.jpg

  1. 而后咱们在touchesBegan方法中添加下面的代码,在Demo中请在touchesBegan中调用[self showDemo3];方法。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];
}
复制代码
  1. 运行程序,点击一下屏幕,而后拖动UIText View,拖动4秒以上,发现过了4秒以后,UIImageView尚未显示图片,当咱们松开的时候,则显示图片,效果以下:

img

UIImageView延迟显示效果.gif

这样咱们就实现了在拖动完以后,在延迟显示UIImageView。

4.3 后台常驻线程(很经常使用)

咱们在开发应用程序的过程当中,若是后台操做特别频繁,常常会在子线程作一些耗时操做(下载文件、后台播放音乐等),咱们最好能让这条线程永远常驻内存。

那么怎么作呢?

添加一条用于常驻内存的强引用的子线程,在该线程的RunLoop下添加一个Sources,开启RunLoop。

具体实现过程以下:

  1. 在项目的ViewController.m中添加一条强引用的thread线程属性,以下图:

img

添加thread属性

  1. 在viewDidLoad中建立线程self.thread,使线程启动并执行run1方法,代码以下。在Demo中,请在viewDidLoad调用[self showDemo4];方法。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 建立线程,并调用run1方法执行任务
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    // 开启线程
    [self.thread start];    
}

- (void) run1
{
    // 这里写任务
    NSLog(@"----run1-----");

    // 添加下边两句代码,就能够开启RunLoop,以后self.thread就变成了常驻线程,可随时添加任务,并交于RunLoop处理
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];

    // 测试是否开启了RunLoop,若是开启RunLoop,则来不了这里,由于RunLoop开启了循环。
    NSLog(@"未开启RunLoop");
}
复制代码
  1. 运行以后发现打印了**----run1-----,而未开启RunLoop**则未打印。

这时,咱们就开启了一条常驻线程,下边咱们来试着添加其余任务,除了以前建立的时候调用了run1方法,咱们另外在点击的时候调用run2方法。

那么,咱们在touchesBegan中调用PerformSelector,从而实如今点击屏幕的时候调用run2方法。Demo地址。具体代码以下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{   
    // 利用performSelector,在self.thread的线程中调用run2方法执行任务
    [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void) run2
{
    NSLog(@"----run2------");
}
复制代码

通过运行测试,除了以前打印的**----run1-----,每当咱们点击屏幕,都能调用----run2------**。 这样咱们就实现了常驻线程的需求。

转自 行走的少年郎

相关文章
相关标签/搜索