iOS runloop

orilme發表於2019-04-18

一、Runloop 簡介

1. 簡介

  • RunLoop就是讓執行緒隨時處理事件但不退出的機制
  • 每一個執行緒都有一個RunLoop
  • RunLoop 實際上就是一個物件,這個物件管理了其需要處理的事件(比如button的點選、各種手勢的的事件、定時器、tableView的代理方法)和訊息,是iOS裡的一種事件處理機制。
  • 執行緒執行了這個函式後,就會一直處於這個函式內部 “接受訊息->等待->處理” 的迴圈中,直到這個迴圈結束(比如傳入 quit 的訊息),函式返回。

2. 基本作用

  • 保持程式的持續執行(比如主執行迴圈)
  • 處理App中的各種事件(比如觸控事件、定時器事件、Selector事件)
  • 節省CPU資源,提高程式效能:該做事時做事,該休息時休息

3. API

OSX / iOS 系統中,有2套API來訪問和使用 RunLoop

  • CFRunLoopRef 是在 CoreFoundation 框架內的,它提供了純 C 函式的 API,所有這些 API 都是執行緒安全的。
  • NSRunLoop 是基於 CFRunLoopRef 的封裝,提供了物件導向的 API,但是這些 API 不是執行緒安全的。所以要了解 RunLoop 內部結構,需要多研究 CFRunLoopRef 層面的API(Core Foundation 層面)

NSRunLoopCFRunLoopRef 都代表著 RunLoop 物件。

4. 存在價值

main 函式中的 RunLoop (主執行迴圈):第14行程式碼的 UIApplicationMain 函式內部就啟動了一個 RunLoop。所以 UIApplicationMain 函式一直沒有返回,保持了程式的持續執行。這個預設啟動的 RunLoop 是跟主執行緒相關聯的。

image.jpeg

二、Runloop 解析

1. Runloop 執行模式

一種 Runloop 執行模式就是一個要監控的 Input 和 Timer 事件源的集合或者是一個要通知的 Runloop 觀察者的集合。每次運 行Runloop,都要指定一個執行模式(顯示地或者隱式地)。在 Runloop 的執行期間,只有和當前執行模式相關的源才能被監控和允許傳送事件。相似的,只有和當前執行模式相關的觀察者才會被通知 Runloop 的行為。和其他模式相關的源會保留新的事件直到 Runloop 執行在了合適的模式才會分發。

在我們的程式碼中,我們可以通過字串來標識模式。Cocoa和Core Foundation定義了一個預設模式和幾個普通的有用的模式,這些模式都是用字串來標識的。我們可以用一個字串當做名字來自定義一個模式,雖然我們自定義模式的名字是隨意的,但是模式的內容不是隨意的,在我們自己建立的要用的模式中至少要新增一個 Input 源、 Timer 源或者 Runloop 觀察者。

在 Runloop 的特殊階段我們可是使用執行模式來過濾我們不想要的源的事件,大多數的情況下,Runloop 都執行在系統提供的預設模式下,然而 Model Panel 可能執行在“模式”模式,當執行在這個模式期間,只有和這個模式相關的事件源才會傳送事件到我們的執行緒。對於第二執行緒來說,我們通常使用自定義模式來阻止低優先順序的事件源在其他關鍵處理的時間內傳送事件。

注意:執行模式不是根據事件型別劃分的,而是根據事件源劃分的。我們不能通過模式來匹配滑鼠按下事件或者鍵盤事件,但是我們可以用執行模式來監聽一組不同的Port、暫時掛起Timers或者改變當前被監控的事件源和Runloop觀察者。

下面列舉了一些Cocoa和Core Foundation定義的標準模式:

  • NSDefaultRunLoopMode:預設的執行模式,用於大部分操作,除了NSConnection物件事件。
  • NSConnectionReplyMode:用來監控NSConnection物件的回覆的,很少能夠用到。
  • NSModalPanelRunLoopMode:用於標明和Mode Panel相關的事件。
  • NSEventTrackingRunLoopMode:用於跟蹤觸控事件觸發的模式(例如UIScrollView上下滾動)。
  • NSRunLoopCommonModes:是一個模式集合,當繫結一個事件源到這個模式集合的時候就相當於繫結到了集合內的每一個模式。Cocoa 應用預設包含 Default、Panel、Event Tracking 模式,Core Foundation 只包含 Default 模式,我們可以通過 CFRunLoopAddCommonMode 新增模式。

2. Runloop 處理邏輯

Runloop接收來自兩種源的事件:

  1. 輸入源(Input sources):傳遞非同步訊息,通常來自於其他執行緒或者程式。
  2. 定時源(Timer sources):傳遞同步訊息,在設定好的時間或者迴圈間斷地發生的事件。

這兩種事件源都是使用應用指定的事件處理方法來處理到達的事件。

下面的圖顯示了Runloop和事件源的概念結構。 Input sources非同步的分發事件到響應的處理器,然後引起runUntilDate:(由執行緒相關的Runloop物件呼叫)方法退出。 Timer sources同步分發事件到相應的處理器但是不會引起Runloop退出。

RunLoop處理邏輯1-官方.png

RunLoop處理邏輯2-官方.png

RunLoop處理邏輯3-網友整理.png

備註:

  • 輸入源:每一個需要Runloop處理事件的物件都有一個輸入源(InputSource),並且把這個輸入源新增到Runloop裡,每產生一個事件(比如使用者做了一個手勢、點了一個button、滑動了一下tableview、定時器到時)就把這個事件放到對應的輸入源。Runloop執行時迴圈檢查每一個輸入源是否有事件需要處理,如果有事件要處理Runloop就就呼叫這個事件的處理方法(通過addTargetxxx指定的方法或者是代理的方法)。如果Runloop裡所有的輸入源都沒有事件要處理,Runloop會休眠。如果Runloop裡一個輸入源都沒有(物件銷燬前會把它之前新增的那個輸入源取消),Runloop(runUntilDate:這個方法)就退出來了。
  • 除了處理輸入源的事件,Runloop也會生成Runloop行為的通知。註冊Runloop的觀察者可以收到這些訊息,然後線上程內用他們做一些額外的處理。我們只能使用Core Foundation介面來註冊執行緒的Runloop觀察者

3. Input Sources

Input Sources 非同步地分發事件到執行緒。大概有兩種型別的 Input Sources,Port-based型別的輸入源監控著應用的Mach埠,自定義的輸入源監控著自定義的事件源。NSRunloop不關心輸入源的型別。兩種輸入源唯一的不同是輸入源的觸發方式,Port-based輸入源是由系統核心觸發的,而自定義的輸入源要我們自己觸發。建立輸入源的時候我們就給給輸入源新增指定的模式。下面是一些輸入源:

  • Port-Based Sources
    Cocoa 和 Core Foundation 提供了類和介面用來建立 Port-Based 源,Cocoa 只要建立 NSPort 物件,並新增到 NSRunloop 中就可以啦,NSPort負責輸入源的建立和配置。Core Foundation 需要手動的常見 port 和輸入源。

  • Custom Input Sources
    我們要用到CFRunLoopSourceRef函式建立輸入源,並定義幾個回撥函式用於配置輸入源、處理事件和刪除輸入源。事件的觸發機制要我們自己定義。

  • Cocoa Perform Selector Sources
    Cocoa定義了可以在任何執行緒上執行方法的事件源,在想要執行的執行緒上執行方法是順序執行的,避免了多個方法線上程上執行的同步問題。Perform Selector Sources在方法執行完之後就會自己從NSRunloop中刪除。
    Perform Selector Sources要求目標執行緒的NSRunloop必須是執行的,主執行緒預設是執行的。NSRunloop在一次迭代過程中會處理所有的Perform Selector呼叫,而不是一次迭代處理一個Perform Selector呼叫。NSObject中定義的Perform Selector方法如下

    • performSelectorOnMainThread:withObject:waitUntilDone:
    • performSelectorOnMainThread:withObject:waitUntilDone:modes:
    • performSelector:onThread:withObject:waitUntilDone:
    • performSelector:onThread:withObject:waitUntilDone:modes:
    • performSelector:withObject:afterDelay:
    • performSelector:withObject:afterDelay:inModes:
    • cancelPreviousPerformRequestsWithTarget:
    • cancelPreviousPerformRequestsWithTarget:selector:object:

    延遲執行是在NSRunloop的下一次迭代中過了指定的延遲事件才執行。取消操作是針對延遲執行方法的。

4. Timer Sources

Timer Sources 同步地在將來的一個確定的時間分發事件到我們的執行緒。Timers 可以讓執行緒通知自己去處理一些事情。Timers 不是一個實時的機制,當 Timers 觸發的時候 NSrunloop 剛好正在執行處理函式,Timer s會等待 NSRunloop 呼叫自己的處理函式。

Timers 可以建立一次性的和重複性的事件,當建立重複性的事件的時候,Timers 只會根據規劃好的觸發時間來重新規劃觸發時間,而不是根據確切的觸發時間。而且由於延遲觸發丟失了幾次觸發的話,Timers 只會補充一次觸發。

5. NSRunloop 觀察者

不像是事件源一樣在事件觸發的時候執行處理函式。NSRunloop 觀察者是在 NSRunloop 幾個執行的特定的點觸發。NSRunloop 可以觀察的幾個事件是:

  • 進入 NSRunloop
  • NSRunloop 將要處理 Timer 事件
  • NSRunloop 將要處理 Input 事件
  • NSRunloop 將要進入睡眠
  • NSRunloop 被喚醒,但是是在處理事件之前
  • 退出 NSRunloop

建立觀察者的方法是 CFRunLoopObserverRef,我們可以通 過Core Foundation 方法新增到指定的 NSRunloop。觀察者也可以建立一次性的和重複性的。一次性的觀察者觸發之後就會從 NSRunloo p中刪除。

三、RunLoop 相關類

Core Foundation 中關於 RunLoop 的5個類

  • CFRunLoopRef
  • CFRunLoopModeRef
  • CFRunLoopSourceRef
  • CFRunLoopTimerRef
  • CFRunLoopObserverRef 注:RunLoop 如果沒有這些東西會直接退出

1. CFRunLoopModeRef

CFRunLoopModeRef代表RunLoop的執行模式:一個 RunLoop 包含若干個 Mode,每個Mode又包含若干個 Source/Timer/Observer
每次RunLoop啟動時,只能指定其中一個 Mode,這個Mode被稱作 CurrentMode 如果需要切換 Mode,只能退出 Loop,再重新指定一個 Mode 進入 這樣做主要是為了分隔開不同組的 Source/Timer/Observer,讓其互不影響。

1465700097876160.jpg

系統預設註冊了5個Mode:(前兩個跟最後一個常用)

  • kCFRunLoopDefaultMode:App的預設Mode,通常主執行緒是在這個Mode下執行
  • UITrackingRunLoopMode:介面跟蹤 Mode,用於 ScrollView 追蹤觸控滑動,保證介面滑動時不受其他 Mode 影響
  • UIInitializationRunLoopMode: 在剛啟動 App 時第進入的第一個 Mode,啟動完成後就不再使用
  • GSEventReceiveRunLoopMode: 接受系統事件的內部 Mode,通常用不到
  • kCFRunLoopCommonModes: 這是一個佔位用的Mode,不是一種真正的Mode

2. CFRunLoopSourceRef 事件源(輸入源)

按照官方文件的分類:

  • Port-Based Sources (基於埠,跟其他執行緒互動,通過核心釋出的訊息)
  • Custom Input Sources (自定義)
  • Cocoa Perform Selector Sources (performSelector...方法)

按照函式呼叫棧的分類

  • Source0:非基於Port的,event事件,只含有回撥,需要先呼叫 CFRunLoopSourceSignal(source),將這個 Source 標記為待處理,然後手動呼叫 CFRunLoopWakeUp(runloop) 來喚醒 RunLoop。
  • Source1:基於Port的,包含了一個 mach_port 和一個回撥,被用於通過核心和其他執行緒相互傳送訊息,能主動喚醒 RunLoop 的執行緒。

函式呼叫棧

函式呼叫棧.png

3. CFRunLoopTimerRef

CFRunLoopTimerRef 是基於時間的觸發器,基本上說的就是 NSTimer (CADisplayLink 也是加到 RunLoop),它受 RunLoop 的 Mode 影響。
GCD的定時器不受 RunLoop 的 Mode 影響。

4. CFRunLoopObserverRef

CFRunLoopObserverRef是觀察者,能夠監聽RunLoop的狀態改變 可以監聽的時間點有以下幾個

可監聽狀態.png

使用

 - (void)observer {
     // 建立observer
     CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
         NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
     });
     // 新增觀察者:監聽RunLoop的狀態
     CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
     // 釋放Observer
     CFRelease(observer);
 }
特別注意
 /*
     CF的記憶體管理(Core Foundation)
     1.凡是帶有Create、Copy、Retain等字眼的函式,建立出來的物件,都需要在最後做一次release
     * 比如CFRunLoopObserverCreate
     2.release函式:CFRelease(物件);
  */
複製程式碼

四、runloop應用

  • NSTimer
  • PerformSelector
  • ImageView顯示
  • 需要讓執行緒執行週期性的工作(常駐執行緒)
  • 自動釋放池
  • 需要使用 Port 或者自定義 Input Source 與其他執行緒進行通訊
  • NSURLConnection 在子執行緒中發起非同步請求

1. NSTimer (最常見RunLoop使用)

場景還原:拖拽時模式由 NSDefaultRunLoopMode 進入 UITrackingRunLoopMode ,NSTimer 不再響應圖片停止輪播,將計時器改成 NSRunLoopCommonModes 模式下兩種模式都可執行。

 - (void)timer {
     NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
     // 定時器只執行在NSDefaultRunLoopMode下,一旦RunLoop進入其他模式,這個定時器就不會工作
     // [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
     // 定時器只執行在UITrackingRunLoopMode下,一旦RunLoop進入其他模式,這個定時器就不會工作
     // [[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
     // 定時器會跑在標記為common modes的模式下
     // 標記為common modes的模式:UITrackingRunLoopMode和NSDefaultRunLoopMode相容
     [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
 }
 - (void)timer2 {
     // 呼叫了scheduledTimer返回的定時器,已經自動被新增到當前runLoop中,而且是NSDefaultRunLoopMode
     NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
     // 修改模式
     [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
 }
複製程式碼

2. ImageView

需求:當使用者在拖拽時(UI互動時)不顯示圖片,拖拽完成時顯示圖片

  • 方法1 監聽UIScrollerView滾動 (通過UIScrollViewDelegate監聽,此處不再舉例)
  • 方法2 RunLoop 設定執行模式
    // 只在NSDefaultRunLoopMode模式下顯示圖片
    // inModes:設定執行模式
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"placeholder"] afterDelay:3.0 inModes:@[NSDefaultRunLoopMode]];
    複製程式碼

3. 常駐執行緒 (重要)

應用場景: 經常在後臺進行耗時操作,如:監控聯網狀態,掃描沙盒等 不希望執行緒處理完事件就銷燬,保持常駐狀態

  • 第一種(推薦)
    開啟
     - (void)run {
       //addPort:新增埠(就是source)  forMode:設定模式
        [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
       //啟動RunLoop
         [[NSRunLoop currentRunLoop] run];
      /*
       //另外兩種啟動方式
         [NSDate distantFuture]:遙遠的未來  這種寫法跟上面的run是一個意思
         [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
         不設定模式
         [[NSRunLoop currentRunLoop] runUntilDate:[NSDate distantFuture]];
       */
     }
    複製程式碼
    退出-退出當前執行緒
     [NSThread exit];
    複製程式碼
  • 第二種(奇葩法)
    優點:退出RunLoop比較方便-定義個標記 while(flag){...}
     - (void)run {
         while (1) {
             [[NSRunLoop currentRunLoop] run];
         }
     }
    複製程式碼

4. 自動釋放池

在休眠前(kCFRunLoopBeforeWaiting)進行釋放,處理事件前建立釋放池,中間建立的物件會放入釋放池。
特別注意:在啟動 RunLoop 之前建議用 @autoreleasepool {...} 包裹。
意義:建立一個大釋放池,釋放 {} 期間建立的臨時物件,一般好的框架的作者都會這麼做。

螢幕快照 2018-10-04 14.40.44.png

- (void)execute {
     @autoreleasepool {
         NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
         [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
         [[NSRunLoop currentRunLoop] run];
}
複製程式碼

5. 補充: GCD定時器

一般的NSTimer定時器因為受到RunLoop,會存在時間不準時的情況。 上文有提到GCD不受RunLoop影響,下面簡單的說一下它的使用

 /** 定時器(這裡不用帶*,因為 dispatch_source_t 就是個類,內部已經包含了*) */
 @property (nonatomic, strong) dispatch_source_t timer;
 int count = 0;
 - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
     // 獲得佇列
     // dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
     dispatch_queue_t queue = dispatch_get_main_queue();
     // 建立一個定時器(dispatch_source_t本質還是個OC物件)
     self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
     // 設定定時器的各種屬性(幾時開始任務,每隔多長時間執行一次)
     // GCD的時間引數,一般是納秒 NSEC_PER_SEC(1秒 == 10的9次方納秒)
     // 何時開始執行第一個任務
     // dispatch_time(DISPATCH_TIME_NOW, 3.0 * NSEC_PER_SEC) 比當前時間晚3秒
     dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));
     uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
     dispatch_source_set_timer(self.timer, start, interval, 0);
     // 設定回撥
     dispatch_source_set_event_handler(self.timer, ^{
         NSLog(@"------------%@", [NSThread currentThread]);
         count++;
 //        if (count == 4) {
 //            // 取消定時器
 //            dispatch_cancel(self.timer);
 //            self.timer = nil;
 //        }
     });
     // 啟動定時器
     dispatch_resume(self.timer);
 }
複製程式碼

五、runloop 與執行緒

每條執行緒都有唯一的一個與之對應的 RunLoop 物件;
主執行緒的 RunLoop 已經自動建立好了,子執行緒的RunLoop需要主動建立;
RunLoop在第一次獲取時建立,線上程結束時銷燬;

  • 獲取RunLoop物件

    // 工作執行緒 需要程式設計師手工寫程式碼讓runloop執行起來
    [NSRunLoop currentLoop]runUntilDate:]
    // Foundation
    [NSRunLoop currentRunLoop]; // 獲得當前執行緒的RunLoop物件
    [NSRunLoop mainRunLoop]; // 獲得主執行緒的RunLoop物件
    // Core Foundation
    CFRunLoopGetCurrent(); // 獲得當前執行緒的RunLoop物件
    CFRunLoopGetMain(); // 獲得主執行緒的RunLoop物件
    複製程式碼
  • 執行緒安全性
    基於 Cocoa 的介面不是執行緒安全的,基於 Core Foundation 的介面是執行緒安全的。

六、RunLoop 面試題

  1. 什麼是RunLoop?

    • 其實它內部就是do-while迴圈,在這個迴圈內部不斷的處理各種任務(比如Source、Timer、Observer)。
    • 一個執行緒對應一個RunLoop,主執行緒的RunLoop預設已經啟動,子執行緒的RunLoop需要手動啟動(呼叫run方法) 。
    • RunLoop只能選擇一個Mode啟動,如果當前Mode中沒有任何Soure、Timer、Observer,那麼就直接退出RunLoop。
  2. 在開發中如何使用RunLoop?什麼應用場景?

    • 開啟一個常駐執行緒(讓一個子執行緒不進入消亡狀態,等待其他執行緒發來訊息,處理其他事件)
    • 在子執行緒中開啟一個定時器
    • 在子執行緒中進行一些長期監控
    • 可以控制定時器在特定模式下執行
    • 可以讓某些事件(行為、任務)在特定模式下執行
    • 可以新增 Observer 監聽 RunLoop 的狀態,比如監聽點選事件的處理(在所有點選事件之前做一些事情)
  3. 在非同步執行緒中下載很多圖片。如果失敗了,該如何處理?請結合runloop來談談解決方案?
    答:(提示:在非同步執行緒中啟動一個runloop重新傳送網路圖片)
    (1)重新下載圖片
    (2)利用 runloop 的輸入源回到主執行緒重新整理 UIImageView。

相關連結

蘋果官方文件
CFRunLoop官方文件
NSRunLoop官方文件
CFRunLoopRef
NSRunloop的使用

相關文章