RunLoop探究


RunLoop,即运行循环,你非要叫它跑圈,也不是不可以

这哥们到底是什么?

我们知道,一个程序的入口函数,也就是在main.m文件中

程序启动首先就会走到这里,这里有个自动释放池,一般在创建大量临时变量的时候会手动创建释放池,这里将整个程序运行期间都置于这个释放池内,程序运行结束之时,自动释放池结束,内存回收。这个自动释放池内,就一句代码,这是什么意思?进入UIApplicationMain函数内可以发现,这个函数返回值是int型。那我们换种写法

运行起来,会发现第十六行并不会执行,代码停留在了第十五行,不会再继续往下执行,也就是说UIApplicationMain函数一直没有返回。我们的程序一直是在主线程上执行的,我们打印下看看

NSLog(@"thread=%@",[NSThread currentThread]);

2018-03-30 22:53:12.821131+0800 RunLoopDemo[74265:4182972] thread=<NSThread: 0x604000066180>{number = 1, name = main}

的确,当前就是在主线程,这是必然的。整个项目运行期间,主线程都不会退出,一旦退出,程序肯定就挂了。究其原因,是因为UIApplicationMain函数内开启了一个死循环,用来保住主线程的生命,程序处于运行状态,使得整个项目运行期间,主线程都不会退出。这个死循环,其实就是RunLoop,RunLopp就是个死循环。这个默认启动的RunLoop是跟主线程相关联的。RunLoop这么设计,它的作用是什么呢?

  • 保证线程不退出,即程序的持续运行
  • 监听事件,询问是否有事件发生,从事件队列里取出事件并处理(网络、触摸、时钟等事件)
  • 节省CPU资源,提高程序性能:该做事时做事,该休息时休息

那就写个代码玩一下试试看

- (void)viewDidLoad {
    [super viewDidLoad];

    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0     target:self selector:@selector(timerMethod) userInfo:nil     repeats:YES];
}

- (void)timerMethod {
    static int a = 0;
    a++;
    NSLog(@"a=%zd",a);
}

一个很简单的时钟事件,这个时候你会发现方法并没有走。上面已经提到,RunLoop用来监听事件,需要将timer加到RunLoop中去

[[NSRunLoop currentRunLoop] addTimer:timer     forMode:NSDefaultRunLoopMode];

注意RunLoop无法手动创建,只能获取,不过其实第一次获取就是创建。这里面有个mode属性,暂时写默认,这时候运行程序,正常运行。这时候我们往控制器上拖个textView, 运行

问题出来了,启动后时钟事件正常运行,但是一旦拖动textView,时钟事件就停止打印,不拖动textView,时钟事件又正常运行。这是什么情况?

上面我们将timer丢给RunLoop的时候,使用的模式modeNSDefaultRunLoopMode默认模式。

iOS中有两套API来访问和使用RunLoop对象,分别是OC语言中的Foundation框架(NSRunLoop)和C语言中的Core Foundation框架(CFRunLoopRef),NSRunLoop是基于CFRunLoopRef的一层OC包装

RunLoop文档

CFRunLoopRef文档

RunLoop其实共五种模式

  • NSDefaultRunLoopMode
  • UITrackingRunLoopMode
  • NSRunLoopCommonModes
  • NSConnectionReplyMode
  • NSModalPanelRunLoopMode

NSDefaultRunLoopMode模式就是RunLoop的默认模式,而UITrackingRunLoopMode模式则是UI模式,UI模式只能被触摸事件唤醒,所以优先级最高。这里第三种模式NSRunLoopCommonModes严格来说不是一种模式,而是一种占位模式,既能起到默认模式的效果又能起到UI模式的效果。第四种和第五种模式分别是在刚启动进入App时进入的第一个Mode,启动后就不再使用,和接受系统时间的内部Mode,作为开发者这两种模式基本不会使用到,

Core Foundation中关于RunLoop有五个类

  • CFRunLoopRef
  • CFRunLoopModeRef
  • CFRunLoopSourceRef
  • CFRunLoopTimerRef
  • CFRunLoopObserverRef

CFRunLoopRef对应的就是NSRunLoop,CFRunLoopModeRef代表上面说到的RunLoop的几种运行模式,CFRunLoopSourceRef是事件源,或者说是输入源,现在事件源分为两种,非基于PortSource0和基于PortSource1,基于Port是指 通过内核和其他线程通信,接收、分发系统事件,CFRunLoopTimerRef是基于事件的触发器,相当于NSTimer,CFRunLoopObserverRef是观察者,能够监听RunLoop的状态改变,可以监听的时间点有以下几个

  • kCFrunLoopEntry (1UL << 0) 即将进入Loop
  • kCFRunLoopBeforeTimers (1UL << 1) 即将处理Timer
  • kCFRunLoopBeforeSources (1UL << 2) 即将处理Source
  • kCFRunLoopBeforeWaiting (1UL << 5) 即将进入休眠
  • kCFRunLoopAfterWaiting (1UL << 6) 刚从休眠中唤醒
  • kCFRunLoopExit (1UL << 7) 即将退出Loop
  • kCFRunLoopAllActivities 所有状态

上面我们是把时钟加到默认模式下,现在我们加在UI模式看下效果

[[NSRunLoop currentRunLoop] addTimer:timer     forMode:UITrackingRunLoopMode];

再次运行程序

问题又出来了,当我们拖到textView时,时钟事件可以正常打印,但是一旦停止就不再打印了。这是因为,时钟是加到RunLoopUI模式下的,而UI模式只有在触摸屏幕的时候才会唤醒,而没有触摸屏幕的时候RunLoop则处于休眠状态,所以才会出现此问题。怎么才能解决这个问题呢?将这个时钟既加到默认模式下,又加到UI模式下

 [[NSRunLoop currentRunLoop] addTimer:timer     forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] addTimer:timer     forMode:UITrackingRunLoopMode];

这要添加两次,而🍎肯定考虑到了这点,所以有了NSRunLoopCommonModes这种占位模式

[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

再次运行,会发现上述问题都得到了解决,拖拽不再影响时钟方法的执行了。简单画个图

这是🍎官网的图

上面提到,RunLoop的作用之一就是监听事件并执行,事件可以有很多种,比如时钟事件、监听事件、网络事件等等。UI模式下的这些事件必须通过触摸才能唤醒RunLoop去执行,执行完之后RunLoop就处于休眠状态,循环往复。而默认模式下,只要有这些事件,就会及时通知RunLoop去执行。

继续深究,如果这个事件是个耗时操作会怎样?我们在timerMethod这个时间下模拟一下

[NSThread sleepForTimeInterval:1.0];

我让它每次睡个2秒钟再执行

问题又出来了,你会发现UI被卡住了,因为当前是在主线程上执行,每次都强迫它睡2秒,这绝壁会卡住。既然这样,那就让这个耗时操作放在子线程去执行

- (void)viewDidLoad {
    [super viewDidLoad];
    NSThread *thread = [[NSThread alloc] initWithBlock:^{
        NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
          NSLog(@"111");
    }];
    [thread start];
}

- (void)timerMethod {
    NSLog(@"222");
    [NSThread sleepForTimeInterval:2.0];
    static int a = 0;
    a++;
}

这时候运行会发现111尽管打印了,但是222没有打印,这说明timerMethod方法并没有执行,这是什么原因?这时候我们自定义一个FN_Thread

@implementation FN_Thread
- (void)dealloc {
    NSLog(@"dealloc");
}

NSThread替换成FN_Thread

 FN_Thread *thread = [[FN_Thread alloc] initWithBlock:^{
        NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
        NSLog(@"111");
    }];
[thread start];

再次运行,会发现打印完111,接着走进了FN_Thread中的dealloc方法,这说明这个线程对象thread被释放了,既然被释放了,那这个线程上的timeMethod方法自然就不会执行了。那我们强引用这个线程对象试一下,加上对应代码

@property (nonatomic, strong) FN_Thread *thread;

_thread = thread;

再次运行,会发现FN_Thread类中的dealloc方法不走了,但是timeMethod方法依然没有执行。线程对象没有释放,当前线程上的时钟方法又没有走,这又是什么原因?再加一个方法看一下

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"thread=%@",_thread);
    [_thread start];
}

运行,触摸屏幕的时候再次开启这个线程,开启前打印一下

这个线程对象还在,但是开启会崩

这是因为,这个线程已经挂了,已经被释放了,肯定无法被开启,但是这个OC对象thread还在,因为被我们强引用了。强引用线程对象,并不会阻止线程的释放。所以会出现上述情况。事实上,子线程block内的代码执行完线程就会被释放,要想线程不被释放。就必须让线程有执行不完的任务。那我们可以手动让线程一直在执行,在block内加如下代码

while (true) {     }

这时候会发现程序可以正常运行了。但这并不是正确的解决方法。正确的姿势应该是将事件加到RunLoop中,然后开启它,因为


每个线程都对应一个RunLoop,但是除了主线程,所有子线程的RunLoop默认都没有开启,需要手动开启

NSLog(@"111");代码前加上

[[NSRunLoop currentRunLoop] run];

再次运行,会发现时钟方法timeMethod终于可以正常运行了,但是你会发现NSLog(@"111");这句代码又没有走了?这是因为NSRunLoop是个死循环,下面的代码根本不会执行。好在,RunLoop还提供给了一个指定线程运行时间的方法[[NSRunLoop currentRunLoop] runUntilDate:];,毕竟一开启就停不下来,这操作很难接得住

这里就让线程存活指定时间感受一下

[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:5]];

再次运行,一切都很明了了

这里如果想强制退出子线程,使用[NSThead exit],不过并不建议这么做

有必要强调一下,如果在主线程调用[NSThead exit]会怎样?

你会发现程序并不会挂掉,但是主线程卡死了,子线程仍然在继续。其实在CPU眼里,主线程和其他子线程一样,一样可以强退

iOS里有个peformSelector方法,我们看一下

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {    
    FN_Thread *thread = [[FN_Thread alloc] initWithBlock:^{
        NSLog(@"touch");
    }];
    [thread start];
    [self performSelector:@selector(performMethod) onThread:thread withObject:nil     waitUntilDone:NO];
}

- (void)performMethod {
    NSLog(@"performMethod");
}

这时候你会发现performMethod方法根本就没走,performSelector就是线程间的通信,performMethod是在子线程thread上执行的,thread线程的block代码走完线程就挂了,所以不会走进performMethod方法,需要加上下面这句代码保住子线程的生命

[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:5]];

这里的performSelector事件就是上面提到的RunLoop处理的Source事件源。如果是performSelectorOnMainThread方法就不需要开启RunLoop,因为主线程的RunLoop在程序启动之时就已经默认开启了

NSTimer作为定时器有时候并不是十分精准的,可能会受到RunLoop的运行模式的影响,再加上其本身的精度有限, 可以使用GCD的定时器作为代替,精度更高,且不会受到RunLoop的影响,因为其是基于C语言的

int count = 0;
- (void)GCDTimer {
    // 获得队列
    dispatch_queue_t queue = dispatch_get_main_queue(); //     dispatch_get_global_queue(0, 0);
    // 创建定时器
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    self.timer = timer;
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 3.0 * NSEC_PER_SEC);
    uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
    dispatch_source_set_timer(timer, start, interval, 0);
    dispatch_source_set_event_handler(timer, ^{
        NSLog(@"count=%zd",count);
        count++;
    });
    // 启动定时器
    dispatch_resume(timer);
    // 取消定时器
    //dispatch_cancel(self.timer);
}

上面提到了RunLoop中的TimerSource,还有一个Observer,监听者,是用来监听RunLoop所处的状态,OC中无法创建,只能使用C语言

- (void)addOberser {
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"activity = %zd", activity);
    });
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer,     kCFRunLoopDefaultMode);
    CFRelease(observer);
}

可以看到RunLoop的运行逻辑,每次都是从睡眠中唤醒,处理一些事件,最后处于休眠状态。监听状态变化的block中可以根据自己需求定义一些处理事件

在项目中经常会遇到加载图片的需求,现在一般都使用SDWebImage库,如果不使用这个库怎么处理?

主要逻辑就是将一个个图片下载任务放入队列,下载成功存入内存缓存,然后写入沙盒,下次再次显示时先从内存缓存里获取,获取不到则从沙盒获取,依然获取不到则进行下载任务。其实使用SDWebImage库也依然会进行这些操作。

如果,这些下载的图片非常的大,分辨率比较高,你会发现这时候滑动的时候会有明显卡顿,是因为即便下载是在子线程,但是在渲染的时候图片会解压缩,这是在主线程进行的,是个耗时的操作,图片分辨率高的情况下卡顿尤其明显。这些下载下来的图片是在一次RunLoop循环的时候渲染显示,如果一次RunLooop循环只处理一张图片,卡顿就会得到有效解决,这需要用到Observer来监听RunLoop的状态,RunLoop在处理完事件会进入休眠状态,那就每次监听在RunLoop进入休眠状态前处理这些图片,直到没有图片处理然后进入休眠状态

RunLoopDemo

LoadManyPictures

LoadBigImage


如有任何疑问或问题请联系我:fishnewsdream@gmail.com,欢迎交流,共同提高!

Objective-C/Swift技术开发交流群201556264,讨论何种技术并不受限,欢迎各位大牛百家争鸣!

微信公众号OldDriverWeekly,欢迎关注并提出宝贵意见

老司机iOS周报,欢迎关注或订阅

刚刚在线工作室,欢迎关注或提出建设性意见!

刚刚在线论坛, 欢迎踊跃提问或解答!

如有转载,请注明出处,谢谢!

本站总访问量 本文总阅读量