RunLoop 从理解到应用

1,467 阅读7分钟

2021.2 @Hanniya

最近因准备面试,有较多学习内容。计划产出的是有较多我个人理解和知识结构的几篇学习内容:RunLoop、Runtime、AutoreleasePool,本篇是 RunLoop 相关,欢迎各位作为查缺补漏来阅读~

面试思路大纲

是什么

  • 是在一个【线程】中,持续调度各种任务的运行循环机制【本质:while循环】

做什么

  1. performTask() 执行任务:Block、Source0、Source1、Main queue、Timer
  2. callout_to_observer() 通知外部:Activity、Source0、Timer
  3. sleep() 睡眠

应用:Timer、线程保活、卡顿检测

1. RunLoop 简介

1.1 作用

  • 保持程序持续运行: 程序一启动,在 UIApplicationMain 就会开一个主线程,跑一个和主线程对应的 RunLoop,这个 RunLoop 保证主线程不会被销毁,也就保证了程序的持续运行。
  • 处理App中的各种事件,如触摸事件、定时器事件、Selector事件等
  • 节省CPU资源,提高程序性能 当没任务时,RunLoop会告诉CPU要去休息,这时CPU就会将其资源释放出来去做其他的事情,当有事情做的时候RunLoop就会去做事

1.2 特点

  • 与线程的关系 线程和 RunLoop 之间一一对应,其对应关系保存在一个全局的 Dictionary 里,线程是 key,RunLoop 是 value。
  • 生命周期 子线程的 RunLoop 的创建发生在第一次获取时(若创建子线程后不主动获取,则不会创建,可以理解为懒加载),RunLoop 的销毁发生在线程结束时。
  • 获取 只能在一个线程的内部获取其 RunLoop(主线程除外)。
//Foundation
[NSRunLoop currentRunLoop]; // 获得当前线程的RunLoop对象
[NSRunLoop mainRunLoop]; // 获得主线程的RunLoop对象

//Core Foundation
CFRunLoopGetCurrent(); // 获得当前线程的RunLoop对象
CFRunLoopGetMain(); // 获得主线程的RunLoop对象

NSRunLoop 是对 CFRunLoopRef 的一层封装
CFRunLoopRef 的 API 是线程安全的;NSRunLoop 提供了面向对象的 API,但这些 API 不是线程安全的。
开一个子线程时创建 RunLoop,不是通过 alloc init 方法创建,而是直接通过调用 currentRunLoop 方法来创建,因为它本身是一个懒加载。

2. RunLoop 做什么

2.1 performTask() 执行任务

DoBlocks()

  • 开发者可使用

DoSources0()

  • 开发者可使用
  • Source 0 不能主动唤醒 RunLoop

DoSources1()

  • 只能系统使用
  • Source 1 能够主动唤醒 RunLoop
  • 基于 mach_msg 函数,通过读取 port 上内核消息队列的消息来决定执行的任务。
  • 任务包括渲染 UI 等

DoMainQueue()

  • 开发者可使用,调用 GCD 的 API 将任务放入到 main queue 中

DoTimers()

  • 开发者可使用,调用 NSTimer 的 API 即可注册被执行的任务

2.2 callout_to_observer() 通知外部

DoObservers-Activity 当前状态

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),        //每次进入Runloop(如切换mode后)
    kCFRunLoopBeforeTimers = (1UL << 1), //即将DoTimers
    kCFRunLoopBeforeSources = (1UL << 2),//即将DoSources
    kCFRunLoopBeforeWaiting = (1UL << 5),//当前线程即将进入睡眠(若当前队列无多余消息则进入睡眠)
    kCFRunLoopAfterWaiting = (1UL << 6), //当前线程从睡眠中恢复(读出队列消息,继续执行)
    kCFRunLoopExit = (1UL << 7),         //退出Runloop
    kCFRunLoopAllActivities = 0x0FFFFFFFU
};

DoObservers-Timer

表示将要处理Timer

DoObservers-Source0

表示将要处理Source0

2.3 sleep() 睡眠

2.4 综合流程图

Runloop 的每次 loop 不总是按顺序执行上面的各种 performTaskcallout_to_observer,而是糅合在一起各种跳转
借用mrpeak的图来理解完整的流程: rl00.png

  • Poll:如果处理了source0任务,poll值为true,睡眠前后不会进行通知。
  • DoBlocks -> DoSource0 -> (睡眠) -> DoSource1/DoMainQueue/DoTimers -> DoBlocks 循环 睡眠唤醒 RunLoop 后 DoSource1/DoMainQueue/DoTimers 只会三选一

3. RunLoop 原理

3.1 本质:结构体

RunLoop 结构体

struct __CFRunLoop {
    ...//省略非核心成员
	  CFMutableSetRef _commonModes; 
    CFMutableSetRef _commonModeItems; 
    CFRunLoopModeRef _currentMode; //指向_CFRunLoopMode结构体的指针
    CFMutableSetRef _modes; //多个mode数组
};

Mode结构体

typedef struct __CFRunLoopMode *CFRunLoopModeRef;
struct __CFRunLoopMode {
    ... //省略非核心成员
    CFMutableSetRef _sources0;
    CFMutableSetRef _sources1;
    CFMutableArrayRef _observers;
    CFMutableArrayRef _timers;
};
  • 一个RunLoop包含多个Mode,每个Mode又包含多个Source/Timer/Observer

mainQueue 任务的执行和 mode 无关,mode 内没有相关信息

3.2 Mode:CFRunLoopModeRef

Mode 分为 Common Mode 和 Private Mode,所以 observer 并不会监控到所有 Runloop 的动态

  • RunLoop 的 Mode 切换

RunLoop启动时选择其中一个Mode作为currentMode;
需要切换Mode时,只能退出RunLoop,再重新指定一个Mode进入,这样做主要是为了分隔开不同组的Source、Timer、Observer,让其互不影响
若当前mode内没有任何Source/Timer/Observer,RunLoop不会空转,会立刻退出。

3.3 Source(0/1)/Timer/Observer

  • Source 事件产生
    • Source0:包含一个函数指针(回调),接受外界触发的事件,不能主动唤醒RunLoop,只能通过Wakeup接口唤醒RunLoop来处理事件(触摸事件、performSelectors)
    • Source1:包含一个mach_port和一个函数指针(回调),能主动唤醒RunLoop(基于Port的线程间通信)
  • Timer:定时器,包含一个时间长度和一个函数指针(回调)
  • Observer:观察者,包含一个函数指针(回调),通过回调监听RunLoop的状态

3.4 RunLoop 的内存管理

即将进入 RunLoop 时,通过 observer 观察到 kCFRunLoopEntry 状态,主线程 RunLoop 会创建一个 AutoreleasePool。

4. 面试题

4.1 不做处理时当拖动 tableview 时 NSTimer 会响应吗?怎么解决

不会响应。

原因:NSTimer 默认只会调度到 kCFRunLoopDefaultMode,当 scrollView 滑动的时候,runloop 会进入 UITrackingRunLoopMode,那么在 doTimer 的时候自然就不会触发 NSTimer 的任务了

解决办法:

  1. 将 NSTimer 也加入到 UITrackingRunLoopMode(但这样timer被添加了两次,不是同一个timer)
  2. 把 NSTimer 加入到 NSRunLoopCommonModes 里,相当于将自己标记为Common,所有也标记为common的mode都会继续处理这个事件。

但即使这样,当 RunLoop 使用系统 private mode 时,也会存在不执行 Timer 的问题。

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

4.2 NSTimer 和 GCD 哪个更精准?为什么

CGD 定时器更精准。因为

  1. NSTimer 是每次 Runloop 检查一次到没到时间,有误差。

RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务且也过了Timer的宽容度,则那个时间点的回调也会跳过去,不会延后执行。

  1. NSTimer 有可能因为 Mode 问题被延迟处理。
    //创建队列
    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.设置定时器的开始时间,间隔时间,精准度
    //精准度 一般为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]);
    });
    dispatch_resume(timer); // 启动

4.3 RunLoop 如何响应用户事件、手势、界面刷新

  • UIEvent 事件历程: 手指触摸屏幕
  1. IOKit.framework 封装事件为 IOHIDEvent 对象
  2. 端口通信:通过 mach port 转发到 APP,主线程 Runloop 中 Source1接收
  3. Runloop 进行回调(Source1回调 -> Source0)
  4. Source0 的回调将触摸事件添加到事件队列(FIFO)
  5. 出队列时 UIApplication 开始寻找最佳响应者(Hit-testing)
  6. 事件被发送至最佳响应者,进行响应或传递
  • 手势: 系统注册了一个 Observer 监测 BeforeWaiting (RunLoop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行 GestureRecognizer 的回调。 当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

  • 界面: 当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay 方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。 系统注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行函数,会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

  • CADisplayLink 可以理解为一个和屏幕刷新率一致的定时器。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去,造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。

4.4 RunLoop 在第三方库的实际应用

  • AFNetworking AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop。

  • AsyncDisplayKit Facebook 推出的用于保持界面流畅性的框架,将绘制和排版放在后台线程进行。使用 Node 来封装 View 和 Layer,并实现了类似的一套界面更新的机制:在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务,然后一一执行。

5. 关于RunLoop可供复习的精选文章

参考:

解密 Runloop
iOS学习——浅谈RunLoop - 云+社区 - 腾讯云
深入理解RunLoop | Garan no dou
iOS底层原理总结 - RunLoop - 掘金

源码:CFRunLoop.c