iOS刨根問底-深入理解RunLoop
概述
RunLoop作為iOS中一個基礎組件和線程有著千絲萬縷的關系,同時也是很多常見技術的幕后功臣。盡管在平時多數開發者很少直接使用RunLoop,但是理解RunLoop可以幫助開發者更好的利用多線程編程模型,同時也可以幫助開發者解答日常開發中的一些疑惑。本文將從RunLoop源碼著手,結合RunLoop的實際應用來逐步解開它的神秘面紗。
開源的RunloopRef
通常所說的RunLoop指的是NSRunloop或者CFRunloopRef,CFRunloopRef是純C的函數,而NSRunloop僅僅是CFRunloopRef的OC封裝,并未提供額外的其他功能,因此下面主要分析CFRunloopRef,蘋果已經開源了CoreFoundation源代碼,因此很容易找到CFRunloop源代碼。
從代碼可以看出CFRunloopRef其實就是__CFRunloop這個結構體指針(按照OC的思路我們可以將RunLoop看成一個對象),這個對象的運行才是我們通常意義上說的運行循環,核心方法是__CFRunloopRun(),為了便于閱讀就不再直接貼源代碼,放一段偽代碼方便大家閱讀:
int32_t __CFRunLoopRun()
{
// 通知即將進入runloop
__CFRunLoopDoObservers(KCFRunLoopEntry);
do
{
// 通知將要處理timer和source
__CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
__CFRunLoopDoObservers(kCFRunLoopBeforeSources);
// 處理非延遲的主線程調用
__CFRunLoopDoBlocks();
// 處理Source0事件
__CFRunLoopDoSource0();
if (sourceHandledThisLoop) {
__CFRunLoopDoBlocks();
}
/// 如果有 Source1 (基于port) 處于 ready 狀態,直接處理這個 Source1 然后跳轉去處理消息。
if (__Source0DidDispatchPortLastTime) {
Boolean hasMsg = __CFRunLoopServiceMachPort();
if (hasMsg) goto handle_msg;
}
/// 通知 Observers: RunLoop 的線程即將進入休眠(sleep)。
if (!sourceHandledThisLoop) {
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}
// GCD dispatch main queue
CheckIfExistMessagesInMainDispatchQueue();
// 即將進入休眠
__CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
// 等待內核mach_msg事件
mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
// 等待。。。
// 從等待中醒來
__CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
// 處理因timer的喚醒
if (wakeUpPort == timerPort)
__CFRunLoopDoTimers();
// 處理異步方法喚醒,如dispatch_async
else if (wakeUpPort == mainDispatchQueuePort)
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
// 處理Source1
else
__CFRunLoopDoSource1();
// 再次確保是否有同步的方法需要調用
__CFRunLoopDoBlocks();
} while (!stop && !timeout);
// 通知即將退出runloop
__CFRunLoopDoObservers(CFRunLoopExit);
}
源代碼盡管不算太長,但是如果不太熟悉的話面對這么一堆不知道做什么的函數調用還是會給人一種神秘感。但是現在可以不用逐行閱讀,后面慢慢解開這層神秘面紗?,F在只要了解上面的偽代碼知道核心的方法__CFRunLoopRun內部其實是一個do while循環,這也正是Runloop運行的本質。執行了這個函數以后就一直處于“等待-處理”的循環之中,直到循環結束。只是不同于我們自己寫的循環它在休眠時幾乎不會占用系統資源,當然這是由于系統內核負責實現的,也是Runloop精華所在。
隨著Swift的開源蘋果也維護了一個Swift版本的跨平臺CoreFoundation版本,除了mac平臺它還是適配了Linux和Windows平臺。但是鑒于目前很多關于Runloop的討論都是以OC版展開的,所以這里也主要分析OC版本。
下圖描述了Runloop運行流程(基本描述了上面Runloop的核心流程,當然可以查看官方The Run Loop Sequence of Events描述):
整個流程并不復雜(需要注意的就是黃色區域的消息處理中并不包含source0,因為它在循環開始之初就會處理),整個流程其實就是一種Event Loop的實現,其他平臺均有類似的實現,只是這里叫做Runloop。但是既然RunLoop是一個消息循環,誰來管理和運行Runloop?那么它接收什么類型的消息?休眠過程是怎么樣的?如何保證休眠時不占用系統資源?如何處理這些消息以及何時退出循環?還有一系列問題需要解開。
注意的是盡管CFRunLoopPerformBlock在上圖中作為喚醒機制有所體現,但事實上執行CFRunLoopPerformBlock只是入隊,下次RunLoop運行才會執行,而如果需要立即執行則必須調用CFRunLoopWakeUp。
Runloop Mode
從源碼很容易看出,Runloop總是運行在某種特定的CFRunLoopModeRef下(每次運行__CFRunLoopRun()函數時必須指定Mode)。而通過CFRunloopRef對應結構體的定義可以很容易知道每種Runloop都可以包含若干個Mode,每個Mode又包含Source/Timer/Observer。每次調用Runloop的主函數__CFRunLoopRun()時必須指定一種Mode,這個Mode稱為** _currentMode**,當切換Mode時必須退出當前Mode,然后重新進入Runloop以保證不同Mode的Source/Timer/Observer互不影響。
struct __CFRunLoop {
CFRuntimeBase _base;
pthread_mutex_t _lock; /* locked for accessing mode list */
__CFPort _wakeUpPort; // used for CFRunLoopWakeUp
Boolean _unused;
volatile _per_run_data *_perRunData; // reset for runs of the run loop
pthread_t _pthread;
uint32_t _winthread;
CFMutableSetRef _commonModes;
CFMutableSetRef _commonModeItems;
CFRunLoopModeRef _currentMode;
CFMutableSetRef _modes;
struct _block_item *_blocks_head;
struct _block_item *_blocks_tail;
CFAbsoluteTime _runTime;
CFAbsoluteTime _sleepTime;
CFTypeRef _counterpart;
};
struct __CFRunLoopMode {
CFRuntimeBase _base;
pthread_mutex_t _lock; /* must have the run loop locked before locking this */
CFStringRef _name;
Boolean _stopped;
char _padding[3];
CFMutableSetRef _sources0;
CFMutableSetRef _sources1;
CFMutableArrayRef _observers;
CFMutableArrayRef _timers;
CFMutableDictionaryRef _portToV1SourceMap;
__CFPortSet _portSet;
CFIndex _observerMask;
#if USE_DISPATCH_SOURCE_FOR_TIMERS
dispatch_source_t _timerSource;
dispatch_queue_t _queue;
Boolean _timerFired; // set to true by the source when a timer has fired
Boolean _dispatchTimerArmed;
#endif
#if USE_MK_TIMER_TOO
mach_port_t _timerPort;
Boolean _mkTimerArmed;
#endif
#if DEPLOYMENT_TARGET_WINDOWS
DWORD _msgQMask;
void (*_msgPump)(void);
#endif
uint64_t _timerSoftDeadline; /* TSR */
uint64_t _timerHardDeadline; /* TSR */
};
系統默認提供的Run Loop Modes有kCFRunLoopDefaultMode(NSDefaultRunLoopMode)和UITrackingRunLoopMode,需要切換到對應的Mode時只需要傳入對應的名稱即可。前者是系統默認的Runloop Mode,例如進入iOS程序默認不做任何操作就處于這種Mode中,此時滑動UIScrollView,主線程就切換Runloop到到UITrackingRunLoopMode,不再接受其他事件操作(除非你將其他Source/Timer設置到UITrackingRunLoopMode下)。
但是對于開發者而言經常用到的Mode還有一個kCFRunLoopCommonModes(NSRunLoopCommonModes),其實這個并不是某種具體的Mode,而是一種模式組合,在iOS系統中默認包含了
** NSDefaultRunLoopMode和 UITrackingRunLoopMode(注意:并不是說Runloop會運行在kCFRunLoopCommonModes這種模式下,而是相當于分別注冊了 NSDefaultRunLoopMode和 UITrackingRunLoopMode。當然你也可以通過調用CFRunLoopAddCommonMode()方法將自定義Mode放到 kCFRunLoopCommonModes**組合)。
注意:我們常常還會碰到一些系統框架自定義Mode,例如Foundation中NSConnectionReplyMode。還有一些系統私有Mode,例如:GSEventReceiveRunLoopMode接受系統事件,UIInitializationRunLoopMode App啟動過程中初始化Mode。更多系統或框架Mode查看這里
CFRunLoopRef和CFRunloopMode、CFRunLoopSourceRef/CFRunloopTimerRef/CFRunLoopObserverRef關系如下圖:
那么CFRunLoopSourceRef、CFRunLoopTimerRef和CFRunLoopObserverRef究竟是什么?它們在Runloop運行流程中起到什么作用呢?
Source
首先看一下官方Runloop結構圖(注意下圖的Input Source Port和前面流程圖中的Source0并不對應,而是對應Source1。Source1和Timer都屬于端口事件源,不同的是所有的Timer都共用一個端口“Mode Timer Port”,而每個Source1都有不同的對應端口):
再結合前面RunLoop核心運行流程可以看出Source0(負責App內部事件,由App負責管理觸發,例如UITouch事件)和Timer(又叫Timer Source,基于時間的觸發器,上層對應NSTimer)是兩個不同的Runloop事件源(當然Source0是Input Source中的一類,Input Source還包括Custom Input Source,由其他線程手動發出),RunLoop被這些事件喚醒之后就會處理并調用事件處理方法(CFRunLoopTimerRef的回調指針和CFRunLoopSourceRef均包含對應的回調指針)。
但是對于CFRunLoopSourceRef除了Source0之外還有另一個版本就是Source1,Source1除了包含回調指針外包含一個mach port,和Source0需要手動觸發不同,Source1可以監聽系統端口和其他線程相互發送消息,它能夠主動喚醒RunLoop(由操作系統內核進行管理,例如CFMessagePort消息)。官方也指出可以自定義Source,因此對于CFRunLoopSourceRef來說它更像一種協議,框架已經默認定義了兩種實現,如果有必要開發人員也可以自定義,詳細情況可以查看官方文檔。
Observer
struct __CFRunLoopObserver {
CFRuntimeBase _base;
pthread_mutex_t _lock;
CFRunLoopRef _runLoop;
CFIndex _rlCount;
CFOptionFlags _activities; /* immutable */
CFIndex _order; /* immutable */
CFRunLoopObserverCallBack _callout; /* immutable */
CFRunLoopObserverContext _context; /* immutable, except invalidation */
};
相對來說CFRunloopObserverRef理解起來并不復雜,它相當于消息循環中的一個監聽器,隨時通知外部當前RunLoop的運行狀態(它包含一個函數指針callout將當前狀態及時告訴觀察者)。具體的Observer狀態如下:
/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 進入RunLoop
kCFRunLoopBeforeTimers = (1UL << 1), // 即將開始Timer處理
kCFRunLoopBeforeSources = (1UL << 2), // 即將開始Source處理
kCFRunLoopBeforeWaiting = (1UL << 5), // 即將進入休眠
kCFRunLoopAfterWaiting = (1UL << 6), //從休眠狀態喚醒
kCFRunLoopExit = (1UL << 7), //退出RunLoop
kCFRunLoopAllActivities = 0x0FFFFFFFU
};
Call out
在開發過程中幾乎所有的操作都是通過Call out進行回調的(無論是Observer的狀態通知還是Timer、Source的處理),而系統在回調時通常使用如下幾個函數進行回調(換句話說你的代碼其實最終都是通過下面幾個函數來負責調用的,即使你自己監聽Observer也會先調用下面的函數然后間接通知你,所以在調用堆棧中經常看到這些函數):
static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();
例如在控制器的touchBegin中打入斷點查看堆棧(由于UIEvent是Source0,所以可以看到一個Source0的Call out函數****CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION****調用):
RunLoop休眠
其實對于Event Loop而言RunLoop最核心的事情就是保證線程在沒有消息時休眠以避免占用系統資源,有消息時能夠及時喚醒。RunLoop的這個機制完全依靠系統內核來完成,具體來說是蘋果操作系統核心組件Darwin中的Mach來完成的(Darwin是開源的)??梢詮南聢D最底層Kernel中找到Mach:
Mach是Darwin的核心,可以說是內核的核心,提供了進程間通信(IPC)、處理器調度等基礎服務。在Mach中,進程、線程間的通信是以消息的方式來完成的,消息在兩個Port之間進行傳遞(這也正是Source1之所以稱之為Port-based Source的原因,因為它就是依靠系統發送消息到指定的Port來觸發的)。消息的發送和接收使用<mach/message.h>中的mach_msg()函數(事實上蘋果提供的Mach API很少,并不鼓勵我們直接調用這些API):
/*
* Routine: mach_msg
* Purpose:
* Send and/or receive a message. If the message operation
* is interrupted, and the user did not request an indication
* of that fact, then restart the appropriate parts of the
* operation silently (trap version does not restart).
*/
__WATCHOS_PROHIBITED __TVOS_PROHIBITED
extern mach_msg_return_t mach_msg(
mach_msg_header_t *msg,
mach_msg_option_t option,
mach_msg_size_t send_size,
mach_msg_size_t rcv_size,
mach_port_name_t rcv_name,
mach_msg_timeout_t timeout,
mach_port_name_t notify);
而mach_msg()的本質是一個調用mach_msg_trap(),這相當于一個系統調用,會觸發內核狀態切換。當程序靜止時,RunLoop停留在__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy),而這個函數內部就是調用了mach_msg讓程序處于休眠狀態。
Runloop和線程的關系
Runloop是基于pthread進行管理的,pthread是基于c的跨平臺多線程操作底層API。它是mach thread的上層封裝(可以參見Kernel Programming Guide),和NSThread一一對應(而NSThread是一套面向對象的API,所以在iOS開發中我們也幾乎不用直接使用pthread)。
蘋果開發的接口中并沒有直接創建Runloop的接口,如果需要使用Runloop通常CFRunLoopGetMain()和CFRunLoopGetCurrent()兩個方法來獲取(通過上面的源代碼也可以看到,核心邏輯在CFRunLoopGet當中),通過代碼并不難發現其實只有當我們使用線程的方法主動get Runloop時才會在第一次創建該線程的Runloop,同時將它保存在全局的Dictionary中(線程和Runloop二者一一對應),默認情況下線程并不會創建Runloop(主線程的Runloop比較特殊,任何線程創建之前都會保證主線程已經存在Runloop),同時在線程結束的時候也會銷毀對應的Runloop。
iOS開發過程中對于開發者而言更多的使用的是NSRunloop,它默認提供了三個常用的run方法:
- (void)run;
- (BOOL)runMode:(NSRunLoopMode)mode beforeDate:(NSDate *)limitDate;
- (void)runUntilDate:(NSDate *)limitDate;
- run方法對應上面CFRunloopRef中的CFRunLoopRun并不會退出,除非調用CFRunLoopStop();通常如果想要永遠不會退出RunLoop才會使用此方法,否則可以使用runUntilDate。
- runMode:beforeDate:則對應CFRunLoopRunInMode(mode,limiteDate,true)方法,只執行一次,執行完就退出;通常用于手動控制RunLoop(例如在while循環中)。
- runUntilDate:方法其實是CFRunLoopRunInMode(kCFRunLoopDefaultMode,limiteDate,false),執行完并不會退出,繼續下一次RunLoop直到timeout。
RunLoop應用
NSTimer
前面一直提到Timer Source作為事件源,事實上它的上層對應就是NSTimer(其實就是CFRunloopTimerRef)這個開發者經常用到的定時器(底層基于使用mk_timer實現),甚至很多開發者接觸RunLoop還是從NSTimer開始的。其實NSTimer定時器的觸發正是基于RunLoop運行的,所以使用NSTimer之前必須注冊到RunLoop,但是RunLoop為了節省資源并不會在非常準確的時間點調用定時器,如果一個任務執行時間較長,那么當錯過一個時間點后只能等到下一個時間點執行,并不會延后執行(NSTimer提供了一個tolerance屬性用于設置寬容度,如果確實想要使用NSTimer并且希望盡可能的準確,則可以設置此屬性)。
NSTimer的創建通常有兩種方式,盡管都是類方法,一種是timerWithXXX,另一種scheduedTimerWithXXX。
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
二者最大的區別就是后者除了創建一個定時器外會自動以NSDefaultRunLoopModeMode添加到當前線程RunLoop中,不添加到RunLoop中的NSTimer是無法正常工作的。例如下面的代碼中如果timer2不加入到RunLoop中是無法正常工作的。同時注意如果滾動UIScrollView(UITableView、UICollectionview是類似的)二者是無法正常工作的,但是如果將NSDefaultRunLoopMode改為NSRunLoopCommonModes則可以正常工作,這也解釋了前面介紹的Mode內容。
#import "ViewController1.h"
@interface ViewController1 ()
@property (nonatomic,weak) NSTimer *timer1;
@property (nonatomic,weak) NSTimer *timer2;
@end
@implementation ViewController1
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blueColor];
// timer1創建后會自動以NSDefaultRunLoopMode默認模式添加到當前RunLoop中,所以可以正常工作
self.timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timeInterval:) userInfo:nil repeats:YES];
NSTimer *tempTimer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timeInterval:) userInfo:nil repeats:YES];
// 如果不把timer2添加到RunLoop中是無法正常工作的(注意如果想要在滾動UIScrollView時timer2可以正常工作可以將NSDefaultRunLoopMode改為NSRunLoopCommonModes)
[[NSRunLoop currentRunLoop] addTimer:tempTimer forMode:NSDefaultRunLoopMode];
self.timer2 = tempTimer;
CGRect rect = [UIScreen mainScreen].bounds;
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectInset(rect, 0, 200)];
[self.view addSubview:scrollView];
UIView *contentView = [[UIView alloc] initWithFrame:CGRectInset(scrollView.bounds, -100, -100)];
contentView.backgroundColor = [UIColor redColor];
[scrollView addSubview:contentView];
scrollView.contentSize = contentView.frame.size;
}
- (void)timeInterval:(NSTimer *)timer {
if (self.timer1 == timer) {
NSLog(@"timer1...");
} else {
NSLog(@"timer2...");
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self dismissViewControllerAnimated:true completion:nil];
}
- (void)dealloc {
NSLog(@"ViewController1 dealloc...");
}
@end
注意上面代碼中UIViewController1對timer1和timer2并沒有強引用,對于普通的對象而言,執行完viewDidLoad方法之后(準確的說應該是執行完viewDidLoad方法后的的一個RunLoop運行結束)二者應該會被釋放,但事實上二者并沒有被釋放。原因是:為了確保定時器正常運轉,當加入到RunLoop以后系統會對NSTimer執行一次retain操作(特別注意:timer2創建時并沒直接賦值給timer2,原因是timer2是weak屬性,如果直接賦值給timer2會被立即釋放,因為timerWithXXX方法創建的NSTimer默認并沒有加入RunLoop,只有后面加入RunLoop以后才可以將引用指向timer2)。
但是即使使用了弱引用,上面的代碼中ViewController1也無法正常釋放,原因是在創建NSTimer2時指定了target為self,這樣一來造成了timer1和timer2對ViewController1有一個強引用。解決這個問題的方法通常有兩種:一種是將target分離出來獨立成一個對象(在這個對象中創建NSTimer并將對象本身作為NSTimer的target),控制器通過這個對象間接使用NSTimer;另一種方式的思路仍然是轉移target,只是可以直接增加NSTimer擴展(分類),讓NSTimer自身做為target,同時可以將操作selector封裝到block中。后者相對優雅,也是目前使用較多的方案(目前有大量類似的封裝,例如:NSTimer+Block)。顯然Apple也認識到了這個問題,如果你可以確保代碼只在iOS 10下運行就可以使用iOS 10新增的系統級block方案(上面的代碼中已經貼出這種方法)。
當然使用上面第二種方法可以解決控制器無法釋放的問題,但是會發現即使控制器被釋放了兩個定時器仍然正常運行,要解決這個問題就需要調用NSTimer的invalidate方法(注意:無論是重復執行的定時器還是一次性的定時器只要調用invalidate方法則會變得無效,只是一次性的定時器執行完操作后會自動調用invalidate方法)。修改后的代碼如下:
#import "ViewController1.h"
@interface ViewController1 ()
@property (nonatomic,weak) NSTimer *timer1;
@property (nonatomic,weak) NSTimer *timer2;
@end
@implementation ViewController1
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blueColor];
self.timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer1...");
}];
NSTimer *tempTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
NSLog(@"timer2...");
}];
[[NSRunLoop currentRunLoop] addTimer:tempTimer forMode:NSDefaultRunLoopMode];
self.timer2 = tempTimer;
CGRect rect = [UIScreen mainScreen].bounds;
UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:CGRectInset(rect, 0, 200)];
[self.view addSubview:scrollView];
UIView *contentView = [[UIView alloc] initWithFrame:CGRectInset(scrollView.bounds, -100, -100)];
contentView.backgroundColor = [UIColor redColor];
[scrollView addSubview:contentView];
scrollView.contentSize = contentView.frame.size;
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self dismissViewControllerAnimated:true completion:nil];
}
- (void)dealloc {
[self.timer1 invalidate];
[self.timer2 invalidate];
NSLog(@"ViewController1 dealloc...");
}
@end
其實和定時器相關的另一個問題大家也經常碰到,那就是NSTimer不是一種實時機制,官方文檔明確說明在一個循環中如果RunLoop沒有被識別(這個時間大概在50-100ms)或者說當前RunLoop在執行一個長的call out(例如執行某個循環操作)則NSTimer可能就會存在誤差,RunLoop在下一次循環中繼續檢查并根據情況確定是否執行(NSTimer的執行時間總是固定在一定的時間間隔,例如1:00:00、1:00:01、1:00:02、1:00:05則跳過了第4、5次運行循環)。
要演示這個問題請看下面的例子(注意:有些示例中可能會讓一個線程中啟動一個定時器,再在主線程啟動一個耗時任務來演示這個問,如果實際測試可能效果不會太明顯,因為現在的iPhone都是多核運算的,這樣一來這個問題會變得相對復雜,因此下面的例子選擇在同一個RunLoop中即加入定時器和執行耗時任務)
#import "ViewController.h"
@interface ViewController ()
@property (nonatomic,weak) NSTimer *timer1;
@property (nonatomic,strong) NSThread *thread1;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor redColor];
// 由于下面的方法無法拿到NSThread的引用,也就無法控制線程的狀態
//[NSThread detachNewThreadSelector:@selector(performTask) toTarget:self withObject:nil];
self.thread1 = [[NSThread alloc] initWithTarget:self selector:@selector(performTask) object:nil];
[self.thread1 start];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.thread1 cancel];
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)dealloc {
[self.timer1 invalidate];
NSLog(@"ViewController dealloc.");
}
- (void)performTask {
// 使用下面的方式創建定時器雖然會自動加入到當前線程的RunLoop中,但是除了主線程外其他線程的RunLoop默認是不會運行的,必須手動調用
__weak typeof(self) weakSelf = self;
self.timer1 = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
if ([NSThread currentThread].isCancelled) {
//[NSObject cancelPreviousPerformRequestsWithTarget:weakSelf selector:@selector(caculate) object:nil];
//[NSThread exit];
[weakSelf.timer1 invalidate];
}
NSLog(@"timer1...");
}];
NSLog(@"runloop before performSelector:%@",[NSRunLoop currentRunLoop]);
// 區分直接調用和「performSelector:withObject:afterDelay:」區別,下面的直接調用無論是否運行RunLoop一樣可以執行,但是后者則不行。
//[self caculate];
[self performSelector:@selector(caculate) withObject:nil afterDelay:2.0];
// 取消當前RunLoop中注冊測selector(注意:只是當前RunLoop,所以也只能在當前RunLoop中取消)
// [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(caculate) object:nil];
NSLog(@"runloop after performSelector:%@",[NSRunLoop currentRunLoop]);
// 非主線程RunLoop必須手動調用
[[NSRunLoop currentRunLoop] run];
NSLog(@"注意:如果RunLoop不退出(運行中),這里的代碼并不會執行,RunLoop本身就是一個循環.");
}
- (void)caculate {
for (int i = 0;i < 9999;++i) {
NSLog(@"%i,%@",i,[NSThread currentThread]);
if ([NSThread currentThread].isCancelled) {
return;
}
}
}
@end
如果運行并且不退出上面的程序會發現,前兩秒NSTimer可以正常執行,但是兩秒后由于同一個RunLoop中循環操作的執行造成定時器跳過了中間執行的機會一直到caculator循環完畢,這也正說明了NSTimer不是實時系統機制的原因。
但是以上程序還有幾點需要說明一下:
- NSTimer會對Target進行強引用直到任務結束或exit之后才會釋放。如果上面的程序沒有進行線程cancel而終止任務則及時關閉控制器也無法正確釋放。
- 非主線程的RunLoop并不會自動運行(同時注意默認情況下非主線程的RunLoop并不會自動創建,直到第一次使用),RunLoop運行必須要在加入NSTimer或Source0、Sourc1、Observer輸入后運行否則會直接退出。例如上面代碼如果run放到NSTimer創建之前則既不會執行定時任務也不會執行循環運算。
- performSelector:withObject:afterDelay:執行的本質還是通過創建一個NSTimer然后加入到當前線程RunLoop(通而過前后兩次打印RunLoop信息可以看到此方法執行之后RunLoop的timer會增加1個。類似的還有performSelector:onThread:withObject:afterDelay:,只是它會在另一個線程的RunLoop中創建一個Timer),所以此方法事實上在任務執行完之前會對觸發對象形成引用,任務執行完進行釋放(例如上面會對ViewController形成引用,注意:performSelector: withObject:等方法則等同于直接調用,原理與此不同)。
- 同時上面的代碼也充分說明了RunLoop是一個循環事實,run方法之后的代碼不會立即執行,直到RunLoop退出。
- 上面程序的運行過程中如果突然dismiss,則程序的實際執行過程要分為兩種情況考慮:如果循環任務caculate還沒有開始則會在timer1中停止timer1運行(停止了線程中第一個任務),然后等待caculate執行并break(停止線程中第二個任務)后線程任務執行結束釋放對控制器的引用;如果循環任務caculate執行過程中dismiss則caculate任務執行結束,等待timer1下個周期運行(因為當前線程的RunLoop并沒有退出,timer1引用計數器并不為0)時檢測到線程取消狀態則執行invalidate方法(第二個任務也結束了),此時線程釋放對于控制器的引用。
CADisplayLink是一個執行頻率(fps)和屏幕刷新相同(可以修改preferredFramesPerSecond改變刷新頻率)的定時器,它也需要加入到RunLoop才能執行。與NSTimer類似,CADisplayLink同樣是基于CFRunloopTimerRef實現,底層使用mk_timer(可以比較加入到RunLoop前后RunLoop中timer的變化)。和NSTimer相比它精度更高(盡管NSTimer也可以修改精度),不過和NStimer類似的是如果遇到大任務它仍然存在丟幀現象。通常情況下CADisaplayLink用于構建幀動畫,看起來相對更加流暢,而NSTimer則有更廣泛的用處。
AutoreleasePool
AutoreleasePool是另一個與RunLoop相關討論較多的話題。其實從RunLoop源代碼分析,AutoreleasePool與RunLoop并沒有直接的關系,之所以將兩個話題放到一起討論最主要的原因是因為在iOS應用啟動后會注冊兩個Observer管理和維護AutoreleasePool。不妨在應用程序剛剛啟動時打印currentRunLoop可以看到系統默認注冊了很多個Observer,其中有兩個Observer的callout都是** _ wrapRunLoopWithAutoreleasePoolHandler**,這兩個是和自動釋放池相關的兩個監聽。
<CFRunLoopObserver 0x6080001246a0 [0x101f81df0]>{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = <CFArray 0x60800004cae0 [0x101f81df0]>{type = mutable-small, count = 0, values = ()}}
'' <CFRunLoopObserver 0x608000124420 [0x101f81df0]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x1020e07ce), context = <CFArray 0x60800004cae0 [0x101f81df0]>{type = mutable-small, count = 0, values = ()}}
第一個Observer會監聽RunLoop的進入,它會回調objc_autoreleasePoolPush()向當前的AutoreleasePoolPage增加一個哨兵對象標志創建自動釋放池。這個Observer的order是-2147483647優先級最高,確保發生在所有回調操作之前。
第二個Observer會監聽RunLoop的進入休眠和即將退出RunLoop兩種狀態,在即將進入休眠時會調用**objc_autoreleasePoolPop() **和 **objc_autoreleasePoolPush() 根據情況從最新加入的對象一直往前清理直到遇到哨兵對象。而在即將退出RunLoop時會調用objc_autoreleasePoolPop() **釋放自動自動釋放池內對象。這個Observer的order是2147483647,優先級最低,確保發生在所有回調操作之后。
主線程的其他操作通常均在這個AutoreleasePool之內(main函數中),以盡可能減少內存維護操作(當然你如果需要顯式釋放【例如循環】時可以自己創建AutoreleasePool否則一般不需要自己創建)。
其實在應用程序啟動后系統還注冊了其他Observer(例如即將進入休眠時執行注冊回調_UIGestureRecognizerUpdateObserver用于手勢處理、回調為_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer用于界面實時繪制更新)和多個Source1(例如context為CFMachPort的Source1用于接收硬件事件響應進而分發到應用程序一直到UIEvent),這里不再一一詳述。
UI更新
如果打印App啟動之后的主線程RunLoop可以發現另外一個callout為_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的Observer,這個監聽專門負責UI變化后的更新,比如修改了frame、調整了UI層級(UIView/CALayer)或者手動設置了setNeedsDisplay/setNeedsLayout之后就會將這些操作提交到全局容器。而這個Observer監聽了主線程RunLoop的即將進入休眠和退出狀態,一旦進入這兩種狀態則會遍歷所有的UI更新并提交進行實際繪制更新。
通常情況下這種方式是完美的,因為除了系統的更新,還可以利用setNeedsDisplay等方法手動觸發下一次RunLoop運行的更新。但是如果當前正在執行大量的邏輯運算可能UI的更新就會比較卡,因此facebook推出了AsyncDisplayKit來解決這個問題。AsyncDisplayKit其實是將UI排版和繪制運算盡可能放到后臺,將UI的最終更新操作放到主線程(這一步也必須在主線程完成),同時提供一套類UIView或CALayer的相關屬性,盡可能保證開發者的開發習慣。這個過程中AsyncDisplayKit在主線程RunLoop中增加了一個Observer監聽即將進入休眠和退出RunLoop兩種狀態,收到回調時遍歷隊列中的待處理任務一一執行。
NSURLConnection
在前面的網絡開發的文章中已經介紹過NSURLConnection的使用,一旦啟動NSURLConnection以后就會不斷調用delegate方法接收數據,這樣一個連續的的動作正是基于RunLoop來運行。
一旦NSURLConnection設置了delegate會立即創建一個線程com.apple.NSURLConnectionLoader,同時內部啟動RunLoop并在NSDefaultMode模式下添加4個Source0。其中CFHTTPCookieStorage用于處理cookie ;CFMultiplexerSource負責各種delegate回調并在回調中喚醒delegate內部的RunLoop(通常是主線程)來執行實際操作。
早期版本的AFNetworking庫也是基于NSURLConnection實現,為了能夠在后臺接收delegate回調AFNetworking內部創建了一個空的線程并啟動了RunLoop,當需要使用這個后臺線程執行任務時AFNetworking通過**performSelector: onThread: **將這個任務放到后臺線程的RunLoop中。
GCD和RunLoop的關系
在RunLoop的源代碼中可以看到用到了GCD的相關內容,但是RunLoop本身和GCD并沒有直接的關系。當調用了dispatch_async(dispatch_get_main_queue(), <#^(void)block#>)時libDispatch會向主線程RunLoop發送消息喚醒RunLoop,RunLoop從消息中獲取block,并且在CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE回調里執行這個block。不過這個操作僅限于主線程,其他線程dispatch操作是全部由libDispatch驅動的。
更多RunLoop使用
前面看了很多RunLoop的系統應用和一些知名第三方庫使用,那么除了這些究竟在實際開發過程中我們自己能不能適當的使用RunLoop幫我們做一些事情呢?
思考這個問題其實只要看RunLoopRef的包含關系就知道了,RunLoop包含多個Mode,而它的Mode又是可以自定義的,這么推斷下來其實無論是Source1、Timer還是Observer開發者都可以利用,但是通常情況下不會自定義Timer,更不會自定義一個完整的Mode,利用更多的其實是Observer和Mode的切換。
例如很多人都熟悉的使用perfromSelector在默認模式下設置圖片,防止UITableView滾動卡頓([[UIImageView allocinitWithFrame:CGRectMake(0, 0, 100, 100)] performSelector:@selector(setImage:) withObject:myImage afterDelay:0.0 inModes:@NSDefaultRunLoopMode])。還有sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在界面空閑狀態下計算出UITableViewCell的高度并進行緩存。再有老譚的PerformanceMonitor關于iOS實時卡頓監控,同樣是利用Observer對RunLoop進行監視。
關于如何自定義一個Custom Input Source官網給出了詳細的流程。