对iOS中runloop使用场景的一次总结

使用CFRunLoopPerformBlock函数, 可以指定在runloopMode下执行block任务。不过一般不这样使用。

autoreleasePool

autoreleasepool是以栈为节点, 双向链表构建的一个数据结构。autoreleasePool与runloop的关系是一个经常被讨论的点,但runloop源码中没有一丁点autoreleasepool的内容。iOS在主线程的runloop中, 注册了两个observer,分别监听runloop的Entry和BeforeWaiting两个状态,对应执行autoreleasePool的push和pop操作。

  1. @autoreleasepool {} 等同于 void *ctx = objc_autoreleasePoolPush();
  2. {}中的代码会在最后部分添加一个 objc_autoreleasePoolPop(ctx);

Entry状态的回调事件为 _obj_autoreleasePoolPush() 函数, 创建新的autoreleasepool。即push操作。

BeforeWaiting状态的回调事件中会调用 _objc_autoreleasePoolPop() 函数来释放旧的autoreleasepool, 然后调用 _obj_autoreleasePoolPush() 函数再创建一个新的autoreleasepool。即先做pop,然后再做push操作。

打印主线程的runloop对象,可以看到下边两个Observer,callout函数都是_wrapRunLoopWithAutoreleasePoolHandler。

observers = (
    "<CFRunLoopObserver 0x600000b00320 [0x7fff80617cb0]>{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x7fff4808bf54), context = <CFArray 0x60000345df80 [0x7fff80617cb0]>{type = mutable-small, count = 0, values = ()}}",
    "<CFRunLoopObserver 0x600000b003c0 [0x7fff80617cb0]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x7fff4808bf54), context = <CFArray 0x60000345df80 [0x7fff80617cb0]>{type = mutable-small, count = 0, values = ()}}"
),
复制代码

第一个Observer监听Entry状态,会调用 _obj_autoreleasePoolPush() 函数, 创建新的autoreleasepool。order = -2147483647说明其优先级最高,确保其回调发生在所有其他回调操作之前。

第二个Observer监听BeforeWaiting状态和Exit状态。调用 _objc_autoreleasePoolPop() 函数来释放旧的autoreleasepool,其中的autorelease对象会全部释放, 然后调用 _obj_autoreleasePoolPush() 函数再创建一个新的autoreleasepool。如果是Exit状态则直接调用pop操作。该Observer的order = 2147483647说明优先级最低,确保发生在所有回调操作之后。

注意,autoreleasepool是可以嵌套使用的,因此其push和pop操作对应会使用到一些标记,即哨兵对象。

添加到autoreleasepool中的对象,在其retainCount为1时,不会继续减1,而是标记为需释放。而释放时机则是通过监听runloop的状态来实现的。而未添加到autoreleasepool中的对象,其释放则与runloop无关,仅仅是遵循ARC即可。

事件响应

iOS注册了一个Source1(基于mach port)来接收系统事件,回调是__IOHIDEventSystemClientQueueCallback()。事件产生时,IOKit框架生成一个IOHIDEvent事件,由SpringBoard(专门用于处理事件响应的进程)接收,随后使用mach msg转发给对应的App,随后注册的Source1就会触发回调,调用_UIApplicationHandleEventQueue()来进行App内部的事件传递流程。

之前有讲过一个很关键的点:

Source1在处理任务的时候,通常会跟Source0一起配合,即分发一些任务给Source0去执行。如UITouch事件,最初是由Source1处理点击屏幕到事件捕获的任务,之后Source1将事件包装分发给Source0去处理。这一点非常关键。

这里,_UIApplicationHandleEventQueue()函数将IOHIDEvent包装成UIEvent,分发给到Source0进行处理,因此我们通常看到的UITouch事件,包括UIButton点击,touch事件,手势等,通过函数调用栈看到的往往只有Source0的callout函数。而实际上,事件响应是Source1和Source0共同完成的。

之前有讲到一个iOS系统内部使用的runloopMode:GSEventReceiveRunLoopMode。GSEvent将系统的事件全部封装好,然后传递给App,如音量键、屏幕点击等。UIEvent只是对于GSEvent的封装。

UIGesture

主线程会注册一个observer,监听BeforeWaiting事件,回调是 _UIGestureRecognizerUpdateObserver * 。

"<CFRunLoopObserver 0x600000b08000 [0x7fff80617cb0]>{valid = Yes, activities = 0x20, repeats = Yes, order = 0, callout = _UIGestureRecognizerUpdateObserver (0x7fff47c2f06a), context = <CFRunLoopObserver context 0x600001110700>}",
复制代码

当_UIApplicationHandleEventQueue()识别到一个手势后,会将之前的touch事件的一系列回调方法(如touchesMove)终止,随后将该UIGestureRecognizer标记为待处理。_UIGestureRecognizerUpdateObserver()函数的内部获取所有刚被标记为待处理的UIGestureRecognizer,执行其对应的回调方法。当UIGestureRecognizer的状态有变化时,该回调也会执行。

当然,手势肯定也是Source1和Source0共同完成的。

GCD

runloop的超时时间就是通过GCD timer来控制的。GCD启动子线程,内部其实用到了runloop。GCD从子线程返回到主线程,会触发runloop的Source1事件。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /// GCD往子线程丢一个延时操作,能够执行,说明GCD内部其实用到了runloop。
    NSLog(@"global after %@", [NSThread currentThread]);
});
复制代码

dispatch_async(main_queue, block)时,libdispatch会向主线程的runloop发送消息唤醒runloop,runloop被唤醒后会从消息中获取block,在callout函数 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE 中执行该block任务。仅限主线程,子线程的dispatch block操作全部由libdispatch完成的。

runloopMode

dispatch_async(dispatch_get_main_queue(), ...)会将block放到commonModes中执行,而CFRunLoopPerformBlock允许指定runloopMode来执行block。

能否唤醒runloop

dispatch_async(dispatch_get_main_queue(), ...)会唤醒主线程的runloop,而CFRunLoopPerformBlock不会主动唤醒runloop。如runloop休眠,则CFRunLoopPerformBlock的block不能执行。可以使用CFRunLoopWakeUp来唤醒runloop。

GCD的main queue是一个串形队列

GCD的main queue是一个串形队列,这样的结果就是dispatch_async(dispatch_get_main_queue(), ...)传入的block会作为一个整体,在runloop的下一次循环时执行。

请看如下代码,***输出 1,3,2***是我们再也熟悉不过的代码了,而后半部分为什么会 ***输出 4,5,6***呢?且等待的1s间隔时机也不一样,分别为1...32和45...6。这里的...表示间隔。

- (void)testGCDMainQueue {
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"main queue task 1");
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"main queue task 2");
        });
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
        NSLog(@"main queue task 3");
    });
    /// 输出 1,3,2
    
    CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
        NSLog(@"main queue task 4");
        CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
            NSLog(@"main queue task 5");
        });
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];
        NSLog(@"main queue task 6");
    });
    /// 输出 4,5,6
}
复制代码

dispatch_async实验的代码段中 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]]; 使得该周期的runloop又持续运行了1s。但是因为main queue是串形队列,所以打印1、runloop再运行1s、打印3,这三句代码是一个任务;而打印2显然是另外一个任务了,所以才会输出1,3,2。

CFRunLoopPerformBlock实验的代码段中,将block丢到了runloop中执行,若该runloop在运行则该block肯定会被调度执行。而 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]]; 使得该周期的runloop又持续运行了1s,与该runloop中的block执行本身无关,所以5会正常打印出来。当然,如果没有 ***[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]];***,打印4,6,5则是理所当然且与GCD一致的。

CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
    NSLog(@"main queue task 4");
    CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, ^{
        NSLog(@"main queue task 5");
    });
    NSLog(@"main queue task 6");
});
/// 输出 4,6,5
复制代码

CFRunLoopPerformBlock与performSelector:inModes:效果类似

如果需要指定block仅在DefaultMode才能执行,通常使用performSelector:inModes:即可。其实也可以使用CFRunLoopPerformBlock函数。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, ^{
      /// 只有在DefaultMode才会执行。
      /// 如果gcd after将block丢到了main queue中,则scrolling时不会打印log;停止时,切换到了DefaultMode才会打印。
      NSLog(@"CFRunLoopPerformBlock kCFRunLoopDefaultMode scrolling stopped");
    });
});
复制代码

NSTimer

NSTimer是基于runloop的,与CFRunLoopTimerRef可以免费桥转换。timer注册到runloop后,runloop为其下一次执行的时间点注册好回调事件,即__CFArmNextTimerInMode操作。如果timer的时间到了,但是runloop正在执行一个长的call out函数等,为了节省资源,runloop并不会在非常准确的时间点执行timer的回调,所以timer有个容忍度Tolerance,可以通过Tolerance来提高NSTimer的精确度。

NSTimer的精确度会受runloop运行状态的影响较大,且NSTimer在子线程中使用需要保证该子线程常驻,即runloop一直存在。关于NSTimer有一个常见的循环引用问题:runloop会对timer持有,timer会对target持有,因此使用不当可能导致target不能正确释放。

如果有一些需求场景对timer的精确度有严格要求,或者子线程没有runloop,则通常可以使用GCD Timer。而GCD的定时器,是依赖于系统内核,不依赖于RunLoop,因此通常更加准时。并且,GCD Timer可以在后台线程运行,根本原因在于GCD自己内部有对runloop进行使用。

而如果在有runloop存活的线程中使用,则GCD timer和NSTimer的准确性差别不大,都是通过mach msg和mach port来唤醒runloop,以触发timer回调。若当前runloop阻塞了,都会存在延迟问题。参考iOS RunLoop详解。

关于Timer的更详细的内容,包括循环引用的解决方案,请参考博客: 比较一下iOS中的三种定时器 。其实关于CFRunLoopTimerRef的使用,应该算是第四种timer了。

CADisplayLink

CADisplayLink的回调函数触发频率和屏幕刷新频率一致,精度比NSTimer更高,但也需要加入到runloop才能执行。其原理是基于CFRunloopTimerRef来实现的,底层使用mk_timer。如果遇到runloop正在执行比较重的任务,CADisplayLink的精度也会受影响。其使用场景没有NSTimer普遍。

后台常驻线程

开发者自行创建的子线程,默认不会开启runloop。有一个比较常见的需求就是子线程保活,这就需要用到runloop的技巧,即需要保持runloop一直运行。

线程的runloop一直运行的前提条件就是:必须有一个Mode Item,即Source、Timer、Observer之一。

/// 使用NSTimer
- (void)addBackgroundRunLoop1 {
    self.thread1 = [[NSThread alloc] initWithBlock:^{
        NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
            NSLog(@"%@", self.thread1.name);

            /// 如果在子线程中周期性地ping一下主线程,若指定时间内,该对应的block未执行,则可认为主线程卡顿。
            dispatch_async(dispatch_get_main_queue(), ^{
                /// 如果一段时间未打印,则可以判断主线程卡顿了。
                NSLog(@"main is ok.");
            });
        }];

        // 线程存活,需要添加一个source1。一般是Timer或一个空的port即可。
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
        [[NSRunLoop currentRunLoop] run]; /// 会一直卡在这里不会继续往下走。
//        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1f]];
        
        // 后续的代码永远不会执行。
        NSLog(@"thread runloop");
    }];
    self.thread1.name = @"addBackgroundRunLoop1";
    [self.thread1 start];
}

/// 使用NSPort
- (void)addBackgroundRunLoop2 {
    self.thread2 = [[NSThread alloc] initWithBlock:^{
        // 线程存活,需要添加一个source1。如果没有特殊需求,一般可以是Timer或一个空的port。
        // addPort然后run,该线程才能一直存活。而addPort对应通过removePort来移除
        [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
        [[NSRunLoop currentRunLoop] run];
    }];
    self.thread2.name = @"addBackgroundRunLoop2";
    [self.thread2 start];
    
    NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [self performSelector:@selector(onTimerInBackgroundRunLoop2) onThread:self.thread2 withObject:nil waitUntilDone:NO];
    }];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
- (void)onTimerInBackgroundRunLoop2 {
    NSLog(@"%@", self.thread2.name);
}
复制代码

AFNetworking 2.x的常驻线程

在AFNetworking 2.x中,就使用到了这种方式,使得子线程一直存活。AFNetworking 2.x希望在后台线程接收delegate的回调函数,因此需要后台线程持续存在,使用到了NSRunLoop的run方法,在run方法调用之前,必须先创建一个runloopMode item(source或timer)加到runloop中去,这里是使用了NSMachPort。且仅是为了让该后台线程常驻,而没有实际的mach msg消息传递,所以空的NSMachPort即可。

/*
 AFN 2.x中使用了常驻子线程,在AFURLConnectionOperation.m文件中。
 AFURLConnectionOperation的start方法中,将operationDidStart方法丢在子线程networkRequestThread中执行。
 [self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
 在operationDidStart方法中:
 self.connection = [[NSURLConnection alloc] initWithRequest:self.request delegate:self startImmediately:NO];
 [self.connection scheduleInRunLoop:runLoop forMode:runLoopMode];
 [self.connection start];
 这样,即做到了在子线程中发起网络请求和数据解析。而子线程一直常驻,不会停止。
 NSURLConnection发起的网络请求,需要在AFURLConnectionOperation中,自行进行请求处理和数据解析。
 
 AFN 3.x中移除了该常驻线程,使用的时候run,结束的时候stop即可。因为NSURLSession自己维护了一个线程池,做request线程的调度和管理。不在需要在当前线程中进行请求和数据解析,可以指定回调的delegateQueue了。
 */
+ (NSThread *)networkRequestThread {
  static NSThread *_networkRequestThread = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    // 为啥不用__block,因为block对 ***静态局部变量是以指针形式*** 进行截获。
    _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(onNetworkRequstThread) object:nil];
    [_networkRequestThread start];
  });
}

+ (void)onNetworkRequstThread {
  @autoreleasepool {
    [[NSThread currentThread] setName:@"AFNetworking"];
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
    [runLoop run];
  }
}
复制代码

推荐方式

以上方式有一个明显的共同缺陷:使用[[NSRunLoop currentRunLoop] run]方法,则runloop一旦运行,则无法停止。若要控制runloop的运行情况,可以添加一个变量,并且改用runMode:beforeDate:接口。

/*
  推荐方式:添加一个BOOL开关来控制。
  BOOL shouldKeepRunning = YES;        // global
  NSRunLoop *rl = [NSRunLoop currentRunLoop];
  while (shouldKeepRunning && [rl runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
  */
- (void)addBackgroundRunLoop3 {
    __block BOOL shouldKeepRunning = YES;
    self.thread3 = [[NSThread alloc] initWithBlock:^{
        [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
        while (shouldKeepRunning) {
            @autoreleasepool {
                [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
            }
        }
    }];
    self.thread3.name = @"addBackgroundRunLoop3";
    [self.thread3 start];
    
    NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [self performSelector:@selector(onTimerInBackgroundRunLoop3) onThread:self.thread3 withObject:nil waitUntilDone:NO];
    }];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        shouldKeepRunning = NO;
    });
}
- (void)onTimerInBackgroundRunLoop3 {
    NSLog(@"%@", self.thread3.name);
}
复制代码

runloop必须要有一个mode item才能一直存活,外部可以通过port发送消息到runloop内。 通过performSelector:onThread:可以将该任务放到子线程中执行。

peformSelector

这类方法的本质其实就是使用NSTimer。peformSelector:afterDelay: 和 performSelector:onThread: 调用时,内部均会创建一个NSTimer,添加到指定线程的runloop中。若后台线程没有runloop,则会失效。所以对于子线程,只能使用dispatch_after来做到延时操作,因为GCD启动子线程,内部其实用到了runloop。

可以利用runloopMode,如仅在Default Mode下设置UIImageView的图片,以免UIScrollView的滚动受到影响。 比如微博,滑动停止时候,图片一个个展示出来。。。

performSelector:withObject:afterDelay:inModes:方法可以指定在runloopMode中执行任务,如仅在DefaultMode下给UIImageView设置图片。则UIScrollView滚动时,设置图片的任务不会执行,以保证滚动的流畅性。一旦停止处理DefaultMode再进行图片设置。

可以使用cancelPreviousPerformRequestsWithTarget:和cancelPreviousPerformRequestsWithTarget:selector:object:来将正在排队的任务取消。

UI界面更新

当操作UI、更新CALayer层级、layoutIfNeeded方法等,对应的UIView、CALayer被标记为dirty,即为待处理,被提交到一个全局的容器中。iOS注册一个Observer监听主线程的BeforeWaiting状态,该状态到来时对所有待处理(dirty)的视图对象发送drawRect:消息。所以,iOS的界面渲染其实只是被修改需要重绘制的视图才会更新。

看两个Observer:

"<CFRunLoopObserver 0x600000b001e0 [0x7fff80617cb0]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 1999000, callout = _beforeCACommitHandler (0x7fff480bc2eb), context = <CFRunLoopObserver context 0x7fe8da300720>}",
"<CFRunLoopObserver 0x600000b00280 [0x7fff80617cb0]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2001000, callout = _afterCACommitHandler (0x7fff480bc354), context = <CFRunLoopObserver context 0x7fe8da300720>}",
复制代码

即CoreAnimation会监听runloop的状态,对应有两个callout函数:_beforeCACommitHandler和_afterCACommitHandler。

比如下边的代码:

UIButton *btn = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
btn.backgroundColor = [UIColor redColor];
[self.view addSubview:btn];

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    CGRect frame = btn.frame;
    // 先向下移动
    frame.origin.y += 200;
    [UIView animateWithDuration:1 animations:^{
        btn.frame = frame;
        [btn setNeedsDisplay];
    }];
    
    // 再向右移动
    frame.origin.x += 200;
    [UIView animateWithDuration:1 animations:^{
        btn.frame = frame;
        [btn setNeedsDisplay];
    }];
});
复制代码

这样做,是没法实现预期的。正因为UI更新是先收集待处理的UI视图,再统一绘制。所以结果即是只有一个移动到右下角的动画效果

卡顿监控

添加Observer监控runloop的状态。

卡顿原因

原因主要有:

  1. 复杂UI、图文混排等绘制量过大。在屏幕的一个V-Sync信号的周期(1/60秒)内,CPU任务或者GPU任务超时都会导致卡顿。
  2. 主线程做网络请求、数据库操作、IO操作等。
  3. 死锁等

如果runloop的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一次runloop循环,则线程受阻。如果是主线程则是卡顿。

所以,问题在于到底监控哪个状态?daiming说是BeforeSources和AfterWaiting。为何不是BeforeWaiting和AfterWaiting???

也就是触发source0回调和接收mach_port消息两个状态。

添加observer到主线程runloop的commonModes,观测到这两种状态就修改时间值。创建一个常驻子线程监控该时间值,若定时器时间到了,则认定主线程超时了。。监控的状态到了,就改变时间阈值,若未变化,则卡顿,dump出堆栈信息上传即可。dump堆栈可以使用PLCrashReporter。

网络请求

CFSocket: 最底层,只负责socket通信
CFNetwork -> 基于CFSocket封装,ASIHttpRequest基于这一层
NSURLConnection -> NSURLConnection是基于CFNetwork的OC封装。AFNetworking 2.x
NSURLSession -> NSURLSession部分功能依然在底层使用到了NSURLConnection(如com.apple.NSURLConnectionLoader线程)。 AFNetworking 3.x, Alamofire
复制代码

runloo会通过一些基于mach port的Source接收来自底层CFSocket的通知。

在前面的网络开发的文章中已经介绍过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中。
复制代码
通常使用 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来做到合并网络请求?猜测应该是利用CFSocket,直接接收系统底层的网络请求相关信息,进行网络请求的拼接;收到网络返回结果后,将返回数据进行拆解,再分发到各自的网络请求的业务方。只是猜测而已。

在UIScrollView停止滚动时设置图片

利用不同runloopMode之间相互隔离的特性,可以做到仅在DefaultMode下对imageView进行图片设置。而当UIScrollView滑动时,处于UITrackingMode,则不会设置图片。

[self.imageView performSelector:@selector(setImage:) withObject:image afterDelay:0 inModes:@[NSDefaultRunLoopMode]];
复制代码

setImage操作必须在主线程执行,会包括 图片解码和渲染 两个阶段。频繁调用或者图片解码耗时,则很容易影响用户体验。通过以上方式可以很好地优化体验。另外,对图片进行异步解码也是一个很好的优化思路,甚至可以将解码操作提前放到runloop空闲的时候去做。

UI任务分解

如非常多的UITableViewCell的绘制, 一次runloop周期会尝试加载一个屏幕上所有的图片,快速滑动时很容易导致卡顿。可以基于runloop的原理进行任务拆分,监听runloop的BeforeWaiting事件,每一次runloop循环加载一张图片。这样需要使用block来包装一个loadImageTask。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"UITableViewCell"];
    cell.selectionStyle = UITableViewCellSelectionStyleNone;
    
    for (NSInteger i=1; i<=5; i++) {
        [[cell.contentView viewWithTag:i] removeFromSuperview];
    }
    
    /// before:一次runloop周期加载一个屏幕上所有的图片,导致卡顿
//    [self addImage1ForCell:cell];
//    [self addImage2ForCell:cell];
//    [self addImage3ForCell:cell];
    
    /// after:优化
    /// 基于runloop原理进行较重的UI任务拆分:监听runloop循环,循环一次加载一张图片。
    /// 使用block来包装一个loadImageTask。
    __weak typeof(self) weakSelf = self;
    [self addLoadImageTask:^{
        [weakSelf addImage1ForCell:cell];
    }];
    [self addLoadImageTask:^{
        [weakSelf addImage2ForCell:cell];
    }];
    [self addLoadImageTask:^{
        [weakSelf addImage3ForCell:cell];
    }];
    
    return cell;
}
复制代码

添加runloop的observer的方式如下:

typedef void(^BlockTask)(void);

/// 用于存储self对象本身
static void *ViewControllerSelf;

@property (nonatomic, strong) NSMutableArray<BlockTask> *loadImageTasks;

- (void)addRunloopObserver {
    /// runloop即将进入休眠时候,则会触发该callback;而每个runloop周期都有即将进入休眠的时机,所以用户滚动时callback会一直调用。
    /// 如果没有任何用户操作,则静止时runloop进入休眠,不会触发callback了。
    CFRunLoopObserverContext context = {
        0,
        (__bridge void *)self,
        &CFRetain,
        &CFRelease,
        NULL
    };
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(NULL, kCFRunLoopBeforeWaiting, YES, 0, &RunloopObserverCallBack, &context);
    
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopCommonModes);
    
    
    /// 而如果添加了这个timer,则用户停止滚动时,回调也会一直被调用。因为timer会唤醒runloop。
    NSTimer *timer = [NSTimer timerWithTimeInterval:0.0001 repeats:YES block:^(NSTimer * _Nonnull timer) {
        /// nothing
    }];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}

/// 如何将loadImageTask的任务(需要该ViewController的实例对象)提供给该回调函数。
void RunloopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    NSLog(@"RunloopObserverCallBack");
    /// 每次触发该回调,都从tasks中出列一个任务执行,即每次回调加载一张图片。
    
    /// 方法1,使用static变量存储self对象。
    ViewController *self = (__bridge ViewController *)ViewControllerSelf;
    
    /// 方法2,使用CFRunLoopObserverContext来传递self对象。
    if (self.loadImageTasks.count == 0) {
        return;
    }
    
    BlockTask task = self.loadImageTasks.firstObject;
    task();
    [self.loadImageTasks removeObjectAtIndex:0];
}
复制代码

布局计算

UITableView+FDTemplateLayoutCell的原理就是使用Observer监听runloop的BeforeWaiting状态,即runloop空闲状态时执行布局计算任务;当用户进行滑动时(UITrackingMode)则暂停计算任务。布局计算主要是计算UITableViewCell的高度并进行预缓存。

因为runloopMode的相互隔离特性,以及UITrackingMode的引入,使得在界面滑动时,其他mode(DefaultMode)下的任务全部会被暂停,以此来保证滑动的流畅性。这样就可以做到不跟UITableView的代码强耦合,也能很好地优化布局计算任务,降低CPU负担。

CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
    // TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);
复制代码

UITableView+FDTemplateLayoutCell中,还用到了利用runloop来分解任务的技巧。如将界面不展示,需要缓存的其他cell的高度计算任务,分解到各个runloop的循环中执行即可。

NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
    if (mutableIndexPathsToBePrecached.count == 0) {
        CFRunLoopRemoveObserver(runLoop, observer, runLoopMode);
        CFRelease(observer); // 注意释放,否则会造成内存泄露
        return;
    }
    NSIndexPath *indexPath = mutableIndexPathsToBePrecached.firstObject;
    [mutableIndexPathsToBePrecached removeObject:indexPath];
    [self performSelector:@selector(fd_precacheIndexPathIfNeeded:)
                 onThread:[NSThread mainThread]
               withObject:indexPath
            waitUntilDone:NO
                    modes:@[NSDefaultRunLoopMode]];
});
复制代码

这样,不至于一个runloop周期内要计算全部的缓存高度,且高度计算仅在DefaultMode下执行。滑动时会暂停计算任务。参考: 优化UITableViewCell高度计算的那些事

这种思路可以用于分解任务,如空闲状态执行计算、统计内存等等。如图片异步解码。只是一个异步操作而已,跟runloop无关。

自定义Source0

作为开发者要使用 source0 也很简单,先创建一个 CFRunLoopSourceContext,context 里需要传入被执行任务的函数指针作为参数,再将该 context 作为构造参数传入 CFRunLoopSourceCreate 创建一个 source,之后通过 CFRunLoopAddSource 将该 source 绑定的某个 runloopMode 即可。

则,__CFRunLoopDoSources0函数调用时,自定义source0的事件会执行。

CFRunLoopAddSource
CFRunLoopRemoveSource
复制代码

而这个在实际项目中有啥用???暂不清楚

NSNotificationQueue

NSNotificationQueue
NSNotificationQueue在NSNotificationCenter起到了一个缓冲的作用。尽管NSNotificationCenter已经分发通知,但放入队列的通知可能会延迟,直到当前的runloop结束或runloop处于空闲状态才发送。具体策略是由后面的参数决定。

如果有多个相同的通知,可以在NSNotificationQueue进行合并,这样只会发送一个通知。NSNotificationQueue会通过先进先出的方式来维护NSNotification的实例,当通知实例位于队列首部,通知队列会将它发送到通知中心,然后依次的像注册的所有观察者派发通知。

每个线程有一个默认和 default notification center相关联的的通知队列。

通过调用initWithNotificationCenter和外部的NSNotificationCenter关联起来,最终也是通过NSNotificationCenter来管理通知的发送、注册。除此之外这里有两个枚举值需要特别注意一下。

NSPostingStyle:用于配置通知什么时候发送
NSPostASAP:在当前通知调用或者计时器结束发出通知
NSPostWhenIdle:当runloop处于空闲时发出通知
NSPostNow:在合并通知完成之后立即发出通知。
NSNotificationCoalescing(注意这是一个NS_OPTIONS):用于配置如何合并通知
NSNotificationNoCoalescing:不合并通知
NSNotificationNoCoalescing:按照通知名字合并通知
NSNotificationCoalescingOnSender:按照传入的object合并通知
复制代码

UI渲染

UI渲染的流程

  1. CPU:对象创建/销毁(alloc/dealloc),布局计算(layout),排版(如计算视图大小、文本高度等),绘制内容(drawRect/drawLayer),准备解码数据等(decompress image),提交渲染所需数据(图层及动画数据)至渲染服务,渲染服务中反序列化这些数据转换成渲染数(render tree),计算中间值及生成纹理等。
  2. GPU:纹理渲染,视图混合,渲染可视的纹理至屏幕。
  3. CPU有很强的通用性,各种数据类型、逻辑判断、分支调整、中断处理等,内部结构异常复杂。计算能力只是CPU的一部分作用,除此之外还擅长各种逻辑控制和通用数据类型的运算。CPU的多核只有很少的一部分,包含寄存器和多级Cache等。
  4. 而GPU擅长处理大规模的并行计算任务(相互没有逻辑依赖),数据类型高度统一,GPU的核心非常多,但都是重复计算所需。GPU没有Cache。

异步绘制:可在异步线程进行CGBitmapxxx的操作,生成bitmap,读取image,最后在主线程将其赋值给layer.contents即可。避免主线程执行UIImageView的setImage:操作,同时执行图像数据解码和渲染导致的主线程卡顿。

CALayer先判断自己的delegate有没有实现异步绘制的代理方法displayLayer,若没有则系统绘制流程。若有则进入进行,可做到异步绘制。

iOS系统注册一个observer,监听BeforeWaiting事件

注册一个observer,监听BeforeWaiting事件,回调方法中将所有打上脏标记的view/layer进行绘制和渲染。即回调中执行CALayer的display方法,进入真正的绘制工作。

如果打印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两种状态,收到回调时遍历队列中的待处理任务一一执行。
复制代码

AsyncDisplayKit

以上已经介绍了UI渲染的流程。对于这些任务,除了必须在主线程执行的,如UI对象操作、布局,其他都要尽量放到后台线程执行。尤其是将耗时操作,如CPU的对象创建销毁,文本计算,布局计算,图片编解码等,尽量放到后台线程执行。仅在主线程做必需的一些操作。

ASDK 所做的,就是尽量将能放入后台的任务放入后台,不能的则尽量推迟 (例如视图的创建、属性的调整)。为此,ASDK 创建了一个名为 ASDisplayNode 的对象,并在内部封装了 UIView/CALayer,它具有和 UIView/CALayer 相似的属性,例如 frame、backgroundColor等。所有这些属性都可以在后台线程更改,开发者可以只通过 Node 来操作其内部的 UIView/CALayer,这样就可以将排版和绘制放入了后台线程。但是无论怎么操作,这些属性总需要在某个时刻同步到主线程的 UIView/CALayer 去。

ASDK 仿照 QuartzCore/UIKit 框架的模式,实现了一套类似的界面更新的机制:即在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务,然后一一执行。

其实,这一套思路应用非常普遍,前端框架如Vue中的Virtual DOM Tree也是类似用于暂时保存待修改的UI状态,之后统一进行处理更新界面信息。

让Crash的App复活

App的崩溃有两种:

  1. 有信号的SIGABRT,一般是过度release对象,或者unrecognized selector
  2. EXC_BAD_ACCESS访问已经释放的内存导致,即野指针错误。

SIGABRT是通过系统发送信号Signal给到App的,App收到这个信号Signal后,会把主线程的runloop杀掉,即crash产生。为了让App回光返照,我们需要来捕获 libsystem_sim_c.dylib 调用 abort() 函数发出的程序终止信号,然后让其执行我们定义的处理signal的方法。在方法中,我们需要开启一个RunLoop,保持主线程不退出。

待实践后补充!!!

一些QA

A run loop is an abstraction that (among other things) provides a mechanism to handle system input sources (sockets, ports, files, keyboard, mouse, timers, etc).

Each NSThread has its own run loop, which can be accessed via the currentRunLoop method.

In general, you do not need to access the run loop directly, though there are some (networking) components that may allow you to specify which run loop they will use for I/O processing.

A run loop for a given thread will wait until one or more of its input sources has some data or event, then fire the appropriate input handler(s) to process each input source that is "ready.".

After doing so, it will then return to its loop, processing input from various sources, and "sleeping" if there is no work to do.

That's a pretty high level description (trying to avoid too many details).

EDIT

An attempt to address the comment. I broke it into pieces.

it means that i can only access/run to run loop inside the thread right?
Indeed. NSRunLoop is not thread safe, and should only be accessed from the context of the thread that is running the loop.

is there any simple example how to add event to run loop?
If you want to monitor a port, you would just add that port to the run loop, and then the run loop would watch that port for activity.

- (void)addPort:(NSPort *)aPort forMode:(NSString *)mode
You can also add a timer explicitly with

- (void)addTimer:(NSTimer *)aTimer forMode:(NSString *)mode
what means it will then return to its loop?
The run loop will process all ready events each iteration (according to its mode). You will need to look at the documentation to discover about run modes, as that's a bit beyond the scope of a general answer.

is run loop inactive when i start the thread?
In most applications, the main run loop will run automatically. However, you are responsible for starting the run loop and responding to incoming events for threads you spin.

is it possible to add some events to Thread run loop outside the thread?
I am not sure what you mean here. You don't add events to the run loop. You add input sources and timer sources (from the thread that owns the run loop). The run loop then watches them for activity. You can, of course, provide data input from other threads and processes, but input will be processed by the run loop that is monitoring those sources on the thread that is running the run loop.

does it mean that sometimes i can use run loop to block thread for a time
Indeed. In fact, a run loop will "stay" in an event handler until that event handler has returned. You can see this in any app simply enough. Install a handler for any IO action (e.g., button press) that sleeps. You will block the main run loop (and the whole UI) until that method completes.

The same applies to any run loop.
复制代码
Look at the "Run Loops" chapter of Apple's Threading Programming Guide. In brief:

There is one run loop associated with each thread.
The run loop has to be run to do anything. Apple's application main function takes care of this for you on the main thread.
A run loop is run in a specific mode. The "common mode" is actually a set of modes, and there is an API for adding modes to that set.
A run loop's main purpose is to monitor timers and run loop sources. Each source is registered with a specific run loop for a specific mode, and will only be checked at the appropriate time when the runloop is running in that mode.
The run loop goes through several stages in each go around its loop, such as checking timers and checking other event sources. If it finds that any source is ready to fire, it triggers the appropriate callback.
Aside from using ready-made run loop tools, you can create your own run loop sources as well as registering a run loop observer to track the progress of the run loop.
One major pitfall is forgetting to run the run loop while waiting for a callback from a runloop source. This is sometimes a problem when you decide to busy-wait for something to happen on the main thread, but you're most likely to run into it when you create your own thread and register a runloop source with that runloop. You are responsible for establishing an autorelease pool and running the runloop if needed on non-main threads, since the application main function will not be there to do it for you.

You would do better to read Apple's Concurrency Programming Guide instead, which suggests alternatives to the runloop mechanism such as operation queues and dispatch sources. The "Replacing Run-Loop Code" section of the "Migrating Away from Threads" chapter suggests using dispatch sources instead of runloop sources to handle events.
复制代码

参考资料

  1. Event loop
  2. Run Loops
  3. NSRunLoop
  4. CFRunLoop
  5. CFRunLoopPerformBlock
  6. CFRunLoopPerformBlock vs dispatch_async
  7. RunLoop.subproj
  8. Kernel Programming Guide: Mach Overview
  9. mach_msg
  10. CFPlatform.c
  11. AsyncDisplayKit,已更名为Texture
  12. 深入理解RunLoop
  13. 解密 Runloop
  14. iOS刨根问底-深入理解RunLoop
  15. 优化UITableViewCell高度计算的那些事
  16. 重拾RunLoop原理
  17. iOS RunLoop详解
我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章