RunLoop 是ios/osx 应用程序运行的基础,它使我们的程序能够不断处在一个循环中,有效地接受事件并处理事件,可以说,它为整个程序的运行搭建了一个框架。不过,在平时上层的开发中,NSRunLoop/CFRunLoopRef 暴露的接口使用的并不多,使得我们忽略了对它原理和机制的探究,而这篇文章的目的就是对Runloop机制进行一次解读,加深了解。
首先,Runloop是跟线程挂钩的,一个线程只能有唯一对应的Runloop,当然根Runloop 可以嵌套子Runloop,不过这种情况使用的并不多。如果一个线程没有创建对应的Runloop,那么运行完一次后就退出了。要想使线程能够在要处理的事件到来时,及时地处理反馈,就要为线程创建一个特定的“循环机制”,使程序在没有事件处理时挂起休息,节省资源;在事件到来时又能够被及时地唤醒工作。这就是Runloop运行最基本的原理。
苹果系统提供了两个关于Runloop的对象:NSRunloop 和 CFRunloopRef。CFRunloopRef是在CoreFoundation框架内的,是c函数的api,线程安全;而NSRunloop是对CFRunloopRef的封装,面向对象,但是线程不安全。这篇文章主要基于CFRunloopRef进行阐述,每个接口在NSRunloop基本都可以找到对应的封装。
前面说到,Runloop和线程是一一对应的。为了更好的理解,我们看下CFRunloopRef这部分相关的代码:
系统不允许我们直接创建Runloop,只提供了CFRunLoopGetMain( ) 获取主线程的Runloop 和 CFRunloopGetCurrent() 获取当前线程的Runloop。可以看到,Runloop和Thread的关系保存在一个全局字典里,第一次获取时创建。当我们创建一个线程时,默认是不会帮我们获取Runloop的,而主线程的Runloop是在启动时系统帮我们创建的。
下面将会从Runloop的基本构造,Runloop的运行逻辑 以及 与Runloop相关的一些应用场景 三个方面介绍Runloop。
一个Runloop包含至少一个mode,每个mode由source/observer/timer的集合构成。每次Runloop运行在其中一种模式(mode)下,如果想切换另一种模式,必须退出当前Runloop,再重新进入,也就是说mode与mode之间是相互隔离的。
// 添加mode
CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
当添加一个新的mode时,系统就自动帮我们创建该mode。但是,mode 只能添加不能删除。
mode有以下5中类型:
而 source/observe/timer 统称mode item。一个mode必须有mode item,runloop才能在该mode下运行。下面介绍下这三种mode item:
1) source0: CFRunLoopSource {order =…, {callout =… }} //order:优先级;callout:回调函数
只含有回调事件,使用时要标记为待处理,然后手动wakeup Runloop来处理。比如:event事件
2) source1: CFRunLoopSource {order = …, {port = …, callout =…} //order:优先级;port:监听的 端口 ; callout:回调函数
包含mach_port和callout回调,可以通过内核和其他线程进程通信,使用时能主动唤醒Runloop。
CFRunLoopTimer {firing =…, interval = …,tolerance = …,next fire date = …,callout = …}
// tolerance 允许时间误差
我们熟悉的NSTimer 和 performSelecter:afterdelay:都是基于它实现的。底层是生成这种时间源并加 入到当前Runloop中,当时间点到时,Runloop被主动唤醒执行回调操作。
CFRunLoopObserver {order = …, activities = …, callout = …} // order(优先级),ativity(监听状态),callout(回调函数)
监听的状态有以下这几种:
利用监听主线程Runloop的状态,系统做了一系列的工作,比如界面绘制,自动释放池的创建释放等,下面会具体介绍。
Runloop暴露的管理mode item的接口有下面这几个:
我们来看下 CFRunloop 以及 CFRunloopMode的定义:
正如上面介绍的,CFRunloopMode 是由几种mode item的集合构成的,而CFRunloop 又包含若干个CFRunloopMode。CFRunloop中还定义了commonModes 和 commonModeItems 两个集合,这里有个介绍:
可以将一个mode标记为common属性,也就是调用下面这个接口将mode加入到_commonModes中:CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
而每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有Mode里。
就比如上面列出的kCFRunLoopDefaultMode 和 UITrackingRunLoopMode,这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,UITrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个ScrollView时,RunLoop 会将 mode 切换为 UITrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。
如果想在滑动的过程中也监听timer的回调,可以将这个 Timer 分别加入这两个 Mode;或者 将 Timer 加入到顶层的 RunLoop 的 “commonModeItems” 中。”commonModeItems” 被 RunLoop 自动更新到所有具有”Common”属性的 Mode 里去。
了解了Runloop 的基本构造后,我们来看下Runloop 内部的运行逻辑。CFRunloop.c 的源码可以在这里https://opensource.apple.com/source/CF/CF-855.17/CFRunLoop.c.auto.html看到,下面是关键部分的源代码:
以及根据源码归纳出的来流程简图:
\
我们在一开始提到,Runloop 运行最基本的原理是:让程序在没有事件处理时挂起休息,节省资源;在事件到来时又能够被及时地唤醒工作,也就是流程图中:休眠,监听特定的端口,等待唤醒。实现这一原理的关键就是mach port 和 mach_msg()函数。
首先,需要先了解下基本背景:Mach是XNU的内核,进程、线程和虚拟内存等对象通过端口发消息进行通信,”消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。
一条 Mach 消息实际上就是一个二进制数据包 (BLOB),其头部定义了当前端口 local_port 和目标端口 remote_port。
而mach_msg()函数实际上是调用了mach_msg_trap(),然后从用户态切换到内核态。RunLoop 调用这个函数去接收特定端口的消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态,也就是上面代码中的这部分:
从上面的流程图看,Runloop 运行的两个关键步骤 就是 休眠监听mach_port 以及 根据特定条件判断是否要继续循环或者退出。整个Runloop其实就是在循环中按照顺序,执行相关的回调。
当程序在断点处暂停时,我们可以从调用栈中看到,是从底层那个回调中触发的。
在知道了Runloop的基本构造以及运行流程之后,我们来了解下与Runloop相关的一些场景:
App启动后,苹果在主线程 RunLoop 里注册了两个 Observer。
第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会创建自动释放池。它的优先级最高,保证创建释放池发生在其他所有回调之前。
第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠)释放旧的池并创建新池;Exit(即将退出Loop) 时释放自动释放池。它们的优先级最低,保证其释放池子发生在其他所有回调之后。
在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。
苹果注册了一个Source1事件源 来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。
当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收,随后用mach_port 转发给需要的App进程。随后苹果注册的那个Source1就会触发回调__IOHIDEventSystemClientQueueCallback(),在回调中触发source0事件源,source0的回调_UIApplicationHandleEventQueue() 会进行应用内部的分发。
_UIApplicationHandleEventQueue()会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。
当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调中会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。
performSelecter分为两种情况:
当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 事件源并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。
当只是调用 performSelecter:时,内部创建的是一个block,并添加到当前线程的runloop中。也就是上面源码多处出现的__CFRunLoopDoBlocks(runloop, currentMode)。
当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,然后在回调里执行。runloop只处理主线程的block,dispatch 到其他线程仍然是由 libDispatch 处理的。
从上面的流程图中看到,runloop一次循环中有两个地方有机会处理dispatch_main:如果唤醒runloop 的不是libDispatch发送的消息,那么在下次休眠前,还有一次机会判断当前是否有dispatch_main事件需要处理。
这篇文章从Runloop的基本构造,Runloop的运行逻辑 以及 与Runloop相关的一些应用场景 三个方面入手,对Runloop的原理和机制进行了初步的探究,希望对大家了解Runloop运行机制方面有一定的帮助。