iOS 多線程:『RunLoop』詳盡總結

1. RunLoop 簡介

1.1 什麼是 RunLoop?

能夠理解爲字面意思:Run 表示運行,Loop 表示循環。結合在一塊兒就是運行的循環的意思。哈哈,我更願意翻譯爲『跑圈』。直觀理解就像是不停的跑圈。html

  • RunLoop 其實是一個對象,這個對象在循環中用來處理程序運行過程當中出現的各類事件(好比說觸摸事件、UI刷新事件、定時器事件、Selector事件),從而保持程序的持續運行。
  • RunLoop 在沒有事件處理的時候,會使線程進入睡眠模式,從而節省 CPU 資源,提升程序性能。

1.2 RunLoop 和線程

RunLoop 和線程是息息相關的,咱們知道線程的做用是用來執行特定的一個或多個任務,在默認狀況下,線程執行完以後就會退出,就不能再執行任務了。這時咱們就須要採用一種方式來讓線程可以不斷地處理任務,並不退出。因此,咱們就有了 RunLoop。git

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

1.3 默認狀況下主線程的 RunLoop 原理

咱們在啓動一個iOS程序的時候,系統會調用建立項目時自動生成的 main.m 的文件。main.m文件以下所示:github

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

其中 UIApplicationMain 函數內部幫咱們開啓了主線程的 RunLoop,UIApplicationMain 內部擁有一個無限循環的代碼,只要程序不退出/崩潰,它就一直循環。上邊的代碼中主線程開啓 RunLoop 的過程能夠簡單的理解爲以下代碼:web

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

    return 0;
}
複製代碼

從上邊可看出,程序一直在 do-while 循環中執行,因此 UIApplicationMain 函數一直沒有返回,咱們在運行程序以後程序不會立刻退出,會保持持續運行狀態。安全

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

img

官方 RunLoop 模型圖框架

從上圖中能夠看出,RunLoop 就是線程中的一個循環,RunLoop 會在循環中會不斷檢測,經過 Input sources(輸入源)和 Timer sources(定時源)兩種來源等待接受事件;而後對接受到的事件通知線程進行處理,並在沒有事件的時候讓線程進行休息。函數

2. RunLoop 相關類

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

  1. CFRunLoopRef:表明 RunLoop 的對象
  2. CFRunLoopModeRef:表明 RunLoop 的運行模式
  3. CFRunLoopSourceRef:就是 RunLoop 模型圖中提到的輸入源 / 事件源
  4. CFRunLoopTimerRef:就是 RunLoop 模型圖中提到的定時源
  5. CFRunLoopObserverRef:觀察者,可以監聽 RunLoop 的狀態改變

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

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

img

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. 而後在點擊動做的代碼中加入一句輸出語句,並打上斷點,以下圖所示:

img

添加Button.png

  1. 而後運行程序,並點擊按鈕。
  2. 而後在項目中單擊下下圖紅色部分。

img

函數調用棧展現圖

  1. 能夠看到以下圖所示就是點擊事件產生的函數調用棧。

img

函數調用棧

因此點擊事件是這樣來的:

  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. 而後運行,看下打印結果,以下圖。

img

打印結果

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

3. RunLoop原理

好了,五個類都講解完了,下邊開始放大招了。這下咱們就能夠來理解RunLoop的運行邏輯了。

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

img

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,並添加屬性,並簡單添加一下約束(否則沒法顯示)以下圖所示。

img

添加UIImageView

  1. 在項目中拖入一張圖片,好比下圖。

img

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尚未顯示圖片,當咱們鬆開的時候,則顯示圖片,效果以下:

img

UIImageView延遲顯示效果.gif

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

4.3 後臺常駐線程(很經常使用)

咱們在開發應用程序的過程當中,若是後臺操做特別頻繁,常常會在子線程作一些耗時操做(下載文件、後臺播放音樂等),咱們最好能讓這條線程永遠常駐內存。

那麼怎麼作呢?

添加一條用於常駐內存的強引用的子線程,在該線程的RunLoop下添加一個Sources,開啓RunLoop。

具體實現過程以下:

  1. 在項目的ViewController.m中添加一條強引用的thread線程屬性,以下圖:

img

添加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------**。 這樣咱們就實現了常駐線程的需求。

轉自 行走的少年郎

相關文章
相關標籤/搜索