参考
RunLoop的概念
RunLoop
是一个机制,让线程能随时处理事件但并不退出。这种机制实现的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。
iOS提供了两个这样的对象:NSRunLoop
和 CFRunLoopRef
。
-
CFRunLoopRef
是在CoreFoundation
框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。 -
NSRunLoop
是基于CFRunLoopRef
的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。
Runloop和线程之间的关系
线程和 RunLoop
之间是一一对应的,其关系是保存在一个全局的 Dictionary
里。线程刚创建时并没有RunLoop
,如果你不主动获取,那它一直都不会有。RunLoop
的创建是发生在第一次获取时,RunLoop
的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop
(主线程除外)。主线程的RunLoop
是一直运行的,RunLoop
在执行完任务后会进入休眠,等待下一次启动。
RunLoop的组成
-
Timer
- 理解的
Timer
- 理解的
-
Source
(RunLoop
数据源的抽象类protocol)-
Source0
:处理App内部时间,App自己负责触发(UIEvent
、CFSocket
) -
Source1
:由RunLoop
和mach
内核管理,由mach-port
驱动
-
-
Observer
- 许多机制都由
Observer
来触发- 例如
CAAnimation
,在afterwaiting
收集完所有animation
后才执行动画
- 例如
- 许多机制都由
RunLoop的Mode
-
NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
):App的默认 Mode,通常主线程是在这个 Mode 下运行的 -
UITrackingRunLoopMode
:界面跟踪 Mode,用于ScrollView
追踪触摸滑动,保证界面滑动时不受其他 Mode 影响 -
UIInitializationRunLoopMode
:在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用(iOS不公开提供) -
NSRunLoopCommonModes
(kCFRunLoopCommonModes
):Mode集合(iOS不公开提供) -
GSEventReceiveRunLoopMode
: 接受系统事件的内部 Mode,通常用不到
RunLoop
只能运行在一个mode下,如果要换mode,当前的loop也需要停下重启成新的。
例如:ScrollView
滚动过程中NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
)的mode会切换到UITrackingRunLoopMode
来保证ScrollView
的流畅滑动,如果我们把一个NSTimer
对象以NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
)添加到主运行循环中的时候, ScrollView
滚动过程中会因为mode的切换,而导致NSTimer将不再被调度,解决方案是将timer添加到NSRunLoopCommonModes
(kCFRunLoopCommonModes
)中或者另起线程避免mode切换来解决。
RunLoop内部逻辑
RunLoop
内部是一个 do-while 循环。当你调用 CFRunLoopRun()
时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。
RunLoop的底层实现
RunLoop 的核心是基于 mach port 的
iOS的内核是Mach
,在 Mach
中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为"对象"。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。"消息"是 Mach
中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach
的IPC
(进程间通信) 的核心。
为了实现消息的发送和接收,mach_msg()
函数实际上是调用了一个 Mach
陷阱 (trap
),即函数mach_msg_trap()
,陷阱这个概念在 Mach
中等同于系统调用。当你在用户态调用 mach_msg_trap()
时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg()
函数会完成实际的工作。
RunLoop
调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在mach_msg_trap()
这个地方。
iOS利用RunLoop实现的功能
-
AutoreleasePool
App启动后,苹果在主线程
RunLoop
里注册了两个Observer
,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()
。第一个
Observer
监视的事件是Entry
(即将进入Loop),其回调内会调用_objc_autoreleasePoolPush()
创建自动释放池。其order
是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。第二个
Observer
监视了两个事件:BeforeWaiting
(准备进入休眠) 时调用_objc_autoreleasePoolPop()
和_objc_autoreleasePoolPush()
释放旧的池并创建新池;Exit
(即将退出Loop) 时调用_objc_autoreleasePoolPop()
来释放自动释放池。这个Observer
的order
是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。在主线程执行的代码,通常是写在诸如事件回调、
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
的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。 -
界面更新
当在操作 UI 时,比如改变了
Frame
、更新了UIView
/CALayer
的层次时,或者手动调用了UIView
/CALayer
的setNeedsLayout
/setNeedsDisplay
方法后,这个UIView
/CALayer
就被标记为待处理,并被提交到一个全局的容器去。苹果注册了一个
Observer
监听BeforeWaiting
(即将进入休眠) 和Exit
(即将退出Loop) 事件,回调去执行一个很长的函数:_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
。这个函数里会遍历所有待处理的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 相似
),造成界面卡顿的感觉。在快速滑动TableView
时,即使一帧的卡顿也会让用户有所察觉。Facebook
开源的AsyncDisplayLink
就是为了解决界面卡顿的问题,其内部也用到了RunLoop
。 -
PerformSelecter
当调用
NSObject
的performSelecter:afterDelay:
后,实际上其内部会创建一个Timer
并添加到当前线程的RunLoop
中。所以如果当前线程没有RunLoop
,则这个方法会失效。当调用
performSelector:onThread:
时,实际上其会创建一个Timer
加到对应的线程去,同样的,如果对应线程没有RunLoop
该方法也会失效。
RunLoop常见应用
- 使用
RunLoop
的Mode
做tableview
滑动优化- 通过不同的
mode
的切换,实现滑动时暂停加载图片等,停止滑动时加载
- 通过不同的
-
NSTimer
计时任务 -
autorelease pool
- 由
RunLoop
维护
- 由
- 卡顿检测
- 利用
Observer
记录主线程RunLoop
休眠的时间 - 利用
Observer
记录主线程RunLoop
唤醒的时间 - 计算这个(唤醒时间 - 休眠时间)的值,将其与正常的时间比较,判断当前是否会掉帧
- 利用
- 让
Crash
的程序回光返照- 接收到
Crash
的Signal
后手动重启RunLoop
- 接收到
- 异步
Test Case
-
sleep
前验证
-
用到的框架
-
AFNetworking
用于维护线程AFNetworking
是基于NSURLConnection
构建的,为了在后台也能接受回调,会创建一个线程,线程中添加一个RunLoop
。由于没有调用RunLoop
的停止方法,所以RunLoop
不会退出。 -
AsyncDisplayKit
ASDK
创建了一个名为ASDisplayNode
的对象,并在内部封装了UIView
/CALayer
,它具有和UIView
/CALayer
相似的属性,例如frame
、backgroundColor
等。所有这些属性都可以在后台线程更改,开发者可以只通过Node
来操作其内部的UIView
/CALayer
,这样就可以将排版和绘制放入了后台线程。
并在主线程的RunLoop
中添加一个Observer
,监听RunLoop
进入休眠和退出的回调事件,收到回调后,遍历执行队列中的任务。 -
YYAsyncLayer
- 实现原理如下:
- 正常情况下:假设一次
RunLoop
需要处理50张图片 - 使用
YYAsyncLayer
的情况:一次RunLoop
处理1张图片,利用50个RunLoop
去处理50张图片- 注意:在不计算休眠时间的情况下,50个
RunLoop
处理时间 = 1次RunLoop
处理50张图片的时间
- 注意:在不计算休眠时间的情况下,50个
- 正常情况下:假设一次
- 实现原理如下:
有关RunLoop的问题
-
RunLoop
与线程的关系- 一对一,一个线程可以有一个
RunLoop
,也可以没有 - 主线程的
RunLoop
已经自动创建好了,子线程的RunLoop
需要主动创建 -
RunLoop
在第一次获取时创建,在线程结束时销毁
- 一对一,一个线程可以有一个
-
RunLoop
只是个死循环吗?- 不是,
RunLoop
是个有时间限制的循环
- 不是,
- 使用
while(true)
和RunLoop
哪个好?-
RunLoop
,因为RunLoop
可以在不需要使用的时候休眠,节省CPU资源,而while(true)
则一直处于CPU活跃状态
-
- 为什么我们主线程需要有
RunLoop
?- 保持线程存活,接受事件
- 为了管理
AutoreleasePool
-
[NSRunLoop currentRunLoop]
实际上做了什么-
[NSRunLoop currentRunLoop]
实则为一个懒加载的方法。它会遍历一张全局静态的数据表,该数据表以线程PID为Key,以与该线程绑定的RunLoop
为Value
。该表创建的时候会首先对当前线程(主线程)的PID放入一个RunLoop
-
-
RunLoop
与autorelease pool
的关系- 对于每一个
Runloop
, 系统会隐式创建一个Autorelease pool
,这样所有的release pool
会构成一个象CallStack
一样的一个栈式结构,对象会自动被放入栈顶的AutoreleasePool
中,在每一个Runloop
结束时,当前栈顶的Autorelease pool
会被销毁,这样这个pool
里的每个Object
会被release
。 - 两次
pop
两次push
,均利用Observer
实现- 进入后
push
- 睡眠前
pop
- 睡眠后
push
- 离开前
pop
- 进入后
- 对于每一个
-
GCD
的dispatch_get_main()
是如何实现的- 当调用
dispatch_async(dispatch_get_main_queue(), block)
时,libDispatch
会向主线程的RunLoop
发送消息,RunLoop
会被唤醒,并从消息中取得这个block
,并在回调\__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
里执行这个block
。但这个逻辑仅限于 利用GCD
将block
分发到主线程,分发到其他线程仍然是由libDispatch
处理的。 -
GCD
有自己的线程池,当需要使用到线程的时候随机找一个线程来跑,但是主线程是唯一的,使用RunLoop
的主线程
- 当调用
- 如何切换
Mode
?为什么要这样做?- 先离开,重新进入后切换
Mode
- 这样是为了保证
Mode
里面的Timer
、Sources
、Observer
互不影响 - 延伸:在主线程
Mode
切换的时候,RunLoop
这一次离开与下一次进入之前有一段间隔,这段间隔会对我们的应用有影响吗(比如会丢事件吗)?- 不会有影响,因为我们会把在这期间收到的事件都放在一个队列中,等待下一次
RunLoop
进入的时候,RunLoop
根据该队列进行处理
- 不会有影响,因为我们会把在这期间收到的事件都放在一个队列中,等待下一次
- 先离开,重新进入后切换
- 使用
Timer
要注意什么- 注意使用内存管理
[timer invalidate];
及设nil
- 使用
addCommonMode
/addUITrackingMode
保证精准度
- 注意使用内存管理
-
CommonModes
本质是什么-
CommonModes
是一个标识,CFRunLoopAddCommonMode
等于给某个Mode
打标识。 - 这里有个概念叫
CommonModes
:一个Mode
可以将自己标记为Common
属性(通过将其ModeName
添加到RunLoop
的commonModes
中)。每当RunLoop
的内容发生变化时,RunLoop
都会自动将_commonModeItems
里的Source
/Observer
/Timer
同步到具有Common
标记的所有Mode里。
-
-
NSThread
在没有RunLoop
的情况下,执行完入口函数,会被立刻关闭吗?- 不会立刻关闭,会在执行完后,过段时间被清理
- 延伸:既然如此,为什么把主线程的
RunLoop
关闭后,应用会崩溃?- 应用保证了主线程一定要有
RunLoop
,没有RunLoop
则崩,与上面问题没有关系
- 应用保证了主线程一定要有