RunLoop 学习及常见问题

什么是 RunLoop

通常在终端中输入命令,执行任务的线程执行完就退出了,等我们再次输入命令,终端再开始执行任务。但在我们的 app 中,要保持一直运行(除非app被挂起),不断接受用户的输入,循环的接受、处理事件,类似于这样:

while(AppIsRunning){  //只要 app 处于运行状态,就要不断等待着处理事件
    id whoWakesMe = SleepForWakingUp();
    id event = GetEvent(whoWakesMe);
    HandleEvent(event);
}

RunLoop 来帮助线程管理一个或多个事件或消息,接受用户输入等事件源,在事件到达时,RunLoop 立刻唤醒线程来处理事件;没有事件需要处理时,RunLoop 帮助线程休眠,避免其占用资源,这里是帮助其休眠,而不是直接退出。
RunLoop 还决定了程序在何时应该处理那些事件,并且为被调用的对象维护一个消息队列,被调用方从这个消息队列中取出需要他处理的事件。

主线程的 RunLoop 默认开启,而子线程需要调用[NSRunLoop currentRunLoop]创建和获取 RunLoop,RunLoop 的销毁发生在线程结束时。

RunLoop 与线程的关系
每个线程创建的时候,都有一个 RunLoop 循环,与线程一一对应。

RunLoop 构成

RunLoop构成

如图可以看到 RunLoop 的大致构成,它与线程一一对应,而拥有多个CFRunLoopMode,mode 是一系列输入事件源、计时器、runLoop 观察者的集合。

RunLoop Mode

RunLoop 只能选择一个 Mode 启动,同时在“跑”的时候,总是在特定的唯一的 mode 下,每次运行 RunLoop 都要显式或隐式的指定运行 mode。这个 mode 包含了当前需要处理的 Source/Timer/Observer,所以 RunLoop 在时刻内,仅能处理与当前 mode 相关联的事件,只有和模式相关的源才会被监视,并允许他们传递事件消息。

为了保证其中的 Source/Timer/Observer 与其他 mode 的相隔离,切换 mode 时,只能先退出当前RunLoop,再以要切换的 mode 重新进入RunLoop。

开发中,通常会遇到这几种Mode:

  • kCFRunLoopDefaultMode:app的默认 Mode,通常主线程在这个 Mode 下运行。
  • UITrackingRunLoopMode:界面跟踪 Mode,ScrollView 的触摸滑动 mode (在iOS中,触摸滑动很流畅的原因是在滑动时,只处理此 mode 下的事件且不受其他mode影响)。
  • UIInitializationRunLoopMode:刚启动 app 进入的第一个 mode,起到过渡的作用,启动完成后不再使用。
  • GSEventReceiveRunLoopMode: Graphic 相关事件的 mode,通常用不到。
  • kCFRunLoopCommonModes:将 mode 标记为"common"属性,当 RunLoop 运行在标记为"common"属性的任一 mode 下,发生事件时,里面的 mode 都会被触发。
RunLoop Source

线程的异步事件源,数据源。有两种Source,可以用是否基于Mach Port(进程间通讯接口)区分:

  • source0:不基于Mach Port,处理app内部事件,用户自定义的thread发出。当我们使用 NSObject 中的 performSelector 系列方法时,都是source0 事件源。
  • source1:基于Mach Port,是由RunLoop和内核管理的。
RunLoop Timer

线程的同步事件源,在预设的时间点到了之后同步的发给线程处理此事件。

RunLoop Observer

Observer 可对 RunLoop 的状态变化进行观察,可观察的变化:

  • 刚进入此 RunLoop 中
  • RunLoop 准备处理一个 Timer
  • RunLoop 准备处理一个 Input Source
  • RunLoop 准备进入睡眠
  • RunLoop 将被唤醒处理事件之前
  • RunLoop 准备退出

因为Observer可对这些事件进行观察追踪,所以也可被看作是一种事件源。

RunLoop处理的流程

RunLoop_1.png

第7步中,当线程进入休眠,发生下列事件,线程将被唤醒:

  • 基于 Port 的事件发生
  • 计时器到时
  • 被代码显式唤醒

第9步中,处理唤醒时收到的消息,并且:

  • 如果是用户定义的计时器到时,处理事件并重启 RunLoop
  • 如果有input 事件源,传递这个消息
  • 如果runloop显式被唤醒,且没有超时,重启RunLoop
    之后,跳回第2步

RunLoop应用举例

在漫长长长长的理论说明后,让我们看看实际开发中,有哪些地方会用到 RunLoop 呢?

解决 NSTimer "不准"的问题

我们有时候会发现 NSTimer "不太准",明明时间已经到了,该执行的回调却未发生,这是因为我们常常将 NSTimer 默认设置为default mode,如果这时屏幕滚动,mode切换为TrackingMode,时间到了,但是 TrackingMode 无法处理 defaultMode下的回调,造成"不准"。
在 SVProgressHUD 中,我们可以设置转圈的提示框自动消失,可开启一个定时器,在到了设定的时间点后消失,如下

strongSelf.fadeOutTimer = [NSTimer timerWithTimeInterval:duration target:strongSelf selector:@selector(dismiss) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:strongSelf.fadeOutTimer forMode:NSRunLoopCommonModes];

strongSelf 即为提示框,将它消失的定时器添加在 RunLoop 的common 模式下,不管时间点到了的那一时刻 RunLoop 运行在哪个mode下,都会处理消失的回调,"准点消失"。

用 dispatch_after 定时,就准了吗
我发现有很多博客写,NSTimer 造成定时不准的问题可以通过 GCD 中的 dispatch_after 来解决,但是 dispatch_after 并不是说在指定时间后执行处理,而只是在指定时间将操作追加到 Dispatch Queue 中。如果指定时间到了,需要加入的队列正在进行耗时操作,定时操作并不能立即执行,也会造成不准。
验证如下:

    //获取主队列
    dispatch_queue_t mainQueue = dispatch_get_main_queue();
    //定时时间
    int64_t delay = 5 * NSEC_PER_SEC;
    //定时时间,即从现在到定时的时间
    dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, delay);

    NSLog(@"开始计时: %@", [NSDate date]);

    dispatch_after(delayTime, mainQueue, ^{
        NSLog(@"时间到: %@", [NSDate date]);
    });

    //在这里设置一些复杂操作,比方来10000次网络请求


可以看到虽然我们只设置延迟5秒进行,但事实上,在10秒才进行了延迟操作。但是日常的开发中,碰到这么这么复杂的情况应该是比较少的,所以 dispatch_after 也可以一用~~~
GCD 中除了主要的 Dispatch Queue 之外,还对 BSD 系内核惯有功能 kqueue 进行包装,可处理内核中发生的各种事件及方法。
其中的 DISPATCH_SOURCE_TYPE_TIMER 可作为定时器,帮助我们延迟调用:


    //获取主队列
    dispatch_queue_t mainQueue = dispatch_get_main_queue();
    //新生成一个定时器,且此定时器不能为局部变量,否则方法执行完就被销毁了,还怎么做定时后的回调呢?
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, mainQueue);
    //定时时间
    int64_t delay = 5 * NSEC_PER_SEC; 
    //一定容差范围时间
    int64_t leeway = 0.1 * NSEC_PER_SEC; 
    //定时时间,即从现在到定时的时间
    dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, delay);

    //设置定时器
    //下一次回调为DISPATCH_TIMER_FOREVER,表示不需要重复
    dispatch_source_set_timer(self.timer, delayTime,DISPATCH_TIMER_FOREVER, leeway);

    //设置时间到了后的回调
    __weak typeof(self) weakSelf = self;
    dispatch_source_set_event_handler(self.timer, ^{
        typeof(self) strongSelf = weakSelf;
        NSLog(@"计时结束: %@", [NSDate date]);
        dispatch_source_cancel(strongSelf.timer);
    });

    //启动定时器
    dispatch_resume(self.timer);

保证线程的持续运行

在 AFNetworking 2.3 中,需要一个自定义线程接受 connection 回调,一开始初始化线程时,没有需要执行的操作,线程会退出(RunLoop中没有source/timer/observer 会立即退出)。为其添加一个MachPort,为了保证线程的存活。

+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        //初始化线程时,调用networkRequestThreadEntryPoint方法
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        //为线程创建RunLoop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        //为RunLoop添加事件,保证其持续运行
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; 
        [runLoop run];
    }
}
解决TableView加载图片时,滑动很卡

TableView 需要加载大量图片时,滑动后,界面会卡,这是因为此时RunLoop 运行在 UITrackingRunLoopMode 下,图片加载在当前mode下,cpu 又要处理加载图片事件,又要处理滑动事件,造成卡顿。
可以显式地将图片的加载设置在 NSDefaultRunLoopMode 下,滑动时的 UITrackingRunLoopMode 并不会去加载图片,解决卡顿问题。

[self.imageView performSelector:@selector(setImage:) withObject:downloadImage afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
自动释放池到底在何时释放?

我们知道,手动指定 autoreleasepool 中的对象,会在作用域结束时释放掉。而设置为 autorelease 的对象是在出了作用域之后,被自动添加到最近创建的自动释放池中。那么这个自动释放池迟早有被撑满需要释放的时刻,这个自动释放池具体是什么时候被释放呢?

在Cocoa框架中,相当于程序主循环的NSRunLoop或者在其他程序可运行的地方,对 NSAutoreleasePool 对象进行生成、持有和废弃处理。
---引自《Objective-C 高级编程》
而它能够释放的原因是系统在每个 runloop 迭代中都加入了自动释放池 Push 和 Pop

下面我们举例讨论下:

@property (nonatomic,weak)NSString * weakStr;

- (void)viewDidLoad {
        [super viewDidLoad];
        NSString *string = [NSString stringWithFormat:@"这个string要设置的很长长长长长长长长长长长长长长长长"];
        //因为苹果引用Tagged Pointer专门存储小的对象,直接存储其值,而不是存储地址
        //如果string很短,用Tagged Pointer存储,无法验证其自动释放,地址被收回的过程
        weakStr = string;

        NSLog(@"viewDidLoad:%@",weakStr);
        NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    NSLog(@"viewWillAppear:%@",weakStr);
    NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}
- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    NSLog(@"viewDidAppear:%@",weakStr);
    NSLog(@"currentRunLoop:%@",[[NSRunLoop currentRunLoop] currentMode]);
}

输出如图:


在mode改变,RunLoop一次循环结束后,autorelease对象被销毁
观察 weakStr 设置方法何时被调用
在viewWillAppear调用结束后,左边的堆栈中出现了一次AutoreleasePoolPage pop操作

我们在viewDidLoad方法中,用stringWithFormat类方法生成一个字符串,这种方法生成的字符串默认被添加进 autoreleasepool 中。
viewDidLoad 和 viewWillAppear 还在app初始化的 UIInitializationRunLoopMode 下,而 viewDidAppear 已经进入了默认mode下了。期间,autoreleasepool 出现了一次销毁,其中的对象也就被销毁了。
所以说,在没有手加Autorelease Pool的情况下,Autorelease对象是在当前的runloop迭代结束时释放的,而它能够释放的原因是系统在每个runloop迭代中都加入了自动释放池Push和Pop。

关于RunLoop的一道题
NSRunLoop 的描述正确的是( )
A. RunLoop 决定程序在何时应该处理哪些 Event
B. Cocoa 中的 NSRunLoop 类并不是线程安全的
C. RunLoop 可以使程序一直运行接受用户输入
D. RunLoop 起到了调用解耦的作用
我怎么觉得 ABCD 四个选项都对嘞……

参考文章:
RunLoops 官方文档
深入理解RunLoop
黑幕背后的Autorelease
Objective-C Autorelease Pool 的实现原理
RunLoop个人小结

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 前言 最近离职了,可以尽情熬夜写点总结,不用担心第二天上班爽并蛋疼着,这篇的主角 RunLoop 一座大山,涵盖的...
    zerocc2014阅读 14,237评论 13 67
  • Runloop是iOS和OSX开发中非常基础的一个概念,从概念开始学习。 RunLoop的概念 -般说,一个线程一...
    小猫仔阅读 4,603评论 0 1
  • 转载:http://www.cocoachina.com/ios/20150601/11970.html RunL...
    Gatling阅读 5,307评论 0 13
  • 弹出框在移动端开发中使用是比较频繁的控件之一 1、在iOS8.0之前使用最多的原生弹出框控件是:UIAlertVi...
    郭伟_技术与产品阅读 4,635评论 0 1
  • 长大时念及故乡,更多的是对家乡亲人的牵挂和对儿时往事的依恋了。—— 题记 又是豌豆花开时节,想来我的家乡又是田间地...
    紫如意阅读 6,049评论 15 5