iOS 多執行緒:『RunLoop』詳盡總結

行走少年郎發表於2019-03-28

本文首發於我的個人部落格:『不羈閣』 https://bujige.net

文章連結:bujige.net/blog/iOS-Co…

文中 Demo 地址:YSC-RunLoopDemo

1. RunLoop 簡介

1.1 什麼是 RunLoop?

可以理解為字面意思:Run 表示執行,Loop 表示迴圈。結合在一起就是執行的迴圈的意思。哈哈,我更願意翻譯為『跑圈』。直觀理解就像是不停的跑圈。

  • RunLoop 實際上是一個物件,這個物件在迴圈中用來處理程式執行過程中出現的各種事件(比如說觸控事件、UI重新整理事件、定時器事件、Selector事件),從而保持程式的持續執行。
  • RunLoop 在沒有事件處理的時候,會使執行緒進入睡眠模式,從而節省 CPU 資源,提高程式效能。

1.2 RunLoop 和執行緒

RunLoop 和執行緒是息息相關的,我們知道執行緒的作用是用來執行特定的一個或多個任務,在預設情況下,執行緒執行完之後就會退出,就不能再執行任務了。這時我們就需要採用一種方式來讓執行緒能夠不斷地處理任務,並不退出。所以,我們就有了 RunLoop。

  1. 一條執行緒對應一個RunLoop物件,每條執行緒都有唯一一個與之對應的 RunLoop 物件。
  2. RunLoop 並不保證執行緒安全。我們只能在當前執行緒內部操作當前執行緒的 RunLoop 物件,而不能在當前執行緒內部去操作其他執行緒的 RunLoop 物件方法。
  3. RunLoop 物件在第一次獲取 RunLoop 時建立,銷燬則是線上程結束的時候。
  4. 主執行緒的 RunLoop 物件系統自動幫助我們建立好了(原理如 1.3 所示),而子執行緒的 RunLoop物件需要我們主動建立和維護。

1.3 預設情況下主執行緒的 RunLoop 原理

我們在啟動一個iOS程式的時候,系統會呼叫建立專案時自動生成的 main.m 的檔案。main.m檔案如下所示:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}
複製程式碼

其中 UIApplicationMain 函式內部幫我們開啟了主執行緒的 RunLoop,UIApplicationMain 內部擁有一個無限迴圈的程式碼,只要程式不退出/崩潰,它就一直迴圈。上邊的程式碼中主執行緒開啟 RunLoop 的過程可以簡單的理解為如下程式碼:

int main(int argc, char * argv[]) {        
    BOOL running = YES;
    do {
        // 執行各種任務,處理各種事件
        // ......
    } while (running);  // 判斷是否需要退出

    return 0;
}
複製程式碼

從上邊可看出,程式一直在 do-while 迴圈中執行,所以 UIApplicationMain 函式一直沒有返回,我們在執行程式之後程式不會馬上退出,會保持持續執行狀態。

下圖是蘋果官方給出的 RunLoop 模型圖。

官方 RunLoop 模型圖

從上圖中可以看出,RunLoop 就是執行緒中的一個迴圈,RunLoop 會在迴圈中會不斷檢測,通過 Input sources(輸入源)和 Timer sources(定時源)兩種來源等待接受事件;然後對接受到的事件通知執行緒進行處理,並在沒有事件的時候讓執行緒進行休息。

2. RunLoop 相關類

下面我們來了解一下Core Foundation框架下關於 RunLoop 的 5 個類,只有弄懂這幾個類的含義,我們才能深入瞭解 RunLoop 的執行機制。

  1. CFRunLoopRef:代表 RunLoop 的物件
  2. CFRunLoopModeRef:代表 RunLoop 的執行模式
  3. CFRunLoopSourceRef:就是 RunLoop 模型圖中提到的輸入源 / 事件源
  4. CFRunLoopTimerRef:就是 RunLoop 模型圖中提到的定時源
  5. CFRunLoopObserverRef:觀察者,能夠監聽 RunLoop 的狀態改變

下邊詳細講解下幾種類的具體含義和關係。

先來看一張表示這 5 個類的關係圖幫助理解(來源:blog.ibireme.com/2015/05/18/…)。

RunLoop相關類關係圖.png

接著來講解這 5 個類的相互關係:

一個RunLoop物件(CFRunLoopRef)中包含若干個執行模式(CFRunLoopModeRef)。而每一個執行模式下又包含若干個輸入源(CFRunLoopSourceRef)、定時源(CFRunLoopTimerRef)、觀察者(CFRunLoopObserverRef)。

  • 每次 RunLoop 啟動時,只能指定其中一個執行模式(CFRunLoopModeRef),這個執行模式(CFRunLoopModeRef)被稱作當前執行模式(CurrentMode)。
  • 如果需要切換執行模式(CFRunLoopModeRef),只能退出當前 Loop,再重新指定一個執行模式(CFRunLoopModeRef)進入。
  • 這樣做主要是為了分隔開不同組的輸入源(CFRunLoopSourceRef)、定時源(CFRunLoopTimerRef)、觀察者(CFRunLoopObserverRef),讓其互不影響 。

下邊我們來詳細講解下這五個類:

2.1 CFRunLoopRef 類

CFRunLoopRef 是 Core Foundation 框架下 RunLoop 物件類。我們可通過以下方式來獲取 RunLoop 物件:

  • Core Foundation
    • CFRunLoopGetCurrent(); // 獲得當前執行緒的 RunLoop 物件
    • CFRunLoopGetMain(); // 獲得主執行緒的 RunLoop 物件

當然,在Foundation 框架下獲取 RunLoop 物件類的方法如下:

  • Foundation
    • [NSRunLoop currentRunLoop]; // 獲得當前執行緒的 RunLoop 物件
    • [NSRunLoop mainRunLoop]; // 獲得主執行緒的 RunLoop 物件

2.2 CFRunLoopModeRef

系統預設定義了多種執行模式(CFRunLoopModeRef),如下:

  1. kCFRunLoopDefaultMode:App的預設執行模式,通常主執行緒是在這個執行模式下執行
  2. UITrackingRunLoopMode:跟蹤使用者互動事件(用於 ScrollView 追蹤觸控滑動,保證介面滑動時不受其他Mode影響)
  3. UIInitializationRunLoopMode:在剛啟動App時第進入的第一個 Mode,啟動完成後就不再使用
  4. GSEventReceiveRunLoopMode:接受系統內部事件,通常用不到
  5. kCFRunLoopCommonModes:偽模式,不是一種真正的執行模式(後邊會用到)

其中kCFRunLoopDefaultModeUITrackingRunLoopModekCFRunLoopCommonModes是我們開發中需要用到的模式,具體使用方法我們在 2.3 CFRunLoopTimerRef 中結合CFRunLoopTimerRef來演示說明。

2.3 CFRunLoopTimerRef

CFRunLoopTimerRef是定時源(RunLoop模型圖中提到過),理解為基於時間的觸發器,基本上就是NSTimer(哈哈,這個理解就簡單了吧)。

下面我們來演示下CFRunLoopModeRef和CFRunLoopTimerRef結合的使用用法,從而加深理解。

  1. 首先我們新建一個iOS專案,在Main.storyboard中拖入一個Text View。
  2. 在ViewController.m檔案中加入以下程式碼,Demo中請呼叫[self ShowDemo1];來演示。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 定義一個定時器,約定兩秒之後呼叫self的run方法
    NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];

    // 將定時器新增到當前RunLoop的NSDefaultRunLoopMode下
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)run
{
    NSLog(@"---run");
}
複製程式碼
  1. 然後執行,這時候我們發現如果我們不對模擬器進行任何操作的話,定時器會穩定的每隔2秒呼叫run方法列印。

  2. 但是當我們拖動Text View滾動時,我們發現:run方法不列印了,也就是說NSTimer不工作了。而當我們鬆開滑鼠的時候,NSTimer就又開始正常工作了。

這是因為:

  • 當我們不做任何操作的時候,RunLoop處於NSDefaultRunLoopMode下。
  • 而當我們拖動Text View的時候,RunLoop就結束NSDefaultRunLoopMode,切換到了UITrackingRunLoopMode模式下,這個模式下沒有新增NSTimer,所以我們的NSTimer就不工作了。
  • 但當我們鬆開滑鼠的時候,RunLoop就結束UITrackingRunLoopMode模式,又切換回NSDefaultRunLoopMode模式,所以NSTimer就又開始正常工作了。

你可以試著將上述程式碼中的[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];語句換為[[NSRunLoop currentRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];,也就是將定時器新增到當前RunLoop的UITrackingRunLoopMode下,你就會發現定時器只會在拖動Text View的模式下工作,而不做操作的時候定時器就不工作。

那難道我們就不能在這兩種模式下讓NSTimer都能正常工作嗎?

當然可以,這就用到了我們之前說過的偽模式(kCFRunLoopCommonModes),這其實不是一種真實的模式,而是一種標記模式,意思就是可以在打上Common Modes標記的模式下執行。

那麼哪些模式被標記上了Common Modes呢?

NSDefaultRunLoopModeUITrackingRunLoopMode

所以我們只要我們將NSTimer新增到當前RunLoop的kCFRunLoopCommonModes(Foundation框架下為NSRunLoopCommonModes)下,我們就可以讓NSTimer在不做操作和拖動Text View兩種情況下愉快的正常工作了。

具體做法就是講新增語句改為[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

既然講到了NSTimer,這裡順便講下NSTimer中的scheduledTimerWithTimeInterval方法和RunLoop的關係。新增下面的程式碼:

[NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
複製程式碼

這句程式碼呼叫了scheduledTimer返回的定時器,NSTimer會自動被加入到了RunLoop的NSDefaultRunLoopMode模式下。這句程式碼相當於下面兩句程式碼:

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

2.4 CFRunLoopSourceRef

CFRunLoopSourceRef是事件源(RunLoop模型圖中提到過),CFRunLoopSourceRef有兩種分類方法。

  • 第一種按照官方文件來分類(就像RunLoop模型圖中那樣):
    • Port-Based Sources(基於埠)
    • Custom Input Sources(自定義)
    • Cocoa Perform Selector Sources
  • 第二種按照函式呼叫棧來分類:
    • Source0 :非基於Port
    • Source1:基於Port,通過核心和其他執行緒通訊,接收、分發系統事件

這兩種分類方式其實沒有區別,只不過第一種是通過官方理論來分類,第二種是在實際應用中通過呼叫函式來分類。

下邊我們舉個例子大致來了解一下函式呼叫棧和Source。

  1. 在我們的專案中的Main.storyboard中新增一個Button按鈕,並新增點選動作。
  2. 然後在點選動作的程式碼中加入一句輸出語句,並打上斷點,如下圖所示:

新增Button.png

  1. 然後執行程式,並點選按鈕。
  2. 然後在專案中單擊下下圖紅色部分。

函式呼叫棧展示圖

  1. 可以看到如下圖所示就是點選事件產生的函式呼叫棧。

函式呼叫棧

所以點選事件是這樣來的:

  1. 首先程式啟動,呼叫16行的main函式,main函式呼叫15行UIApplicationMain函式,然後一直往上呼叫函式,最終呼叫到0行的BtnClick函式,即點選函式。

  2. 同時我們可以看到11行中有Sources0,也就是說我們點選事件是屬於Sources0函式的,點選事件就是在Sources0中處理的。

  3. 而至於Sources1,則是用來接收、分發系統事件,然後再分發到Sources0中處理的。

2.5 CFRunLoopObserverRef

CFRunLoopObserverRef是觀察者,用來監聽RunLoop的狀態改變

CFRunLoopObserverRef可以監聽的狀態改變有以下幾種:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0),               // 即將進入Loop:1
    kCFRunLoopBeforeTimers = (1UL << 1),        // 即將處理Timer:2    
    kCFRunLoopBeforeSources = (1UL << 2),       // 即將處理Source:4
    kCFRunLoopBeforeWaiting = (1UL << 5),       // 即將進入休眠:32
    kCFRunLoopAfterWaiting = (1UL << 6),        // 即將從休眠中喚醒:64
    kCFRunLoopExit = (1UL << 7),                // 即將從Loop中退出:128
    kCFRunLoopAllActivities = 0x0FFFFFFFU       // 監聽全部狀態改變  
};
複製程式碼

下邊我們通過程式碼來監聽下RunLoop中的狀態改變。

  1. 在ViewController.m中新增如下程式碼,Demo中請呼叫[self showDemo2];方法。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 建立觀察者
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"監聽到RunLoop發生改變---%zd",activity);
    });

    // 新增觀察者到當前RunLoop中
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

    // 釋放observer,最後新增完需要釋放掉
    CFRelease(observer);
}
複製程式碼
  1. 然後執行,看下列印結果,如下圖。

列印結果

可以看到RunLoop的狀態在不斷的改變,最終變成了狀態 32,也就是即將進入睡眠狀態,說明RunLoop之後就會進入睡眠狀態。

3. RunLoop原理

好了,五個類都講解完了,下邊開始放大招了。這下我們就可以來理解RunLoop的執行邏輯了。

下邊上一張之前提到的文章中博主提供的執行邏輯圖(來源:blog.ibireme.com/2015/05/18/…

RunLoop執行邏輯圖

這張圖對於我們理解RunLoop來說太有幫助了,下邊我們可以來說下官方文件給我們的RunLoop邏輯。

在每次執行開啟RunLoop的時候,所線上程的RunLoop會自動處理之前未處理的事件,並且通知相關的觀察者。

具體的順序如下:

  1. 通知觀察者RunLoop已經啟動
  2. 通知觀察者即將要開始的定時器
  3. 通知觀察者任何即將啟動的非基於埠的源
  4. 啟動任何準備好的非基於埠的源
  5. 如果基於埠的源準備好並處於等待狀態,立即啟動;並進入步驟9
  6. 通知觀察者執行緒進入休眠狀態
  7. 將執行緒置於休眠知道任一下面的事件發生:
    • 某一事件到達基於埠的源
    • 定時器啟動
    • RunLoop設定的時間已經超時
    • RunLoop被顯示喚醒
  8. 通知觀察者執行緒將被喚醒
  9. 處理未處理的事件
    • 如果使用者定義的定時器啟動,處理定時器事件並重啟RunLoop。進入步驟2
    • 如果輸入源啟動,傳遞相應的訊息
    • 如果RunLoop被顯示喚醒而且時間還沒超時,重啟RunLoop。進入步驟2
  10. 通知觀察者RunLoop結束。

4. RunLoop實戰應用

哈哈,講了這麼多雲裡霧裡的原理知識,下邊終於到了實戰應用環節。

光弄懂是沒啥用的,能夠實戰應用才是硬道理。下面講解一下RunLoop的幾種應用。

4.1 NSTimer的使用

NSTimer的使用方法在講解CFRunLoopTimerRef類的時候詳細講解過,具體參考上邊 2.3 CFRunLoopTimerRef

4.2 ImageView推遲顯示

有時候,我們會遇到這種情況: 當介面中含有UITableView,而且每個UITableViewCell裡邊都有圖片。這時候當我們滾動UITableView的時候,如果有一堆的圖片需要顯示,那麼可能會出現卡頓的現象。

怎麼解決這個問題呢?

這時候,我們應該推遲圖片的顯示,也就是ImageView推遲顯示圖片。有兩種方法:

1. 監聽UIScrollView的滾動

因為UITableView繼承自UIScrollView,所以我們可以通過監聽UIScrollView的滾動,實現UIScrollView相關delegate即可。

2. 利用PerformSelector設定當前執行緒的RunLoop的執行模式

利用performSelector方法為UIImageView呼叫setImage:方法,並利用inModes將其設定為RunLoop下NSDefaultRunLoopMode執行模式。程式碼如下:

[self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:NSDefaultRunLoopMode];
複製程式碼

下邊利用Demo演示一下該方法。

  1. 在專案中的Main.storyboard中新增一個UIImageView,並新增屬性,並簡單新增一下約束(不然無法顯示)如下圖所示。

新增UIImageView

  1. 在專案中拖入一張圖片,比如下圖。

tupian.jpg

  1. 然後我們在touchesBegan方法中新增下面的程式碼,在Demo中請在touchesBegan中呼叫[self showDemo3];方法。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];
}
複製程式碼
  1. 執行程式,點選一下螢幕,然後拖動UIText View,拖動4秒以上,發現過了4秒之後,UIImageView還沒有顯示圖片,當我們鬆開的時候,則顯示圖片,效果如下:

UIImageView延遲顯示效果.gif

這樣我們就實現了在拖動完之後,在延遲顯示UIImageView。

4.3 後臺常駐執行緒(很常用)

我們在開發應用程式的過程中,如果後臺操作特別頻繁,經常會在子執行緒做一些耗時操作(下載檔案、後臺播放音樂等),我們最好能讓這條執行緒永遠常駐記憶體。

那麼怎麼做呢?

新增一條用於常駐記憶體的強引用的子執行緒,在該執行緒的RunLoop下新增一個Sources,開啟RunLoop。

具體實現過程如下:

  1. 在專案的ViewController.m中新增一條強引用的thread執行緒屬性,如下圖:

新增thread屬性

  1. 在viewDidLoad中建立執行緒self.thread,使執行緒啟動並執行run1方法,程式碼如下。在Demo中,請在viewDidLoad呼叫[self showDemo4];方法。
- (void)viewDidLoad {
    [super viewDidLoad];

    // 建立執行緒,並呼叫run1方法執行任務
    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run1) object:nil];
    // 開啟執行緒
    [self.thread start];    
}

- (void) run1
{
    // 這裡寫任務
    NSLog(@"----run1-----");

    // 新增下邊兩句程式碼,就可以開啟RunLoop,之後self.thread就變成了常駐執行緒,可隨時新增任務,並交於RunLoop處理
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] run];

    // 測試是否開啟了RunLoop,如果開啟RunLoop,則來不了這裡,因為RunLoop開啟了迴圈。
    NSLog(@"未開啟RunLoop");
}
複製程式碼
  1. 執行之後發現列印了**----run1-----,而未開啟RunLoop**則未列印。

這時,我們就開啟了一條常駐執行緒,下邊我們來試著新增其他任務,除了之前建立的時候呼叫了run1方法,我們另外在點選的時候呼叫run2方法。

那麼,我們在touchesBegan中呼叫PerformSelector,從而實現在點選螢幕的時候呼叫run2方法。Demo地址。具體程式碼如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{   
    // 利用performSelector,在self.thread的執行緒中呼叫run2方法執行任務
    [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void) run2
{
    NSLog(@"----run2------");
}
複製程式碼

經過執行測試,除了之前列印的 ----run1-----,每當我們點選螢幕,都能呼叫 ----run2------。 這樣我們就實現了常駐執行緒的需求。


iOS多執行緒詳盡總結系列文章:

相關文章