NSRunLoop学习笔记

1. 简介

  官方的介绍“The programmatic interface to objects that manage input sources. A NSRunLoop object processes input for sources such as mouse and keyboard events from the window system, NSPort objects, and NSConnection objects. A NSRunLoop object also processes NSTimer events.”即:NSRunLoop是用来处理来自窗口的鼠标和键盘事件、NSPort 和NSConnection的对象,同时也用来处理NSTimer的事件。

  通俗来讲,RunLoop是一种类似while循环的高级循环机制。一般的 while 循环会导致 CPU 进入忙等待状态,而 Runloop 则是一种“闲”等待。当没有事件时,Runloop 会进入休眠状态,有事件发生时, Runloop 会去找对应的 Handler 处理事件。Runloop 可以让线程在需要做事的时候忙起来,不需要的话就让线程休眠。

注意,NSRunLoop是非线程安全的,在不同线程中调用NSRunLoop的方法会导致程序异常

2. 事件源

NSRunLoop学习笔记_第1张图片
runloop.jpg

由图中可以看出NSRunLoop只处理两种源:输入源、时间源。而输入源又可以分为:NSPort、自定义源、performSelector:OnThread:delay:, 下面简单介绍下这几种源:

2.1 NSPort 基于端口的源

Cocoa和 Core Foundation 为使用端口相关的对象和函数创建的基于端口的源提供了内在支持。Cocoa中你从不需要直接创建输入源。你只需要简单的创建端口对象,并使用NSPort的方法将端口对象加入到run loop。端口对象会处理创建以及配置输入源。

NSPort一般分三种: NSMessagePort(基本废弃)、NSMachPort、 NSSocketPort。 系统中的NSURLConnection就是基于NSSocketPort进行通信的,所以当在后台线程中使用NSURLConnection 时,需要手动启动RunLoop, 因为后台线程中的RunLoop默认是没有启动的,后面会讲到。

2.2 自定义输入源

在Core Foundation程序中,必须使用CFRunLoopSourceRef类型相关的函数来创建自定义输入源,接着使用回调函数来配置输入源。Core Fundation会在恰当的时候调用回调函数,处理输入事件以及清理源。常见的触摸、滚动事件等就是该类源,由系统内部实现。

关于如何自定义一个输入源,官网给出了详细的说明。

2.3 performSelector:OnThread

Cocoa提供了可以在任一线程执行函数(perform selector)的输入源。和基于端口的源一样,perform selector请求会在目标线程上序列化,减缓许多在单个线程上容易引起的同步问题。而和基于端口的源不同的是,perform selector执行完后会自动清除出run loop。

此方法简单实用,使用也更广泛。

2.4 定时源

定时源就是NSTimer了,定时源在预设的时间点同步地传递消息。因为Timer是基于RunLoop的,也就决定了它不是实时的。

3. Run Loop Modes

RunLoop对于上述四种事件源的监视,可以通过设置模式来决定监视哪些源。 RunLoop只会处理与当前模式相关联的源,未与当前模式关联的源则处于暂停状态。

Cocoa的NSRunLoop和Core Foundation的CFRunLoop预先定义了一些模式:

Mode Name Description
Default NSDefaultRunLoopMode (Cocoa) kCFRunLoopDefaultMode (Core Foundation) The mode to deal with input sources other than NSConnection objects.系统默认的Mode。处理除NSConnection对象以外的输入源的Mode
Event Tracking NSEventTrackingRunLoopMode (Cocoa) A run loop should be set to this mode when tracking events modally, such as a mouse-dragging loop.
Common Modes NSRunLoopCommonModes (Cocoa) kCFRunLoopCommonModes (Core Foundation) Objects added to a run loop using this value as the mode are monitored by all run loop modes that have been declared as a member of the set of “common" modes. 其实这个并不是某种具体的Mode,而是一种模式组合,在iOS系统中默认包含了NSDefaultRunLoopModeUITrackingRunLoopMode(注意:并不是说Runloop会运行在kCFRunLoopCommonModes这种模式下,而是相当于分别注册了NSDefaultRunLoopModeUITrackingRunLoopMode。当然你也可以通过调用CFRunLoopAddCommonMode()方法将自定义Mode放到 kCFRunLoopCommonModes组合)
Tracking UITrackingRunLoopMode (Cocoa) The mode set while tracking in controls takes place. You can use this mode to add timers that fire during tracking. 常用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
ModalPanel NSModalPanelRunLoopMode (Cocoa) A run loop should be set to this mode when waiting for input from a modal panel, such as NSSavePanel or NSOpenPanel.
Connection NSConnectionReplyMode (Cocoa) 此模式用于处理NSConnection的回调事件

注意:我们常常还会碰到一些系统框架自定义Mode,例如Foundation中NSConnectionReplyMode。还有一些系统私有Mode,例如:GSEventReceiveRunLoopMode接受系统事件,UIInitializationRunLoopMode App启动过程中初始化Mode。更多系统或框架Mode查看这里

4. RunLoop引起的常见问题

1. TableView滑动时,Timer暂停了

我们做个测试: 在一个 viewController 的 scrollViewWillBeginDecelerating: 方法里面打个断点, 然后滑动 tableView。 待断点处, 使用 lldb 打印一下 [NSRunLoop currentRunLoop] 。 在描述中可以看到当前的RunLoop的运行模式:

current mode = UITrackingRunLoopMode
common modes = {type = mutable set, count = 2,
entries =>
0 : {contents = "UITrackingRunLoopMode"}
1 : {contents = "kCFRunLoopDefaultMode"}
}

也就是说,当前主线程的 RunLoop 正在以 UITrackingRunLoopMode 的模式运行。 这个时候 RunLoop 只会处理与 UITrackingRunLoopMode “绑定”的源, 比如触摸、滚动等事件;而 NSTimer 是默认“绑定”到 NSRunLoopDefaultMode 上的, 所以 Timer 是事情是不会被 RunLoop 处理的,我们的看到的时定时器被暂停了!

常见的解决方案是把Timer“绑定”到 NSRunLoopCommonModes 模式上。

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

这样这个Timer就可以和当前组中的两种模式 UITrackingRunLoopModekCFRunLoopDefaultMode 相关联了。 RunLoop在这两种模式下,Timer都可以正常运行了。

2. 后台的NSURLConnection不回调,Timer不运行

我们知道每个线程都有它的RunLoop, 我们可以通过 [NSRunLoop currentRunLoop]CFRunLoopGetCurrent()来获取。 但是主线程和后台线程是不一样的。主线程的RunLoop是一直在启动的。而后台线程的RunLoop是默认没有启动的。

后台线程的RunLoop没有启动的情况下的现象就是:“代码执行完,线程就结束被回收了”。就像我们简单的程序执行完就退出了。 所以如果我们希望在代码执行完成后还要保留线程等待一些异步的事件时,比如NSURLConnection和NSTimer, 就需要手动启动后台线程的RunLoop。

启动RunLoop,我们需要设定RunLoop的模式,我们可以设置 NSDefaultRunLoopMode。 那默认就是监听所有时间源:

//Cocoa
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
  
//Core Foundation
CFRunLoopRun();

我们也可以设置其他模式运行, 甚至自定义运行Mode,但是我们就需要把“事件源” “绑定”到该模式上:

extern NSString *kMyCustomRunLoopMode;
//NSURLConnection    
[_connection scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:kMyCustomRunLoopMode];
[[NSRunLoop currentRunLoop] runMode:kMyCustomRunLoopMode beforeDate:[NSDate distantFuture]];

//Timer
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:kMyCustomRunLoopMode];
[[NSRunLoop currentRunLoop] runMode:kMyCustomRunLoopMode beforeDate:[NSDate distantFuture]];

5. RunLoop的应用

5.1 NSTimer

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

NSTimer的创建通常有两种方式,尽管都是类方法,一种是timerWithXXX,另一种scheduedTimerWithXXX。

    + (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 ;
    + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
    + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
    + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo

二者最大的区别就是后者除了创建一个定时器外会自动以NSDefaultRunLoopModeMode添加到当前线程RunLoop中,不添加到RunLoop中的NSTimer是无法正常工作的。

其实和定时器相关的另一个问题大家也经常碰到,那就是NSTimer不是一种实时机制,官方文档明确说明在一个循环中如果RunLoop没有被识别(这个时间大概在50-100ms)或者说当前RunLoop在执行一个长的call out(例如执行某个循环操作)则NSTimer可能就会存在误差,RunLoop在下一次循环中继续检查并根据情况确定是否执行(NSTimer的执行时间总是固定在一定的时间间隔,例如1:00:00、1:00:01、1:00:02、1:00:05则跳过了第4、5次运行循环)。
要演示这个问题请看下面的例子(注意:有些示例中可能会让一个线程中启动一个定时器,再在主线程启动一个耗时任务来演示这个问,如果实际测试可能效果不会太明显,因为现在的iPhone都是多核运算的,这样一来这个问题会变得相对复杂,因此下面的例子选择在同一个RunLoop中即加入定时器和执行耗时任务)

 #import "ViewController.h"
    
    @interface ViewController ()
    @property (nonatomic,weak) NSTimer *timer1;
    @property (nonatomic,strong) NSThread *thread1;
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.view.backgroundColor = [UIColor redColor];
        
        // 由于下面的方法无法拿到NSThread的引用,也就无法控制线程的状态
        //[NSThread detachNewThreadSelector:@selector(performTask) toTarget:self withObject:nil];
        self.thread1 = [[NSThread alloc] initWithTarget:self selector:@selector(performTask) object:nil];
        [self.thread1 start];
    }
    
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
        [self.thread1 cancel];
        [self dismissViewControllerAnimated:YES completion:nil];
    }
    
    - (void)dealloc {
        [self.timer1 invalidate];
        NSLog(@"ViewController dealloc.");
    }
    
    - (void)performTask {
        // 使用下面的方式创建定时器虽然会自动加入到当前线程的RunLoop中,但是除了主线程外其他线程的RunLoop默认是不会运行的,必须手动调用
        __weak typeof(self) weakSelf = self;
        self.timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
            if ([NSThread currentThread].isCancelled) {
                //[NSObject cancelPreviousPerformRequestsWithTarget:weakSelf selector:@selector(caculate) object:nil];
                //[NSThread exit];
                [weakSelf.timer1 invalidate];
            }
            NSLog(@"timer1...");
        }];
        
        NSLog(@"runloop before performSelector:%@",[NSRunLoop currentRunLoop]);
        
        // 区分直接调用和「performSelector:withObject:afterDelay:」区别,下面的直接调用无论是否运行RunLoop一样可以执行,但是后者则不行。
        //[self caculate];
        [self performSelector:@selector(caculate) withObject:nil afterDelay:2.0];
    
        // 取消当前RunLoop中注册测selector(注意:只是当前RunLoop,所以也只能在当前RunLoop中取消)
        // [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(caculate) object:nil];
        NSLog(@"runloop after performSelector:%@",[NSRunLoop currentRunLoop]);
        
        // 非主线程RunLoop必须手动调用
        [[NSRunLoop currentRunLoop] run];
        
        NSLog(@"注意:如果RunLoop不退出(运行中),这里的代码并不会执行,RunLoop本身就是一个循环.");
        
        
    }
    
    - (void)caculate {
        for (int i = 0;i < 9999;++i) {
            NSLog(@"%i,%@",i,[NSThread currentThread]);
            if ([NSThread currentThread].isCancelled) {
                return;
            }
        }
    }
    
    @end

如果运行并且不退出上面的程序会发现,前两秒NSTimer可以正常执行,但是两秒后由于同一个RunLoop中循环操作的执行造成定时器跳过了中间执行的机会一直到caculator循环完毕,这也正说明了NSTimer不是实时系统机制的原因。

注意事项

  1. NSTimer会对Target进行强引用直到任务结束或exit之后才会释放。如果上面的程序没有进行线程cancel而终止任务则及时关闭控制器也无法正确释放。
  2. 非主线程的RunLoop并不会自动运行(同时注意默认情况下非主线程的RunLoop并不会自动创建,直到第一次使用),RunLoop运行必须要在加入NSTimer或Source0、Sourc1、Observer输入后运行否则会直接退出。例如上面代码如果run放到NSTimer创建之前则既不会执行定时任务也不会执行循环运算。
  3. performSelector:withObject:afterDelay:执行的本质还是通过创建一个NSTimer然后加入到当前线程RunLoop(通而过前后两次打印RunLoop信息可以看到此方法执行之后RunLoop的timer会增加1个。类似的还有performSelector:onThread:withObject:afterDelay:,只是它会在另一个线程的RunLoop中创建一个Timer),所以此方法事实上在任务执行完之前会对触发对象形成引用,任务执行完进行释放(例如上面会对ViewController形成引用,注意:performSelector: withObject:等方法则等同于直接调用,原理与此不同)。
  4. 同时上面的代码也充分说明了RunLoop是一个循环事实,run方法之后的代码不会立即执行,直到RunLoop退出。
    5.上面程序的运行过程中如果突然dismiss,则程序的实际执行过程要分为两种情况考虑:如果循环任务caculate还没有开始则会在timer1中停止timer1运行(停止了线程中第一个任务),然后等待caculate执行并break(停止线程中第二个任务)后线程任务执行结束释放对控制器的引用;如果循环任务caculate执行过程中dismiss则caculate任务执行结束,等待timer1下个周期运行(因为当前线程的RunLoop并没有退出,timer1引用计数器并不为0)时检测到线程取消状态则执行invalidate方法(第二个任务也结束了),此时线程释放对于控制器的引用。

5.2 CADisplayLink

CADisplayLink是一个执行频率(fps)和屏幕刷新相同(可以修改preferredFramesPerSecond改变刷新频率)的定时器,它也需要加入到RunLoop才能执行。与NSTimer类似,CADisplayLink同样是基于CFRunloopTimerRef实现,底层使用mk_timer(可以比较加入到RunLoop前后RunLoop中timer的变化)。和NSTimer相比它精度更高(尽管NSTimer也可以修改精度),不过和NStimer类似的是如果遇到大任务它仍然存在丢帧现象。通常情况下CADisaplayLink用于构建帧动画,看起来相对更加流畅,而NSTimer则有更广泛的用处。

5.3 NSURLConnection

一旦启动NSURLConnection以后就会不断调用delegate方法接收数据,这样一个连续的的动作正是基于RunLoop来运行。
一旦NSURLConnection设置了delegate会立即创建一个线程com.apple.NSURLConnectionLoader,同时内部启动RunLoop并在NSDefaultMode模式下添加4个Source0。其中CFHTTPCookieStorage用于处理cookie ;CFMultiplexerSource负责各种delegate回调并在回调中唤醒delegate内部的RunLoop(通常是主线程)来执行实际操作。
早期版本的AFNetworking库也是基于NSURLConnection实现,为了能够在后台接收delegate回调AFNetworking内部创建了一个空的线程并启动了RunLoop,当需要使用这个后台线程执行任务时AFNetworking通过performSelector: onThread: 将这个任务放到后台线程的RunLoop中。

5.4 UI更新

如果打印App启动之后的主线程RunLoop可以发现另外一个callout为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer,这个监听专门负责UI变化后的更新,比如修改了frame、调整了UI层级(UIView/CALayer)或者手动设置了setNeedsDisplay/setNeedsLayout之后就会将这些操作提交到全局容器。而这个Observer监听了主线程RunLoop的即将进入休眠和退出状态,一旦进入这两种状态则会遍历所有的UI更新并提交进行实际绘制更新。
通常情况下这种方式是完美的,因为除了系统的更新,还可以利用setNeedsDisplay等方法手动触发下一次RunLoop运行的更新。但是如果当前正在执行大量的逻辑运算可能UI的更新就会比较卡,因此facebook推出了AsyncDisplayKit来解决这个问题。AsyncDisplayKit其实是将UI排版和绘制运算尽可能放到后台,将UI的最终更新操作放到主线程(这一步也必须在主线程完成),同时提供一套类UIView或CALayer的相关属性,尽可能保证开发者的开发习惯。这个过程中AsyncDisplayKit在主线程RunLoop中增加了一个Observer监听即将进入休眠和退出RunLoop两种状态,收到回调时遍历队列中的待处理任务一一执行。

5.5 AutoreleasePool

AutoreleasePool是另一个与RunLoop相关讨论较多的话题。其实从RunLoop源代码分析,AutoreleasePool与RunLoop并没有直接的关系,之所以将两个话题放到一起讨论最主要的原因是因为在iOS应用启动后会注册两个Observer管理和维护AutoreleasePool。不妨在应用程序刚刚启动时打印currentRunLoop可以看到系统默认注册了很多个Observer,其中有两个Observer的callout都是_ wrapRunLoopWithAutoreleasePoolHandler,这两个是和自动释放池相关的两个监听。

 {valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = {type = mutable-small, count = 0, values = ()}}
    '' {valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = {type = mutable-small, count = 0, values = ()}}

第一个Observer会监听RunLoop的进入,它会回调objc_autoreleasePoolPush()向当前的AutoreleasePoolPage增加一个哨兵对象标志创建自动释放池。这个Observer的order是-2147483647优先级最高,确保发生在所有回调操作之前。
第二个Observer会监听RunLoop的进入休眠和即将退出RunLoop两种状态,在即将进入休眠时会调用objc_autoreleasePoolPop()objc_autoreleasePoolPush() 根据情况从最新加入的对象一直往前清理直到遇到哨兵对象。而在即将退出RunLoop时会调用objc_autoreleasePoolPop() 释放自动自动释放池内对象。这个Observer的order是2147483647,优先级最低,确保发生在所有回调操作之后。
主线程的其他操作通常均在这个AutoreleasePool之内(main函数中),以尽可能减少内存维护操作(当然你如果需要显式释放【例如循环】时可以自己创建AutoreleasePool否则一般不需要自己创建)。
其实在应用程序启动后系统还注册了其他Observer(例如即将进入休眠时执行注册回调_UIGestureRecognizerUpdateObserver用于手势处理、回调为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer用于界面实时绘制更新)和多个Source1(例如context为CFMachPort的Source1用于接收硬件事件响应进而分发到应用程序一直到UIEvent),这里不再一一详述。

5.6 GCD和RunLoop的关系

在RunLoop的源代码中可以看到用到了GCD的相关内容,但是RunLoop本身和GCD并没有直接的关系。当调用了dispatch_async(dispatch_get_main_queue(), <#^(void)block#>)时libDispatch会向主线程RunLoop发送消息唤醒RunLoop,RunLoop从消息中获取block,并且在__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__回调里执行这个block。不过这个操作仅限于主线程,其他线程dispatch操作是全部由libDispatch驱动的。

5.7 更多RunLoop使用

思考这个问题其实只要看RunLoopRef的包含关系就知道了,RunLoop包含多个Mode,而它的Mode又是可以自定义的,这么推断下来其实无论是Source1、Timer还是Observer开发者都可以利用,但是通常情况下不会自定义Timer,更不会自定义一个完整的Mode,利用更多的其实是Observer和Mode的切换。
例如很多人都熟悉的使用perfromSelector在默认模式下设置图片,防止UITableView滚动卡顿([[UIImageView alloc initWithFrame:CGRectMake(0, 0, 100, 100)] performSelector:@selector(setImage:) withObject:myImage afterDelay:0.0 inModes:@NSDefaultRunLoopMode])。还有sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在界面空闲状态下计算出UITableViewCell的高度并进行缓存。再有PerformanceMonitor关于iOS实时卡顿监控,同样是利用Observer对RunLoop进行监视。

Reference

  • NSRunLoop
  • 深入理解RunLoop
  • Cocoa深入学习:NSOperationQueue、NSRunLoop和线程安全
  • 《NSRunLoop》
  • Runloop
  • iOS刨根问底-深入理解RunLoop

你可能感兴趣的:(NSRunLoop学习笔记)