Objective-C 之 RunLoop 底层实现

307 阅读15分钟

RunLoop 简介

RunLoop 概念

RunLoop 是线程中的一个do-while循环,一个线程如果没有 RunLoop,只能执行一个任务,执行完成后线程就会退出。有 RunLoop 会保持程序的持续运行,在循环中,通过 Input sources(输入源)和Timer sources(定时源)两种来源等待处理事件。RunLoop 在没有事件处理时,会使线程进入睡眠模式,从而节省 CPU 资源,提高程序性能。

在启动一个 iOS 程序时,由于UIApplicationMain 函数内部自动开启了跟主线程相关联的 RunLoop,UIApplicationMain 内部一直执do-while循环中,所以UIApplicationMain函数一直没有返回,从而保持程序的持续运行。

RunLoop 作用

  • 保持程序的持续运行。
  • 处理程序中的各种事件。
  • 节省CPU资源,提高程序性能。

RunLoop 对象

苹果提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。

  • CFRunLoopRef 在 CoreFoundation 框架内,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
  • NSRunLoop 在 Foundation 框架内,是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

RunLoop 与线程

线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的字典里。

主线程 RunLoop 默认已经创建并开启,

子线程创建时并没有 RunLoop,如果不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取(currentRunLoop懒加载),RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop。

RunLoop 相关类

在 CoreFoundation 里面关于 RunLoop 有5个类:

CFRunLoopRef 	      // RunLoop 的对象
CFRunLoopModeRef      // RunLoop 的运行模式(NSRunLoopMode)
CFRunLoopSourceRef    // RunLoop 的输入源
CFRunLoopTimerRef     // RunLoop 的定时源
CFRunLoopObserverRef  //RunLoop 的观察者对象(能够监听 RunLoop 的状态改变)

CFRunLoopModeRef

CFRunLoopModeRef(NSRunLoopMode) 是 RunLoop 的运行模式。一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次启动 RunLoop 时,只能指定其中一个 Mode,该 Mode 被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

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

系统默认注册了5个Mode

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

kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 、kCFRunLoopCommonModes(NSRunLoopCommonModes) UITrackingRunLoopMode、是开发中需要用到的模式

  • NSDefaultRunLoopMode 是 App 平时所处的状态。
  • UITrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。
  • NSRunLoopCommonModes 标记以上两种 Mode 。

CFRunLoopSourceRef

CFRunLoopSourceRef是事件源(输入源)Source有两个版本:Source0 和 Source1。

  • 第一种按照官方文档来分类 (以前的分法)
    • Port-Based Sources(基于端口)
    • Custom Input Sources(自定义)
    • Cocoa Perform Selector Sources
  • 第二种按照函数调用栈来分类 (现在的分法)
    • Source0 :非基于Port 用户主动触发时间,只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
    • Source1:基于Port,包含了一个 mach_port 和一个回调(函数指针)通过内核和其他线程通信,接收、分发系统事件。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

这两种分类方式其实没有区别,只不过第一种是通过官方理论来分类,第二种是在实际应用中通过调用函数来分类。

下边我们举个例子大致来了解一下函数调用栈和Source。

  1. 在我们的项目中的 Main.storyboard 中添加一个 Button 按钮,并添加点击动作。
  2. 然后在点击动作的代码中加入一句输出语句,并打上断点.
  3. 可以看到点击事件产生的函数调用栈。

所以点击事件是这样来的:

  1. 首先程序启动,调用main函数,main函数调用UIApplicationMain函数,然后一直往上调用函数,最终调用到btnClick函数,即点击函数。
  2. 同时我们可以看到11行中有Sources0,也就是说我们点击事件是属于Sources0函数的,点击事件就是在Sources0中处理的。
  3. 而至于Sources1,则是用来接收、分发系统事件,然后再分发到Sources0中处理的。

CFRunLoopTimerRef

CFRunLoopTimerRef 是定时源(RunLoop模型图中提到过),理解为基于时间的触发器,基本上就是 NSTimer。

CFRunLoopObserverRef

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

CFRunLoopObserverRef 可以监听的状态改变有以下几种:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),               // 即将进入Loop:1
    kCFRunLoopBeforeTimers = (1UL << 1),        // 即将处理Timer:2    
    kCFRunLoopBeforeSources = (1UL << 2),       // 即将处理Source:4
    kCFRunLoopBeforeWaiting = (1UL << 5),       // 即将进入休眠:32
    kCFRunLoopAfterWaiting = (1UL << 6),        // 即将从休眠中唤醒:64
    kCFRunLoopExit = (1UL << 7),                // 即将从Loop中退出:128
    kCFRunLoopAllActivities = 0x0FFFFFFFU       // 监听全部状态改变  
};

下边我们通过代码来监听下RunLoop中的状态改变。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 创建观察者
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
           NSLog(@"监听到RunLoop发生改变---%zd",activity);
       });
    
    // 添加观察者到当前RunLoop中
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

    // 释放observer,最后添加完需要释放掉
    CFRelease(observer);
}

可以看到RunLoop的状态在不断的改变,最终变成了状态 32,也就是即将进入睡眠状态,说明RunLoop之后就会进入睡眠状态。

RunLoop 的内部逻辑

在每次运行开启 RunLoop 时,所在线程的 RunLoop 会自动处理之前未处理的事件,并且通知相关的观察者。具体的顺序如下:

  1. 通知观察者 RunLoop 已经启动
  2. 通知观察者即将要开始的定时器
  3. 通知观察者任何即将启动的非基于端口的源
  4. 启动任何准备好的非基于端口的源
  5. 如果基于端口的源准备好并处于等待状态,立即启动;并进入步骤9
  6. 通知观察者线程进入休眠状态
  7. 将线程置于休眠直到下面的事件发生:
    • 某一事件到达基于端口的源
    • 定时器启动
    • RunLoop 设置的时间已经超时
    • RunLoop 被显示唤醒
  8. 通知观察者线程将被唤醒
  9. 处理未处理的事件
    • 如果用户定义的定时器启动,处理定时器事件并重启RunLoop。进入步骤2
    • 如果输入源启动,传递相应的消息
    • 如果RunLoop被显示唤醒而且时间还没超时,重启RunLoop。进入步骤2
  10. 通知观察者RunLoop结束。

RunLoop 具体实现

// 用DefaultMode启动
// RunLoop的主函数,是一个死循环
//CFRunLoopRunSpecific具体处理runloop的运行情况
//CFRunLoopGetCurrent()  当前runloop对象
//kCFRunLoopDefaultMode  runloop的运行模式的名称
//1.0e10                 runloop默认的运行时间,即超时为10的九次方
//returnAfterSourceHandled 回调处理


// 根据modeName找到对应mode(运行模式)
// 判断mode里没有source/timer, 没有直接返回。

// 1.1 通知 Observers: RunLoop 即将进入 loop。---(OB会创建释放池)
// 1.2 内部函数,进入loop

// 2.1 通知 Observers: RunLoop 即将触发 Timer 回调。
// 2.2 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
// 2.3 RunLoop 触发 Source0 (非port) 回调。
// 2.4 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。

// 3.1 如果没有待处理消息,通知 Observers: RunLoop 的线程即将进入休眠(sleep)。--- (OB会销毁释放池并建立新释放池)
// 3.2. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
 /*
	一个基于 port 的Source1 的事件。
	一个 Timer 到时间了
	RunLoop 启动时设置的最大超时时间到了
  被手动唤醒
*/
// 3.3. 被唤醒,通知 Observers: RunLoop 的线程刚刚被唤醒了。

// 4.0 处理消息。
// 4.1 如果消息是Timer类型,触发这个Timer的回调。
// 4.2 如果消息是dispatch到main_queue的block,执行block。
// 4.3 如果消息是Source1类型,处理这个事件


// 5.1 如果处理事件完毕,启动Runloop时设置参数为一次性执行,设置while参数退出Runloop
// 5.2 如果启动Runloop时设置的最大运转时间到期,设置while参数退出Runloop
// 5.3 如果启动Runloop被外部调用强制停止,设置while参数退出Runloop
// 5.4 如果启动Runloop的modeItems为空,设置while参数退出Runloop
// 5.5 如果没超时,mode里没空,loop也没被停止,那继续loop,回到第2步循环。

// 6. 如果第6步判断后loop退出,通知 Observers: RunLoop 退出。--- (OB会销毁新释放池)

RunLoop 应用

我们可以通过添加 observer 监听 RunLoop 状态,当 RunLoop 进入到 beforeWaiting 时,处理一些比较耗时 的操作,比如图片解码,删除、读入大文件等等,凭此来提升 app 的流畅度。

我们还可以通过添加 beforeSource 和 afterWaiting 等 observer 来监控 App 卡顿情 况,如果 RunLoop ⻓时间停留在这两个状态,可以视为发生了阻塞,如果实在主线程,给用 户的体验就是 App 卡顿。

除此之外,系统还有很多事件用到。

定时器(NSTimer)

NSTimer 其实就是 CFRunLoopTimerRef。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。RunLoop 为了节省资源,并不会在非常准确的时间点回调这个 Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

注意:GCD的定时器不会受到 Runloop 运行模式的影响。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook 开源的 AsyncDisplayLink 就是为了解决界面卡顿的问题,其内部也用到了 RunLoop。

- (void)timer {
    // 创建定时器对象
    NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(run) userInfo:nil repeats:YES];
    // 将定时器添加到当前RunLoop的NSDefaultRunLoopMode下
    //只有当运行模式为NSDefaultRunLoopMode的时候,定时器才会工作
    //[NSRunLoop.currentRunLoop addTimer:timer forMode:NSDefaultRunLoopMode];
    
    //当滚动textView的时候,主运行循环会切换运行模式(默认->界面追踪运行模式)
    //只有当运行模式为NUITrackingRunLoopMode的时候,定时器才会工作
    //[NSRunLoop.currentRunLoop addTimer:timer forMode:UITrackingRunLoopMode];
    
    //把定时器对象添加到runloop中,并指定运行模式为commonModes:
    //只有当运行模式为被标记为commonModes的运行模式的时候,定时器才会工作
    //被标记为commonModes运行模式:UITrackingRunLoopMode | NSDefaultRunLoopMode
    [NSRunLoop.currentRunLoop addTimer:timer forMode:NSRunLoopCommonModes];
}

- (void)run {
    NSLog(@"run--%@", NSRunLoop.currentRunLoop.currentMode);
}
//该方法内部会自动将创建的定时器对象添加到当前的runloop,并且指定运行模式为默认
[NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(run) userInfo:nil repeats:YES];
//注意: 将定时器运行在子线程, 需要手动运行 RunLoop
[NSRunLoop.currentRunLoop run];

GCD

实际上 RunLoop 底层也会用到 GCD 的东西, 例如 dispatch_async()。

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。

performSelector

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

当界面中含有 UITableView,而且每个 UITableViewCell 里边都有图片。这时候当我们滚动 UITableView 的时候,如果有一堆的图片需要显示,那么可能会出现卡顿的现象。这时候,应该推迟图片的显示,也就是ImageView推迟显示图片。有两种方法:

  1. 监听UIScrollView的滚动

    因为UITableView继承自UIScrollView,所以我们可以通过监听UIScrollView的滚动,实现UIScrollView相关delegate即可。

  2. 利用PerformSelector设置当前线程的RunLoop的运行模式

    利用performSelector方法为UIImageView调用setImage:方法,并利用inModes将其设置为RunLoop下NSDefaultRunLoopMode运行模式。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tt"] afterDelay:2 inModes:@[NSDefaultRunLoopMode]];
}
//点击屏幕,然后拖动 UITextView,拖动2秒以上,发现过了2秒之后,UIImageView 还没有显示图片,当我们松开的时候,则显示图片
//实现了延迟显示UIImageView。

常驻线程

- (IBAction)newThreadClick {
    //创建线程
    _thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    
    _thread.name = @"thread";
    
    //启动线程
    [_thread start];
}

- (IBAction)goOnClick {
    [self performSelector:@selector(run2) onThread:_thread withObject:nil waitUntilDone:YES];
}

//当该方法执行完毕线程对象释放, 解决方法: 开启 RunLoop
- (void)run1 {
    
    NSLog(@"run1---%@", NSThread.currentThread.name);
    
    // 子线程的 RunLoop 需要手动获取 + 启动
    //RunLoop 启动后,选择运行模式, 判断运行模式是否为空(source/timer)
    
    //1. 获取
    NSRunLoop *runLoop = NSRunLoop.currentRunLoop;
    
    //2. 为运行模式添加 source/timer 使得运行模式不为空
    [runLoop addPort:NSPort.port forMode:NSDefaultRunLoopMode];
    
    //3.启动 当前线程变成了常驻线程,可随时添加任务,并交于RunLoop处理
    [runLoop run];
    
    NSLog(@"----------------end--------------------");
}

- (void)run2 {
    NSLog(@"run2+++%@", NSThread.currentThread.name);
}

自动释放池(AutoreleasePool)

  • 当 RunLoop 启动时, 第一次创建自动释放池;
  • 当 RunLoop 退出时, 最后一次销毁自动释放池;
  • 当 RunLoop 休眠时, 销毁之前自动释放池, 创建新的自动释放池;

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

界面更新((AutoLayout)

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面(Autolayout)。

网络请求

iOS 中,关于网络请求的接口自下至上有如下几层:

  • CFSocket 是最底层的接口,只负责 socket 通信。
  • CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作于这一层。
  • NSURLConnection 是基于 CFNetwork 的更高层的封装,提供面向对象的接口。
  • NSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程),AFNetworking 和 Alamofire 工作于这一层。

下面主要介绍下 NSURLConnection 的工作过程。

通常使用 NSURLConnection 时,你会传入一个 Delegate,当调用了 [connection start] 后,这个 Delegate 就会不停收到事件回调。实际上,start 这个函数的内部会会获取 CurrentRunLoop,然后在其中的 DefaultMode 添加了4个 Source0 (即需要手动触发的Source)。CFMultiplexerSource 是负责各种 Delegate 回调的,CFHTTPCookieStorage 是处理各种 Cookie 的。

当开始网络传输时,我们可以看到 NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoader 和 com.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0 通知到上层的 Delegate。

NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。