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都在这里。