iOS RunLoop入门小结

  • 说明
  • iOS中的RunLoop
  • 使用场景
    • 1.保持线程的存活,而不是线性的执行完任务就退出了
      • <1>不开启RunLoop的线程
      • <2>开启RunLoop的线程
      • (1)实验用self来持有子线程
      • (2)实验让线程不结束任务导致进入死亡状态]
      • (3)Event Loop模式
      • (4)初步尝试使用RunLoop
      • (5)初体验产生的疑问
        • ①为什么总是要把RunLoop和线程放在一起来讲?
        • ②如何创建RunLoop?
        • ③线程默认不开启RunLoop,为什么我们的App或者说主线程却可以一直运行而不会结束?
        • ④RunLoop能正常运行的条件是什么?
        • ⑤除了[runLoop run]还有那些方法启动RunLoop?
      • (6)初体验结论
    • 2.保持线程的存活后,让线程在我们需要的时候响应消息。
    • 3.让线程定时执行某任务(Timer)
      • <1>初识NSTimer遇到的坑
      • <2>使用NSTimer遇到的坑
      • <3>NSTimer导致的ViewController无法释放问题
      • <4>performSelecter:afterDelay:
    • 4.监听Observer达到一些目的
  • 关于RunLoop的释放问题(RunLoop带autoreleasepool的正确写法)
    • <1>分析
    • <2>需要用while循环控制的RunLoop
    • <3>不需要用while循环控制的RunLoop
    • <4>关于主线程中autoreleasepool的题外话
  • 关于子线程RunLoop切换Mode的思考
    • 本文最大干货,子线程中模拟主线程DefaultMode与TrackingMode的切换,长代码预警
  • 参考文档
    • <1>RunLoop
    • <2>autoreleasepool

说明

常言道透过现象看本质,如果一开始讲概念很难有实质性的理解。所以我们从现象(RunLoop的使用场景,引发了什么现象。)开始,尽量由现象引发疑问从而引出一些概念性的东西。
写这篇文章的过程其实也是我以一个RunLoop入门者的角度出发,看了很多资料以后总结、印证的过程,如有不对希望大家指正。本篇可能不会深入过多概念性的东西(刚接触的时候看太多概念或者底层的东西反而容易发懵,我把一些刚接触的时候可能不太容易理解的部分放在了RunLoop入门学习补充资料之中),所写的大多数是我经过思考理解了的部分或者我认为日常开发中会运用到的部分,如果希望更深入理解的可以看文末我参考过的文档。
ps:文中会有比较多的Demo部分,大家可以跟着做一下,刚接触RunLoop 的时候我也是光看不写,看了半天最后发现好像都懂了,又好像啥都没记住。写Demo的时候就是练手,会发现很多问题,也加深了理解和记忆。

iOS中的RunLoop

这一部分内容我们先做个了解,有个印象就行,暂时不用过于深入。
iOS 系统中,提供了两种RunLoop:NSRunLoop 和 CFRunLoopRef。
CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

CFRunLoopRef 的代码是开源的,所以有些源码部分以CFRunLoop来讲。

使用场景

1.保持线程的存活,而不是线性的执行完任务就退出了

<1>不开启RunLoop的线程

在遇到一些耗时操作时,为了避免主线程阻塞导致界面卡顿,影响用户体验,往往我们会把这些耗时操作放在一个临时开辟的子线程中。操作完成了,子线程线性的执行了代码也就退出了,就像下面一样。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    MyThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----执行子线程任务",[NSThread currentThread]);
}

其中MyThread为一个重写了dealloc的NSThread的子类

@implementation MyThread

-(void)dealloc{
    NSLog(@"%@线程被释放了", self.name);
}

@end

我们不妨来猜一下输出结果。

 <NSThread: 0x60c000074ac0>{number = 1, name = main}----开辟子线程
 <MyThread: 0x60c000274300>{number = 3, name = subThread}----执行子线程任务
 subThread线程被释放了

就像一开始所说的一样,子线程执行完操作就自动退出了。

<2>开启RunLoop的线程

(1)实验用self来持有子线程

如果子线程的操作是偶尔或者干脆只需要执行一次的话,像上面那样就没什么问题。但是如果这个操作需要频繁执行,那么按照上面那样的逻辑,我们就需要频繁创建子线程,这是很消耗资源的。就像平时我们在设计类的时候会把需要频繁使用的对象保持起来,而不是频繁创建一样。我们试试把线程“保持”起来,让它在需要的时候执行任务,不需要的时候就啥都不干。
我们将刚才的代码稍作改动

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"%@----开辟子线程",[NSThread currentThread]);

    self.subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    self.subThread.name = @"subThread";
    [self.subThread start];

}

现在我们再来猜猜打印结果,是否像我们想象那样执行完操作后线程未被释放。

<NSThread: 0x60c0000776c0>{number = 1, name = main}----开辟子线程
<MyThread: 0x6080002627c0>{number = 3, name = subThread}----执行子线程任务

子线程内部操作完成后并没有被释放,看样子我们成功持有了子线程。那么按照刚才的设想,我们就可以在任何需要的时候开启子线程完成线程里面的操作。
我们在[self.subThread start];后面再添加上一句[self.subThread start];再运行试试看结果。

 <NSThread: 0x60400006d480>{number = 1, name = main}----开辟子线程
 <MyThread: 0x6080000773c0>{number = 3, name = subThread}----执行子线程任务
 *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[MyThread start]: attempt to start the thread again'

对,你没看错,它崩溃了。原因如下:
因为执行完任务后,虽然Thread没有被释放,还处于内存中,但是它处于死亡状态(当线程的任务结束后就会进入这种状态)。打个比方,人死不能复生,线程死了也不能复生(重新开启),苹果不允许在线程死亡后再次开启。所以会报错attempt to start the thread again(尝试重新开启线程)

(2)实验让线程不结束任务导致进入死亡状态

既然是线程的任务结束导致了线程进入死亡状态,那么我们不让线程结束任务就行了呗。所以我们用while循环让线程的任务无法结束就行了呗。

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"%@----开辟子线程",[NSThread currentThread]);

    self.subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    self.subThread.name = @"subThread";
    [self.subThread start];

}

- (void)subThreadTodo
{
    do {
        NSLog(@"%@----执行子线程任务",[NSThread currentThread]);
    } while (1);
}

一通操作过后代码变成这样。但是写完仔细一想,确实子线程不会进入死亡状态了,但是子线程却在不分时间地点场合的疯狂执行任务。这根我们一开始想象的,需要的时候执行任务,不需要的时候就啥都不干差远了。看起来似乎又是一次失败的尝试,但是别灰心,我们已经越来越接近答案了。

(3)Event Loop模式

我们在开发中应该听说过或者看到过这下面一系列方法

performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:

performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:

performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:

概括来讲它们的作用都是在某个线程内响应某个方法。既然有这种方法的存在,那么我们是不是可以更改一下思路,让子线程的任务由执行任务变为接收别人发给它的消息,去执行对应的任务,没人给他发消息就休息呢?现在我们来修改一下subThreadTodo

- (void)subThreadTodo
{
    do {
        1.接收消息
        2.如果没有消息就休息
        3.休息直到接收到了消息,执行消息对应的任务
    } while (消息 != 退出);
}

这样一来就达到了最初的目的,线程的任务直到我们主动让线程退出为止永远不会结束(不会进入死亡状态),可以在需要的时候让线程做对应的事情。但是怎么实现呢?我们不需要考虑,因为苹果已经帮我们做好了。事实上,这是一种模型,它被称为Event Loop。很多平台都有这种模型,而iOS/OSX中的体现就是RunLoop,可以说RunLoop的本质就是do while循环。当然,实际上它的逻辑不会像刚才写的那么简单,还涉及了很多其他东西,为了刚接触的时候不发懵,我们就这么简单的理解它。如果想看真实逻辑的,可以查看文末的文档或者RunLoop入门学习补充资料(1.RunLoop运行逻辑)。

(4)初步尝试使用RunLoop

绕了一大圈,终于讲到了RunLoop,现在我们来初步了解下RunLoop如何使用,顺便做个小测试。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);
    //获取当前子线程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //下面这一行必须加,否则RunLoop无法正常启用。我们暂时先不管这一行的意思,稍后再讲。
    [runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];
    //让RunLoop跑起来
    [runLoop run];
    NSLog(@"%@----执行子线程任务结束",[NSThread currentThread]);
}

经过之前的几个实验和猜想,我们现在再来猜测一下加入了RunLoop后的输出应该是怎样的。注意,这个例子中并没有对线程用self进行引用

<NSThread: 0x60c000066a00>{number = 1, name = main}----开辟子线程
<MyThread: 0x604000065980>{number = 3, name = subThread}----开始执行子线程任务

这里没有对线程进行引用,也没有让线程内部的任务进行显式的循环。为什么子线程的里面的任务没有执行到输出任务结束这一步,为什么子线程没有销毁?就是因为[runLoop run];这一行的存在。
前面讲了,RunLoop本质就是个Event Loop的do while循环,所以运行到这一行以后子线程就一直在进行接受消息->等待->处理的循环。所以不会运行[runLoop run];之后的代码(这点需要注意,在使用RunLoop的时候如果要进行一些数据处理之类的要放在这个函数之前否则写的代码不会被执行),也就不会因为任务结束导致线程死亡进而销毁。这也就是我们最常使用RunLoop的场景之一,就如小节标题保持线程的存活,而不是线性的执行完任务就退出了

(5)初体验产生的疑问

通过初步使用RunLoop的小实验可以引发一些与概念有关的疑问。如果嫌太长不想看的可以直接看下面的结论。

①为什么总是要把RunLoop和线程放在一起来讲?

总的来讲就是:RunLoop是保证线程不会退出,并且能在不处理消息的时候让线程休眠,节约资源,在接收到消息的时候唤醒线程做出对应处理的消息循环机制。它是寄生于线程的,所以提到RunLoop必然会涉及到线程。

②如何创建RunLoop?

苹果不允许直接创建 RunLoop,它只提供了四个自动获取的函数

[NSRunLoop currentRunLoop];//获取当前线程的RunLoop
[NSRunLoop mainRunLoop];
CFRunLoopGetMain();
CFRunLoopGetCurrent();

这些函数内部的逻辑大概是下面这样:

/// 全局的Dictionary,key 是 线程, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
 
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
    OSSpinLockLock(&loopsLock);
    
    if (!loopsDic) {
        // 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
        loopsDic = CFDictionaryCreateMutable();
        CFRunLoopRef mainLoop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
    }
    
    /// 直接从 Dictionary 里获取。
    CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
    
    if (!loop) {
        /// 取不到时,创建一个
        loop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, thread, loop);
        /// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
        _CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
    }
    
    OSSpinLockUnLock(&loopsLock);
    return loop;
}
 
CFRunLoopRef CFRunLoopGetMain() {
    return _CFRunLoopGet(pthread_main_thread_np());
}
 
CFRunLoopRef CFRunLoopGetCurrent() {
    return _CFRunLoopGet(pthread_self());
}

注:这并不是源码,而是大神为了方便我们理解,对源码进行了一些可读性优化后的结果。但是其大致还是与源码一直的,如有想看这部分的源码可以看RunLoop入门学习补充资料(2.获取RunLoop的源码)。此段代码摘自: https://blog.ibireme.com/2015/05/18/runloop/
通过看这部分代码我们可以产生一个概念,那就是,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。

sunnyxx大神在线下分享视频中提到过虽然一个线程只有唯一一个与之对应的RunLoop的,但是与线程对应的那个RunLoop可以包含子RunLoop。(暂时只找到了https://blog.csdn.net/frank_jb/article/details/49329861 这篇文档中有RunLoop的嵌套实例,但是我觉得作为一个入门者来说,嵌套这部分暂时有个印象就好,等以后对RunLoop有个比较全面的理解了以后再来深入,不然更容易头昏。)

③线程默认不开启RunLoop,为什么我们的App或者说主线程却可以一直运行而不会结束?

主线程是唯一一个例外,当App启动以后主线程会自动开启一个RunLoop来保证主线程的存活并处理各种事件。而且从上面的源代码来看,任意一个子线程的RunLoop都会保证主线程的RunLoop的存在。

④RunLoop能正常运行的条件是什么?

看到刚才代码中注释说暂时不管的代码,第一次接触肯定会想[runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];这一句是什么意思?为什么必须加这一句RunLoop才能正常运行?
我们仍然通过实验看现象来理解

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);
    //获取当前子线程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //注释掉下面这行和不注释掉下面这行分别运行一次
    [runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];
    NSLog(@"RunLoop:%@",runLoop);
    //让RunLoop跑起来
    [runLoop run];
    NSLog(@"%@----执行子线程任务结束",[NSThread currentThread]);
}

注释掉得到的结果

1.png

不注释得到的结果

2.png

注释掉以后我们看似run了RunLoop但是最后线程还是结束了任务,然后销毁了。与没注释得到的结果比较,造成这一切的原因就在上面两张图片中标注部分的区别上。要解释这一部分就又要开始讲到让我们抓耳挠腮的概念部分,我们先来看一张眼熟到不行的RunLoop结构图。

3.png

一开始接触RunLoop我看到这张图的时候也是懵逼的,现在我们结合刚才的打印结果来理解。
1.图中RunLoop蓝色部分就对应我们打印结果中,整个RunLoop部分的打印结果
2.多个绿色部分共同被包含在RunLoop内就对应,打印结果中modes中同时包含多个Mode(这里可是看打印结果中标注出来的第一行往上再数两行。modes = ... count = 1。一个RunLoop可以包含多个Mode,每个Mode的Name不一样,只是在这个打印结果当中目前刚好Mode个数为1)
3.每一个绿色部分Mode整体就对应,打印结果中被标注出来的整体。
4.黄色部分Source对应标注部分source0+source1
5.黄色部分Observer对应标注部分observer部分
6.黄色部分Timer对应标注部分timers部分

讲完了结构我们继续来讲Mode是什么。
我对Mode的理解就是”行为模式“,就像我们说到上学这个行为模式,它就应该包含起床,出门,去学校,上课,午休等等。但是,如果上学这个行为模式什么都不包含,那么即使我们进行上学这个行为,我们也一直睡在床上什么都不会做。就像刚才注释掉addPort那一行代码得到的结果一样,RunLoop在kCFRunLoopDefaultMode下run了,但是因为该Mode下所有东西都为null(不包含任何内容),所以RunLoop什么都没做又退出来了,然后线程就结束任务最后销毁。之所以要有Mode的存在是为了让RunLoop在不同的”行为模式“之下执行不同的”动作“互不影响。比如执行上学这个行为模式就不能进行娱乐这个行为模式下的游戏这个动作。RunLoop同一时间只能运行在一种Mode下,当前运行的这个Mode叫currentMode。(这里也许比较抽象,在下面timer部分会有实例结合实例分析。)

一般我们常用的Mode有三种

1.kCFRunLoopDefaultMode(CFRunLoop)/NSDefaultRunLoopMode(NSRunLoop)
默认模式,在RunLoop没有指定Mode的时候,默认就跑在DefaultMode下。一般情况下App都是运行在这个mode下的

2.(CFStringRef)UITrackingRunLoopMode(CFRunLoop)/UITrackingRunLoopMode(NSRunLoop)
一般作用于ScrollView滚动的时候的模式,保证滑动的时候不受其他事件影响。

3.kCFRunLoopCommonModes(CFRunLoop)/NSRunLoopCommonModes(NSRunLoop)
这个并不是某种具体的Mode,而是一种模式组合,在主线程中默认包含了NSDefaultRunLoopMode和 UITrackingRunLoopMode。子线程中只包含NSDefaultRunLoopMode。
注意:
①在选择RunLoop的runMode时不可以填这种模式否则会导致RunLoop运行不成功。
②在添加事件源的时候填写这个模式就相当于向组合中所有包含的Mode中注册了这个事件源。
③你也可以通过调用CFRunLoopAddCommonMode()方法将自定义Mode放到 kCFRunLoopCommonModes组合。

讲完了”行为模式“,现在我们来讲”行为模式“包含的具体”动作“。
Source是什么?
source就是输入源事件,分为source0和source1这两种。

1.source0:诸如UIEvent(触摸,滑动等),performSelector这种需要手动触发的操作。
2.source1:处理系统内核的mach_msg事件(系统内部的端口事件)。诸如唤醒RunLoop或者让RunLoop进入休眠节省资源等。
一般来说日常开发中我们需要关注的是source0,source1只需要了解。
之所以说source0更重要是因为日常开发中,我们需要对常驻线程进行操作的事件大多都是source0,稍后的实验会讲到。

Timer是什么?
Timer即为定时源事件。通俗来讲就是我们很熟悉的NSTimer,其实NSTimer定时器的触发正是基于RunLoop运行的,所以使用NSTimer之前必须注册到RunLoop,但是RunLoop为了节省资源并不会在非常准确的时间点调用定时器,如果一个任务执行时间较长,那么当错过一个时间点后只能等到下一个时间点执行,并不会延后执行(NSTimer提供了一个tolerance属性用于设置宽容度,如果确实想要使用NSTimer并且希望尽可能的准确,则可以设置此属性)。

Observer是什么?
它相当于消息循环中的一个监听器,随时通知外部当前RunLoop的运行状态。NSRunLoop没有相关方法,只能通过CFRunLoop相关方法创建

    // 创建observer
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {

        NSLog(@"----监听到RunLoop状态发生改变---%zd", activity);

    });

    // 添加观察者:监听RunLoop的状态
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

由于它与这一问的关系并不大所以暂时不做过多阐述,希望进一步了解Observer可以查看文末的文档或者RunLoop入门学习补充资料(3.Observer)。

重点:它不能作为让RunLoop正常运行的条件,只有Observer的RunLoop也是无法正常运行的。

上面的 Source/Timer/Observer 被统称为 mode item,一个item可以被同时加入多个mode。但一个item被重复加入同一个mode时是不会有效果的。如果一个mode中一个item 都没有(只有Observer也不行),则 RunLoop 会直接退出,不进入循环。

对比刚才的打印日志,再结合刚才讲到的RunLoop结构内容,我们不妨做个猜测。RunLoop能正常运行的条件就是,至少要包含一个Mode(RunLoop默认就包含DefaultMode),并且该Mode下需要有至少一个的事件源(Timer/Source)。事实上经过NSRunLoop封装后,只可以往mode中添加两类事件源:NSPort(对应的是source1)和NSTimer(Timer源放在后面讲)。接下来我们还是用实验来加强理解。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);
    //获取当前子线程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //给RunLoop添加一个事件源,注意添加的Mode
    //关于这里的[NSMachPort port]我的理解是,给RunLoop添加了一个占位事件源,告诉RunLoop有事可做,让RunLoop运行起来。
    //但是暂时这个事件源不会有具体的动作,而是要等RunLoop跑起来过后等有消息传递了才会有具体动作。
    [runLoop addPort:[NSMachPort port] forMode:UITrackingRunLoopMode];

    [runLoop run];
    NSLog(@"%@----执行子线程任务结束",[NSThread currentThread]);
}

又到了愉快的猜谜时间,是否像我们猜测的那样,只要有Mode,并且Mode包含事件源RunLoop就可以正常运行了呢?

<NSThread: 0x60800006d1c0>{number = 1, name = main}----开辟子线程
<MyThread: 0x600000071800>{number = 3, name = subThread}----开始执行子线程任务
<MyThread: 0x600000071800>{number = 3, name = subThread}----执行子线程任务结束
subThread线程被释放了

最后跟我们想的不一样,线程释放了,RunLoop没有成功启用。原因就出在[runLoop run];上面。
这句的意思是,在NSDefaultRunLoopMode下运行RunLoop。而我们添加的事件源是在另外一个Mode下,NSDefaultRunLoopMode仍然空空如也,所以RunLoop也就直接退出了。所以我们还要加一个条件,RunLoop正常运行的条件是:1.有Mode。2.Mode有事件源。3.运行在有事件源的Mode下。

⑤除了[runLoop run]还有那些方法启动RunLoop?

NSRunLoop中总共包装了3个方法供我们使用

1.- (void)run;

除非希望子线程永远存在,否则不建议使用,因为这个接口会导致Run Loop永久性的运行NSDefaultRunLoopMode模式,即使使用 CFRunLoopStop(runloopRef);也无法停止RunLoop的运行,那么这个子线程也就无法停止,只能永久运行下去。

2.- (void)runUntilDate:(NSDate *)limitDate;

比上面的接口好点,有个超时时间,可以控制每次RunLoop的运行时间,也是运行在NSDefaultRunLoopMode模式。这个方法运行RunLoop一段时间会退出给你检查运行条件的机会,如果需要可以再次运行RunLoop。注意CFRunLoopStop(runloopRef);仍然无法停止RunLoop的运行,因此最好自己设置一个合理的RunLoop运行时间。比如

while (!Stop){

    [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];

}
3.- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;

有一个超时时间限制,而且可以设置运行模式
这个接口在非Timer事件触发、显式的用CFRunLoopStop停止RunLoop或者到达limitDate后会退出返回。如果仅是Timer事件触发并不会让RunLoop退出返回,但是如果是PerfromSelector事件或者其他Input Source事件触发处理后,RunLoop会退出返回YES。同样可以像上面那样用while包起来使用。

(6)初体验结论

①.RunLoop是寄生于线程的消息循环机制,它能保证线程存活,而不是线性执行完任务就消亡。
②.RunLoop与线程是一一对应的,每个线程只有唯一与之对应的一个RunLoop。我们不能创建RunLoop,只能在当前线程当中获取线程对应的RunLoop(主线程RunLoop除外)。
③.子线程默认没有RunLoop,需要我们去主动开启,但是主线程是自动开启了RunLoop的。
④.RunLoop想要正常启用需要运行在添加了事件源的Mode下。
⑤.RunLoop有三种启动方式runrunUntilDate:(NSDate *)limitDaterunMode:(NSString *)mode beforeDate:(NSDate *)limitDate。第一种无条件永远运行RunLoop并且无法停止,线程永远存在。第二种会在时间到后退出RunLoop,同样无法主动停止RunLoop。前两种都是在NSDefaultRunLoopMode模式下运行。第三种可以选定运行模式,并且在时间到后或者触发了非Timer的事件后退出。

2.保持线程的存活后,让线程在我们需要的时候响应消息。

前面讲到了几个在某个线程内响应某方法的方法,现在我们就来讲讲这几个方法的具体含义

performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:
在主线程中响应指定Selector。这两个方法给你提供了选项来阻断当前线程(不是执行Selector的线程而是调用上述方法的线程)直到selector被执行完毕。

performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:
在某个子线程(NSThread对像)中响应指定Selector。这两个方法同样给你提供了选项来阻断当前线程直到Selector被执行完毕。

performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:
在当前线程中执行Selector,并附加了延迟选项。多个排队的Selector会按照顺序一个一个的执行。

其实,这几个方法都是向线程中的RunLoop发送了消息,然后RunLoop接收到了消息就唤醒线程,去做对应的事情。所以想要正常使用这几个方法,响应selector的线程必须开启了RunLoop。惯例用例子来感受。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    //subThread用weak声明,用weak声明,用weak声明
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];
    
}
//子线程执行的内容
- (void)subThreadTodo
{
   NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);

   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];

   [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
   NSLog(@"%@----执行子线程任务结束",[NSThread currentThread]);
}
//我们希望放在子线程中执行的任务
- (void)wantTodo{
    //断点2
    NSLog(@"当前线程:%@执行任务处理数据", [NSThread currentThread]);
    
}
//屏幕点击事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //断点1
    //在子线程中去响应wantTodo方法
    [self performSelector:@selector(wantTodo) onThread:self.subThread withObject:nil waitUntilDone:NO];

运行之前我们先把Xcode左边侧栏中选到显示CPU、Memory使用情况那一页,把最下面的按钮中第一个按钮选中状态取消掉。(否则看不见RunLoop的堆栈信息。)

4.png

然后我们运行程序,先暂时不做任何操作。

<NSThread: 0x604000068c40>{number = 1, name = main}----开辟子线程
<MyThread: 0x60c000261a40>{number = 3, name = subThread}----开始执行子线程任务

子线程开启,RunLoop正常运行,似乎与刚才没有任何不同。然后我们点击屏幕任何一个地方。断点1触发,我们来查看左侧的堆栈。

5.png

前面提到过UIEvent事件属于source0,从这里的堆栈就可以得到印证。我们在主线程中触发了touchesBegan,然后主线程的RunLoop就开始响应source0事件源,然后去调用对应的方法。我们放过断点继续查看。

6.png

同样是前面提到的,performSelector也是source0依然可以从堆栈得到印证。放过断点1后调用了performSelector,然后subThread的RunLoop开始响应source0事件源,然后去调用对应的方法,所以来到了断点2。放过断点2查看结果,整个流程结束,打印日志如下。

<NSThread: 0x60c000074f40>{number = 1, name = main}----开辟子线程
<MyThread: 0x604000073f40>{number = 3, name = subThread}----开始执行子线程任务
当前线程:<MyThread: 0x604000073f40>{number = 3, name = subThread}执行任务处理数据
<MyThread: 0x604000073f40>{number = 3, name = subThread}----执行子线程任务结束
subThread线程被释放了

最后子线程任务结束然后被释放是因为之前提到的,runMode:(NSString *)mode beforeDate:(NSDate *)limitDate这种启动RunLoop的方式有一个特性,那就是这个接口在非Timer事件触发(此处是达成了这个条件)、显式的用CFRunLoopStop停止RunLoop或者到达limitDate后会退出。而例子当中也没有用while把RunLoop包围起来,所以RunLoop退出后子线程完成了任务最后退出了。
前面两种方法的使用大概就如同这个例子,大同小异。而第三种afterDelay的与前两种不同,并不是属于source0的,而是属于Timer源放在后面来讲。

看了刚才的堆栈信息可能会有疑问,标注出来的部分中,最长的那一串是什么,是干嘛的?为啥在执行发送给RunLoop的消息对应的事件之前,总要调用这么一长串?

其实RunLoop进行回调时,一般都是通过一个很长的函数(call out)调用出去(无论是Observer的状态通知还是Timer、Source的处理),而系统在回调时通常使用如下几个函数进行回调(换句话说你的代码其实最终都是通过下面几个函数来负责调用的)

 static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
 static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
 static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
 static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
 static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
 static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();

上面几个函数之所以那么长,我估计官方是想让我们观名知意,看名字就可以猜出作用。但是同样的,刚接触RunLoop的时候过多的接触这些深层次的东西反而会觉得找不到方向。我觉得入门还是先从表面一些的东西入手比较好。等有一些比较全面的了解以后想要深入理解了再来看这部分,现在有个印象知道这个概念就好。想要深入的话可以查看文末的文档或者RunLoop入门学习补充资料(4.RunLoop回调函数触发逻辑)。

3.让线程定时执行某任务(Timer)

说到timer估计大家都不陌生,日常开发中我们经常都会用到。可能很多人听说RunLoop还是在使用NSTimer的时候。NSTimer有如下几个创建方式:

 + (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
 + (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
 + (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;//iOS10以后新加的方法
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;//iOS10以后新加的方法

<1>初识NSTimer遇到的坑

刚接触Timer的时候,很多人(包括我)肯定都踩过一个坑,那就是创建了Timer却没有启动,百思不得其解,然后才知道还要把Timer加到一个叫RunLoop的东西里面才能正常运行。就像下面一样:

- (void)viewDidLoad {
    [super viewDidLoad];

    NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(wantTodo) userInfo:nil repeats:YES];
    //timerWith开头的方法创建的Timer如果不加下面一句无法运行。
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

当时我们可能不理解为什么,只知道必须要这么做才能正常启动Timer。但是现在我们可以知道原因了。其实NSTimer定时器的触发正是基于RunLoop运行的,所以使用NSTimer之前必须注册到RunLoop。同时我们也应该知道Timer并不是严格的按照设定的时间点来触发的,RunLoop为了节省资源并不会在非常准确的时间点调用定时器,如果一个任务执行时间较长,那么当错过一个时间点后只能等到下一个时间点执行,并不会延后执行。(NSTimer提供了一个tolerance属性用于设置宽容度,如果确实想要使用NSTimer并且希望尽可能的准确,则可以设置此属性)
注意:GCD的timer与NStimer不是一个东西。他俩中只有NSTimer是与RunLoop相关的。关于GCDTimer与NStimer对比放在了RunLoop入门学习补充资料(5.GCDTimer与NStimer对比)

但是凡事都有例外,似乎scheduedTimerWith开头的方法创建的NSTimer就不需要添加到RunLoop中就可以运行。事实上,这一系列方法的真实逻辑是,创建一个定时器并自动添加到当前线程RunLoop的NSDefaultRunLoopMode中。在声明一次,不添加到RunLoop中的NSTimer是无法正常工作的

<2>使用NSTimer遇到的坑

不管是跟着网上说的[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];后发现Timer能正常使用,还是自己摸索发现直接用scheduedTimerWith创建的Timer直接就生效。用Timer的时间长了总有一天突然发现,为啥我的Timer运行的好好的突然就时好时坏了。于是找半天原因,发现是在进行Scrollview的滚动操作时Timer不进行响应,滑动结束后timer又恢复正常了。发现现象了但是,为啥啊?抓半天头发然后网上搜资料,然后我们就发现又回到了RunLoop的Mode这个点上。以前我们不懂为什么,现在对RunLoop有一定了解了,我们不妨来分析一下以便加深理解。

1.在之前讲Mode的时候提到过,RunLoop每次只能运行在一个Mode下,其意义是让不同Mode中的item互不影响。

2.NSTimer是一个Timer源(item),在上面哪个例子中不管是`[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];`还是`scheduedTimerWith`我们都是把Timer加到了主线程RunLoop的NSDefaultRunLoopMode中。一般情况下主线程RunLoop就运行在NSDefaultRunLoopMode下,所以定时器正常运行。

3.当Scrollview开始滑动时,主线程RunLoop自动切换了当前运行的Mode(currentMode),变成了UITrackingRunLoopMode。所以现在RunLoop要处理的就是UITrackingRunLoopMode中item。

4.我们的timer是添加在NSDefaultRunLoopMode中的,并没有添加到UITrackingRunLoopMode中。即我们的timer不是UITrackingRunLoopMode中的item。

5.本着不同Mode中的item互不影响的原则,RunLoop也就不会处理非当前Mode的item,所以定时器就不会响应。

6.当Scrollview滑动结束,主线程RunLoop自动切换了当前运行的Mode(currentMode),变成了NSDefaultRunLoopMode。我们的Timer是NSDefaultRunLoopMode的item,所以RunLoop会处理它,所以又正常响应了。

7.如果想Timer在两种Mode中都得到响应怎么办?前面提到过,一个item可以被同时加入多个mode。让Timer同时成为两种Mode的item就可以了(分别添加或者直接加到commonMode中),这样不管RunLoop处于什么Mode,timer都是当前Mode的item,都会得到处理。

<3>NSTimer导致的ViewController无法释放问题

https://www.cnblogs.com/kenshincui/p/6823841.html 在这篇文档的NSTimer部分提到,用iOS10之前的(非Block的方法)方法创建NSTimer会因为设置target为self导致Timer对ViewController有一个强引用,最后结果就是ViewController无法释放。这一部分因为篇幅太长而且与RunLoop本身关系不是那么紧密所以不在这部分展开,感兴趣的可以看下。总的来说timer更推荐使用GCDTimer。(对比参考RunLoop入门学习补充资料5.GCDTimer与NStimer对比)

<4>performSelecter:afterDelay:

前面在讲performSelecter方法时提到过,这个方法与其他两种方法不同,不同在哪,我们来验证下。由于这个方法是作用于当前线程的,所以为了在RunLoop比较干净纯粹的子线程中响应这个方法会比较绕,不过一开始与之前的例子都没什么太大区别,区别在于wantTodo函数部分。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];

}
    
- (void)subThreadTodo
{
   NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);

   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];

   [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
   NSLog(@"%@----执行子线程任务结束",[NSThread currentThread]);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    [self performSelector:@selector(wantTodo) onThread:self.subThread withObject:nil waitUntilDone:NO];
    
}

- (void)wantTodo{
    //断点1
    //1.这个方法是作用于当前线程,现在在子线程中调用这个函数,所以会作用于子线程的RunLoop
    //self这个位置只要是继承自NSObject的对象都能填,但是他要有后面SEL的方法,否则会崩溃。(直白的说这个方法就是在当前线程中延迟调用某个对象的某个方法。)
    [self performSelector:@selector(afterDelayTodo) withObject:nil afterDelay:0];
    
}

- (void)afterDelayTodo{
    //断点2
    NSLog(@"当前线程:%@执行任务处理数据", [NSThread currentThread]);
}

跟之前perform的例子中一样的操作,到断点1的时候结果与之前也没什么不同,我们放过断点1,来到断点2.

7.png

可以看到,跟之前调用其他performSelecter时的DoSource0等等一系列堆栈不一样了,全部变成了Timer相关。实际上,当调用 performSelecter:afterDelay: 后,其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中,所以这个方法是属于Timer源的。关于这个方法有个比较经典的用法,很多文档都说过。

当tableview的cell上有需要从网络获取的图片的时候,异步线程会去加载图片,加载完成后主线程就会设置cell的图片,有可能会造成卡顿。可以让设置图片的任务在CFRunLoopDefaultMode下进行,当滚动tableView的时候,RunLoop是在 UITrackingRunLoopMode 下进行,不去设置图片,而是当停止的时候,再去设置图片。(这个场景的核心还是利用不同Mode的切换的思想,可以拓展其他地方)

[self.myImageView performSelector:@selector(setImage:)
                           withObject:[UIImage imageNamed:@""]
                           afterDelay:0
                              inModes:@[NSDefaultRunLoopMode]];

当然不是说这种方法就一定好,毕竟他在滑动的时候不会显示图片,万一你的需求跟这刚好相反呢,而且现在SDWebImage处理的已经很好了,已经很少有人用这种方法了。但是这个利用Mode切换的思想可以借鉴,万一其他地方用上就很合适呢。

4.监听Observer达到一些目的

这个就比现在我们讲的更进一阶,因为它涉及到的包括RunLoop的运行逻辑还有一些其他你想实现的功能的拓展。暂时我们只知道有这个用法就行,如果现在来钻研可能就迷失了。
目前知道的比较有名的有:
1.sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在界面空闲状态下计算出UITableViewCell的高度并进行缓存。
2.FaceBook的AsyncDisplayKit
共同之处在于,通过合理利用RunLoop机制,将很多不是必须在主线程中执行的操作放在子线程中实现,然后在合适的时机同步到主线程中,这样可以节省在主线程执行操作的时间,避免卡顿。

关于RunLoop的释放问题(RunLoop带autoreleasepool的正确写法)

<1>分析

Timer和Source以及一些回调block等等,都需要占用一部分存储空间,所以要释放掉,如果不释放掉,就会一直积累,占用的内存也就越来越大。
在主线程中
1.当RunLoop开启时,会自动创建一个自动释放池。
2.当RunLoop在休息之前会释放掉自动释放池的东西。
3.然后重新创建一个新的空的自动释放池。
4.当RunLoop被唤醒重新开始跑圈时,Timer,Source等新的事件就会放到新的自动释放池中。
5.重复2-4。

所以主线程中,有关RunLoop的释放问题不需要我们关心。

注意:这里说的是主线程(关于子线程的autoreleasepool是否需要手动创建还有个研究过程,因为网上众说纷纭,有的说不需要创建有的说需要。)这部分的资料也比较少,总结了有限的资料加上自己的一些理解我认为RunLoop正确的写法应该是下面这样的,如果不对希望指正。

1.Sunnyxx 孙源大神在《黑幕背后的Autorelease》中提到"在没有手加Autorelease Pool的情况下,Autorelease对象是在当前的runloop迭代结束时释放的,而它能够释放的原因是系统在每个runloop迭代中都加入了自动释放池Push和Pop(这里说的是主线程)"加上之前提到的"1.当RunLoop开启时,会自动创建一个自动释放池。"我们可以得到一个结论:系统自动管理的autoreleasepool,或者说系统自己管理的Autorelease对象的自动释放的实现依赖于RunLoop

2.只有主线程的RunLoop是自动开启了的,子线程中,RunLoop是需要我们手动获取(或者说手动激活)的,就更不可能自动创建了autoreleasepool(自动管理对象的释放)。所以子线程的autoreleasepool需要我们手动创建。(这一点可以参考《iOS中autoreleasepool的理解和使用》中对苹果文档的翻译:"你生成了一个辅助线程。
一旦线程开始执行你必须自己创建自动释放池。否则,应用将泄漏对象。"
)

3.结论:NSThread和NSOperationQueue开辟子线程需要手动创建autoreleasepool。GCD开辟子线程不需要手动创建autoreleasepool,因为GCD的每个队列都会自行创建autoreleasepool。(参考自:《关于iOS子线程上的autorelease对象释放问题?》

所以得出以下两个写法

<2>需要用while循环控制的RunLoop

    @autoreleasepool {
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        while (!self.isNeedStopRunLoop) {
            
            //这里RunLoop不需要添加autoreleasepool
            //每个RunLoop内部都会自动管理autoreleasepool
            //事件源等一些autorelease对象会在RunLoop的迭代中自动释放。

                [runLoop runMode:NSDefaultRunLoopMode
                  beforeDate:[NSDate distantFuture]];
            //[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];

        }
        
    }

<3>不需要用while循环控制的RunLoop

    @autoreleasepool {
    
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
        //[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
        
    }

<4>关于主线程中autoreleasepool的题外话

主线程中一般不需要我们手动添加autoreleasepool,但是,如果你希望某个对象或者变量尽快释放的时候我们也可以手动添加。比如下面这种情况:

1.很长的循环(while 同理)
    int lagerNum = 1024 * 1024 * 2 ;
    for(int i = 0 ; i < lagerNum; i++)
    {
        NSString *str = [NSString stringWithFormat:@"Hello"];
        str = [str uppercaseString];
        str = [NSString stringByAppendingFormat:@"-%@",@"World!"];
    }

"在没有手加Autorelease Pool的情况下,Autorelease对象是在当前的runloop迭代结束时释放的,而它能够释放的原因是系统在每个runloop迭代中都加入了自动释放池Push和Pop。按照我们以前的想法,每次循环的大括号结束后,当前的变量就会释放。可是现在我们了解的自动释放的相关知识后就知道其实要等到一次主线程的RunLoop迭代以后才会一起释放这些变量。而如果还来不及等到RunLoop迭代结束去释放变量,这期间就积累了足够多的变量,就会导致内存警告或者崩溃。所以我们可以手动添加autoreleasepool,让对象尽快释放。

1.改
    int lagerNum = 1024 * 1024 * 2 ;
    for(int i = 0 ; i < lagerNum; i++)
    {
      @autoreleasepool{
           NSString *str = [NSString stringWithFormat:@"Hello"];
           str = [str uppercaseString];
           str = [NSString stringByAppendingFormat:@"-%@",@"World!"];
       }
    }

关于子线程RunLoop切换Mode的思考

学习RunLoop查看文档的过程中,很多地方都会讲到RunLoop的Mode切换,就像之前提到的主线程RunLoop中defaultMode和UITrackingRunLoopMode的切换。同时关于这一部分说的最多的也是"同一时间只能运行一个Mode,如果想切换Mode需要退出当前Mode重新设定运行Mode"。

但是,关于如何切换,我没有找到文档有提及(也许是我看的文档还是太少,如果知道有相关文档的大大麻烦给个链接),就像RunLoop的Mode切换是自动进行的一样。事实上主线程的RunLoop的Mode切换确实是自动的,不需要我们来管理的。那么子线程中呢?或许对大神们来说这个问题不是问题,但是作为一个小白我还是想搞清楚这个问题。虽然暂时不清楚是不是子线程中根本不存在Mode切换的问题还是不存在这种用法,但是研究一下总是好的,万一哪天用上了呢。或者这是不可行的,以后别人提起你可以明确的说不行。

本文最大干货,子线程中模拟主线程DefaultMode与TrackingMode的切换,长代码预警

@interface ViewController ()<UITextViewDelegate>

@property (nonatomic, weak)NSThread *subThread;//子线程

@property (nonatomic, weak)NSRunLoopMode runLoopMode;//想设置的RunLoop的Mode

@property (nonatomic, assign)BOOL isNeedRunLoopStop;//控制是否需要停止RunLoop

@property (weak, nonatomic) IBOutlet UITextView *myTextView;//只要是Scrollview及其子类都行

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.myTextView.delegate = self;
    
    self.isNeedRunLoopStop = NO;
    
    NSLog(@"%@----开辟子线程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];
    
}
    
- (void)subThreadTodo
{
   NSLog(@"%@----开始执行子线程任务",[NSThread currentThread]);

    @autoreleasepool{
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        
        //NSDefaultRunLoopMode下暂时什么都不干,只是为了让RunLoop能在该模式下运行添加了一个source1
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        
        NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTodo) userInfo:nil repeats:YES];
        
        //给UITrackingRunLoopMode添加一个timer,为了等下切换到该模式的时候能看到效果。
        //提示:子线程RunLoop如果不给UITrackingRunLoopMode添加item就没有这个Mode,可以看前面初体验疑问的截图。
        //但是,NSDefaultRunLoopMode是无论如何都存在的,就算你不给他添加item,他也只是内容为空而已。
        [runLoop addTimer:timer forMode:UITrackingRunLoopMode];
        
        self.runLoopMode = NSDefaultRunLoopMode;
        
        //这一句先保持注释状态,之后再根据下面文章的提示取消注释看效果。
        //CFRunLoopAddCommonMode(CFRunLoopGetCurrent(), (CFStringRef)UITrackingRunLoopMode);
        
        while (!self.isNeedRunLoopStop) {//用while来控制RunLoop的运行与否

            //让RunLoop在我们希望的Mode下运行
            [runLoop runMode:self.runLoopMode beforeDate:[NSDate distantFuture]];
            
        }
        
    }
    
    
}


- (void)changeSubThreadRunLoopMode:(NSRunLoopMode)mode{

    //改变我们希望RunLoop运行的Mode的方法
    //到时候用[performSelector:onThread:withObject:waitUntilDone:]来调用
    //结合[runMode:beforeDate:]触发非Timer的事件源会退出RunLoop的特性
    //再结合上面While的写法,就退出了之前的RunLoop并让RunLoop以我们希望的Mode重新Run。

    //断点3
    NSLog(@"当前线程:%@ RunLoop即将将Mode改变成:%@\n", [NSThread currentThread], mode);
    
    self.runLoopMode = mode;

}

- (void)timerTodo{
    //上面的Timer执行的函数,只是为了等下切换的mode后有打印好观察。
    NSLog(@"Timer启动啦,当前RunLoopMode:%@\n", [[NSRunLoop currentRunLoop] currentMode]);

}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    if (self.runLoopMode != UITrackingRunLoopMode) {
        //如果有滑动事件,并且RunLoop的Mode不为UITrackingRunLoopMode
        //就改变Mode并退出当前RunLoop然后让RunLoop以更改后的Mode重新Run
        //加if是为了避免重复操作,切换RunLoopMode只需要一次
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:UITrackingRunLoopMode waitUntilDone:NO];
    }
    
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    //拖拽结束会调用这个方法,如果还有拖拽后的滑动动画就不做操作
    if (!decelerate) {
        //如果没有后续动画了就切换Mode为NSDefaultRunLoopMode
        if (self.runLoopMode != NSDefaultRunLoopMode) {
            //断点1
            [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO];
        }
    }

}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    
    //拖拽后的后续滑动动画结束(如果有才会到这,没有就不会到这个函数里面)
    //也就是说上面那个函数如果切换了Mode就不会走这里,否则说明需要在这里切换Mode
    //切换Mode为NSDefaultRunLoopMode
    if (self.runLoopMode != NSDefaultRunLoopMode) {
        //断点2
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO];
    }

}

@end

好了代码看完了,激动人心的时刻到了,我们来看效果。

<NSThread: 0x600000065bc0>{number = 1, name = main}----开辟子线程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----开始执行子线程任务

程序启动以后,打印如上,没什么好说的,一切正常,接下来才是重点。来让我们拖拽textView。

<NSThread: 0x600000065bc0>{number = 1, name = main}----开辟子线程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----开始执行子线程任务
当前线程:<MyThread: 0x60c00007ec80>{number = 3, name = subThread} RunLoop即将将Mode改变成:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode

成功了!!!依照我们所想的Mode切换成了UITrackingRunLoopMode。好就差最后一步了,我们松手看看是什么情况。

<NSThread: 0x600000065bc0>{number = 1, name = main}----开辟子线程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----开始执行子线程任务
当前线程:<MyThread: 0x60c00007ec80>{number = 3, name = subThread} RunLoop即将将Mode改变成:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode

可以看到即使我们松手,Timer依旧在执行,说明Mode切换失败了。可是我们的逻辑是对的啊,然后我多种查找原因。最后发现,切换成UITrackingRunLoopMode后我们的执行逻辑是没错的,都是可以进断点1和断点2的地方。但是,虽然执行到了断点1和断点2,却不会走到断点3的位置去。说明performSelector的方法根本没被执行,所以切换Mode失败了。

折腾半天,我曾一度以为子线程中切换Mode是不现实的,因为刚好在这个时候看到一个文档说UITrackingRunLoopMode在子线程RunLoop中无效。我以为UITrackingRunLoopMode下不会响应performSelector方法。但是转念一想,主线程RunLoop又是怎么样的呢?于是我做了一个小测试。

对上面的代码稍作改动,看看主线程在UITrackingRunLoopMode下会不会响应performSelector方法。

- (void)changeSubThreadRunLoopMode:(NSRunLoopMode)mode{

    NSLog(@"当前线程:%@ RunLoop即将将Mode改变成:%@\n", [NSThread currentThread], mode);

    NSLog(@"%@", [NSRunLoop currentRunLoop]);
    
}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    if (self.runLoopMode != UITrackingRunLoopMode) {
        //改变成UITrackingRunLoopMode后打印一次日志
        [self performSelectorOnMainThread:@selector(changeSubThreadRunLoopMode:) withObject:UITrackingRunLoopMode waitUntilDone:NO];
    }
    
}

打印结果很长我只截图了比较关键的部分并标注。

8.jpeg
9.png
10.png
11.png
12.png

从日志搜索结果和与响应perform方法之前的日志对比(对比请自行进行,太长了)综合来看,图中标识的perform事件源就是我们在UITrackingRunLoopMode下调用的那个perform方法。事实证明UITrackingRunLoopMode下也是可以响应perform方法,我们之前的实验失败并不是UITrackingRunLoopMode的锅

那么是怎么回事呢?原因就出在第一张 截图的第二块标识部分。主线程的NSRunLoopCommonModes默认是包含UITrackingRunLoopModeNSDefaultRunLoopMode子线程的NSRunLoopCommonModes默认是只包含,只包含,只包含,重要的事情说三次,只包含NSDefaultRunLoopMode的。而从后面截图标识部分来看,performSelector:onThread:withObject:waitUntilDone:,其实就是向RunLoop中所有标识为CommonMode的Mode添加一个source0。

这也就是为什么主线程在UITrackingRunLoopMode下可以响应perform方法而子线程却不行。因为这个source0在子线程中根本没有被添加到UITrackingRunLoopMode下,也就不会做出对应的响应。

知道为什么就简单了,我们只需在子线程中把UITrackingRunLoopMode标识为CommonMode就可以了。前面提到过只有CFRunLoop有相关方法可以添加CommonMode,那么我们只需要添加下面这一行代码就行了(也就是把刚才例子中注释的部分取消注释)。

CFRunLoopAddCommonMode(CFRunLoopGetCurrent(), (CFStringRef)UITrackingRunLoopMode);

接下来我们再看看效果

<NSThread: 0x604000065500>{number = 1, name = main}----开辟子线程
<MyThread: 0x60400026e600>{number = 3, name = subThread}----开始执行子线程任务
当前线程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即将将Mode改变成:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
当前线程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即将将Mode改变成:kCFRunLoopDefaultMode
当前线程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即将将Mode改变成:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
Timer启动啦,当前RunLoopMode:UITrackingRunLoopMode
当前线程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即将将Mode改变成:kCFRunLoopDefaultMode

最后就实现了类似主线程的mode切换效果。拖拽滑动期间是UITrackingRunLoopMode,会触发timer的内容打印日志。
滑动停止以后就切换成了kCFRunLoopDefaultMode,不在打印timer的日志。

到这一步mode切换的实验就结束了,虽然我仍然没想到啥时候会用上,但至少我们知道了子线程中的Mode切换是可行的,并且搞清楚了与RunLoop相关的那几个perform方法其实是向所有注册了CommonMode的Mode添加source0而不是向某一个特定Mode添加。如果我这个实验有错误请大大们指出。

更正:

performSelectorOnMainThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:

这两个方法,是向所有注册了CommonMode的Mode添加source0

performSelectorOnMainThread:withObject:waitUntilDone:modes:
performSelector:onThread:withObject:waitUntilDone:modes:
performSelector:withObject:afterDelay:inModes:

这三个方法是给特定modes(参数是包含至少一个mode的数组)添加source0(前两个)或者timer(afterDelay)。

performSelector:withObject:afterDelay:

这个方法是向NSDefaultRunLoopMode添加timer源

更正过后,我们就有两种方式实现这个子线程切换mode的实验。

1.跟之前写的实验逻辑一样不需要改变。

2.不使用CFRunLoopAddCommonMode添加UITrackingRunLoopMode,而是做出如下改动。

//因为在这个实验中触发下面两个代理的时候子线程RunLoop是处于UITrackingRunLoopMode
//而子线程CommonMode默认只有NSDefaultRunLoopMode
//所以调用performSelector:onThread:withObject:waitUntilDone:时,在UITrackingRunLoopMode下无法响应(理由可以看实验的分析过程)
//调用performSelector:onThread:withObject:waitUntilDone:modes:指定响应的mode为UITrackingRunLoopMode即可解决问题
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    //拖拽结束会调用这个方法,如果还有拖拽后的滑动动画就不做操作
    if (!decelerate) {
        //如果没有后续动画了就切换Mode为NSDefaultRunLoopMode
        if (self.runLoopMode != NSDefaultRunLoopMode) {
            //断点1
            [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO modes:@[UITrackingRunLoopMode]];
        }
    }

}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    
    //拖拽后的后续滑动动画结束(如果有才会到这,没有就不会到这个函数里面)
    //也就是说上面那个函数如果切换了Mode就不会走这里,否则说明需要在这里切换Mode
    //切换Mode为NSDefaultRunLoopMode
    if (self.runLoopMode != NSDefaultRunLoopMode) {
        //断点2
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO modes:@[UITrackingRunLoopMode]];
    }

}

参考文档

<1>RunLoop

1.iOS刨根问底-深入理解RunLoop Kenshin Cui大神的文章 讲的很全但是概念也挺多的,一开始可能会懵但是多看几次会有不少收获。里面关于NSTimer部分可以着重看下,里面提到了ViewController无法释放的问题
2.深入理解RunLoop里面关于Mode和实现功能部分讲的很细
3.iOS线下分享《RunLoop》 by 孙源 sunnyxx 也是个大神,但是不太推荐作为入门直接去看,视频不管是反复看某一段还是理解都有不便,适合有一定自己理解或者了解了去做个印证或者补充。
4.iOS-RunLoop充满灵性的死循环相对比较适合新手看一些,里面也有不少例子,也有关于GCDTimer的例子
5.【iOS程序启动与运转】- RunLoop个人小结不光讲了RunLoop还讲了像是App启动的详细过程这种扩展。
6.RunLoop 总结:RunLoop的应用场景(一)里面也提到了很多很好的文档,也有大量例子讲解。
7.NSRunLoop原理详解——不再有盲点讲的也很不错,适合入门看,里面讲到了RunLoop的嵌套
8.iOS开发 底层抛析运行循环—— RunLoop
9.iOS中RunLoop机制的探索
10.RunLoop 原理和核心机制同样很不错,没有过多概念,多数是例子和自己的理解混合着将。第一次在RunLoop的文章看到@autoreleasepool的具体代码也是在这篇文章。
11.[iOS]浅谈NSRunloop工作原理和相关应用
12.学习 RunLoop (二)主要是一些关于RunLoop的实例。里面关于自动释放池释放的时间和RunLoop的关系这一部分讲的挺详细的。里面讲到这里的自动释放池指的是主线程的自动释放池,我们看不见它的创建和销毁(后面就讲了系统自己创建的自动释放池与RunLoop的关系)。自己手动创建@autoreleasepool {}是根据代码块来的,出了这个代码块就释放了。
13.避免使用 GCD Global队列创建Runloop常驻线程这一篇暂时还没研究,应该也是需要注意的,留个存档以后研究。

<2>autoreleasepool

1.自动释放池什么时候创建,什么时候销毁?
2.iOS中autoreleasepool的理解和使用
3.黑幕背后的Autorelease sunnyxx大神的,讲了底层原理。
4.关于iOS子线程上的autorelease对象释放问题?

还有一些其他已经找不到的记录的参考文档无法贴出,在这里向作者们致以诚挚的歉意,因为文中可能会有引用但是没有说出处。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 1 Runloop机制原理 深入理解RunLoop http://www.cocoachina.com/ios/2...
    Kevin_Junbaozi阅读 3,978评论 4 30
  • Runloop 是和线程紧密相关的一个基础组件,是很多线程有关功能的幕后功臣。尽管在平常使用中几乎不太会直接用到,...
    jackyshan阅读 9,841评论 10 75
  • iOS刨根问底-深入理解RunLoop 概述 RunLoop作为iOS中一个基础组件和线程有着千丝万缕的关系,同时...
    reallychao阅读 820评论 0 6
  • 最近看了很多RunLoop的文章,看完很懵逼,决心整理一下,文章中大部分内容都是引用大神们的,但好歹对自己有个交代...
    小凉介阅读 6,672评论 12 79
  • runtime 和 runloop 作为一个程序员进阶是必须的,也是非常重要的, 在面试过程中是经常会被问到的, ...
    SOI阅读 21,776评论 3 63