当前位置:网站首页>理解 OC 中 RunLoop

理解 OC 中 RunLoop

2020-11-09 10:56:00 osc_7dn4hojn

理解 OC 中 RunLoop

什么是RunLoop?

可以简单理解为,让程序保持运行的一个while循环,这个循环内监听各种事件(如触摸事件、performSelector、定时器NSTimer等),没有事件的时候睡眠,从而有效的利用CPU(只有在有事件的时候才用CPU,没事件的时候睡眠)

不管RunLoop有多复杂,其本质就是上面所说的:一个循环,有事件的时候处理事件,无事件的时候休眠(这里的睡眠是指用户态切换到内核态,这样的休眠线程是被挂起的,不会再占用cpu资源)。

RumLoop与线程有如下关系:

  • 一个线程只有一个RunLoop对象
  • 主线程的RunLoop默认已经创建好了,而子线程的需要手动创建。
  • RunLoop在第一次获取时创建,在线程结束时销毁。

我们验证一下,在main函数返回之前,打印一下:

int main(int argc, char *argv[])
{
    NSString *appDelegateClassName;
    @autoreleasepool {
        // Setup code that might create autoreleased objects goes here.
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    int ret = UIApplicationMain(argc, argv, nil, appDelegateClassName);
    NSLog(@"after ret");
    return ret;
}

结果没有打印,这说明主进程已经进入了一个RunLoop主了,主进程不结束,就跳不出RunLoop,也就执行不了之后的打印。

我们打印一下主线程的RunLoop试试:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"%@", [NSRunLoop currentRunLoop]);
}

// 打印结果(只取关键信息):
// CFRunLoop 0x600001704700
// current mode = kCFRunLoopDefaultMode,

这说明主线程在一个RunLoop中,并且当前的运行模式是kCFRunLoopDefaultMode

这样感觉RunLoop很简单,但它又很复杂,因为要考虑的因素有很多,比如各种事件的处理顺序,定时器、多线程等等

对于一个复杂问题,解决方法之一就是抽象,苹果为解决上面的问题,抽象出了RunLoop对象,RunLoop中包含多个Mode类,每个mode类中包含若干个 Source,Observer和Timer类,关系如下:

在这里插入图片描述

Mode是RunLoop的运行模式,有五类:

kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
UIInitializationRunLoopMode // 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
GSEventReceiveRunLoopMode // 接受系统事件的内部 Mode,通常用不到
kCFRunLoopCommonModes // 这是一个占位用的Mode,不是一种真正的Mode,可以简单理解为kCFRunLoopDefaultMode和UITrackingRunLoopMode的结合

这里的Source是事件源,比如触摸事件。

Observer是观察者,监听事件源的事件,可以简单理解为线程,比如主线程RunLoop的的Observer是主线程。

还有一些规定:

  • RunLoop虽然有多个Mode,但RunLoop函数执行的时候,只能指定一个Mode
  • 如果要切换Mode,需要等到一个Loop循环结束,再让新的Mode进入

上面说一个RunLoop只有一个Mode在执行,下面做个试验看看:

@interface ViewController ()

@property (weak, nonatomic) IBOutlet UITextView *textView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)timerTest {
    NSLog(@"%s", __func__);
}

@end

这里我们在ViewControlller里面创建了一个timer,把他加到NSDefaultRunLoopMode中,这个ViewControlller有个可以滚动的UITextView(继承UIScrollViewUIScrollView默认的Mode是UITrackingRunLoopMode

当我们滑动UITextView的时候,timer停止触发事件了,说明RunLoop的Mode从Default切换到了UITrackingRunLoopMode

解决方法就是把timer放入kCFRunLoopCommonModes中,这个Mode相当于同时是kCFRunLoopDefaultMode和UITrackingRunLoopMode:

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

上面是一个经典的例子,可以解决在UIScrollView(包括其子类)中有NSTimer定时的场景。

受此启发,我们可以用RunLoop解决卡顿问题,有一种卡顿问题就是UITableView中有很多高清大图需要载入,在滑动屏幕的时候卡顿。

我们先分析一下卡顿的原因:最根本的原因是RunLoop转一圈的时间太长了,因为一次RunLoop循环需要解析很多张高清大图,系统渲染每一张高清大图都需要一定的时间,这样需要等到渲染的RunLoop结束之后,才能切换滑动屏幕RunLoop的Mode(UITrackingRunLoopMode),解决方法就是:

  • 创建一个定时器:每间隔一定时间(可以是0.01s)执行一个空方法来唤醒RunLoop
  • 将加载图片的方法装入block,将block加入一个有数量限制的数组,当block超过最大数量限制,移除最早添加的block
  • 监听RunLoop的苏醒,苏醒回掉就执行一次就从数组中取出一个block事件执行,执行完的事件从数组中删除

这样设计让RunLoop的每次循环只执行一个加载图片的block(减少RunLoop单次循环的时间)。给数组设置一个最大数量限制,可以防止同一时间需要渲染的图片过多(减少RunLoop渲染图片的总时间)。

下面我们可以看看RunLoop里面长什么样了:

RunLoop内部逻辑

在这里插入图片描述

这里引入了新概念:source0是触摸事件和所有执行performSelector方法,source1是基于port的线程间的通信。

这里我们可以大概看出RunLoop中处理事件的顺序,可以简要的总结为:

  1. 先通知Timer,Sources要处理事件了
  2. 处理source0
  3. 看看有没有source1,没有就休眠,有就不休眠
  4. 休眠状态下sources,timer,dispatch,手动都可以唤醒
  5. 3结束或者4唤醒后,就开始处理各种其他事件(timer,source1,dispatch)
  6. 如果第五步处理了至少一个事件,则开始新一轮的RunLoop,否则退出RunLoop

以上逻辑可以推出,在RunLoop中,只要有任何一个事件,RunLoop就不会退出,除非是RunLoop在休眠超时被唤醒或者外部强制停止,才会退出。

下面用一个例子感受一下RunLoop里的逻辑:

- (void)viewDidLoad {
    [super viewDidLoad];

    [self createObserver];
    
    [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerFired) userInfo:nil repeats:YES];
}

- (void)timerFired
{
    NSLog(@"---- timer fired ----");
}

- (void)createObserver
{
    //创建监听者
    /*
    第一个参数 CFAllocatorRef allocator:分配存储空间 CFAllocatorGetDefault()默认分配
    第二个参数 CFOptionFlags activities:要监听的状态 kCFRunLoopAllActivities 监听所有状态
    第三个参数 Boolean repeats:YES:持续监听 NO:不持续
    第四个参数 CFIndex order:优先级,一般填0即可
    第五个参数 :回调 两个参数observer:监听者 activity:监听的事件
    */

    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        switch (activity) {
            case kCFRunLoopEntry:
                NSLog(@"RunLoop进入");
                break;
            case kCFRunLoopBeforeTimers:
                NSLog(@"RunLoop要处理Timers了");
                break;
            case kCFRunLoopBeforeSources:
                NSLog(@"RunLoop要处理Sources了");
                break;
            case kCFRunLoopBeforeWaiting:
                NSLog(@"RunLoop要休息了");
                break;
            case kCFRunLoopAfterWaiting:
                NSLog(@"RunLoop醒来了");
                break;
            case kCFRunLoopExit:
                NSLog(@"RunLoop退出了");
                break;

            default:
                break;
        }
    });

    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);  // 添加监听者,关键!
    CFRelease(observer); // 释放
}

这里给RunLoop创建了一个观察者,观察者的回调打印RunLoop里的逻辑,另外有一个Timer每隔1.0秒触发一下。结果如下:

// 23:26:30 RunLoop醒来了
// 23:26:30 ---- timer fired ----
// 23:26:30 RunLoop要处理Timers了
// 23:26:30 RunLoop要处理Sources了
// 23:26:30 RunLoop要休息了
// 23:26:31 RunLoop醒来了
// 23:26:31 ---- timer fired ----
// 23:26:31 RunLoop要处理Timers了
// 23:26:31 RunLoop要处理Sources了
// 23:26:31 RunLoop要休息了

可以看到,Timer要触发的时候,唤醒了RunLoop,RunLoop醒来后去处理Timer,执行了Timer的方法(打印---- timer fired ----),然后RunLoop回到循环的开头,通知观察者要处理Timers和Sources了,结果发现没有要处理的,然后就去休息了,如此循环。。。基本和上面的逻辑一致。

这里介绍一个RunLoop的应用:

创建一个常驻线程

首先我们创建一个继承自NSThread的类BZThread,用来打印销毁时候的信息,然后在viewDidLoad中创建一个线程:

@interface BZThread : NSThread
@end
  
@implementation BZThread
- (void)dealloc {
    NSLog(@"BZThread is dealloced");
}
@end

@interface ViewController ()

@property NSThread *thread;

@end

@implementation ViewController


- (void)viewDidLoad {
    [super viewDidLoad];
    NSThread *thread = [[BZThread alloc] initWithTarget:self selector:@selector(threadTest) object:nil];
    self.thread = thread;
    [self.thread start];
}

- (void)threadTest {
    NSLog(@"thread is created");
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self performSelector:@selector(doSomethingInThread) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void)doSomethingInThread {
    NSLog(@"doSomethingInThread is fired");
  
}

@end

// BZThread is created

我们发现,线程是被创建了,也被ViewControlelr持有了(没有马上被销毁),但是我们在这个线程里执行方法没有反应,这说明这个线程的RunLoop没有运行起来。

解决方法是在这个线程方法里,给这个线程的RunLoop创建一个Mode:

- (void)threadTest {
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];
    NSLog(@"thread is created");
}

点击屏幕,我们就执行了线程的方法了:

// doSomethingInThread is fired

这是因为,虽然一个线程对应一个RunLoop,但一个RunLoop至少需要一个Mode,才能跑起来,主线程默认就有Mode了,而新的线程需要我们手动去创建新的Mode。

最后介绍一个RunLoop的应用:

检测卡顿:

如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。

如何检查卡顿呢?需要创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。一旦发现进入睡眠前的 状态,或者唤醒后的状态,在设置的时间阈值内一直没有变化,即可判定为卡顿。接下来,我们就可以 dump 出堆栈的信息,从而进一步分析出具体是哪个方法的执行时间过长。

版权声明
本文为[osc_7dn4hojn]所创,转载请带上原文链接,感谢
https://my.oschina.net/u/4380369/blog/4708786