iOS开发

iOS RunLoop,面试装13的神器

2019-01-02  本文已影响7人  shenXs
图层0@2x.png

今天我们来学习下iOS中一个较为重要的核心--RunLoop。其实我们对RunLoop既熟悉又陌生。熟悉是因为我们在开发中时不时的都会用到它,陌生是因为它较为底层,我们对它的了解不是多。今天我们就一起来揭开RunLoop神秘的面纱,对他进行一个较为简单的介绍。通过我的检点介绍,如果大家能对它有一定的了解和认识,那无论是在平时的工作中,还是在以后的面试中,它都是我们能拿上台面的一个利器。好,废话不多说,我们进入正题吧。

1.RunLoop的简单介绍

1.1 RunLoop的基本概念

那么到底是什么RunLoop呢,从字面意思上可以得知,他就是“跑循环”,翻译的雅一点就是“运行的循环”。那么在iOS SDK中,RunLoop实际上也是一个对象,这个对象在循环的处理程序在运行过程中发出各种事件,例如,用户点击了屏幕(TouchEvent),UI界面的刷新事件,定时器事件(Timer),Selector事件等等。当我们将我们的程序退出到后台,注意只是退出到后台,并没有terminate,这时RunLoop不会处理任何事件,此时为了节省CPU的资源,RunLoop会自动进入休眠模式。

1.2 RunLoop和线程的关系

我们一谈论到RunLoop,其实我们就会提到与之息息相关的东西,线程。我们知道,线程的作用是用来执行一个或者多个特定任务的,一般情况下,某个线程的任务执行完毕,他就return掉。那么了,我们为了让线程能够不断执行我们指派的,或者系统分配任务,让其任务执行完不能退出,这时候我们就用到了RunLoop。
一个线程对应唯一一个RunLoop对象,但是往往,RunLoop并不能保证我们线程安全,因为我们只能在当前线程中操作与之对应的RunLoop对象,而不能在当前线程中去操作其他线程的RunLoop对象。子线程的RunLoop对象,需要我们手动去创建和维护,当线程结束时,它也就销毁了。这里我们就会想到,那么主线程mainThread的RunLoop,是谁创建的呢?其实这个问题有点白痴:),是系统自动创建的。

1.3 主线程的RunLoop

当我们创建一个新工程的时候,我们找他的main.m,我们可以看到他的代码很简单,就一个main方法,代码如下:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

这个方法就是我们程序的入口,在这个方法中,首先标注了一个自动释放池autoreleasepool,然后再返回一个系统函数UIApplicationMain执行结果,其实这个UIApplicationMain函数就是为我们的程序创建了一个主线程的RunLoop,只要我们的程序不crash掉,那他就会一直运行循环,直到我们将程序退出到后台,或者terminate掉,它才会被挂起或是被销毁掉。
我们再来看看,官方文档上是如何图解RunLoop的,如下图所示:

640.jpeg
从上图中我们可以看出,RunLoop实际就是一个循环,它会一直监听输入源InputSources和定时源TimerSources,一旦接收到事件,它便会对其进行处理,如果长时间没有检测到事件,那么它会自动进入休眠状态。

2.RunLoop相关类介绍

我们要想对RunLoop有跟进一步的理解,我则需要对CoreFoundation框架下的与RunLoop相关的5个类,他们分别是:

首先我们来看下5个类的关系,如下图所示,

640.jpeg
我们来简单解释下他们之间的关系,一个CFRunLoopRef对象包含若干个CFRunLoopMode运行模式,每个运行模式又包含若干个CFRunLoopSourceRef输入源、CFRunLoopTimerRef定时源、CFRunLoopObserverRef观察者。这里需要注意的是,虽然一个RunLoopRef对象可以包含若干个CFRunLoopMode运行模式,但是该RunLoopRef对象当前运行的模式只能是指定的它包含的若干个CFRunLoopMode运行模式中的一个,那么这个被指定的运行模式就叫做“当前运行模式”CurrentMode。但是CurrentMode是可以进行切换的,如果需要切换运行模式,则需要退出当前的Loop,然后重新指定CFRunLoopMode。这样做的目的其实也很容明白,因为每一个CFRunLoopMode包含若干个CFRunLoopSourceRefCFRunLoopTimerRefCFRunLoopObserverRef,为了能有效的隔离不同CFRunLoopModeCFRunLoopSourceRefCFRunLoopTimerRefCFRunLoopObserverRef,使其不同CFRunLoopSourceRef互不影响,所以一个CFRunLoopRef只能指定一个CurrentMode
接下来,我们将逐一详细的来介绍与RunLoop密切相关的这5个类。
2.1 CFRunLoopRef

CFRunLoopRef是CoreFoundation内库中RunLoop对象类。我们可以以下方式来获取CFRunLoopRef对象。

//  获取当前线程的CFRunLoopRef
CFRunLoopRef runLoopRef = CFRunLoopGetCurrent();
//  获取主线程的CFRunLoopRef
CFRunLoopRef mainRunLoopRef = CFRunLoopGetMain();

NSRunLoop是Foundation内库中RunLoop的对象。我们同样可以使用以下方法来回去NSRunLoop对象

//  获取当前RunLoop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
//  获取主线程的RunLoop
NSRunLoop *mainRunLoop = [NSRunLoop mainRunLoop];
2.2 CFRunLoopMode

iOS系统第一了很多种运行模式,下面我们一一简单的介绍:

2.3 CFRunLoopTimerRef (Timer事件源)

CFRunLoopTimerRef是我们上文提及到的定时源,他是RunLoop监听的事件源之一,在RunLoop相关类的关系图中我们提过它。我们可以简单的将其理解为基于时间的触发器。我们也可以将其理解我们开发时常用的NSTimer。
接下来我们举一个简单的例子来演示一下CFRunLoopModeCFRunLoopTimerRef相结合的简单用法。
1.首先我们再UIViewController.view上添加一个scrollView, 在scrollView上我们再添加一个Label, 然后我们再懒加载一个定时器, 然后我们启动定时器,定时器将每隔1秒修改Label.text,代码如下:

- (void)viewDidLoad {
  [self.view addSubview:self.scrollView];
  [self.scrollView addSubview: self.label];
  [self.timer fire];
}

- (void)on_timer {
    _num += 1;
    self.label.text = [NSString stringWithFormat:@"%zd", _num];
}

//  懒加载一个定时器
- (NSTimer *)timer {
    if (!_timer) {
        _timer = ({
            NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(on_timer) userInfo:nil repeats:YES];
            timer;
        });
    }
    return _timer;
}

我们运行我们的程序,我们不做任何操作,我们可以看到我们的label.text会每隔一秒钟就变化一次。如果此时我们滑动我们的scrollView,我们就会发现label.text将不会再发生改变,当我们停止滑动,手指离开屏幕的时候,label.text又开始继续发生改变。那么到底是什么原因到导致这样的结果呢?
当我们再初始化Timer的时候,没有手动将Timer注入到某种运行模式下,此时系统会默认将其注入到NSDefaultRunLoopMode,所以我们运行程序,且不做任何操作的时候,此时RunLoop是当currentMode是NSDefaultRunLoopMode,Timer正常工作。当我们滑动scrollView的时候,此时系统会将currentMode切换到UITrackingRunLoopMode,我们的Timer并没有注入到这个模式下,所以Timer失效。当我们的滑动结束,手指离开屏幕的时候,currentMode又切换到NSDefaultRunLoopMode,此时Timer又正常工作。那么我么在初始化Timer的时候,可以将其注入到指定的运行模式下吗?是可以的,代码如下:

//  手动的将Timer注入到UITrackingRunLoopMode模式下
[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];

此时我们手动将Timer注入到UITrackingRunLoopMode,然后我们运行程序,其实显而易见,此时我们不做任何操作,Timer是不会正常工作的,当我们滑动scrollView的时候,Timer开始正常工作,原因与上同理。这里我来问个S13问题:), 难倒我们就不能在这两种模式下让Timer都能正常工作吗?(这个问题真的S13)。
答案是当然可以的,其实我是为了引出伪模式(kCFRunLoopCommonModes)。kCFRunLoopCommonModes其实都不能算是一种运行模式,它只是一种标记,它可以对其他运行模型进行标记。说道这里,大家可能都明白了系统的NSDefaultRunLoopModeUITrackingRunLoopMode都是被标记上Common modes。所以我们只需要将我们的timer手动注入到kCFRunLoopCommonModes下,那么此时无论系统的currentMode是default还是UITracking,timer将都能正常工作。修改代码如下:

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

这里我们说道了Timer,那么我就顺便就说说这个timer
NSTimer有两个便利构造函数入下:

//  构造函数1
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
//  构造函数2
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;

构造函数1,返回的timer对象会自动注入到NSDefaultRunLoopMode,其实相当于构造函数1,再手动注入到运行模式中,例如:

NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(...) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
2.4 CFRunLoopSourceRef (Input事件源)

所有的事件源有两种分类方法,我们来具体看看他哪两种分类:

  1. 按照官方文档来分类(和前面我贴出的官方RunLoop模型图里一样):
    Port-Based Sources :基于端口事件
    Custom Input Sources:用户自定义事件

  2. 按照函数调用栈来分类:
    Source0:非基于端口事件
    Source1:基于端口,通过内核和其他线程通信、接收、分发系统事件

其实根本一点讲,这两种分类是没有区别的,只不过呢,第一种是根据官方给出的理论来进行分类的。第二是我们在实际应用中通过函数的调用来进行分类的。

2.5 CFRunLoopObserverRef (I观察者类)

RunLoop的状态会根据用户的操作以及程序状态的变化而发生改变,通常我们会去监听RunLoop的实时变化,此时我们就会用到一个观察者类CFRunLoopObserverRef。RunLoop的状态集是以一个枚举类型来表示,入下面代码所示:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),                       // 即将进入Loop:1
    kCFRunLoopBeforeTimers = (1UL << 1),          // 即将处理Timer:2    
    kCFRunLoopBeforeSources = (1UL << 2),        // 即将处理Source:4
    kCFRunLoopBeforeWaiting = (1UL << 5),         // 即将进入休眠:32
    kCFRunLoopAfterWaiting = (1UL << 6),            // 即将从休眠中唤醒:64
    kCFRunLoopExit = (1UL << 7),                         // 即将从Loop中退出:128
    kCFRunLoopAllActivities = 0x0FFFFFFFU       // 监听全部状态改变  
};

下面我们用个小小的例子来观察下,RunLoop的状态到底是怎么变化的,以及我们如何使用CFRunLoopObserverRef来监听RunLoop的状态变化。
首先我们在viewDidLoad中添加如下代码:

CFRunLoopObserverRef observerRef =
    CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(),
                                       kCFRunLoopAllActivities,
                                       YES,
                                       0,
                                       ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"监听到RunLoop发生改变---%zd",activity);
    });
    
    // 2.append Observer to RunLoop
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observerRef, kCFRunLoopDefaultMode);
    
    // 3.release observer
    CFRelease(observerRef);

然后我们运行我们的程序,然后控制台会打印出一大串信息,这里我只贴出最后部分打印信息:

2018-12-25 10:36:03.293175+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---2
2018-12-25 10:36:03.293363+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---4
2018-12-25 10:36:03.293517+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---32
2018-12-25 10:37:00.032407+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---64
2018-12-25 10:37:00.036248+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---2
2018-12-25 10:37:00.036335+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---4
2018-12-25 10:37:00.036392+0800 TestApp[12836:1611962] 监听到RunLoop发生改变---32

我们可以看到,RunLoop的状态是在不断的变化的,他最后的状态是32,也就是我们上面的枚举中的kCFRunLoopBeforeWaiting,RunLoop即将进入休眠。

3.RunLoop的原理介绍

OK,我们上面讲解了RunLoop的基本概念,以及与RunLoop相关的几个类的介绍,接下来我们将具体的来说一说RunLoop的原理。它到底是怎么运作,希望通过下面讲解,大家都可以了解RunLoop的内部运行逻辑。

这里我贴一张我自己画的图,简单的描述了一下RunLoop的运行逻辑,如下图所示:


屏幕快照 2018-12-25 上午11.38.44.png

上图是我自己按照自己的理解画的RunLoop运行逻辑图,可能对大家在理解RunLoop的运行逻辑的时候有一点帮助,下面我们来看看官方文档对RunLoop的运行逻辑是如何阐述的。

当我们运行我们的程序的时候,所有线程的RunLoop会同时被唤醒,并且开始自动处理之前未处理完的事件,通知也发出通知,通知其相应的Observer。详细的顺序如下:

  1. 通知观察者RunLoop已经启动
  2. 通知观察者即将要开始的定时器
  3. 通知观察者任何即将启动的非基于端口的源
  4. 启动任何准备好的非基于端口的源
  5. 如果基于端口的源准备好并处于等待状态,立即启动;并进入步骤9
  6. 通知观察者线程进入休眠状态
  7. 将线程置于休眠知道任一下面的事件发生:
    某一事件到达基于端口的源
    定时器启动
    RunLoop设置的时间已经超时
    RunLoop被显示唤醒
  8. 通知观察者线程将被唤醒
  9. 处理未处理的事件
    如果用户定义的定时器启动,处理定时器事件并重启RunLoop。进入步骤2
    如果输入源启动,传递相应的消息
    如果RunLoop被显示唤醒而且时间还没超时,重启RunLoop。进入步骤2
  10. 通知观察者RunLoop结束。
    以上就是官方对RunLoop的运行逻辑给出的说明,我是直接用有道翻译出来的,没有去做校验,大家可以对照我上面贴出的图,大致看下RunLoop的运行逻辑。

4.RunLoop开发应用

上面说一大堆,很多都是概念性东西,要想真正的掌握RunLoop我们还是得到实际运用中。通过在实际开发过程中的实际应用,我们对RunLoop的认识才能更加深刻。

4.1 NSTimer的应用

NSTimer,我们上面的2.3CFRunLoopTimerRef (Timer事件源)中已经提到过有关NSTimer和RunLoop的关系,这里呢,我们就不再重复了。

4.2 开启常驻线程

在我们的实际开发过程中,有时候可能会遇到在后台频繁操作的的一些需求,例如,文件现在,音乐播放,实时定位等等,这些操作经常会在子线程做一些耗时的操作,此时,我们就可以应用RunLoop将这些耗时的子线程放到我们的常驻线程中去。

具体的操作室,代码如下:
1.首先我们开启一条子线程:

- (void)viewDidLoad {
    [super viewDidLoad];

    // 初始化线程,执行任务run1
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    [self.thread start];    
}

- (void) run1
{
    NSLog(@"----run1-----");

    // 添加下边两句代码,就可以开启RunLoop,之后self.thread就变成了常驻线程,可随时添加任务,并交于RunLoop处理
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];

    // 如果开启RunLoop,则不会执行下面这句打印,因为RunLoop开启了循环。
    NSLog(@"未开启RunLoop");
}

我们运行我们的程序,控制并没有打印 "未开启RunLoop", 说明我们开启了一条常驻线程,此时我们再往线程中添加一些子任务代码如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{   
    // 在self.thread的线程中执行任务run2
    [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO];
}

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

此时我们运行我们的程序,每当我们点击我们的屏幕的时候,控制台都会打印“----run2------”,这样我们就实现了常驻线程的需求。当然这里我只是简单的给大家演示了一下常驻线程的例子,那么大家可以根据自己不同是实际开发需求,来进行编码,并将RunLoop特性发挥起来。

以上就是对RunLoop的基本概念,相关类,运行逻辑,以及实际应用的简单介绍,当然我个人是水平也是比较次,有不对的,或者不准确的地方,希望大家留言指正。

上一篇下一篇

猜你喜欢

热点阅读