今天,来讲一个iOS开发中比较基础的东西,RunLoop。
RunLoop 是什么
RunLoop,可以分解成两个简单的英文单词 “Run” 和 “Loop”,在程序中解释这两个单词就是运行和循环的意思,所以可以把RunLoop理解成循环运行,直接解释就是不停的跑圈。
那么用代码来说的话,RunLoop实际上就是一个do-while循环,在这个循环中不断处理各种事件,比如UI事件,触摸事件等等,如果在没有事情做得时候就会进入休眠模式。
先放一张苹果官方文档中的图,后边我们也会介绍这个图中所表达的意思。
![logo](/2018/03/28/iOS开发-RunLoop/runloop.jpg)
RunLoop 有什么用
那知道了 RunLoop 就是一直跑圈的一个东西,那他到底有什么用呢?根据他的特性,RunLoop主要的作用有4点。
- 1.使程序一直处于运行状态。
- 2.决定什么时候处理什么Event。
- 3.调用解耦。
- 4.节省CPU时间。
RunLoop 与线程
RunLoop和线程是一一对应的,我们不能自己创建RunLoop对象,但是我们可以获取系统提供的RunLoop对象。当我们获取RunLoop时,系统就会创建他。
主线程的RunLoop会在应用启动的时候完成启动,而其他线程在刚创建的时候并没有RunLoop,如果你不主动获取,那他一直都不会有,当你第一次获取他的时候,RunLoop才会被创建。而当线程结束的时候,这个线程中的RunLoop也会被销毁掉。
说了这么多,那么你可能会问,我在开发的时候怎么样能知道什么时候启动了RunLoop呢?那RunLoop在真正开发的时候出现在哪里呢?
别急,我们可以通过一个小例子来看看。比如我现在在ViewController里添加一个按钮,然后跑起来执行给按钮的点击事件响应函数clickedBtnClicked
添加一个断点,然后我们来看调用的堆栈信息:
![logo](/2018/03/28/iOS开发-RunLoop/runloop2.png)
我们从下往上看:
19、18 start 是dyld调用的,让我们程序跑起来。
然后17运行了main函数,main函数中的方法
|
|
调用了UIApplicationMain
。
16行就是UIApplicationMain
的内容。
然后15行是一个发出点击事件的类,这里我们不多介绍。
14~10行就是我们今天说的主角RunLoop。
再往上就是一些业务层的调用,直到最后的ViewController
调用clickedBtnClicked
方法。
那么这几个RunLoop的具体是怎么回事儿?我们接着往下看。
RunLoop 的对外接口
RunLoop 在Foundation层和Core Foundation中都有对应的接口。分别是:
- Foundation -> NSRunLoop
- Core Foundation -> CFRunLoop
但是NSRunLoop完全是CFRunLoop面向对象的封装,但是线程不安全,所以这里我们就仅对CFRunLoop进行说明,但是需要注意的一点是,NSRunLoop中运行RunLoop的三个方法中,前两个方法是没有办法使用CFRunLoop中的方法停止的。
|
|
那接下来我们来说CFRunLoop。CFRunLoop下有5个相关的类:
- CFRunLoopRef:代表RunLoop对象
- CFRunLoopModeRef:代表RunLoop对象的模式
- CFRunLoopSourceRef:代表RunLoop对象的输入源
- CFRunLoopTimerRef:代表RunLoop对象的定时源
- CFRunLoopObserverRef:观察者,监听RunLoop的状态变化
用一张图表明这五个类的关系就是:
![logo](/2018/03/28/iOS开发-RunLoop/runloop3.png)
其中每个RunLoop中对应多个Mode,每个Mode下又包含多个Source/Timer/Observe。
每次调用RunLoop的主函数时都会指定其中一个Mode,这个Mode被称为CurrentMode。
如果想要切换Mode时必须先退出Loop,再重新指定一个Mode进入,这样做主要是为了隔离开不同组的Source/Timer/Observe,使其不会互相发生影响。
CFRunLoopRef
CFRunLoopRef就是RunLoop对象类。
获取RunLoop的函数:
|
|
操作RunLoop的函数:
|
|
比如这个Demo:
|
|
CFRunLoopModeRef
系统一共提供了5中Mode:
- 1.kCFRunLoopDefaultMode:系统中默认的运行模式。
- 2.UITrackingRunLoopMode:用户交互事件模式。比如ScrollView滑动时,为了保证用户体验流畅,会切换到这个模式。
- 3.UIInitializationRunLoopMode:刚启动的时候进入的第一个Mode,启动完成后就不再使用了。
- 4.GSEventReceiveRunLoopMode:接受内部事件,通常用不到。
- 5.kCFRunLoopCommonModes:这个不是一个真正的mode。
所以真正我们平时用到的Mode就只有两种,kCFRunLoopDefaultMode和UITrackingRunLoopMode。
而另外一种kCFRunLoopCommonModes主要是用来标记,每当RunLoop的内容发生变化时,RunLoop都会自动将_commonModeItems 里的Source/Timer/Observer同步到具有Common标记的Mode里。
比如说我们应该都会遇到过的一个问题,就是在添加NSTimer时,如果将它加入到kCFRunLoopDefaultMode时,滑动页面中的ScrollView就会导致计时器失效,这就是因为滑动时runloop会自动改变到UITrackingRunLoopMode下,而在kCFRunLoopDefaultMode下的timer就失效了,那如果将它加入到kCFRunLoopCommonModes中的话,就可以修复这个问题,就是因为kCFRunLoopCommonModes中标记了
kCFRunLoopDefaultMode和UITrackingRunLoopMode这两种Mode。
|
|
CFRunLoop中也提供了管理Mode的接口:
|
|
CFRunLoopSourceRef
这个类是事件产生的地方,Source有两种版本,Source0和Source1。
Source0 只包含了一个回调,并不能主动触发事件。使用时,需要先调用CFRunLoopSourceSignal(source),将这个Source标记为待处理,然后手动调用CFRunLoopWakeUp(runloop) 来唤醒这个RunLoop,让他处理事件。
Source1 包含了一个mach_port和一个回调,被用于内核和其他线程互相发送消息。这种Source能主动唤醒RunLoop的线程。
比如说之前的点击事件的堆栈信息中就可以看到,这个RunLoop的Source就是Source0类型的。
![logo](/2018/03/28/iOS开发-RunLoop/runloop2.png)
CFRunLoopTimerRef
这个类是基于时间的触发器,在之前的官方RunLoop模型中也有他的存在,常用的计时器NSTimer即使基于他来封装的。
|
|
比如这段代码加入到runloop中之后,runloop就会注册一个对应的时间点(2s),当时间点到时,runloop就会唤醒这个回调方法(timerDo:)。
说到计时器,除了NSTimer之外,平时还可能会用到GCD的计时器,或者CADisplayLink,他们与基于runloop的NSTimer还是有一些差别的。
比如GCD的计时器:
|
|
可以用精确的参数,不用依赖于runloop的mode,性能消耗更小。
而CADisplayLink主要是跟随屏幕的刷新频率保持一致(1s/60次)。
CFRunLoopObserverRef
这个类是一个观察者,每个观察者都包含了一个回调,每当runloop的状态发生变化时,观察者就能通过这个回调得知信息。回调的状态有:
|
|
比如我们添加一个观察者,观察主线程中的runloop。
|
|
可以看到打印的结果:
|
|
到最后停在了 即将进入休眠的状态。
如果这个时候runloop中一个Source/Timer/Observer都没有了的话,runloop就会退出。
RunLoop 的内部实现
先上一张YY大神整理的图:
![logo](/2018/03/28/iOS开发-RunLoop/runloop4.png)
这张图说明了一切,再放一个加了注释的runloop代码:
|
|
相信看完了这两个YY大神整理的内容之后,runloop的实现原理就已经讲的很清楚了。总结一下就是runloop内部和之前说的一样,就是一个循环,只要有Timer/Source/Observer的时候循环就会一直执行,当操作结束之后,runloop就会停下来,进入休眠状态等待唤醒。
RunLoop 的本质
runloop的本质就是 mach port 和 mach_msg()。
Mach是XNU的内核,进程、线程和虚拟内存等对象通过端口发消息进行通信,RunLoop是通过mach_msg()函数发送消息,如果没有port消息,内核就会将线程置于等待状态,如果有消息,就会判断消息类型处理事件,并通过modeItem的callback回调。
![logo](/2018/03/28/iOS开发-RunLoop/runloop5.png)
RunLoop 在苹果中的使用
Autorelease Pool
App启动后,苹果在主线程的RunLoop里注册了两个Observer,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()。
第一个Observer监听的事件是Entry(即将进入Loop),其回调会调用_objc_autoreleasePoolPush()创建自动释放池,这个Observer的优先级最高,保证发生在全部的回调之前。
第二个Observer监听了两个事件:
- BeforeWaiting(准备进入休眠)时调用_objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()来释放旧的池并创建新的池。
- Exit(即将退出)时调用_objc_autoreleasePoolPop()来释放池。这个Observer的优先级最低,保证释放池在所有的操作之后。
事件响应
当一个硬件事件(触摸/锁屏/摇晃/加速)发生后,首先有IOKit.framework生成一个IOHIDEvent事件并由SpringBoard接受,之后由mach port转发给需要的App进程。
苹果注册了一个Source1来接受系统事件,通过回调函数触发Source0(所以Event实际上是基于Source0)的,调用_UIApplicationHandleEventQueue() 进行应用内部的分发。
_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。
手势识别
当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。
苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。
当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。
界面刷新
当UI发生改变时(Frame变化,UIView/CALayer的结构变化)时,或手动调用了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法后,这个UIView/CALayer就被标记为待处理。
苹果注册了一个用来监听BeforeWaiting和Exit的Observer,在他的回调函数里会遍历所有待处理的UIView/CALayer来执行实际的绘制和调整,并更新UI界面。
定时器
NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。
GCD任务
当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。
网络请求
关于网络请求的接口:最底层是CFSocket层,然后是CFNetwork将其封装,然后是NSURLConnection对CFNetwork进行面向对象的封装,NSURLSession 是 iOS7 中新增的接口,也用到NSURLConnection的loader线程。所以还是以NSURLConnection为例。
当开始网络传输时,NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoader 和 com.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0 通知到上层的 Delegate。
![logo](/2018/03/28/iOS开发-RunLoop/runloop6.png)
RunLoop 的应用
说了这么多,那我们在平时的开发中最有可能用到的RunLoop应用是什么时候呢,比如之前说的NSTimer的问题,还有就是比如AFN中用到的开辟一条常驻的线程。
接下来我们就来做了一个使用runloop常驻线程的例子。
首先我们创建一条线程并让他跑起来:
|
|
|
|
接下来我们添加一个按钮,当点击 按钮的时候在刚刚我们建立的线程中调用方法demoNo4Thread2
。
|
|
这时候我们会发现,程序运行起来只会打印:
|
|
因为runloop中并没有事情,导致myThread已经结束了,所以点击按钮时,并没有办法唤起方法。
这时候,我们在demoNo4Thread
中给当前的runloop添加一个port,并让他跑起来。
|
|
这时候再运行程序,就可以看到,demo No 4 thread is over没有打印,点击按钮的时候,也能响应对应的方法。
|
|
这样一条常驻的线程就已经开辟好了。
最后
以上就是这次文章的全部内容了,其中大部分内容都来自于YY大神的博客中,仅供个人学习使用,如果有什么不对的地方还请各位大佬多多批评。
Demo地址在这里