走进Run Loop的世界 (一):什么是Run Loop?

转自:http://chun.tips/blog/2014/10/20/zou-jin-run-loopde-shi-jie-%5B%3F%5D-:shi-yao-shi-run-loop%3F/ 

在刚刚接触iOS开发的时候,我们在Xcode的帮助下生成了第一个工程。工程里会包含一个main.m的文件,默认的代码大致如下:

int main(int argc, char * argv[]) {
     @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([YourAppDelegate class]));
    }
}

当我们的程序启动时,以上代码会被调用,主线程也随之开始运行。Run Loop做为主线程的一部分也同时被启动。

UIApplicationMain()方法在这里不仅完成了初始化我们的程序并设置程序Delegate的任务,而且随之开启了主线程的Run Loop, 开始接受处理事件。

Run Loop本质和它的意思一样是运行着的循环,更确切的说是线程中的循环。它用来接受循环中的事件和安排线程工作,并在没有工作时,让线程进入睡眠状态。

下图展示了Run Loop的模型,图片来源于:https://developer.apple.com

走进Run Loop的世界 (一):什么是Run Loop?_第1张图片

从图中可以看出,Run Loop是线程中的一个循环,并对接收到的事件进行处理。我们的代码可以通过提供while或者for循环来驱动Run Loop。在循环中,Run Loop对象来负责事件处理代码(接收事件并且调用事件处理方法)。

Run Loop从两个不同的事件源中接收消息:

  • Input source用来投递异步消息,通常消息来自另外的线程或者程序。在接收到消息并调用程序指定方法时,线程中对应的NSRunLoop对象会通过执行runUntilDate:方法来退出。
  • Timer source用来投递timer事件(Schedule或者Repeat)中的同步消息。在处理消息时,并不会退出Run Loop。
  • Run Loop还有一个观察者Observer的概念,可以往Run Loop中加入自己的观察者以便监控Run Loop的运行过程。(详细请继续阅读)

Run Loop Modes

Run Loop Mode可以理解为一个集合中包括所有要监视的事件源和要通知的Run Loop中注册的观察者。每一次运行自己的Run Loop时,都需要显示或者隐示的指定其运行于哪一种Mode。在设置Run Loop Mode后,你的Run Loop会自动过滤和其他Mode相关的事件源,而只监视和当前设置Mode相关的源(通知相关的观察者)。大多数时候,Run Loop都是运行在系统定义的默认模式上。

Model Panel可以运行在 Model 模式下。(OS X开发中遇到)

Run Loop Mode 区分基于事件的源,而不是事件的种类。比如你不能通过Run Loop Mode去只选择鼠标点击事件或者键盘输入事件。你可以使用Run Loop Mode去监听端口,暂停计时器或者改变其他源。

Cocoa和Core Foundataion为我们定义了默认的和常用的Mode。Run Loop Mode的名称可以使用字符串来标识,我们也可以使用字符串指定一个Mode名称来自定义Mode。

我们可以给Mode指定任意名称,但是它对应的集合内容不能是任意的。我们需要添加Input source, Timer source 或者 Observer到自己自定义的Mode。

下面列出iOS下一些已经定义的Run Loop Modes:

1) NSDefaultRunLoopMode: 大多数工作中默认的运行方式。
2) NSConnectionReplyMode: 使用这个Mode去监听NSConnection对象的状态,我们很少需要自己使用这个Mode。
3) NSModalPanelRunLoopMode: 使用这个Mode在Model Panel情况下去区分事件(OS X开发中会遇到)。
4) UITrackingRunLoopMode: 使用这个Mode去跟踪来自用户交互的事件(比如UITableView上下滑动)。
5) GSEventReceiveRunLoopMode: 用来接受系统事件,内部的Run Loop Mode。
6) NSRunLoopCommonModes: 这是一个伪模式,其为一组run loop mode的集合。如果将Input source加入此模式,意味着关联Input source到Common Modes中包含的所有模式下。在iOS系统中NSRunLoopCommonMode包含NSDefaultRunLoopMode、NSTaskDeathCheckMode、UITrackingRunLoopMode.可使用CFRunLoopAddCommonMode方法向Common Modes中添加自定义mode。

Run Loop运行时只能以一种固定的Mode运行,只会监控这个Mode下添加的Timer source和Input source。如果这个Mode下没有添加事件源,Run Loop会立刻返回。

Run Loop不能在运行在NSRunLoopCommonModes,因为NSRunLoopCommonModes是个Mode集合,而不是一个具体的Mode。我们可以在添加事件源的时候使用NSRunLoopCommonModes,只要Run Loop运行在NSRunLoopCommonModes中任何一个Mode,这个事件源都可以被触发。

事件源

Input source有两个不同的种类: Port-Based Sources 和 Custom Input Sources。Run Loop本身并不关心Input source是哪一种类型。系统会实现两种不同的Input source供我们使用。这两种不同类型的Input source的区别在于:Port-Based Sources由内核自动发送,Custom Input Sources需要从其他线程手动发送。

走进Run Loop世界系列的第二章会专门讨论如何自定义事件源。

Custom Input Sources

我们可以使用Core Foundation里面的CFRunLoopSourceRef类型相关的函数来创建custom input source。

Port-Based Sources

通过内置的端口相关的对象和函数,配置基于端口的Input source。 (比如在主线程创建子线程时传入一个NSPort对象,主线程和子线程就可以进行通讯。NSPort对象会负责自己创建和配置Input source。)

Cocoa Perform Selector Sources

Cocoa框架为我们定义了一些Custom Input Sources,允许我们在线程中执行一系列selector方法。

//在主线程的Run Loop下执行指定的 @selector 方法
performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:

//在当前线程的Run Loop下执行指定的 @selector 方法
performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:

//在当前线程的Run Loop下延迟加载指定的 @selector 方法
performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:

//取消当前线程的调用
cancelPreviousPerformRequestsWithTarget:
cancelPreviousPerformRequestsWithTarget:selector:object:

和Port-Based Sources一样,这些selector的请求会在目标线程中序列化,以减缓线程中多个方法执行带来的同步问题。

和Port-Based Sources不一样的是,一个selector方法执行完之后会自动从当前Run Loop中移除。

Timer Sources

Timer source在预设的时间点同步的传递消息。Timer是线程通知自己做某件事的一种方式。

Foundation中 NSTimer Class提供了相关方法来设置Timer source。需要注意的是除了scheduledTimerWithTimeInterval开头的方法创建的Timer都需要手动添加到当前Run Loop中。(scheduledTimerWithTimeInterval 创建的timer会自动以Default Mode加载到当前Run Loop中。)

Timer在选择使用一次后,在执行完成时,会从Run Loop中移除。选择循环时,会一直保存在当前Run Loop中,直到调用invalidated方法。

Run Loop Observers

事件源是同步或者异步的事件驱动时触发,而Run Loop Observer则在Run Loop本身进入某个状态时得到通知:

  • Run Loop 进入的时候
  • Run Loop 处理一个Timer的时候
  • Run Loop 处理一个Input Source的时候
  • Run Loop 进入睡眠的时候
  • Run Loop 被唤醒的时候,在唤醒它的事件被处理之前
  • Run Loop 停止的时候

Observer需要使用Core Foundataion框架。和Timer一样,Run Loop Observers也可以使用一次或者选择repeat。如果只使用一次,Observer会在它被执行后自己从Run Loop中移除。而循环的Observer会一直保存在Run Loop中。

Run Loop 事件队列

Run Loop本质是一个处理事件源的循环。我们对Run Loop的运行时具有控制权,如果当前没有时间发生,Run Loop会让当前线程进入睡眠模式,来减轻CPU压力。如果有事件发生,Run Loop就处理事件并通知相关的Observer。具体的顺序如下:

  • 1) Run Loop进入的时候,会通知Observer
  • 2) Timer即将被触发时,会通知Observer
  • 3) 有其它非Port-Based Input Source即将被触发时,会通知Observer
  • 4) 启动非Port-Based Input Source的事件源
  • 5) 如果基于Port的Input Source事件源即将触发时,立即处理该事件,并跳转到9
  • 6) 通知Observer当前线程进入睡眠状态
  • 7) 将线程置入睡眠状态直到有以下事件发生:1. Port-Based Input Source被触发。2.Timer被触发。 3.Run Loop设置的时间已经超时。 4.Run Loop被显示唤醒。
  • 8) 通知Observer线程将要被唤醒
  • 9) 处理被触发的事件:1. 如果是用户自定义的Timer,处理Timer事件后重启Run Loop并直接进入步骤2。 2.如果线程被显示唤醒又没有超时,那么进入步骤2。 3.如果是其他Input Source事件源有事件发生,直接传递这个消息。
  • 10) 通知Observer Run Loop结束,Run Loop退出。

基于非Timer的Input source事件被处理后,Run Loop在将要退出时发送通知。基于Timer source处理事件后不会退出Run Loop。

何时使用 Run Loop

我们应该只在创建辅助线程的时候,才显示的运行一个Run Loop。对于辅助线程,我们仍然需要判断是否需要启动Run Loop。比如我们使用一个线程去处理一个预先定义的长时间的任务,我们应当避免启动Run Loop。下面是官方Document提供的使用Run Loop的几个场景:

  • 需要使用Port-Based Input Source或者Custom Input Source和其他线程通讯时
  • 需要在线程中使用Timer
  • 需要在线程中使用上文中提到的selector相关方法
  • 需要让线程执行周期性的工作

Run Loop 对象的线程安全问题

使用Core Foundation中的方法通常是线程安全的,可以被任意线程调用。如果修改了Run Loop的配置然后需要执行某些操作,我们最好是在Run Loop所在的线程中执行这些操作。

使用Foundation中的NSRunLoop类来修改自己的Run Loop,我们必须在Run Loop的所在线程中完成这些操作。在其他线程中给Run Loop添加事件源或者Timer会导致程序崩溃。

Show Time

如何在线程中使用Run Loop

示例代码可以在GitHub上查看:https://github.com/yechunjun/RunLoopDemo

1.使用NSRunLoop的currentRunLoop可以获得当前线程的Run Loop对象。(或者 CFRunLoopGetCurrent)

NSRunLoop *currentThreadRunLoop = [NSRunLoop currentRunLoop];
// 或者
CFRunLoopRef currentThreadRunLoop = CFRunLoopGetCurrent();

2.在配置Run Loop之前,我们必须添加一个事件源或者Timer source给它。如果Run Loop没有任何源需要监视的话,会立刻退出。同样的我们可以给Run Loop注册Observer。

- (void)main
{
    @autoreleasepool {
        NSLog(@"Thread Enter");

        NSRunLoop *currentThreadRunLoop = [NSRunLoop currentRunLoop];

        // 创建一个 Run Loop Observer,并添加到当前Run Loop中, 设置Mode为Default
        CFRunLoopObserverContext context = {0, (__bridge void *)(self), NULL, NULL, NULL};
        CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, ¤tRunLoopObserver, &context);

        if (observer) {
            CFRunLoopRef runLoopRef = currentThreadRunLoop.getCFRunLoop;
            CFRunLoopAddObserver(runLoopRef, observer, kCFRunLoopDefaultMode);
        }

        // 创建一个Timer,重复调用来驱动Run Loop
        [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(handleTimerTask) userInfo:nil repeats:YES];

        NSInteger loopCount = 10;

        // 执行Run Loop10次后退出,每次Run Loop返回的时候检查是否有使线程退出的条件成立
        do {
            NSLog(@"LoopCount: %ld", loopCount);
            [currentThreadRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
            loopCount --;
        } while (loopCount);

        NSLog(@"Thread Exit");
    }
}

上述代码中,我们可以通过currentRunLoopObserver的回调函数查看当前Run Loop的状态。

我们一般可以通过这几张方式启动Run Loop:

  • 无条件的 : 不推荐使用,这种方式启动Run Loop会让一个线程处于永久的循环中。退出Run Loop的唯一方式就是显示的去杀死它。  - (void)run;
  • 设置超时时间  - (void)runUntilDate:(NSDate *)limitDate;
  • 特定的Mode  - (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;

退出Run Loop一般如下:

  • 设置超时时间:推荐使用
  • 通知Run Loop停止:使用CFRunLoopStop来显式的停止Run loop。无条件启动的Run Loop中调用这个方法退出Run Loop。

尽管移除Run Loop的Input source和Timer也可能导致Run loop退出,但这并不是可靠的退出Run loop的方法。一些状态下系统会添加Input source到Run loop里面来处理所需事件。由于我们的代码未必会考虑到这些Input source,这样可能导致无法移除这些事件源,从而导致Run loop不能正常退出。

为当前长时间运行的线程配置Run Loop的时候,最好添加至少一个Input source到Run Loop以接收消息。虽然我们可以使用Timer来进入Run Loop,但是一旦Timer触发后,它通常就变为无效了,这会导致Run Loop退出。虽然附加一个循环的Timer可以让Run Loop运行一个相对较长的周期,但是这也会导致周期性的唤醒线程,这实际上是轮询(polling)的另一种形式而已。与之相反,Input source会一直等待某事件发生,在事件发生前它会让线程处于休眠状态。

如何正确使用Run Loop阻塞线程

在这里,我们做了一个测试。一个是产生一个普通的Thread执行任务,线程完成之后再继续运行。另一个是启动一个线程执行任务,并且使用Run Loop,等待线程完成之后再继续执行任务。

// Normal Thread Task
- (void)handleNormalThreadButtonTouchUpInside
{
    NSLog(@"Enter handleNormalThreadButtonTouchUpInside");

    self.normalThreadDidFinishFlag = NO;

    NSThread *normalThread = [[NSThread alloc] initWithTarget:self selector:@selector(handleNormalThreadTask) object:nil];
    [normalThread start];

    while (!self.normalThreadDidFinishFlag) {
        [NSThread sleepForTimeInterval:0.5];
    }

    NSLog(@"Exit handleNormalThreadButtonTouchUpInside");
}

- (void)handleNormalThreadTask
{
    NSLog(@"Enter Normal Thread");

    for (NSInteger i = 0; i < 5; i ++) {
        NSLog(@"In Normal Thread, count = %ld", i);
        sleep(1);
    }

    _normalThreadDidFinishFlag = YES;

    NSLog(@"Exit Normal Thread");
}

在Normal Thread中,在执行过程中UI线程被阻塞,直到线程执行完成时,才能够正常响应UI线程事件。

// Thread with Run Loop
- (void)handleRunLoopThreadButtonTouchUpInside
{
    NSLog(@"Enter handleRunLoopThreadButtonTouchUpInside");

    self.runLoopThreadDidFinishFlag = NO;

    NSThread *runLoopThread = [[NSThread alloc] initWithTarget:self selector:@selector(handleRunLoopThreadTask) object:nil];
    [runLoopThread start];

    while (!self.runLoopThreadDidFinishFlag) {
        NSLog(@"Begin RunLoop");

        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];

        NSLog(@"End RunLoop");
    }

    NSLog(@"Exit handleRunLoopThreadButtonTouchUpInside");
}

- (void)handleRunLoopThreadTask
{
    NSLog(@"Enter Run Loop Thread");

    for (NSInteger i = 0; i < 5; i ++) {
        NSLog(@"In Run Loop Thread, count = %ld", i);
        sleep(1);
    }

#if 0
    // 错误示范
    _runLoopThreadDidFinishFlag = YES;
    // 这个时候并不能执行线程完成之后的任务,因为Run Loop所在的线程并不知道runLoopThreadDidFinishFlag被重新赋值。Run Loop这个时候没有被任务事件源唤醒。
    // 你会发现这个时候点击屏幕中的UI,线程将会继续执行。 因为Run Loop被UI事件唤醒。
    // 正确的做法是使用 "selector"方法唤醒Run Loop。 即如下:
#else
    [self performSelectorOnMainThread:@selector(updateRunLoopThreadDidFinishFlag) withObject:nil waitUntilDone:NO];
#endif

    NSLog(@"Exit Run Loop Thread");
}

- (void)updateRunLoopThreadDidFinishFlag
{
    self.runLoopThreadDidFinishFlag = YES;
}

在Run Loop被启动之后,Thread执行任务时,UI线程能够正常接收到事件,不会被阻塞。Thread完成任务之后,继续执行下面的任务。

注意代码中的错误示范,正确的实现方式,需要在当前线程中使用上文提到的selector系列方法激活目标线程的Run Loop来响应事件。

Run Loop开发中遇到的问题

1) NSTimer, NSURLConnection和NSStream默认运行在Default Mode下,UIScrollView在接收到用户交互事件时,主线程Run Loop会设置为UITrackingRunLoopMode下,这个时候NSTimer不能fire,NSURLConnection的数据也无法处理。

// 在UITableViewController中启动一个NSTimer,每隔0.5秒刷新Label上的text,刷新100次后暂停。

@interface TestTableViewController ()

@property (nonatomic, strong) UILabel *testLabel;
@property (nonatomic) NSInteger count;
@property (nonatomic, strong) NSTimer *testTimer;

@end

@implementation TestTableViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.count = 0;

    self.testLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 100, 100, 50)];
    [self.view addSubview:self.testLabel];

    self.testTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self        selector:@selector(updateTestLabel) userInfo:nil repeats:YES];
}

- (void)updateTestLabel
{
    self.count ++;
    self.testLabel.text = [NSString stringWithFormat:@"%ld", self.count];
    if (self.count == 100) {
       [self.testTimer invalidate];
       self.testTimer = nil;
    }
}

@end

上述代码,在正常情况下Label可以刷新text,但是在用户拖动TableView时,label将不在更新,直到手指离开屏幕。 解决方法,一种是修改Timer运行的Run Loop模式,将其加入NSRunLoopCommonModes中。

/* self.testTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self         selector:@selector(updateTestLabel) userInfo:nil repeats:YES]; */

self.testTimer = [NSTimer timerWithTimeInterval:0.5 target:self selector:@selector(updateTestLabel) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.testTimer forMode:NSRunLoopCommonModes];

另外一种解决方法是在另外一个线程中处理Timer事件,在perform到主线程去更新Label。

NSURLConnection和NSTimer也大致如此,其中注意NSURLConnection要使用- (id)initWithRequest:(NSURLRequest *)request delegate:(id)delegate startImmediately:(BOOL)startImmediately生成NSURLConnection对象,并且第三个参数要设置为NO。之后再用- (void)scheduleInRunLoop:(NSRunLoop *)aRunLoop forMode:(NSString *)mode设置Run Loop与其模式。

关于NSURLConnection的最佳实践可以参考AFNetworking。

2)NSTimer的构造方法会对传入的target对象强引用,直到这个timer对象invalidated。在使用时需要注意内存问题,根据需要,要在适当的地方调用invalidated方法。

3) 运行一次的Timer源也可能导致Run Loop退出:一次的Timer在执行完之后会自己从Run Loop中删除,如果使用while来驱动Run Loop的话,下一次再运行Run Loop就可能导致退出,因为此时已经没有其他的源需要监控。

走进Run Loop世界系列的第二章会为大家带来如何自定义事件源。

你可能感兴趣的:(小知识)