RunLoop刨根問底

西木中堂發表於2018-02-27

概述

RunLoop是 iOS 和 OSX 開發中非常基礎的一個概念,同時也是很多常見技術的幕後功臣。儘管在平時多數開發者很少直接使用RunLoop,但是理解RunLoop可以幫助開發者更好的利用多執行緒程式設計模型,同時也可以幫助開發者解答日常開發中的一些疑惑。本文將從RunLoop原始碼著手,結合RunLoop的實際應用來逐步解開它的神祕面紗

RunLoop和RunloopRef

通常所說的RunLoop指的是NSRunloop或者CFRunloopRef,CFRunloopRef是純C的函式,而NSRunloop僅僅是CFRunloopRef的OC封裝,並未提供額外的其他功能,因此下面主要分析CFRunloopRef,蘋果已經開源了CoreFoundation原始碼,因此很容易找到CFRunloop原始碼。 從程式碼可以看出CFRunloopRef其實就是 __CFRunloop 這個結構體指標,這個物件的執行才是我們通常意義上說的執行迴圈,核心方法是 __CFRunloopRun() ,貼出部分原始碼:

SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) {
    CHECK_FOR_FORK();
    //如果CFRunLoopRef被標記已釋放,返回
    if (__CFRunLoopIsDeallocating(rl)) return kCFRunLoopRunFinished;
    //CFRunLoopRef執行緒安全鎖
    __CFRunLoopLock(rl);
    
    //取出進入RunLoop時指定的RunLoopMode
    CFRunLoopModeRef currentMode = __CFRunLoopFindMode(rl, modeName, false);

    int32_t result = kCFRunLoopRunFinished;
    
    //如果RunLoop的observer監聽了kCFRunLoopEntry,通知observer即將進入runloop
    if (currentMode->_observerMask & kCFRunLoopEntry ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
    result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
    
    //如果RunLoop的observer監聽了kCFRunLoopExit,通知observer即將退出runloop
    if (currentMode->_observerMask & kCFRunLoopExit ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
    
    //釋放執行緒安全鎖
    __CFRunLoopModeUnlock(currentMode);
    __CFRunLoopUnlock(rl);
    return result;
}

static int32_t __CFRunLoopRun(CFRunLoopRef rl, CFRunLoopModeRef rlm, CFTimeInterval seconds, Boolean stopAfterHandle, CFRunLoopModeRef previousMode)
{
  do{
    // 通知將要處理timer和source
    if (rlm->_observerMask & kCFRunLoopBeforeTimers) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);
    if (rlm->_observerMask & kCFRunLoopBeforeSources) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);
    
    // 處理非延遲的主執行緒呼叫
    __CFRunLoopDoBlocks(rl, rlm);
    
    // 處理Source0事件
    Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle);
    if (sourceHandledThisLoop) {
        __CFRunLoopDoBlocks(rl, rlm);
    }

    /// 如果有 Source1 (基於port) 處於 ready 狀態,直接處理這個 Source1 然後跳轉去處理訊息。
    if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) {
        msg = (mach_msg_header_t *)msg_buffer;
        if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0)) {
            goto handle_msg;
        }
    }
        
    /// 通知 Observers: RunLoop 的執行緒即將進入休眠(sleep)。
    if (rlm->_observerMask & kCFRunLoopBeforeWaiting) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
    __CFRunLoopSetSleeping(rl);
    
    // 等待核心mach_msg事件
     __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY);
    
    // 等待。。。
    
    // 從等待中醒來
    __CFRunLoopUnsetSleeping(rl);
    if (rlm->_observerMask & kCFRunLoopAfterWaiting) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);
    
    // 處理因timer的喚醒
    if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort){
        CFRUNLOOP_WAKEUP_FOR_TIMER();
        
        if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
            // Re-arm the next timer, because we apparently fired early
            __CFArmNextTimerInMode(rlm, rl);
        }
    }else if (livePort == dispatchPort){// 處理非同步方法喚醒,如dispatch_async
        CFRUNLOOP_WAKEUP_FOR_DISPATCH();
        __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
        _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL);
    }else{
    		CFRUNLOOP_WAKEUP_FOR_SOURCE();
    		__CFRunLoopDoSource1();// 處理Source1
    }
    
    // 再次確保是否有同步的方法需要呼叫
    __CFRunLoopDoBlocks(rl, rlm);
    
	} while (!stop && !timeout);
}
複製程式碼

__CFRunLoopRun 內部其實是一個 do while 迴圈,這也正是Runloop執行的本質。執行了這個函式以後就一直處於“等待-處理”的迴圈之中,直到迴圈結束。只是不同於我們自己寫的迴圈它在休眠時幾乎不會佔用系統資源,當然這是由於系統核心負責實現的,也是Runloop精華所在

下圖描述了Runloop執行流程(基本描述了上面Runloop的核心流程,當然可以檢視官方The Run Loop Sequence of Events描述:

RunLoop刨根問底

整個流程並不複雜(需要注意的就是 黃色 區域的訊息處理中並不包含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;
   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組合

CFRunLoopRef和CFRunloopMode、CFRunLoopSourceRef/CFRunloopTimerRef/CFRunLoopObserverRef關係如下圖:

RunLoop刨根問底

那麼CFRunLoopSourceRef、CFRunLoopTimerRef和CFRunLoopObserverRef究竟是什麼?它們在Runloop執行流程中起到什麼作用呢?

Source

首先看一下官方Runloop結構圖(注意下圖的Input Source Port和前面流程圖中的Source0並不對應,而是對應Source1。Source1和Timer都屬於埠事件源,不同的是所有的Timer都共用一個埠“Mode Timer Port”,而每個Source1都有不同的對應埠):

RunLoop刨根問底

再結合前面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刨根問底

RunLoop休眠

其實對於Event Loop而言RunLoop最核心的事情就是保證執行緒在沒有訊息時休眠以避免佔用系統資源,有訊息時能夠及時喚醒。RunLoop的這個機制完全依靠系統核心來完成,具體來說是蘋果作業系統核心元件Darwin中的Mach來完成的Darwin 可以從下圖最底層Kernel中找到Mach:

RunLoop刨根問底

Mach是Darwin的核心,可以說是核心的核心,提供了程式間通訊(IPC)、處理器排程等基礎服務。在Mach中,程式、執行緒間的通訊是以訊息的方式來完成的,訊息在兩個Port之間進行傳遞(這也正是Source1之所以稱之為Port-based Source的原因,因為它就是依靠系統傳送訊息到指定的Port來觸發的)。訊息的傳送和接收使用 <mach/message.h> 中的 mach_msg() 函式(事實上蘋果提供的Mach API很少,並不鼓勵我們直接呼叫這些API):

__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的上層封裝,和NSThread一一對應(而NSThread是一套物件導向的API,所以在iOS開發中我們也幾乎不用直接使用pthread)

RunLoop刨根問底

蘋果開發的介面中並沒有直接建立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不是實時系統機制的原因。

但是以上程式還有幾點需要說明一下:

  1. NSTimer會對Target進行強引用直到任務結束或exit之後才會釋放。如果上面的程式沒有進行執行緒cancel而終止任務則及時關閉控制器也無法正確釋放
  2. 非主執行緒的RunLoop並不會自動執行(同時注意預設情況下非主執行緒的RunLoop並不會自動建立,直到第一次使用),RunLoop執行必須要在加入NSTimer或Source0、Sourc1、Observer輸入後執行否則會直接退出。例如上面程式碼如果run放到NSTimer建立之前則既不會執行定時任務也不會執行迴圈運算
  3. performSelector:withObject:afterDelay: 執行的本質還是通過建立一個NSTimer然後加入到當前執行緒RunLoop(通而過前後兩次列印RunLoop資訊可以看到此方法執行之後RunLoop的timer會增加1個。類似的還有performSelector:onThread:withObject:afterDelay:,只是它會在另一個執行緒的RunLoop中建立一個Timer),所以此方法事實上在任務執行完之前會對觸發物件形成引用,任務執行完進行釋放(例如上面會對ViewController形成引用,注意: performSelector: withObject: 等方法則等同於直接呼叫,原理與此不同)
  4. 同時上面的程式碼也充分說明了RunLoop是一個迴圈事實,run方法之後的程式碼不會立即執行,直到RunLoop退出
  5. 上面程式的執行過程中如果突然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和多個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的更新就會比較卡,因此誕生了非同步繪製框架Texture來解決這個問題。Texture其實是將UI排版和繪製運算儘可能放到後臺,將UI的最終更新操作放到主執行緒(這一步也必須在主執行緒完成),同時提供一套類UIView或CALayer的相關屬性,儘可能保證開發者的開發習慣。這個過程中Texture在主執行緒RunLoop中增加了一個Observer監聽即將進入休眠和退出RunLoop兩種狀態,收到回撥時遍歷佇列中的待處理任務一一執行

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 alloc initWithFrame:CGRectMake(0, 0, 100, 100)] performSelector:@selector(setImage:) withObject:myImage afterDelay:0.0 inModes:@NSDefaultRunLoopMode]

還有sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在介面空閒狀態下計算出UITableViewCell的高度並進行快取。再有老譚的PerformanceMonitor關於iOS實時卡頓監控,同樣是利用Observer對RunLoop進行監視。

相關文章