logo头像

『专注精彩』

iOS知识点-RunLoop

一、基础篇

1.RunLoop是什么

  • RunLoop字面意思是跑圈,实际就是运行循环(即死循环)

    其实它内部就是do-while循环,在这个循环内部不断的处理各种任务(比如Source、Timer、Observer)

2.RunLoop基本作用

  • 保持程序持续运行(保证程序不退出)
  • 处理(监听)APP中的各种事件(如,监听触摸事件、定时器事件、Selector事件)
  • 节省CPU资源,提高程序性能(有事情时就做事情,没事情时就休息待命)

3.获取RunLoop对象

  • Foundation框架:

    [NSRunLoop currentRunLoop];    // 获得当前线程的RunLoop对象
    [NSRunLoop mainRunLoop];     // 获得主线程的RunLoop对象
    // NSRunLoop类是OC编写的,是对CFRunLoopRef的一个简单的封装
    
  • Core Foundation框架:

    CFRunLoopGetCurrent();    // 获得当前线程的RunLoop对象
    CFRunLoopGetMain();     // 获得主线程的RunLoop对象
    // CFRunLoopRef是C语言编写的,更底层,开源
    

二、提高篇

1.RunLoop和线程间的关系

  • 一个线程对应一个RunLoop(key和value的关系)。

  • 线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop在第一次获取时创建,在线程结束时销毁。

    创建子线程的RunLoop直接调用 [NSRunLoop currentRunLoop];, 这个Get方法是懒加载的。

  • 主线程的RunLoop默认是自动开启,其它线程(子线程)的RunLoop需要手动开启。

    [[NSRunLoop currentRunLoop] run]; // 手动开启RunLoop

  • 分析源码:

    /**
        iOS 开发中能遇到两个线程对象: pthread_t 和 NSThread。
        可以通过 pthread_main_thread_np() 或 [NSThread mainThread] 来获取主线程;
        通过 pthread_self() 或 [NSThread currentThread] 来获取当前线程。
        CFRunLoop 是基于 pthread 来管理的。
    
        苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:
    */
    
    /// 全局的Dictionary,key 是 pthread_t(线程), value 是 CFRunLoopRef(RunLoop)
    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());
    }
    

2.RunLoop相关类

  • Core Foundation中关于RunLoop的5个类:

    CFRunLoopRef        // 获得当前RunLoop和主RunLoop
    CFRunLoopModeRef    // 代表的是RunLoop的运行模式
    CFRunLoopSourceRef    // 事件源,输入源
    CFRunLoopTimerRef        // 定时器时间
    CFRunLoopObserverRef    // 观察者,能够监听RunLoop的状态改变
    

    RunLoop的相关类之间的关系如下:

    RunLoop相关类

    一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。但RunLoop每次只能选择一个模式运行。要保证运行循环RunLoop不退出,每个模式里面至少存在一个Source或者一个Timer,Observer可以有也可以没有,只是监听RunLoop的运行状态。

    CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。

    Source0:基于用户主动触发的事件(触摸事件,performSelector 都会触发Source0事件)

    ​ 点击button 或点击屏幕,当点击屏幕时,手指和屏幕产生一个事件,这个事件会自动打包生成一个Source0事件

    Source1:基于Port的线程间通信(与内核相关,自发调用的)
    注意:Source1在处理的时候会分发一些操作给Source0去处理

    CFRunLoopTimerRef 是基于时间的触发器。

    其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。

    CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

    typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
        kCFRunLoopEntry         = (1UL << 0), // 即将进入运行循环
        kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理定时器事件
        kCFRunLoopBeforeSources = (1UL << 2), // 即将处理输入源事件
        kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
        kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
        kCFRunLoopExit          = (1UL << 7), // 退出运行循环
        kCFRunLoopAllActivities = 0x0FFFFFFFU // 运行循环所有活动
    };
    

    添加观察者到运行循环的代码:

    // 监听RunLoop的各种活动状态(包括唤醒,休息,以及处理各种事件等...)
    - (void)observerRunLoopActivity {
        /* 
         1.创建观察者
             参数1: 分配内存空间的方式,传默认
             参数2: RunLoop的运行状态
             参数3: 是否持续观察
             参数4: 优先级,传0
             参数5: 观察者观测到状态改变时触发的方法
         */
        CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
            switch (activity) {
                case kCFRunLoopEntry:
                    NSLog(@"RunLoop进入");
                    break;
                case kCFRunLoopBeforeTimers:
                    NSLog(@"RunLoop要处理定时器(Timers)事件了");
                    break;
                case kCFRunLoopBeforeSources:
                    NSLog(@"RunLoop要处理输入源(Sources)事件了");
                    break;
                case kCFRunLoopBeforeWaiting:
                    NSLog(@"RunLoop要休息了");
                    break;
                case kCFRunLoopAfterWaiting:
                    NSLog(@"RunLoop醒来了");
                    break;
                case kCFRunLoopExit:
                    NSLog(@"RunLoop退出了");
                    break;
                default:
                    break;
            }
        });
        /* 
         2.添加观察者到运行循环
             参数1: 要监听哪个RunLoop, 传入当前的运行循环
             参数2: 观察者/监听者, 观察运行循环的各种状态
             参数3: 运行循环的模式,要监听RunLoop在哪种运行模式下的状态
         */
        CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
          /** 
           3.释放观察者 
           CF的内存管理(Core Foundation):凡是带有Create、Copy、Retain等字眼的函数,创建出来的对象,都需要在最后做一次release
          */
        CFRelease(observer);
    }
    

3.RunLoop的model

  • RunLoop 有五种运行模式,其中我们常用的是1、2、5这三个。

    kCFRunLoopDefaultMode    // 1> App的默认Mode,通常主线程是在这个Mode下运行
    UITrackingRunLoopMode    // 2> 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
    UIInitializationRunLoopMode    // 3> 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
    GSEventReceiveRunLoopMode    // 4> 接受系统事件的内部 Mode,通常用不到
    kCFRunLoopCommonModes        // 5> 这是一个占位用的Mode,不是一种真正的Mode
    
  • Model间的切换:

    我们平时在开发中一定遇到过,当我们使用NSTimer每一段时间执行一些事情时滑动UIScrollView,NSTimer就会暂停,当我们停止滑动以后,NSTimer又会重新恢复的情况

    // 创建定时器并添加到RunLoop
    // [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(show) userInfo:nil repeats:YES];
    
    NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(show) userInfo:nil repeats:YES];
    // 把定时器添加到RunLoop中
    // 1.NSDefaultRunLoopMode 默认运行模式,此时定时器任务只会在默认模式下执行
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; 
    
    // 当scrollView滑动的时候,timer失效,停止滑动时,timer恢复
    // 原因:当scrollView滑动的时候,RunLoop的Mode会自动切换成UITrackingRunLoopMode模式,因此timer失效,当停止滑动,RunLoop又会切换回NSDefaultRunLoopMode模式,因此timer又会重新启动了
    
    // 2. UITrackingRunLoopMode 界面跟踪模式,此时定时器任务只会在滑动scrollView时执行
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
    
    // 3. 那个如何让timer在两个模式下都可以运行呢?(即滚动视图时,不会对定时器产生影响)
    // 3.1 在两个模式下都添加timer 是可以的,但是timer添加了两次,并不是同一个timer
    // 3.2 使用占位的运行模式 NSRunLoopCommonModes标记,凡是被打上NSRunLoopCommonModes标记的都可以运行,因此也就是说如果我们使用NSRunLoopCommonModes,timer可以在UITrackingRunLoopMode,kCFRunLoopDefaultMode两种模式下运行
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    

    在实际开发中,一般不把timer放到主线程的RunLoop中,因为主线程在执行阻塞的任务时,timer计时会不准。
    如何让计时准确?如果timer在主线程中阻塞了怎么办?
    1》放入子线程中(即要开辟一个新的线程,但是成本是需要开辟一个新的线程)
    2》写一种跟RunLoop没有关系的计时,即GCD。(不会阻塞,推荐使用这种)

    // GCD定时器(常用)
    // 创建队列
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    // 1.创建一个GCD定时器
    /*
     第一个参数:表明创建的是一个定时器
     第四个参数:队列
     */
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    // 需要对timer进行强引用,保证其不会被释放掉,才会按时调用block块
    // 局部变量,让指针强引用
    self.timer = timer;
    // 2.设置定时器的开始时间,间隔时间,精准度
    /*
     第1个参数:要给哪个定时器设置
     第2个参数:开始时间
     第3个参数:间隔时间
     第4个参数:精准度 一般为0 在允许范围内增加误差可提高程序的性能
     GCD的单位是纳秒 所以要*NSEC_PER_SEC
     */
    dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 2.0 * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
    
    // 3.设置定时器要执行的事情
    dispatch_source_set_event_handler(timer, ^{
        NSLog(@"---%@--",[NSThread currentThread]);
        // 取消定时
          if (判断条件) {
            dispatch_source_cancel(timer);
              self.timer = nil;
          }
    });
    // 4.启动
    dispatch_resume(timer);
    

4.应用场景

  • 定时器:实例化定时器并指定监听方法后,需要把定时器加到RunLoop上。加到RunLoop上之后,才能在每个时间触发的时候去监听事件。

    self.timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(show) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; 
    

    使用定时器还有一个细节:就是在定时器不使用的时候,必须要销毁,否则会产生循环引用。

    target后有一个self,定时器会对self强引用;viewController本身也会对定时器强引用(定时器通常会保存到viewController的实例变量/属性中),所以就会产生循环引用。

  • 常驻线程:永远活着的线程。开启一个子线程,再手动开启这个子线程的RunLoop,这个子线程就是常驻线程。

    常驻线程的生命周期跟APP相同。跟主线程并行,永远不会被销毁,一直在后台默默的做一些事情。(常驻线程的使用一般比较少,在实际开发中基本上没有这种需求)

    在子线程里面也有运行循环(RunLoop),这个运行循环(RunLoop)默认不被开启;只有我们调用它的时候才会被开启(即需要手动开启)。

    常驻线程

    特别注意:

    在启动RunLoop之前建议用 @autoreleasepool {…}包裹。

    意义:创建一个大释放池,释放{}期间创建的临时对象,一般好的框架的作者都会这么做。

    - (void)executeTask {
        @autoreleasepool {
            NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
            [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
            [[NSRunLoop currentRunLoop] run];
        }
    }
    
  • 自动释放池

    Q:autoreleasePool对象是什么时候释放的?
    A:自动释放池的释放和创建与RunLoop有关。
        当RunLoop开启时,就会自动创建一个自动释放池。
        当Runloop准备休眠的时候,会释放旧的autoreleasePool对象,再重新创建一个新的空的autoreleasePool对象。
        当RunLoop从休眠中被唤醒的时候,Timer,Source等新的事件就会放到新的自动释放池中。
        当Runloop即将退出的时候,会释放掉相关所有的autoreleasePool对象。
    

    注意:只有主线程的RunLoop会默认启动。也就意味着会自动创建自动释放池,子线程需要在线程调度方法中手动添加自动释放池。

  • performSelector方法

    performSelector其实是创建了一个Timer,然后添加到当前的线程中。如果当前线程没有Runloop,这个方法则走不通的。

    // 可以设置只在某个运行模式(modes)下执行方法(aSelector)
    - (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay inModes:(NSArray<NSRunLoopMode> *)modes;
    - (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;
    
  • 用于Socket开发:使用RunLoop能够监听网络端口数据的接收与发送情况。(平常企业开发中用Socket开发比较少,通常是做硬件通讯的时候,用得比较多。比如:智能家居开发、游戏机等)

  • iOS中默认开启的事件循环,保证主线程不退出。

    重新启动

    第14行代码的UIApplicationMain函数内部就启动了一个RunLoop

    所以UIApplicationMain函数一直没有返回,保持了程序的持续运行

    这个默认启动的RunLoop是跟主线程相关联的。

三、总结

  • RunLoop知识点的大致框架:

    RunLoop知识点的大致框架

  • 思考:以后为了增加用户体验,在用户UI交互的时候不做事件处理,我们可以把需要做的操作放到NSDefaultRunLoopMode。

  • RunLoop处理逻辑流程图:

    RunLoop处理逻辑流程图

在实际中的使用场景其实很明确了, 在程序中中有大量临时变量(循环/遍历中)的时候最好手动创建autoreleasepool{}

四、面试题

  1. 讲讲 RunLoop,项目中有用到吗?
  2. RunLoop内部实现逻辑?
  3. Runloop和线程的关系?
  4. timer 与 Runloop 的关系?
  5. 程序中添加每3秒响应一次的NSTimer,当拖动tableview时timer可能无法响应要怎么解决?
  6. Runloop 是怎么响应用户操作的, 具体流程是什么样的?
  7. 说说RunLoop的几种状态?
  8. Runloop的mode作用是什么?
  • main函数中的autoreleasepool的作用?
  • 系统的autoreleasepool我们自己创建的autoreleasepool释放时机差别在哪?
  • 在ARC的环境中, 什么情况下需要使用autoreleasepool? 不使用autoreleasepool变量什么时候会被释放?
上一篇