iOS RunLoop应用

作者: Dylan_J | 来源:发表于2019-11-20 14:38 被阅读0次

    在开发过程中几乎所有的操作都是通过Call out进行回调的(无论是Observer的状态通知还是Timer、Source的处理),而系统在回调时通常使用如下几个函数进行回调(换句话说你的代码其实最终都是通过下面几个函数来负责调用的,即使你自己监听Observer也会先调用下面的函数然后间接通知你,所以在调用堆栈中经常看到这些函数):

        static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
        static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
        static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
        static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
        static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
        static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();
    

    实际代码块如下:

    {
        /// 1. 通知Observers,即将进入RunLoop
        /// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
        do {
     
            /// 2. 通知 Observers: 即将触发 Timer 回调。
            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
            /// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources);
            __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
     
            /// 4. 触发 Source0 (非基于port的) 回调。
            __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);
            __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
     
            /// 6. 通知Observers,即将进入休眠
            /// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);
     
            /// 7. sleep to wait msg.
            mach_msg() -> mach_msg_trap();
            
     
            /// 8. 通知Observers,线程被唤醒
            __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);
     
            /// 9. 如果是被Timer唤醒的,回调Timer
            __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);
     
            /// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);
     
            /// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
            __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);
     
     
        } while (...);
     
        /// 10. 通知Observers,即将退出RunLoop
        /// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
        __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
    }
    
    NSTimer

    Timer Source作为事件源,事实上它的上层对应就是NSTimer(其实就是CFRunLoopTimerRef)这个开发者进程用到的定时器(底层基于使用mk_timer实现)

    NSTimer其实就是CFRunLoopTimerRef,他们之间是toll-free bridged的。一个NStimer注册到RunLoop后,RunLoop会为其重复的时间点注册好事件。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer有个属性叫Tolerance(宽容度),标示了当时间点到后,容许有多少最大误差。由于NSTimer的这种机制,因此NSTimer的执行必须依赖于RunLoop,如果没有RunLoop,NSTimer是不会执行的。

    如果某个时间点被错过了,则那个时间点的回调也会跳过去,不会延后执行。

    GCD Timer

    GCD则不同,GCD的线程管理是通过系统来直接管理的。GCD Timer是通过dispatch port给RunLoop发送消息,来使RunLoop执行相应的Block,如果所在线程没有RunLoop,那么GCD会临时创建一个线程去执行Block,执行完之后再销毁掉,因此GCD的Timer是不依赖RunLoop的。

    至于这两个Timer的准确性问题,如果不在RunLoop的线程里面执行,那么只能使用GCD Timer,由于GCD Timer是基于MK Timer(mach kernel timer),已经很底层了,因此是很准确的。

    如果在RunLoop的线程里面执行,由于GCD Timer和NSTimer都是通过port发送消息的机制来触发RunLoop的,因此准确性差别应该不是很大。如果线程RunLoop阻塞了,不管是GCD Timer还是NSTimer都会存在延迟的问题。

    CADisplayLink

    CADisplayLink是一个执行频率(fps)和屏幕刷新相同(可以修改preferredFramesPerSecond改变刷新频率)的定时器,它也需要加入到RunLoop才能执行。与NSTimer类似,CADisplayLink同样是基于CFRunLoopTimerRef实现,底层使用mk_timer(可以比较加入到RunLoop前后RunLoop中timer的变化)。和NSTimer相比它精度更高(尽管NSTimer也可以修改精度),不过和NSTimer类似的是如果遇到大任务它仍然存在丢帧现象。通常情况下CADisplayLink用于构建帧动画,看起来相对更加流畅,而NSTimer则有更广泛的用处。

    AutoreleasePool

    AutoreleasePool是另一个与RunLoop相关讨论较多的话题。其实从RunLoop源代码分析,AutoreleasePool与RunLoop并没有直接的关系,之所以将两个话题放在一起讨论最主要的原因是因为在iOS应用启动后会注册两个Observer管理和维护AutoreleasePool。不妨在应用程序刚刚启动时打印currentRunLoop可以看到系统默认注册了很多个Observer,其中有两个Observer的callout都是_wrapRunLoopWithAutoreleasePoolHandler,这两个是和自动释放池相关的两个监听。

    <CFRunLoopObserver 0x6080001246a0 [0x101f81df0]>{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = <CFArray 0x60800004cae0 [0x101f81df0]>{type = mutable-small, count = 0, values = ()}}
    
    <CFRunLoopObserver 0x608000124420 [0x101f81df0]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = <CFArray 0x60800004cae0 [0x101f81df0]>{type = mutable-small, count = 0, values = ()}}
    

    第一个Observer会监听RunLoop的进入,它会回调objc_autoreleasePoolPush()当前的AutoreleasePoolPage增加一个哨兵对象标志创建自动释放池。这个Observer的order是-2147483647优先级最高,确保发生在所有回调操作之前。

    第二个Observer会监听RunLoop的进入休眠个即将退出RunLoop两种状态,在即将进入休眠时会调用objc_autoreleasePoolPop()和objc_autoreleasePoolPush()根据情况从最新加入的对象一直往前清理直到遇到哨兵对象。而在即将退出RunLoop时会调用objc_autoreleasePoolPop()释放自动释放池内对象。这个Observer的order是2147483647,优先级最低,确保发生在所有回调操作之后。
    主线程的其他操作通常均在这个AutoreleasePool之内(main函数中),以尽可能减少内存维护操作(如果需要显式释放【例如循环】时可以自己创建AutoreleasePool否则一般不需要自己创建)。
    其实在应用程序启动后系统还注册了其他Observer(例如即将进入休眠时执行注册回调_UIGestureRecognizerUpdateObserver用于手势处理、回调为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的observer用于界面实时绘制更新)和多个Source1(例如context为CFMachPort的Source1用于接收硬件事件响应进而分发到应用程序一直到UIEvent)。

    在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被RunLoop创建好的AutoreleasePool环绕着,所以不会出现内存泄漏,开发者也不必显示创建Pool了。

    自动释放池的创建和释放,销毁的时机如下所示

    • kCFRunLoopEntry // 进入runloop之前,创建一个自动释放池
    • kCFRunloopBeforeWaiting // 休眠之前,销毁自动释放池,创建一个新的自动释放池
    • kCFRunLoopExit // 退出RunLoop之前,销毁自动释放池

    事件响应

    苹果注册了一个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更新

    如果打印App启动之后的主线程RunLoop可以发现另外一个callout为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer,这个监听专门负责UI变化后的更新,比如修改了frame、调整了UI层级(UIView/CALayer)或者手动设置了setNeedsDisplay/setNeedsLayout之后就会将这些操作提交到全局容器。而这个Observer监听了主线程RunLoop的即将进入休眠和退出状态,一旦进入这两种状态则会遍历所有的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];
    

    通常情况下这种方式是完美的,因为除了系统的更新,还可以利用setNeedsDisplay等方法手动触发下一次RunLoop运行的更新。但如果当前正在执行大量的逻辑运算可能UI的更新就会比较卡,因此facebook推出了AsyncDisplayKit来解决这个问题。AsyncDisplayKit其实是将UI排版和绘制运算尽可能放到后台,将UI的最终更新操作放到主线程(这一步也必须在主线程完成),同时提供一套类UIView或CALayer的相关属性,尽可能保证开发者的开发习惯。这个过程中AsyncDisplayKit在主线程RunLoop中增加了一个Observer监听即将进入休眠和退出RunLoop两种状态,收到回调时遍历队列中的待处理任务一一执行。

    NSURLConnection

    一旦启动NSURLConnection以后就会不断调用delegate方法接收数据,这样一个连续的动作正是基于RunLoop来进行的。
    一旦NSURLConnection设置了delegate会创建一个线程com.apple.NSURLConnectionLoader,同时内部启动RunLoop并在NSDefaultMode模式下添加4个Source0。其中CFHTTPCookieStorage用于处理cookie,CFMultiplexerSource负责各种delegate回调并在回调用唤醒delegate内部的RunLoop(通常是主线程)来执行实际操作。
    早期版本的AFNetworking库也是基于NSURLConnection实现,为了能够在后台接收delegate回调AFNetworking内部创建了一个空的线程并启动了RunLoop,当需要使用这个后台线程执行任务时AFNetworking通过performSelector:onThread: 将这个任务放到后台线程的RunLoop中。
    当调用了performSelector:onThread:时,实际上其会创建一个Timer加到对应的线程去,同样的,如果对应线程没有RunLoop该方法也会失效。

    GCD和RunLoop的关系

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

    滚动的ScrollView导致定时器失效

    在界面上有一个UIScrollView控件,如果此时还有一个定时器在执行一个事件,你会发现当你滚动ScrollView的时候定时器会失效。
    因为当你滚动ScrollView的时候,RunLoop会切换到UITrackingRunLoopMode模式,而定时器运行在defaultMode下面,系统一次只能处理一种模式RunLoop,所以导致defaultMode下的定时器失效。
    解决办法:

    • 把timer注册到NSRunLoopCommonModes,它包含了defaultMode和trackingMode两种模式。
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    
    • 使用GCD创建定时器,GCD创建的定时器不会受RunLoop的影响。
    图片下载

    由于图片渲染到屏幕需要消耗较多资源,为了提高用户体验,当用户滚动TableView的时候,只在后台下载图片,但是不显示图片,当用户停下来的时候才显示图片

    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"imgName"] afterDelay:3.0 inModes:@[NSDefaultRunLoopMode]];
    

    上面的代码可以达到如下效果:
    用户点击屏幕,在主线程中,三秒之后显示图片,但是当用户点击屏幕后,如果此用户又开始滚动textView,那么就算过了三秒,图片也不会显示出来,当用户停止了滚动,才会显示图片。
    这是因为限定了方法setImage只能在NSDefaultRunLoopMode模式下使用。而滚动textView的时候,程序运行在tracking模式下面,所以方法setImage不会执行。

    常驻线程

    需要创建一个在后台一直存在的程序,来做一些需要频繁处理的任务。比如检测网络状态等。

    默认情况一个线程创建出来,运行完要做的事情,线程就会消亡。而程序启动的时候,就创建的主线程已经加入到RunLoop,所以主线程不会消亡。

    这个时候我们就需要把自己创建的线程加到RunLoop中来,就可以实现线程常驻后台。

    - (void)viewDidLoad {
        [super viewDidLoad];
        
        self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [self.thread start];
    }
    
    - (void)run
    {
        NSLog(@"----------run----%@", [NSThread currentThread]);
        @autoreleasepool{
        /*如果不加这句,会发现runloop创建出来就挂了,因为runloop如果没有CFRunLoopSourceRef事件源输入或者定时器,就会立马消亡。
          下面的方法给runloop添加一个NSport,就是添加一个事件源,也可以添加一个定时器,或者observer,让runloop不会挂掉*/
        [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
        
        // 方法1 ,2,3实现的效果相同,让runloop无限期运行下去
        [[NSRunLoop currentRunLoop] run];
       }
    
        
        // 方法2
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        
        // 方法3
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate distantFuture]];
        
        NSLog(@"---------");
    }
    
    - (void)test
    {
        NSLog(@"----------test----%@", [NSThread currentThread]);
    }
    
    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        [self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
        [self.thread start];
    }
    - (void)run
    {
        [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(test) userInfo:nil repeats:YES];
        
        [[NSRunLoop currentRunLoop] run];
    }
    

    如果没有实现添加NSPort或者NSTimer,会发现执行完run方法,线程就会消亡,后续再执行touchbegin方法失效。
    我们必须保证线程不会消亡,才可以在后台接受时间处理。
    RunLoop启动前内部必须要有至少一个Timer/Observer/Source,所以在[runLoop run]之前先创建一个新的NSMachPort添加进去了。通常情况下,调用这需要有这个NSMachPort(mach_port)并在外部线程通过这个port发送消息到RunLoop内,但此处添加port只是为了让RunLoop不至于退出,并没有用于实际的发送消息。
    可以发现执行完了run方法,这个时候再点击屏幕,可以不断执行test方法,因为线程self.thread一直常驻后台,等待事件加入其中,然后执行。

    观察事件状态,优化性能

    实现cell高度缓存计算,因为任务需要在最无感知的时刻进行,所以应该同时满足:

    • RunLoop处于“空闲”状态Mode
    • 当这一次RunLoop迭代处理完成了所有事件,马上要休眠时
    CFRunLoopRef runLoop = CFRunLoopGetCurrent();
    CFStringRef runLoopMode = kCFRunLoopDefaultMode;
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _)) {
        // TODO here
    });
    CFRunLoopAddObserver(runLoop, observer, runLoopMode);
    // 在其中的 TODO 位置,就可以开始任务的收集和分发了,当然,不能忘记适时的移除这个 observer
    

    相关文章

      网友评论

        本文标题:iOS RunLoop应用

        本文链接:https://www.haomeiwen.com/subject/jsyoictx.html