面试遇到RunLoop的第二天-应用

1,346 阅读11分钟

接着上一篇文章对RunLoop原理的分析之后,本文继续分析一下RunLoop在实际开发中的应用。

RunLoop在实际开发中的应用可以分为下面几个点:

  • 控制线程生命周期,也就是常说的线程保活
  • 解决NSTimer在滑动时停止的问题
  • 监控应用卡顿
  • 性能优化

其中前面两点是开发中会用到的,本文着重说一下,后面两点呢,可以都归为性能优化,计划是后面专门写一篇性能优化的专题来说,到时候我们再详细讨论RunLoop在性能优化方面能帮我们开发者做哪些事。

NSTimer 失效

NSTimer失效的问题,不管是开发中,或者是面试中,很高概率会遇到。这个问题表现为页面内有可滑动的控件(比如scrollview)时,在滑动控件时会影响已开启的定时器。

写一段简单的测试代码,根据打印结果看看滑动对定时器的影响

    static int count = 0;
    [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"定时器 %d", ++count);
        
    }];

观察打印时间,是拖拽页面上的scrollview 导致了定时器停止,停止拖动,定时器又开始继续打印

至于NSTimer失效的原因呢,也很明显了,回顾上篇文章中runloop的mode部分的知识点

RunLoop启动时只能选择其中一个mode作为currentMode,如果需要切换mode,只能退出当前RunLoop,再重新选择一个mode。

通过scheduledTimerWithTimeInterval方法创建的NSTimer呢,是只能在默认模式下工作的,而拖拽scrollview切换了runloop的模式到UITrackingRunLoopMode,切换模式导致runloop退出又重新进入,所以 定时器停止工作了。那么如何解决这个问题呢?

显然,我们需要创建一个可以在NSDefaultRunLoopMode和UITrackingRunLoopMode这两种模式下都可以工作的timer,NSRunLoop提供了addTimer: forMode:的方法,可以用来解决NSTimer 失效的问题。

   static int count = 0;
//    解决拖拽定时器停止
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"定时器 %d", ++count);
    }];
//    NSRunLoopCommonModes 只是一个标记 代表timer在设置了common标记的模式下都能运行
//NSDefaultRunLoopMode UITrackingRunLoopMode这两个模式都是被标记为common的模式 所以在拖动和停止的时候timer都可以工作
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

上面代码中的注释也写的很清楚了,之所以选择NSRunLoopCommonModes呢,是因为NSRunLoopCommonModes 只是一个标记 代表timer在设置了common标记的模式下都能运行,所谓设置了common标记的模式,可以在RunLoop结构体中找到相关的定义

所有设置了common标记的模式都放在_commonModes这个集合中,而这个集合就包含了NSDefaultRunLoopModeUITrackingRunLoopMode这两个模式。

这样就解决了NSTimer 失效的问题。但是这里还会引起另外一个问题----循环引用,这个问题可以使用中间者解决,具体解决方案可以查看另外一篇文章中有详细介绍

线程保活(常驻线程)

控制线程生命周期,也就是常说的线程保活。在开发中的应用场景是,需要频繁的在子线程中处理任务,而不断创建销毁线程,是很消耗性能的,这种情况下,如果我们可以控制线程的生命周期,让线程可以在有任务时被唤醒,没有任务的时候休眠,就可以提高程序的性能,而这一点正是符合了runloop的运行原理。

下面通过一个demo去实践一下线程是如何被“保活”的,以及被“保活”的线程,会带来哪些问题?

实现过程分析

首先创建一个线程,MyThread继承自NSThread,重写dealloc方法,方便我们查看线程的销毁情况

self.thread = [[MyThread alloc] initWithTarget:self selector:@selector(run) object:nil];
[self.thread start];

run方法中先只写一句打印,作为测试,然后再touchesBegan中,我们继续在上面创建的线程中添加任务,这里用到了 performSelector: onThread: withObject: waitUntilDone:方法,在指定的线程中执行任务(调用方法)

- (void)test {
    NSLog(@"%s %@",__func__,[NSThread currentThread]);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    //测试 继续在子线程中执行任务
    [self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
    
}

测试结果你一定也分析出来了,test方法根本不会被执行,因为线程开启之后,run方法执行完,线程就销毁了,准确来说线程已经失去了再添加任务的能力,最终需要等到ViewController销毁时,线程才会dealloc.

那如何可以实现线程保活呢?给该线程添加一个runloop,让他循环起来,或者说阻塞住,他就可以不结束不销毁。

上篇文章中也说到了,创建runloop的方法,只要在线程中获取runloop就会自动创建了。

//线程保活  启动runloop
- (void)run {
    NSLog(@"%s %@",__func__,[NSThread currentThread]);
    [[NSRunLoop currentRunLoop] run];
//    end不会打印 线程处于休眠状态
    NSLog(@"end");
}

修改代码,继续测试,点击屏幕触发touchesBegan,然后,test方法还是没有被调用,线程依然是挂掉了,这又是什么原因呢?分析之后就明白了,NSRunLooprun方法底层是执行了runMode, 但是mode中没有任何sources0,sources1,observers,timers 所以runloop还是立刻退出了。

继续修改代码,往runloop里面添加source /timer / observer 为他保活

- (void)run {
    NSLog(@"%s %@",__func__,[NSThread currentThread]);
    [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];

}

再测试,果然线程没挂掉

到这里已经初步实现了线程保活,为啥说初步呢,因为这个方案是有问题的,最开始说MyThread继承自NSThread,重写dealloc方法,是为了方便我们查看线程的销毁情况,现在就用上了,退出这个ViewController, 就看到,VC并没有销毁,thread也没有销毁。

那初步分析,肯定是内部有循环引用导致的,在VC的dealloc方法中添加上self.thread = nil;也并没有用。

再分析可能是线程保活这部分代码导致了循环引用,接下来还一种方式实现,使用initWithBlock 代替initWithTarget

    //线程保活的问题 导致循环引用  使用initWithBlock  代替initWithTarget
    self.thread = [[MyThread alloc] initWithBlock:^{
          NSLog(@"%s %@",__func__,[NSThread currentThread]);
        [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
        [[NSRunLoop currentRunLoop] run];
 
        //    end不会打印 线程处于休眠状态
        NSLog(@"end");
    }];

再测试,好使了,ViewController可以正常销毁了,但是thread依然没有销毁,还是存在内存泄漏。

继续分析,应该是因为runloop没有停止,所以线程一直阻塞着,不能被释放,那就调用CFRunLoopStop给runloop停止一下

- (void)stop {
//    必须保证stop在self.thread这个线程中执行
    CFRunLoopStop(CFRunLoopGetCurrent());
}

- (void)dealloc
{
    NSLog(@"%s",__func__);
    [self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:NO];
    self.thread = nil;
}

再测试,发现线程还是不能正常销毁。。。 都快要放弃了,但是作为一名优秀的manager,怎能这么轻易放弃呢。继续研究继续分析,

接下来一起看看[[NSRunLoop currentRunLoop] run];这里的run方法底层到底做了什么,是不是导致线程一直不能释放的罪魁祸首呢。

it runs the receiver in the NSDefaultRunLoopMode by repeatedly invoking runMode:beforeDate:. In other words, this method effectively begins an infinite loop that processes data from the run loop’s input sources and timers.

意思是调用NSRunLooprun方法是开启了一个无限循环的runloop,而CFRunLoopStop只是停止了其中的一次run 而不会停止这个runlopp,NSRunLoop的run方法是没法停止的,果然我们上面的stop方法无效,既然停不掉,那只能换一种创建runloop的方法了,曲线救国吧。

使用runMode: beforeDate:方法开启一个runloop,distantFuture 是将来一个无限远的时间

[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];

但是这种启动方式开启的runloop, 被唤醒之后执行完一次任务, runloop就退出了。绕了一大圈又回到了起点,只能另辟新径了。

下面也就是最终线程保活的方案了:添加一个是否需要停止保活的标志位,并且在给线程添加任务和结束线程生命周期的时候加一些安全的判断

@interface ViewController ()
@property (nonatomic, strong) MyThread *thread;
@property (nonatomic, assign, getter=isStopped) BOOL stopped;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    __weak typeof(self) weakSelf = self;
    self.thread = [[MyThread alloc] initWithBlock:^{
        NSLog(@"%s %@",__func__,[NSThread currentThread]);
        [[NSRunLoop currentRunLoop] addPort:[[NSPort alloc] init] forMode:NSDefaultRunLoopMode];
        while (!weakSelf.isStopped) {
            [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }
        NSLog(@"end");
    }];
    [self.thread start];
//
     
}


- (void)test {
    NSLog(@"%s %@",__func__,[NSThread currentThread]);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
   if (self.thread) {
       return;
   }
    //测试 继续在子线程中执行任务
    [self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
//    NSLog(@"waitUntilDone: yes  先执行test  再打印这一句  no就test和打印同时执行");
}

- (void)stopThread {
    if (self.thread) {
        return;
    }
    [self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void)stop {
    //    修改判断条件 设置为yes  就不会再重新启动runlopp
    self.stopped = YES;
    
//    必须保证stop在self.thread这个线程中执行
    CFRunLoopStop(CFRunLoopGetCurrent());
    self.thread  = nil;
}

- (void)dealloc
{
    NSLog(@"%s",__func__);
    [self stopThread];
}


@end

然而。。然而。。还有一个致命的bug,dealloc中调用stopThread的时候,居然crash了,因为坏内存访问。之前测试的时候我们手动调用stopThread,关闭runloop并没有问题,并且可以正常结束线程的生命周期,而开发中,肯定还是希望线程的生命周期可以跟随ViewController的生命周期一起销毁。

这个问题是[self performSelector:@selector(stop) onThread:self.thread withObject:nil waitUntilDone:NO];这句代码导致,准确来说是因为waitUntilDone这个参数设置为NO 导致的,上面也提到过,这个参数是代表是否要等待子线程中的代码执行完毕再往下执行,设置为NO,则不需要等待子线程执行完毕。这里在dealloc中调用stopThread,VC即将马上就要被销毁,还没等到子线程中调用stop,把标志位设置为yes,和之后一系列停止runloop的操作完成呢,VC就已经销毁,出现坏内存访问。

所以解决这个问题就是把waitUntilDone这个参数改为YES,代表子线程的代码执行完毕,才会往下走。

你以为到这里就结束了,所有问题都解决了吗?并没有,thread依然没销毁,感觉分析了这么久来来回回就一直在解决内存的问题,这次问题比较好找,因为while (!weakSelf.isStopped)再走到这句判断的时候,vc已经被销毁,weakSelf已经是nil了,所以这个条件又满足了,还是继续唤醒了runloop了。

最终这个判断条件改成(weakSelf && !weakSelf.isStopped)就可以完美解决

 while (weakSelf && !weakSelf.isStopped) {
            [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
        }

分享到这里,大家应该就可以对线程保活的实现有一个比较深刻的印象了。其实直接给出一个最终的方案就好,啰啰嗦嗦分析了一大堆,就是为了有一个分析的过程,这样如果你忘记了结果,也可以通过自己的分析,得到最终的结果。

封装

经过上面整个的分析,完善的过程,最终我们成功的实现了线程保活,在开发中,遇到需要频繁在子线程中执行任务的情况,就可以用线程保活去实现了,当然这里必须强调一下,使用场景一定要是在子线程中串行执行任务的情况,因为只创建了一个子线程。

接下来我们可以把上面的代码进行抽离整合,封装成一个工具类,方便需要的时候用。

封装完的代码就不贴了,就是把上面的代码抽离到一个单独的类中,主要提一下,除了NSRunLoop的API之外,还可以使用CFRunLoopRun的API去实现,代码还可以更加精简,甚至可以去掉stopped这个标志位的判断。通过CFRunLoopRunInMode方法的第三个参数returnAfterSourceHandled,去控制执行完source,runloop是否要退出。

- (instancetype)init {
    if (self = [super init]) {
        self.stopped = NO;
        self.innerThread = [[NSThread alloc] initWithBlock:^{
//            runloop中添加source
            CFRunLoopSourceContext context = {0};//要初始化一下结构体
            
            CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
            
            CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
            
            CFRelease(source);
//            while (weakSelf && !weakSelf.stopped) {
////           returnAfterSourceHandled = true  代表执行完source就会退出当前loop
//                CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0e10, true);
//            }
            //采取C语言的方式 直接用下面这一句代码就可 不需要加标志位判断
            CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0e10, false);
            NSLog(@"-----end------");
        }];
        [self.innerThread start];
    }
    return self;
}

C语言实现线程保活的源码

感兴趣的还可以看下AFNetworking2.0的源码,是基于NSURLConnection实现,为了能够在后台接收delegate回调AFNetworking内部创建了一个空的线程并启动了RunLoop,当需要使用这个后台线程执行任务时AFNetworking通过**performSelector: onThread: **将这个任务放到后台线程的RunLoop中。

AutoreleasePool

关于AutoreleasePool和runloop的关系,参见另外一篇文章----面试遇到内存管理的第三天-copy和autorelease,详细讲述了AutoreleasePool的创建和释放,是通过系统注册的Observer,监听runloop的进入和休眠以及退出的状态,去做相应的操作。

GCD

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