iOS开发:Runloop专栏

       在iOS开发过程中,Runloop的使用也是不容小觑的,虽然也是不太常用,但是这部分对于iOS开发也是相当重要的,而且在面试找工作的时候也是面试官必考的部分。那么下来就来谈谈Runloop的理论及使用。

        一、Runloop概念

        1.Runloop概念:Runloops是与线程相关的基础框架的一部分。一个Runloop就是一个事件处理的循环,用来不停的调度工作以及处理输入事件。其实它内部就是do-while循环,这个循环内部不断地处理各种任务(比如Timer,Observer)。使用Runloop的目的是让线程在有工作任务的时候忙于工作,在没工作任务的时候处于休眠状态。

        2.NSRunLoop和CFRunLoopRef

       在开发的时候我们不能在一个线程中去操作另外一个线程的Runloop对象,如果这样做很可能会造成无法估量的后果。不过值得庆幸的是CoreFundation中的不透明类CFRunLoopRef是线程安全的,而且这两种类型的Runloop完全可以混合使用。

       Cocoa中的NSRunLoop类可以通过实例方法: - (CFRunLoopRef)getCFRunLoop;
       获取对应的CFRunLoopRef类,来达到线程安全的目的。
       CFRunLoopRef是在CoreFoundation框架内的,它提供了C语言函数的API,所有这些API都是关于线程安全的。
       NSRunLoop是基于CFRunLoopRef的封装,提供了面向对象的API,但这些API不是线程安全的。

        3.Runloop和线程的关系

        Runloop,见名知意,loop表示某种循环,和run放在一起就表示一直在运行着的循环。实际上,Runloop和线程是密不可分的,可以说Runloop是为了线程而生,没有线程,Runloop就没有存在的必要。Runloops是线程的基础架构部分,Cocoa和CoreFundation都提供了Runloop对象方便配置和管理线程的Runloop(以下都已Cocoa为例)。每个线程,包括程序的主线程(main thread)都有与之相应的Runloop对象。

         4.主线程中的Runloop默认情况下是启动的

         iOS应用程序里面,程序启动后会有一个如下的main()函数:
         int main(int argc,char *argv[]) {
            @autoreleasepool {
              return UIApplicationMain(argc, argv, nil, NSStringFromClass([appDelegate class]));
           }
  }
       重点是UIApplicationMain()函数,这个方法会为main thread设置一个NSRunLoop对象,这就诠释了刚开始说的为啥我们的应用可以在无人操作的时候休息,需要让它干活的时候又能立马响应。对于其它线程来说,Runloop默认是没有启动的,如果你需要更多的线程交互则可以手动配置和启动,如果线程只是去执行一个长时间的已确定的任务则不需要。

        在任何一个Cocoa程序的线程中,都可以通过:NSRunLoop   *runloop = [NSRunLoop currentRunLoop]; 来获取到当前线程的Runloop。

        5.Runloop的接口和几个类

        在 CoreFoundation 里面关于 RunLoop 有5个类:CFRunLoopRef、CFRunLoopModeRef、CFRunLoopSourceRef、CFRunLoopTimerRef、CFRunLoopObserverRef,其中CFRunLoopModeRef类并没有对外暴露,只是通过CFRunLoopRef 的接口进行了封装。它们的关系如下:


        一个 RunLoop包含若干个Mode,每个Mode又包含若干个 Source/Timer/Observer。每次调用RunLoop 的主函数时,只能指定其中一个Mode,这个Mode被称作CurrentMode。如果需要切换Mode,只能退出 Loop,再重新指定一个Mode进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。
        CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。
        Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
        Source1 包含了一个 mach_port和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。
CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。
        CFRunLoopObserverRef 是观察者,每个Observer 都包含了一个回调(函数指针),当 RunLoop的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {

    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop

    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer

    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source

    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠

    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒

    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop

};

        上面的 Source/Timer/Observer被统称为mode item,一个item可以被同时加入多个mode。但一个 item 被重复加入同一个mode时是不会有效果的。若一个mode中一个 item都没有,则RunLoop会直接退出,不进入循环。

        二、Runloop使用场景

       1.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了。


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


       3.PerformSelecter
       当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。


       4.事件响应
       苹果注册了一个 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 事件都是在这个回调中完成的。

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


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

_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()

    QuartzCore:CA::Transaction::observer_callback:

        CA::Transaction::commit();

            CA::Context::commit_transaction();

                CA::Layer::layout_and_display_if_needed();

                    CA::Layer::layout_if_needed();

                        [CALayer layoutSublayers];

                            [UIView layoutSubviews];

                    CA::Layer::display_if_needed();

                        [CALayer display];

                            [UIView drawRect];


       7.关于GCD
       实际上 RunLoop 底层也会用到 GCD 的东西。但同时 GCD 提供的某些接口也用到了 RunLoop, 例如 dispatch_async()。当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。


        8.关于网络请求
        iOS 中,关于网络请求的接口自下至上有如下几层:

CFSocket

CFNetwork       ->ASIHttpRequest

NSURLConnection ->AFNetworking

NSURLSession    ->AFNetworking2, Alamofire

• 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。
        NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。

        三、Runloop内部逻辑


       由上图可以看到,实际上RunLoop就是这样一个函数,其内部是一个 do-while 循环。当你调用CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。



以上就是关于Runloop的分析介绍,欢迎关注三掌柜的微信公众号,有更多精彩内容等你开,欢迎关注!