Runloop的应用场景

runloop是iOS开发中比较重要的一个概念,之前的博客也有总结过它的基本概念runloop笔记,不过很多人包括我,之前也都是只知道其概念,并没有去总结它在实际开发中的应用,这一篇就来总结一下它在实际开发中的运用,可能并不全面,后面会陆续补充。

1.常驻线程

之前在AFNetworking2.0系列里面一直有这样的一段代码。


+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        // 先用 NSThread 创建了一个线程
        [[NSThread currentThread] setName:@"AFNetworking"];
        // 使用 run 方法添加 runloop
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

AFNetworking 2.0 先用 NSThread 创建了一个线程,并使用 NSRunLoop 的 run 方法给这个新线程添加了一个 runloop。

那么它为什么要这么做呢?这是因为在AFNetworking 2.0时,iOS的原生网络,更准确的说是NSURLConnection还存在一些设计上的缺陷。NSURLConnection 发起请求后,所在的线程需要一直存活,以等待接收NSURLConnectionDelegate回调方法。但是,网络返回的时间不确定,所以这个线程就需要一直常驻在内存中。如果我们使用主线程来进行这项工作,那么就会给主线程增加很大的负担,所以AFNetworking 2.0就使了一个常驻的线程,用来处理请求的发起与响应。

在iOS使用了NSURLSession替代NSURLConnection之后,AFNetworking3.0也就取消了这个操作,NSURLSession可以指定回调 NSOperationQueue,这样请求就不需要让线程一直常驻在内存里去等待回调了。

self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];

NSURLSession 发起的请求,可以指定回调的 delegateQueue,不再需要在当前线程进行代理方法的回调。所以说,NSURLSession 解决了 NSURLConnection 的线程回调问题。

我们可以看到,创建常驻线程的方式很简单,但是AFNetworking也使用的非常谨慎,因为如果我们每个功能模块都创建这样一个常驻线程来处理自己的事情,那么一个APP中可能就会存在数十个常驻线程的运行,这样就会造成资源的极大浪费,而不是合理利用。

我自己写了一段代码验证了一下:

//自定义线程
#import "BZThread.h"

@implementation BZThread

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

@end
//执行代码
- (void)threadTest {
    BZThread *thread = [[BZThread alloc] initWithTarget:self selector:@selector(subThreadOpetion) object:nil];
    [thread start];
}

- (void)subThreadOpetion {
    @autoreleasepool {
        NSLog(@"%@----子线程任务开始",[NSThread currentThread]);
        [NSThread sleepForTimeInterval:3.0];
        NSLog(@"%@----子线程任务结束",[NSThread currentThread]);
    }
}
//控制台输出
2020-06-13 13:15:07.718009+0800 runloopDemo[3964:993110] <BZThread: 0x2814c1640>{number = 5, name = (null)}----子线程任务开始
2020-06-13 13:15:10.723402+0800 runloopDemo[3964:993110] <BZThread: 0x2814c1640>{number = 5, name = (null)}----子线程任务结束
2020-06-13 13:15:10.724248+0800 runloopDemo[3964:993110] -[BZThread dealloc]

可以看到我们的线程在执行完任务就会自动销毁,再使用常驻线程测试一下。

- (void)threadTest {
    BZThread *thread = [[BZThread alloc] initWithTarget:self selector:@selector(subThreadEntryPoint) object:nil];
    [thread setName:@"BZThread"];
    [thread start];
    self.subThread = thread;
}

- (void)subThreadEntryPoint {
    @autoreleasepool {
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        // NSLog(@"runLoop--%@", runLoop);
        NSLog(@"启动RunLoop前--%@",runLoop.currentMode);
        [runLoop run];
    }
}

- (void)subThreadOpetion {
    @autoreleasepool {
        NSLog(@"%@----子线程任务开始",[NSThread currentThread]);
        [NSThread sleepForTimeInterval:3.0];
        NSLog(@"%@----子线程任务结束",[NSThread currentThread]);
    }
}
//控制台输出
2020-06-13 13:18:29.722947+0800 runloopDemo[3964:993685] 启动RunLoop前--(null)
2020-06-13 13:18:31.308423+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子线程任务开始
2020-06-13 13:18:34.314521+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子线程任务结束
2020-06-13 13:18:40.421560+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子线程任务开始
2020-06-13 13:18:43.426793+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子线程任务结束

开启常驻线程之后,这个线程在执行完任务之后依旧存活,下一次执行时依旧可以使用此线程。

不过常驻线程还是要慎用,一不小心就会造成资源的浪费,即使AFN的大神们也使用的小心翼翼,如果我们确实有这个需要,让线程存活一段时间,可以使用其他的方法。

如果确实需要保活线程一段时间的话,可以选择使用 NSRunLoop 的另外两个方法runUntilDate:runMode:beforeDate,来指定线程的保活时长。让线程存活时间可预期,总比让线程常驻,至少在硬件资源利用率这点上要更加合理。或者,你还可以使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法来完成 runloop 的开启和停止,达到将线程保活一段时间的目的。

2.Timer的不正常计时

runloop在运行中存在好几种mode,每次只能选择一种mode运行,runloop为了保证界面的运作流畅,有一个专门在滑动中使用的UITrackingRunLoopMode,而我们主线程中使用定时器是kCFRunLoopDefaultMode,这样就导致我们在视图滑动时会造成定时器的计时不准确,这个冲突在开发中影响还是比较大的。

举一个最常见的例子,现在的APP中轮播图是非常常见的一种空间,它一般会支持自动滚动,那么它滚动的间隔就需要靠定时器来控制,这样就无法保证功能的正常运作。我们可以指定Runloop的模式为kCFRunLoopCommonModes即可,因为kCFRunLoopCommonModes包含kCFRunLoopDefaultMode

代码以及效果来验证一下,我们先不将计时器加入到指定runloop中。

- (void)setupTimer{
    [self invalidateTimer];
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(countAdd) userInfo:nil repeats:YES];
    _timer = timer;
    //如果这句代码注释掉滑动时计时器将不正常
//    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}

效果图如下:


滑动中计时器停止

可以看到在滑动中计时器完全处于停止状态无法滑动,再解开注释代码,加入到NSRunLoopCommonModes中看一下效果,效果图如下:


计时器正常

这样就可以达到我们正常计时的效果。

3.监控卡顿

在开发中交互出现卡顿是很致命的一个问题,造成用户体验不好就会很容易失去用户。一般造成线程卡顿的原因有很多,比如大量的图文混排,I/O操作,网络同步请求等,如果这些操作放在来主线程上来做就会表现为丢帧。苹果手机正常情况下是60帧每秒,我们可以通过fps来监控卡顿,但是这样并不准确,因为如果fps在20-30左右的情况下肉眼还算是流畅,但这时候其实已经发生卡顿了。

我们可以利用监听runloop的状态来判断调用方法是否执行时间过长,runloop有六种状态。

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry , // 进入 loop
    kCFRunLoopBeforeTimers , // 触发 Timer 回调
    kCFRunLoopBeforeSources , // 触发 Source0 回调
    kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
    kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
    kCFRunLoopExit , // 退出 loop
    kCFRunLoopAllActivities  // loop 所有状态改变
}

如果我们的主线程迟迟无法进入休眠或者唤醒后迟迟无法进入下一个状态,就可以认为是出现了卡顿,因此我们可以使用观察者来观察kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting这两个状态来判断是否出现了卡顿,因为可能是睡眠之前的Source0事件或者唤醒runloop的mach_port事件执行受阻。为什么不是观察kCFRunLoopBeforeWaiting呢?因为如果走到这一步说明Source0事件已经处理完毕了。

那么如何监控呢?通过代码来说明:

//创建一个观察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                              kCFRunLoopAllActivities,
                                              YES,
                                              0,
                                              &runLoopObserverCallBack,
                                              &context);
//将观察者添加到主线程runloop的common模式下的观察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);

首先创建一个观察者,将它添加到主线程中进行主线程runloop状态的观察。我们在观察者的回调中方法如下:

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    BZStuckMonitor *lagMonitor = (__bridge BZStuckMonitor*)info;
    lagMonitor->runLoopActivity = activity;
    //获取信号量的值
    dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
    //信号通知,dispatch_semaphore_signal使信号量加1
    dispatch_semaphore_signal(semaphore);
}

当主线程runloop的状态发生改变则会通知信号量,让信号量的值加1,然后我们开启一个持续执行的loop。

//创建子线程监控
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //子线程开启一个持续的loop用来进行监控
        while (YES) {
            long semaphoreWait = dispatch_semaphore_wait(self->dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 10*NSEC_PER_MSEC));
            //  semaphoreWait 的值不为 0, 说明线程被堵塞
            if (semaphoreWait != 0) {
                if (!self->runLoopObserver) {
                    self->dispatchSemaphore = 0;
                    self->runLoopActivity = 0;
                    return;
                }
                // BeforeSources和 AfterWaiting 这两个 runloop 状态的区间时间能够检测到是否卡顿
                if (self->runLoopActivity == kCFRunLoopBeforeSources || self->runLoopActivity == kCFRunLoopAfterWaiting) {
                    // 将堆栈信息上报服务器的代码放到这里
                    NSLog(@"卡顿了");
                } //end activity
            }// end semaphore wait
        }// end while
    });

dispatch_semaphore_wait方法会将信号量的值减1,如果减完之后信号量的值为0则可以继续执行,如果不为0则会阻塞,至于阻塞多久合适呢?如果这个值设置为40毫秒,那用户肉眼就已经能明显感知有卡顿了,所以不能精准的测试出卡顿,我个人认为设置为10毫秒可能测试出来比较准确。

为了测试我写了一个很长的UICollectionView,然后让其加载很大的本地图片,这样就会出现主线程解压缩步骤,而且还为cell绘制圆角阴影等,然后快速滑动。

效果如下:


界面卡顿

控制台打印如下:

2020-06-13 14:23:01.882126+0800 runloopDemo[4018:1000055] 卡顿了
2020-06-13 14:23:01.888794+0800 runloopDemo[4018:1000055] 卡顿了
2020-06-13 14:23:01.895314+0800 runloopDemo[4018:1000055] 卡顿了
2020-06-13 14:23:01.901711+0800 runloopDemo[4018:1000055] 卡顿了
2020-06-13 14:23:01.908091+0800 runloopDemo[4018:1000055] 卡顿了
2020-06-13 14:23:01.914493+0800 runloopDemo[4018:1000055] 卡顿了

可以配合使用打印堆栈的工具来获取卡顿时正在执行的方法,这一块可以单独拿出来总结一下,以后会验证总结再发上来。

目前这三种就是开发中比较常见的runloop的使用方式,这一篇博客使用到的所有demo都在这里

参考文章:

如何利用 RunLoop 原理去监控卡顿?
NSURLSession与NSURLConnection区别

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