type
status
date
slug
summary
tags
category
icon
password
假如你已经阅读完前面的三篇RunLoop的文章,就会对RunLoop是什么,有一个比较全貌的了解。
本文,将针对RunLoop在iOS/macOS中的应用,做一些常用场景的分析。

一、线程保活

线程保活,就是利用RunLoop的运行循环,使得某个线程能长时间的运行,在有任务时执行任务,没有任务时也保持运行,不退出线程。
相关内容,将会在另外一篇 【占位待补:多线程(九)线程保活】中详细描述。

二、定时器

本节示例代码——NSTimer失效

1. 定时器的使用

定时器,在开发中一般使用NSTimer,可以产生基于时间的通知,但它并不是实时机制。和输入源一样,定时器也RunLoop的特定模式相关。如果定时器所在的模式当前未被RunLoop监视,那么定时器并不会被调用。
NSTimer 其实就是 CFRunLoopTimerRef,两者是 toll-free bridged 的。
NSTimer有两种创建方式。
其中,方式a,仅仅创建,并返回,如果要是的NSTimer被调用,需要手动加入RunLoop
方式b,创建并且会自动将其添加到当前的RunLoop中的default mode下。
这种方式下,不用再往mode里添加,也能正常调用Timer。

2. 滑动时失效

在上面创建NSTimer的方式中,不管方式a,还是b,假如页面在滑动ScrollView时,定时器都会停止调用。
因为在滑动ScrollView时,RunLoop处于UITrackingRunLoopMode运行模式下,该模式中如果不手动添加对应的Timer,是不会有定时器的,所以在滑动时,也就不会调用定时器的回调。
那么,解决方式,就是将定时器,添加到UITrackingRunLoopMode下。
或者利用之前的Common Mode。

3. 不准时

一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook 开源的 AsyncDisplayLink 就是为了解决界面卡顿的问题,

三、其他应用

1. AutoreleasePool

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。
在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。
  • 第一个Observer会监听Entry(即将进入Loop),其回调内会调用objc_autoreleasePoolPush()向当前的AutoreleasePoolPage增加一个哨兵对象标志创建自动释放池。这个Observer的order是-2147483647优先级最高,确保发生在所有回调操作之前。
  • 第二个Observer会监听RunLoopBeforeWaiting(准备进入休眠)和Exit(即将退出Loop)两种状态。
  • 在即将进入休眠时会调用objc_autoreleasePoolPop()objc_autoreleasePoolPush() 根据情况从最新加入的对象一直往前清理直到遇到哨兵对象。
  • 即将退出RunLoop时会调用objc_autoreleasePoolPop() 释放自动自动释放池内对象。这个Observer的order是2147483647,优先级最低,确保发生在所有回调操作之后。
当然你如果需要显式释放,例如循环,可以自己创建AutoreleasePool,否则一般不需要自己创建。

2. 事件响应

苹果注册了一个 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 事件都是在这个回调中完成的。
比如,UIButton点击事件,通过Source1接收后,包装成Event,最后进行分发是由Source0事件回调来处理的。

3. 手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。
苹果注册了一个 Observer 监测 BeforeWaiting(Loop即将进入休眠)事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。
当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

4. 界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠)Exit (即将退出Loop)事件,回调去执行一个很长的函数:**ZN2CA11Transaction17observer_callbackEP19CFRunLoopObservermPv()**。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
这个函数内部的调用栈大概是这样的:
通常情况下这种方式是完美的,因为除了系统的更新,以及setNeedsDisplay等方法手动触发下一次RunLoop运行的更新。但是如果当前正在执行大量的逻辑运算可能UI的更新就会比较卡,因此facebook推出了AsyncDisplayKit来解决这个问题。AsyncDisplayKit其实是将UI排版和绘制运算尽可能放到后台,将UI的最终更新操作放到主线程(这一步也必须在主线程完成),同时提供一套类UIView或CALayer的相关属性,尽可能保证开发者的开发习惯。这个过程中AsyncDisplayKit在主线程RunLoop中增加了一个Observer监听即将进入休眠和退出RunLoop两种状态,收到回调时遍历队列中的待处理任务一一执行。

5. PerformSelecter

perforSelector有下面三类:
在苹果开源的objc源码,我们从NSObject.mm得到如下源码:
从源码可以直接看出,在不包含delay时,其直接调用objc_msgSend,与RunLoop无关。
但是,找不到- (void) performSelector: withObject: afterDelay:的源码。苹果并没有开源。
我们通过GNUstep项目,找到该方法的Foundation的源码
其实本质上,是转换为一个包含NSTimer定时器的GSTimedPerformer对象,实质上是个Timers事件,添加到RunLoop中。
注意:GNUstep项目只是一个开源实现,其实现和苹果实现大部分一致,所以可参考性很强,但并不是完全一致。

6. 关于GCD

在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驱动的。

7. 关于网络请求

iOS 中,关于网络请求的接口自下至上有如下几层:
  • CFSocket 是最底层的接口,只负责 socket 通信。CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作于这一层。NSURLConnection 是基于 CFNetwork 的更高层的封装,提供面向对象的接口,AFNetworking 工作于这一层。NSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程),AFNetworking2 和 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。
notion image
NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。

参考

链接
  1. objc源码
  1. GNUstep 源码
  1. 深入理解RunLoop
示例代码
  1. NSTimer失效