OC-Run Loop的理解和使用

  • Run Loop是什么

RunLoop顾名思义,是运行循环。它跟线程是一一对应的,每一个线程都有一个RunLoop,在需要的时候创建。RunLoop的作用很简单,就是保持线程不会退出,并且处理一些事件。

如果没有RunLoop,线程只要一执行完代码就会退出。RunLoop类似一个while循环,但是又不像while循环会占用CPU资源,RunLoop在等待的时候处于休眠状态,只有接收到事件时,才会被唤醒,然后再做相应的处理。

程序启动时,系统会自动为我们开启主线程的RunLoop,这就保证了我们的程序不会退出,并且可以一直响应我们的操作。而子线程的RunLoop并没有开启,需要我们手动开启。

  • Run Loop使用

说到使用RunLoop,其实我们在使用NSTimer的时候就已经使用过它了,只不过那时候并没有对RunLoop深入研究,我们来重新体验一下一个NSTimer的简单使用:

    //创建一个Timer
    NSTimer *timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"timer");
    }];
    
    //把它加到RunLoop里
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

一个NSTimer必须和RunLoop一起工作,不然它没办法运作。这里再给RunLoop添加timer的时候,有一个参数叫Mode,这是RunLoop模式,不同模式处理不同类型输入源的事件。

  1. NSDefaultRunLoopMode:App的默认Mode,通常主线程是在这个Mode下运行。
  2. UITrackingRunLoopMode:界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。
  3. UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
  4. GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。
  5. NSRunLoopCommonModes: 这是一个占位用的Mode,不是一种真正的Mode,它会同时处理默认模式和UI模式中的事件。

到这里又引发出来了新的问题,为什么NSTimer必须添加到RunLoop才能使用呢?

这就涉及到了RunLoop所能处理的事件了:

Run loop接收输入事件来自两种不同的来源:输入源(input source)和定时源(timer source)。输入源传递异步事件,通常消息来自于其他线程或程序。定时源则传递同步事件,发生在特定时间或者重复的时间间隔。两种源都使用程序的某一特定的处理例程来处理到达的事件。

这张官方的图简单的描述了RunLoop所能处理的事件来源:


接下来,我们来玩一玩RunLoop。在OC中,有NSRunLoopCFRunLoop两种方式来获取并且操作RunLoop。其中NSRunLoopCFRunLoop的封装。我们以NSRunLoop为主对RunLoop进行使用。首先,我们创建一个线程,然后开启它的runloop,我们如何证明它的runloop已经开启呢?结合上图,我们只需要找一个runloop能够处理的事件,然后让它去处理就可以了,我这里挑选了- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait ;方法:

//首先持有一个线程对象,方便我们之后使用它
@property (nonatomic, strong) NSThread *thread;

- (void)viewDidLoad {
    [super viewDidLoad];
    //初始化并开启,在线程内部开启它的runloop
    self.thread = [[NSThread alloc] initWithBlock:^{
        NSLog(@"这是一条子线程%@",[NSThread currentThread]);
        
        [[NSRunLoop currentRunLoop] run];
    }];
    [self.thread start];
}

//点击屏幕时,在线程上执行下面的打印
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    [self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
}

//打印出线程,以便我们确认是同一条
- (void)test{
    NSLog(@"哈哈哈%@",[NSThread currentThread]);
}

我们可以通过[NSRunLoop currentRunLoop]获取当前线程的RunLoop,开启RunLoop只需要调用其run方法。

按照我们预想的结果,运行以后每次点击屏幕都应该有输出,但是实际上我们点击屏幕并没有任何效果。这是因为开启RunLoop之前必须给其指定至少一种输入源或者定时源,不然开启之后会马上退出。说到这里,我们得看一下RunLoop在一次循环的周期内,到底做了什么事情:

每次运行run loop,你线程的run loop对会自动处理之前未处理的消息,并通知相关的观察者。具体的顺序如下:

  1. 通知观察者run loop已经启动
  2. 通知观察者任何即将要开始的定时器
  3. 通知观察者任何即将启动的非基于端口的源
  4. 启动任何准备好的非基于端口的源
  5. 如果基于端口的源准备好并处于等待状态,立即启动;并进入步骤9。
  6. 通知观察者线程进入休眠
  7. 将线程置于休眠直到任一下面的事件发生:
    • 某一事件到达基于端口的源
    • 定时器启动
    • Run loop设置的时间已经超时
    • run loop被显式唤醒
  8. 通知观察者线程将被唤醒。
  9. 处理未处理的事件
    • 如果用户定义的定时器启动,处理定时器事件并重启run loop。进入步骤2
    • 如果输入源启动,传递相应的消息
    • 如果run loop被显式唤醒而且时间还没超时,重启run loop。进入步骤2
  10. 通知观察者run loop结束。

从以上我们可以知道,如果是定时器事件,执行之后会直接重启RunLoop,如果是其它事件,处理完毕后,不会再次唤醒RunLoop,要想它继续监听事件,我们必须得手动唤醒它。之前在我们点击的时候,runloop已经退出了,所以代码并没有执行。

不过这难不倒我们,我们可以给它一个循环,让它不断得开启:

    while (true) {
        [[NSRunLoop currentRunLoop] run];
    }

再次运行,点击屏幕就可以看到打印出的信息:

开启RunLoop还有另外几个方法,我们平时最好不要直接使用run方法,可能会造成无限循环:

//同run方法,增加超时参数limitDate,避免进入无限循环。使用在UI线程(亦即主线程)上,可以达到暂停的效果。
(void)runUntilDate:(NSDate *)limitDate; 

//等待消息处理,好比在PC终端窗口上等待键盘输入。一旦有合适事件(mode相当于定义了事件的类型)被处理了,则立刻返回;类同run方法,如果没有事件处理也立刻返回;有否事件处理由返回布尔值判断。同样limitDate为超时参数。
(BOOL)runMode:(NSString )mode beforeDate:(NSDate )limitDate;

以上是一些简单的操作,我们可以利用RunLoop去监测一些事件,当它发生的时候再去做处理。但是用while循环会让CPU一直在工作,所以我们最好设置一种终止RunLoop循环的条件。

前面提到,RunLoop在开启时,需要给它指定输入源,而输入源是可以自定义的,不过它需要使用CFRunLoop。接下来我们可以自己自定义一种输入源:

/* Run Loop Source Context的三个回调方法,其实是C语言函数 */

// 当把当前的Run Loop Source添加到Run Loop中时,会回调这个方法。
void runLoopSourceScheduleRoutine (void *info, CFRunLoopRef runLoopRef, CFStringRef mode)
{
    NSLog(@"Input source被添加%@",[NSThread currentThread]);

}

// 当前Input source被告知需要处理事件的回调方法
void runLoopSourcePerformRoutine (void *info)
{
    NSLog(@"回调方法%@",[NSThread currentThread]);
}

// 如果使用CFRunLoopSourceInvalidate函数把输入源从Run Loop里面移除的话,系统会回调该方法。
void runLoopSourceCancelRoutine (void *info, CFRunLoopRef runLoopRef, CFStringRef mode)
{
    NSLog(@"Input source被移除%@",[NSThread currentThread]);
}

//创建两个属性来保存`runLoopSource `和`runLoop `
@implementation ViewController{
    CFRunLoopSourceRef runLoopSource;
    CFRunLoopRef runLoop;
}

        //在之前的线程代码中为RunLoop添加Source
        self.thread = [[NSThread alloc] initWithBlock:^{
        NSLog(@"这是一条子线程%@",[NSThread currentThread]);
        
        runLoop = CFRunLoopGetCurrent();
        
        CFRunLoopSourceContext context = {0, (__bridge void *)(self), NULL, NULL, NULL, NULL, NULL,
            &runLoopSourceScheduleRoutine,
            &runLoopSourceCancelRoutine,
            &runLoopSourcePerformRoutine};
        
        //CFAllocatorRef内存分配器,默认NULL,CFIndex优先索引,默认0,CFRunLoopSourceContext上下文
        runLoopSource = CFRunLoopSourceCreate(NULL, 0, &context);
        CFRunLoopAddSource(runLoop, runLoopSource, kCFRunLoopDefaultMode);
        
        [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];

    }];
    [self.thread start];

然后我们需要在点击的时候通知InputSource,并且唤醒runLoop

    //通知InputSource
    CFRunLoopSourceSignal(InputSource);
    //唤醒runLoop
    CFRunLoopWakeUp(runLoop);

然后点击测试一下:

因为我们设置了超时时间,所以10秒以后,RunLoop就会退出。同时它的InputSource被自动移除。

以上,我们简单的自己创建并添加了RunLoop的InputSource,实际开发中,我们可以对InputSource进行封装,使用起来更方便。这里就不做这一步了,网上可以找到比较完善的例子。

RunLoop还有一个观察者,可以让我们监听到RunLoop的各种状态,它也需要用CFRunLoop来实现,接下来,我们在上面的基础上,对RunLoop添加观察者进行监听:

// RunLoop监听回调
void currentRunLoopObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    NSString *activityDescription;
    switch (activity) {
        case kCFRunLoopEntry:
            activityDescription = @"kCFRunLoopEntry";
            break;
        case kCFRunLoopBeforeTimers:
            activityDescription = @"kCFRunLoopBeforeTimers";
            break;
        case kCFRunLoopBeforeSources:
            activityDescription = @"kCFRunLoopBeforeSources";
            break;
        case kCFRunLoopBeforeWaiting:
            activityDescription = @"kCFRunLoopBeforeWaiting";
            break;
        case kCFRunLoopAfterWaiting:
            activityDescription = @"kCFRunLoopAfterWaiting";
            break;
        case kCFRunLoopExit:
            activityDescription = @"kCFRunLoopExit";
            break;
        default:
            break;
    }
    NSLog(@"Run Loop activity: %@", activityDescription);
}

        //为runLoop添加观察者
        CFRunLoopObserverContext  runLoopObserverContext = {0, NULL, NULL, NULL, NULL};
        CFRunLoopObserverRef    observer = CFRunLoopObserverCreate(NULL,//内存分配器,默认NULL
                                                                   kCFRunLoopAllActivities,//监听所有状态
                                                                   YES,//是否循环
                                                                   0,//优先索引,一般为0
                                                                   &currentRunLoopObserver,//回调方法
                                                                   &runLoopObserverContext//上下文
                                                                   );
        if (observer)
        {
            CFRunLoopAddObserver(runLoop, observer, kCFRunLoopDefaultMode);
        }

运行以后:

上面,我们对Runloop的使用做了简单的分析,但是对我们好像还是没什么卵用。接下来,我们通过一个实际的案例来运用RunLoop,让它变成我们的法宝。

  • Run Loop的实际应用

我们在实际开发中经常会遇到TableView中有大量的图片显示,在滑动过程中,能明显得感觉到卡顿。我们这里用TableView显示多张大图简单模拟一下:

    self.tableView = [[UITableView alloc] initWithFrame:self.view.frame];
    self.tableView.delegate = self;
    self.tableView.dataSource = self;
    self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero];
    
    [self.view addSubview:self.tableView];

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 299;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return 100;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    
    NSString *identifier = @"identifier";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
    
    if (!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
        cell.selectionStyle = UITableViewCellSelectionStyleNone;
    }

    for (UIView *view in cell.subviews) {
        [view removeFromSuperview];
    }
    
    UIImageView *imageView1 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView1.frame = CGRectMake(10, 10, 100, 80);
        [cell addSubview:imageView1];
    
    UIImageView *imageView2 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView2.frame = CGRectMake(120, 10, 100, 80);
        [cell addSubview:imageView2];
    
    UIImageView *imageView3 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView3.frame = CGRectMake(230, 10, 100, 80);
        [cell addSubview:imageView3];
    
    return cell;
}

那么怎么做优化呢?利用我们前面了解的RunLoop可以实现这个优化。我们知道卡顿的主要原因是因为加载大量大图是比较耗时的,而在主线程上处理耗时操作时,我们滑动或者点击屏幕就会有卡顿的感觉,因为在同一条线程上的任务只能串行执行。而我们滑动屏幕时,一瞬间要显示很多张图片,这就形成了一个耗时操作。

经过思考,我们可以把这些图片在每一次runLoop循环中添加一张,这样的话,因为每次只添加一张图片,时间大大缩短,就不会有卡顿的感觉了。

我们这里利用runLoop的观察者来监听每一次runloop循环,然后在监听事件里,添加一张图片。我们这里把添加图片当做任务放到一个数组里面,任务就是一个block,这样我们在回调里面只需要拿出任务执行就OK了:

//定义一个任务
typedef void(^RunLoopTask)(void);
//用来存放任务的数组
@property (nonatomic, strong) NSMutableArray<RunLoopTask> *tasks;
//最大任务数量
@property (nonatomic, assign) NSInteger maxTaskCount;

    //初始化数据
    self.maxTaskCount = 24;
    self.tasks = [NSMutableArray array];

//添加任务到数组
- (void)addTask:(RunLoopTask)task{
    
    [self.tasks addObject:task];
    
    //保证之前没来得及显示的图片不会再绘制
    if (self.tasks.count > _maxTaskCount) {
        [self.tasks removeObjectAtIndex:0];
    }
}


//添加任务
    [self addTask:^{
        UIImageView *imageView1 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView1.frame = CGRectMake(10, 10, 100, 80);
        [cell addSubview:imageView1];
    }];
    
    [self addTask:^{
        UIImageView *imageView2 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView2.frame = CGRectMake(120, 10, 100, 80);
        [cell addSubview:imageView2];
    }];
    

    [self addTask:^{
        UIImageView *imageView3 = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"timg2"]];
        imageView3.frame = CGRectMake(230, 10, 100, 80);
        [cell addSubview:imageView3];
    }];

- (void)addObserverToMainRunLoop{
    //为runLoop添加观察者
    CFRunLoopObserverContext  runLoopObserverContext = {0, (__bridge void *)(self), NULL, NULL, NULL};
    CFRunLoopObserverRef    observer = CFRunLoopObserverCreate(NULL,//内存分配器,默认NULL
                                                               kCFRunLoopBeforeWaiting,//等待之前
                                                               YES,//是否循环
                                                               0,//优先索引,一般为0
                                                               &currentRunLoopObserver,//回调方法
                                                               &runLoopObserverContext//上下文
                                                               );
    if (observer)
    {
        CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    }
    CFRelease(observer);
}

// RunLoop监听回调
static void currentRunLoopObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    MyViewController *vc = (__bridge MyViewController *)info;
    if (vc.tasks.count == 0) {
        return;
    }
    RunLoopTask task = vc.tasks.firstObject;
    task();
    [vc.tasks removeObjectAtIndex:0];
}

然后再运行,就很流畅了。当然代码并不是完整代码,篇幅有限,只能把主要代码贴上来。
总结一下,我们可以把耗时的大量UI操作利用RunLoop分解,使界面保持流畅。

本文参考iOS多线程编程指南(三)Run Loop
本文所涉及到所有的代码点击前往

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容