三国杀武将|手游三国杀边锋版

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

米米狗 2019-05-21 10:50:08 2766
本文來自 行走少年郎 ,作者 米米狗

本文首發于我的個人博客:『不羈閣』 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 模型圖。

image.png

從上圖中可以看出,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/…)。

image.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");
}

3. 然后運行,這時候我們發現如果我們不對模擬器進行任何操作的話,定時器會穩定的每隔2秒調用run方法打印。

4. 但是當我們拖動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呢?

NSDefaultRunLoopMode UITrackingRunLoopMode

所以我們只要我們將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. 然后在點擊動作的代碼中加入一句輸出語句,并打上斷點,如下圖所示:

image.png

3. 然后運行程序,并點擊按鈕。

4. 然后在項目中單擊下下圖紅色部分。

image.png5. 可以看到如下圖所示就是點擊事件產生的函數調用棧。

image.png

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

  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);
}

2. 然后運行,看下打印結果,如下圖。

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

3. RunLoop原理

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

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

image.png

這張圖對于我們理解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演示一下該方法。

在項目中的Main.storyboard中添加一個UIImageView,并添加屬性,并簡單添加一下約束(不然無法顯示)如下圖所示。

image.png

在項目中拖入一張圖片,比如下圖。

image.png

3. 然后我們在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]];
}

運行程序,點擊一下屏幕,然后拖動UIText View,拖動4秒以上,發現過了4秒之后,UIImageView還沒有顯示圖片,當我們松開的時候,則顯示圖片,效果如下:

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

4.3 后臺常駐線程(很常用)


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

那么怎么做呢?

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

具體實現過程如下:

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

image.png

2. 在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");
}

3. 運行之后發現打印了**----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多線程詳盡總結系列文章:

三国杀武将