runloop是iOS開(kāi)發(fā)中比較重要的一個(gè)概念,之前的博客也有總結(jié)過(guò)它的基本概念runloop筆記,不過(guò)很多人包括我,之前也都是只知道其概念,并沒(méi)有去總結(jié)它在實(shí)際開(kāi)發(fā)中的應(yīng)用,這一篇就來(lái)總結(jié)一下它在實(shí)際開(kāi)發(fā)中的運(yùn)用,可能并不全面,后面會(huì)陸續(xù)補(bǔ)充。
1.常駐線程
之前在AFNetworking2.0系列里面一直有這樣的一段代碼。
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
// 先用 NSThread 創(chuàng)建了一個(gè)線程
[[NSThread currentThread] setName:@"AFNetworking"];
// 使用 run 方法添加 runloop
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
AFNetworking 2.0 先用 NSThread 創(chuàng)建了一個(gè)線程,并使用 NSRunLoop 的 run 方法給這個(gè)新線程添加了一個(gè) runloop。
那么它為什么要這么做呢?這是因?yàn)樵贏FNetworking 2.0時(shí),iOS的原生網(wǎng)絡(luò),更準(zhǔn)確的說(shuō)是NSURLConnection還存在一些設(shè)計(jì)上的缺陷。NSURLConnection 發(fā)起請(qǐng)求后,所在的線程需要一直存活,以等待接收NSURLConnectionDelegate回調(diào)方法。但是,網(wǎng)絡(luò)返回的時(shí)間不確定,所以這個(gè)線程就需要一直常駐在內(nèi)存中。如果我們使用主線程來(lái)進(jìn)行這項(xiàng)工作,那么就會(huì)給主線程增加很大的負(fù)擔(dān),所以AFNetworking 2.0就使了一個(gè)常駐的線程,用來(lái)處理請(qǐng)求的發(fā)起與響應(yīng)。
在iOS使用了NSURLSession替代NSURLConnection之后,AFNetworking3.0也就取消了這個(gè)操作,NSURLSession可以指定回調(diào) NSOperationQueue,這樣請(qǐng)求就不需要讓線程一直常駐在內(nèi)存里去等待回調(diào)了。
self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
NSURLSession 發(fā)起的請(qǐng)求,可以指定回調(diào)的 delegateQueue,不再需要在當(dāng)前線程進(jìn)行代理方法的回調(diào)。所以說(shuō),NSURLSession 解決了 NSURLConnection 的線程回調(diào)問(wèn)題。
我們可以看到,創(chuàng)建常駐線程的方式很簡(jiǎn)單,但是AFNetworking也使用的非常謹(jǐn)慎,因?yàn)槿绻覀兠總€(gè)功能模塊都創(chuàng)建這樣一個(gè)常駐線程來(lái)處理自己的事情,那么一個(gè)APP中可能就會(huì)存在數(shù)十個(gè)常駐線程的運(yùn)行,這樣就會(huì)造成資源的極大浪費(fèi),而不是合理利用。
我自己寫(xiě)了一段代碼驗(yàn)證了一下:
//自定義線程
#import "BZThread.h"
@implementation BZThread
- (void)dealloc{
NSLog(@"%s",__func__);
}
@end
//執(zhí)行代碼
- (void)threadTest {
BZThread *thread = [[BZThread alloc] initWithTarget:self selector:@selector(subThreadOpetion) object:nil];
[thread start];
}
- (void)subThreadOpetion {
@autoreleasepool {
NSLog(@"%@----子線程任務(wù)開(kāi)始",[NSThread currentThread]);
[NSThread sleepForTimeInterval:3.0];
NSLog(@"%@----子線程任務(wù)結(jié)束",[NSThread currentThread]);
}
}
//控制臺(tái)輸出
2020-06-13 13:15:07.718009+0800 runloopDemo[3964:993110] <BZThread: 0x2814c1640>{number = 5, name = (null)}----子線程任務(wù)開(kāi)始
2020-06-13 13:15:10.723402+0800 runloopDemo[3964:993110] <BZThread: 0x2814c1640>{number = 5, name = (null)}----子線程任務(wù)結(jié)束
2020-06-13 13:15:10.724248+0800 runloopDemo[3964:993110] -[BZThread dealloc]
可以看到我們的線程在執(zhí)行完任務(wù)就會(huì)自動(dòng)銷(xiāo)毀,再使用常駐線程測(cè)試一下。
- (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(@"啟動(dòng)RunLoop前--%@",runLoop.currentMode);
[runLoop run];
}
}
- (void)subThreadOpetion {
@autoreleasepool {
NSLog(@"%@----子線程任務(wù)開(kāi)始",[NSThread currentThread]);
[NSThread sleepForTimeInterval:3.0];
NSLog(@"%@----子線程任務(wù)結(jié)束",[NSThread currentThread]);
}
}
//控制臺(tái)輸出
2020-06-13 13:18:29.722947+0800 runloopDemo[3964:993685] 啟動(dòng)RunLoop前--(null)
2020-06-13 13:18:31.308423+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子線程任務(wù)開(kāi)始
2020-06-13 13:18:34.314521+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子線程任務(wù)結(jié)束
2020-06-13 13:18:40.421560+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子線程任務(wù)開(kāi)始
2020-06-13 13:18:43.426793+0800 runloopDemo[3964:993685] <BZThread: 0x281418b00>{number = 6, name = BZThread}----子線程任務(wù)結(jié)束
開(kāi)啟常駐線程之后,這個(gè)線程在執(zhí)行完任務(wù)之后依舊存活,下一次執(zhí)行時(shí)依舊可以使用此線程。
不過(guò)常駐線程還是要慎用,一不小心就會(huì)造成資源的浪費(fèi),即使AFN的大神們也使用的小心翼翼,如果我們確實(shí)有這個(gè)需要,讓線程存活一段時(shí)間,可以使用其他的方法。
如果確實(shí)需要保活線程一段時(shí)間的話,可以選擇使用 NSRunLoop 的另外兩個(gè)方法runUntilDate:和 runMode:beforeDate,來(lái)指定線程的保活時(shí)長(zhǎng)。讓線程存活時(shí)間可預(yù)期,總比讓線程常駐,至少在硬件資源利用率這點(diǎn)上要更加合理?;蛘撸氵€可以使用 CFRunLoopRef 的 CFRunLoopRun 和 CFRunLoopStop 方法來(lái)完成 runloop 的開(kāi)啟和停止,達(dá)到將線程?;钜欢螘r(shí)間的目的。
2.Timer的不正常計(jì)時(shí)
runloop在運(yùn)行中存在好幾種mode,每次只能選擇一種mode運(yùn)行,runloop為了保證界面的運(yùn)作流暢,有一個(gè)專(zhuān)門(mén)在滑動(dòng)中使用的UITrackingRunLoopMode,而我們主線程中使用定時(shí)器是kCFRunLoopDefaultMode,這樣就導(dǎo)致我們?cè)谝晥D滑動(dòng)時(shí)會(huì)造成定時(shí)器的計(jì)時(shí)不準(zhǔn)確,這個(gè)沖突在開(kāi)發(fā)中影響還是比較大的。
舉一個(gè)最常見(jiàn)的例子,現(xiàn)在的APP中輪播圖是非常常見(jiàn)的一種空間,它一般會(huì)支持自動(dòng)滾動(dòng),那么它滾動(dòng)的間隔就需要靠定時(shí)器來(lái)控制,這樣就無(wú)法保證功能的正常運(yùn)作。我們可以指定Runloop的模式為kCFRunLoopCommonModes即可,因?yàn)閗CFRunLoopCommonModes包含kCFRunLoopDefaultMode。
代碼以及效果來(lái)驗(yàn)證一下,我們先不將計(jì)時(shí)器加入到指定runloop中。
- (void)setupTimer{
[self invalidateTimer];
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(countAdd) userInfo:nil repeats:YES];
_timer = timer;
//如果這句代碼注釋掉滑動(dòng)時(shí)計(jì)時(shí)器將不正常
// [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
效果圖如下:

可以看到在滑動(dòng)中計(jì)時(shí)器完全處于停止?fàn)顟B(tài)無(wú)法滑動(dòng),再解開(kāi)注釋代碼,加入到NSRunLoopCommonModes中看一下效果,效果圖如下:

這樣就可以達(dá)到我們正常計(jì)時(shí)的效果。
3.監(jiān)控卡頓
在開(kāi)發(fā)中交互出現(xiàn)卡頓是很致命的一個(gè)問(wèn)題,造成用戶體驗(yàn)不好就會(huì)很容易失去用戶。一般造成線程卡頓的原因有很多,比如大量的圖文混排,I/O操作,網(wǎng)絡(luò)同步請(qǐng)求等,如果這些操作放在來(lái)主線程上來(lái)做就會(huì)表現(xiàn)為丟幀。蘋(píng)果手機(jī)正常情況下是60幀每秒,我們可以通過(guò)fps來(lái)監(jiān)控卡頓,但是這樣并不準(zhǔn)確,因?yàn)槿绻鹒ps在20-30左右的情況下肉眼還算是流暢,但這時(shí)候其實(shí)已經(jīng)發(fā)生卡頓了。
我們可以利用監(jiān)聽(tīng)runloop的狀態(tài)來(lái)判斷調(diào)用方法是否執(zhí)行時(shí)間過(guò)長(zhǎng),runloop有六種狀態(tài)。
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // 進(jìn)入 loop
kCFRunLoopBeforeTimers , // 觸發(fā) Timer 回調(diào)
kCFRunLoopBeforeSources , // 觸發(fā) Source0 回調(diào)
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
kCFRunLoopExit , // 退出 loop
kCFRunLoopAllActivities // loop 所有狀態(tài)改變
}
如果我們的主線程遲遲無(wú)法進(jìn)入休眠或者喚醒后遲遲無(wú)法進(jìn)入下一個(gè)狀態(tài),就可以認(rèn)為是出現(xiàn)了卡頓,因此我們可以使用觀察者來(lái)觀察kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting這兩個(gè)狀態(tài)來(lái)判斷是否出現(xiàn)了卡頓,因?yàn)榭赡苁撬咧暗腟ource0事件或者喚醒runloop的mach_port事件執(zhí)行受阻。為什么不是觀察kCFRunLoopBeforeWaiting呢?因?yàn)槿绻叩竭@一步說(shuō)明Source0事件已經(jīng)處理完畢了。
那么如何監(jiān)控呢?通過(guò)代碼來(lái)說(shuō)明:
//創(chuàng)建一個(gè)觀察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
//將觀察者添加到主線程runloop的common模式下的觀察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
首先創(chuàng)建一個(gè)觀察者,將它添加到主線程中進(jìn)行主線程runloop狀態(tài)的觀察。我們?cè)谟^察者的回調(diào)中方法如下:
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
BZStuckMonitor *lagMonitor = (__bridge BZStuckMonitor*)info;
lagMonitor->runLoopActivity = activity;
//獲取信號(hào)量的值
dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
//信號(hào)通知,dispatch_semaphore_signal使信號(hào)量加1
dispatch_semaphore_signal(semaphore);
}
當(dāng)主線程runloop的狀態(tài)發(fā)生改變則會(huì)通知信號(hào)量,讓信號(hào)量的值加1,然后我們開(kāi)啟一個(gè)持續(xù)執(zhí)行的loop。
//創(chuàng)建子線程監(jiān)控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//子線程開(kāi)啟一個(gè)持續(xù)的loop用來(lái)進(jìn)行監(jiān)控
while (YES) {
long semaphoreWait = dispatch_semaphore_wait(self->dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 10*NSEC_PER_MSEC));
// semaphoreWait 的值不為 0, 說(shuō)明線程被堵塞
if (semaphoreWait != 0) {
if (!self->runLoopObserver) {
self->dispatchSemaphore = 0;
self->runLoopActivity = 0;
return;
}
// BeforeSources和 AfterWaiting 這兩個(gè) runloop 狀態(tài)的區(qū)間時(shí)間能夠檢測(cè)到是否卡頓
if (self->runLoopActivity == kCFRunLoopBeforeSources || self->runLoopActivity == kCFRunLoopAfterWaiting) {
// 將堆棧信息上報(bào)服務(wù)器的代碼放到這里
NSLog(@"卡頓了");
} //end activity
}// end semaphore wait
}// end while
});
dispatch_semaphore_wait方法會(huì)將信號(hào)量的值減1,如果減完之后信號(hào)量的值為0則可以繼續(xù)執(zhí)行,如果不為0則會(huì)阻塞,至于阻塞多久合適呢?如果這個(gè)值設(shè)置為40毫秒,那用戶肉眼就已經(jīng)能明顯感知有卡頓了,所以不能精準(zhǔn)的測(cè)試出卡頓,我個(gè)人認(rèn)為設(shè)置為10毫秒可能測(cè)試出來(lái)比較準(zhǔn)確。
為了測(cè)試我寫(xiě)了一個(gè)很長(zhǎng)的UICollectionView,然后讓其加載很大的本地圖片,這樣就會(huì)出現(xiàn)主線程解壓縮步驟,而且還為cell繪制圓角陰影等,然后快速滑動(dòng)。
效果如下:

控制臺(tái)打印如下:
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] 卡頓了
可以配合使用打印堆棧的工具來(lái)獲取卡頓時(shí)正在執(zhí)行的方法,這一塊可以單獨(dú)拿出來(lái)總結(jié)一下,以后會(huì)驗(yàn)證總結(jié)再發(fā)上來(lái)。
目前這三種就是開(kāi)發(fā)中比較常見(jiàn)的runloop的使用方式,這一篇博客使用到的所有demo都在這里。
參考文章:
如何利用 RunLoop 原理去監(jiān)控卡頓?
NSURLSession與NSURLConnection區(qū)別