iOS RunLoop入門小結

  • 說明
  • iOS中的RunLoop
  • 使用場景
    • 1.保持線程的存活,而不是線性的執行完任務就退出了
      • <1>不開啟RunLoop的線程
      • <2>開啟RunLoop的線程
      • (1)實驗用self來持有子線程
      • (2)實驗讓線程不結束任務導致進入死亡狀態]
      • (3)Event Loop模式
      • (4)初步嘗試使用RunLoop
      • (5)初體驗產生的疑問
        • ①為什么總是要把RunLoop和線程放在一起來講?
        • ②如何創建RunLoop?
        • ③線程默認不開啟RunLoop,為什么我們的App或者說主線程卻可以一直運行而不會結束?
        • ④RunLoop能正常運行的條件是什么?
        • ⑤除了[runLoop run]還有那些方法啟動RunLoop?
      • (6)初體驗結論
    • 2.保持線程的存活后,讓線程在我們需要的時候響應消息。
    • 3.讓線程定時執行某任務(Timer)
      • <1>初識NSTimer遇到的坑
      • <2>使用NSTimer遇到的坑
      • <3>NSTimer導致的ViewController無法釋放問題
      • <4>performSelecter:afterDelay:
    • 4.監聽Observer達到一些目的
  • 關于RunLoop的釋放問題(RunLoop帶autoreleasepool的正確寫法)
    • <1>分析
    • <2>需要用while循環控制的RunLoop
    • <3>不需要用while循環控制的RunLoop
    • <4>關于主線程中autoreleasepool的題外話
  • 關于子線程RunLoop切換Mode的思考
    • 本文最大干貨,子線程中模擬主線程DefaultMode與TrackingMode的切換,長代碼預警
  • 參考文檔
    • <1>RunLoop
    • <2>autoreleasepool

說明

常言道透過現象看本質,如果一開始講概念很難有實質性的理解。所以我們從現象(RunLoop的使用場景,引發了什么現象。)開始,盡量由現象引發疑問從而引出一些概念性的東西。
寫這篇文章的過程其實也是我以一個RunLoop入門者的角度出發,看了很多資料以后總結、印證的過程,如有不對希望大家指正。本篇可能不會深入過多概念性的東西(剛接觸的時候看太多概念或者底層的東西反而容易發懵,我把一些剛接觸的時候可能不太容易理解的部分放在了RunLoop入門學習補充資料之中),所寫的大多數是我經過思考理解了的部分或者我認為日常開發中會運用到的部分,如果希望更深入理解的可以看文末我參考過的文檔。
ps:文中會有比較多的Demo部分,大家可以跟著做一下,剛接觸RunLoop 的時候我也是光看不寫,看了半天最后發現好像都懂了,又好像啥都沒記住。寫Demo的時候就是練手,會發現很多問題,也加深了理解和記憶。

iOS中的RunLoop

這一部分內容我們先做個了解,有個印象就行,暫時不用過于深入。
iOS 系統中,提供了兩種RunLoop:NSRunLoop 和 CFRunLoopRef。
CFRunLoopRef 是在 CoreFoundation 框架內的,它提供了純 C 函數的 API,所有這些 API 都是線程安全的。
NSRunLoop 是基于 CFRunLoopRef 的封裝,提供了面向對象的 API,但是這些 API 不是線程安全的。

CFRunLoopRef 的代碼是開源的,所以有些源碼部分以CFRunLoop來講。

使用場景

1.保持線程的存活,而不是線性的執行完任務就退出了

<1>不開啟RunLoop的線程

在遇到一些耗時操作時,為了避免主線程阻塞導致界面卡頓,影響用戶體驗,往往我們會把這些耗時操作放在一個臨時開辟的子線程中。操作完成了,子線程線性的執行了代碼也就退出了,就像下面一樣。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    MyThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----執行子線程任務",[NSThread currentThread]);
}

其中MyThread為一個重寫了dealloc的NSThread的子類

@implementation MyThread

-(void)dealloc{
    NSLog(@"%@線程被釋放了", self.name);
}

@end

我們不妨來猜一下輸出結果。

 <NSThread: 0x60c000074ac0>{number = 1, name = main}----開辟子線程
 <MyThread: 0x60c000274300>{number = 3, name = subThread}----執行子線程任務
 subThread線程被釋放了

就像一開始所說的一樣,子線程執行完操作就自動退出了。

<2>開啟RunLoop的線程

(1)實驗用self來持有子線程

如果子線程的操作是偶爾或者干脆只需要執行一次的話,像上面那樣就沒什么問題。但是如果這個操作需要頻繁執行,那么按照上面那樣的邏輯,我們就需要頻繁創建子線程,這是很消耗資源的。就像平時我們在設計類的時候會把需要頻繁使用的對象保持起來,而不是頻繁創建一樣。我們試試把線程“保持”起來,讓它在需要的時候執行任務,不需要的時候就啥都不干。
我們將剛才的代碼稍作改動

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"%@----開辟子線程",[NSThread currentThread]);

    self.subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    self.subThread.name = @"subThread";
    [self.subThread start];

}

現在我們再來猜猜打印結果,是否像我們想象那樣執行完操作后線程未被釋放。

<NSThread: 0x60c0000776c0>{number = 1, name = main}----開辟子線程
<MyThread: 0x6080002627c0>{number = 3, name = subThread}----執行子線程任務

子線程內部操作完成后并沒有被釋放,看樣子我們成功持有了子線程。那么按照剛才的設想,我們就可以在任何需要的時候開啟子線程完成線程里面的操作。
我們在[self.subThread start];后面再添加上一句[self.subThread start];再運行試試看結果。

 <NSThread: 0x60400006d480>{number = 1, name = main}----開辟子線程
 <MyThread: 0x6080000773c0>{number = 3, name = subThread}----執行子線程任務
 *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[MyThread start]: attempt to start the thread again'

對,你沒看錯,它崩潰了。原因如下:
因為執行完任務后,雖然Thread沒有被釋放,還處于內存中,但是它處于死亡狀態(當線程的任務結束后就會進入這種狀態)。打個比方,人死不能復生,線程死了也不能復生(重新開啟),蘋果不允許在線程死亡后再次開啟。所以會報錯attempt to start the thread again(嘗試重新開啟線程)

(2)實驗讓線程不結束任務導致進入死亡狀態

既然是線程的任務結束導致了線程進入死亡狀態,那么我們不讓線程結束任務就行了唄。所以我們用while循環讓線程的任務無法結束就行了唄。

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"%@----開辟子線程",[NSThread currentThread]);

    self.subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    self.subThread.name = @"subThread";
    [self.subThread start];

}

- (void)subThreadTodo
{
    do {
        NSLog(@"%@----執行子線程任務",[NSThread currentThread]);
    } while (1);
}

一通操作過后代碼變成這樣。但是寫完仔細一想,確實子線程不會進入死亡狀態了,但是子線程卻在不分時間地點場合的瘋狂執行任務。這根我們一開始想象的,需要的時候執行任務,不需要的時候就啥都不干差遠了。看起來似乎又是一次失敗的嘗試,但是別灰心,我們已經越來越接近答案了。

(3)Event Loop模式

我們在開發中應該聽說過或者看到過這下面一系列方法

performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:

performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:

performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:

概括來講它們的作用都是在某個線程內響應某個方法。既然有這種方法的存在,那么我們是不是可以更改一下思路,讓子線程的任務由執行任務變為接收別人發給它的消息,去執行對應的任務,沒人給他發消息就休息呢?現在我們來修改一下subThreadTodo

- (void)subThreadTodo
{
    do {
        1.接收消息
        2.如果沒有消息就休息
        3.休息直到接收到了消息,執行消息對應的任務
    } while (消息 != 退出);
}

這樣一來就達到了最初的目的,線程的任務直到我們主動讓線程退出為止永遠不會結束(不會進入死亡狀態),可以在需要的時候讓線程做對應的事情。但是怎么實現呢?我們不需要考慮,因為蘋果已經幫我們做好了。事實上,這是一種模型,它被稱為Event Loop。很多平臺都有這種模型,而iOS/OSX中的體現就是RunLoop,可以說RunLoop的本質就是do while循環。當然,實際上它的邏輯不會像剛才寫的那么簡單,還涉及了很多其他東西,為了剛接觸的時候不發懵,我們就這么簡單的理解它。如果想看真實邏輯的,可以查看文末的文檔或者RunLoop入門學習補充資料(1.RunLoop運行邏輯)。

(4)初步嘗試使用RunLoop

繞了一大圈,終于講到了RunLoop,現在我們來初步了解下RunLoop如何使用,順便做個小測試。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);
    //獲取當前子線程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //下面這一行必須加,否則RunLoop無法正常啟用。我們暫時先不管這一行的意思,稍后再講。
    [runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];
    //讓RunLoop跑起來
    [runLoop run];
    NSLog(@"%@----執行子線程任務結束",[NSThread currentThread]);
}

經過之前的幾個實驗和猜想,我們現在再來猜測一下加入了RunLoop后的輸出應該是怎樣的。注意,這個例子中并沒有對線程用self進行引用

<NSThread: 0x60c000066a00>{number = 1, name = main}----開辟子線程
<MyThread: 0x604000065980>{number = 3, name = subThread}----開始執行子線程任務

這里沒有對線程進行引用,也沒有讓線程內部的任務進行顯式的循環。為什么子線程的里面的任務沒有執行到輸出任務結束這一步,為什么子線程沒有銷毀?就是因為[runLoop run];這一行的存在。
前面講了,RunLoop本質就是個Event Loop的do while循環,所以運行到這一行以后子線程就一直在進行接受消息->等待->處理的循環。所以不會運行[runLoop run];之后的代碼(這點需要注意,在使用RunLoop的時候如果要進行一些數據處理之類的要放在這個函數之前否則寫的代碼不會被執行),也就不會因為任務結束導致線程死亡進而銷毀。這也就是我們最常使用RunLoop的場景之一,就如小節標題保持線程的存活,而不是線性的執行完任務就退出了

(5)初體驗產生的疑問

通過初步使用RunLoop的小實驗可以引發一些與概念有關的疑問。如果嫌太長不想看的可以直接看下面的結論。

①為什么總是要把RunLoop和線程放在一起來講?

總的來講就是:RunLoop是保證線程不會退出,并且能在不處理消息的時候讓線程休眠,節約資源,在接收到消息的時候喚醒線程做出對應處理的消息循環機制。它是寄生于線程的,所以提到RunLoop必然會涉及到線程。

②如何創建RunLoop?

蘋果不允許直接創建 RunLoop,它只提供了四個自動獲取的函數

[NSRunLoop currentRunLoop];//獲取當前線程的RunLoop
[NSRunLoop mainRunLoop];
CFRunLoopGetMain();
CFRunLoopGetCurrent();

這些函數內部的邏輯大概是下面這樣:

/// 全局的Dictionary,key 是 線程, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 訪問 loopsDic 時的鎖
static CFSpinLock_t loopsLock;
 
/// 獲取一個 pthread 對應的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
    OSSpinLockLock(&loopsLock);
    
    if (!loopsDic) {
        // 第一次進入時,初始化全局Dic,并先為主線程創建一個 RunLoop。
        loopsDic = CFDictionaryCreateMutable();
        CFRunLoopRef mainLoop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
    }
    
    /// 直接從 Dictionary 里獲取。
    CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
    
    if (!loop) {
        /// 取不到時,創建一個
        loop = _CFRunLoopCreate();
        CFDictionarySetValue(loopsDic, thread, loop);
        /// 注冊一個回調,當線程銷毀時,順便也銷毀其對應的 RunLoop。
        _CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
    }
    
    OSSpinLockUnLock(&loopsLock);
    return loop;
}
 
CFRunLoopRef CFRunLoopGetMain() {
    return _CFRunLoopGet(pthread_main_thread_np());
}
 
CFRunLoopRef CFRunLoopGetCurrent() {
    return _CFRunLoopGet(pthread_self());
}

注:這并不是源碼,而是大神為了方便我們理解,對源碼進行了一些可讀性優化后的結果。但是其大致還是與源碼一直的,如有想看這部分的源碼可以看RunLoop入門學習補充資料(2.獲取RunLoop的源碼)。此段代碼摘自: https://blog.ibireme.com/2015/05/18/runloop/
通過看這部分代碼我們可以產生一個概念,那就是,線程和 RunLoop 之間是一一對應的,其關系是保存在一個全局的 Dictionary 里。線程剛創建時并沒有 RunLoop,如果你不主動獲取,那它一直都不會有。RunLoop 的創建是發生在第一次獲取時,RunLoop 的銷毀是發生在線程結束時。你只能在一個線程的內部獲取其 RunLoop(主線程除外)。

sunnyxx大神在線下分享視頻中提到過雖然一個線程只有唯一一個與之對應的RunLoop的,但是與線程對應的那個RunLoop可以包含子RunLoop。(暫時只找到了https://blog.csdn.net/frank_jb/article/details/49329861 這篇文檔中有RunLoop的嵌套實例,但是我覺得作為一個入門者來說,嵌套這部分暫時有個印象就好,等以后對RunLoop有個比較全面的理解了以后再來深入,不然更容易頭昏。)

③線程默認不開啟RunLoop,為什么我們的App或者說主線程卻可以一直運行而不會結束?

主線程是唯一一個例外,當App啟動以后主線程會自動開啟一個RunLoop來保證主線程的存活并處理各種事件。而且從上面的源代碼來看,任意一個子線程的RunLoop都會保證主線程的RunLoop的存在。

④RunLoop能正常運行的條件是什么?

看到剛才代碼中注釋說暫時不管的代碼,第一次接觸肯定會想[runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];這一句是什么意思?為什么必須加這一句RunLoop才能正常運行?
我們仍然通過實驗看現象來理解

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);
    //獲取當前子線程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //注釋掉下面這行和不注釋掉下面這行分別運行一次
    [runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];
    NSLog(@"RunLoop:%@",runLoop);
    //讓RunLoop跑起來
    [runLoop run];
    NSLog(@"%@----執行子線程任務結束",[NSThread currentThread]);
}

注釋掉得到的結果

1.png

不注釋得到的結果

2.png

注釋掉以后我們看似run了RunLoop但是最后線程還是結束了任務,然后銷毀了。與沒注釋得到的結果比較,造成這一切的原因就在上面兩張圖片中標注部分的區別上。要解釋這一部分就又要開始講到讓我們抓耳撓腮的概念部分,我們先來看一張眼熟到不行的RunLoop結構圖。

3.png

一開始接觸RunLoop我看到這張圖的時候也是懵逼的,現在我們結合剛才的打印結果來理解。
1.圖中RunLoop藍色部分就對應我們打印結果中,整個RunLoop部分的打印結果
2.多個綠色部分共同被包含在RunLoop內就對應,打印結果中modes中同時包含多個Mode(這里可是看打印結果中標注出來的第一行往上再數兩行。modes = ... count = 1。一個RunLoop可以包含多個Mode,每個Mode的Name不一樣,只是在這個打印結果當中目前剛好Mode個數為1)
3.每一個綠色部分Mode整體就對應,打印結果中被標注出來的整體。
4.黃色部分Source對應標注部分source0+source1
5.黃色部分Observer對應標注部分observer部分
6.黃色部分Timer對應標注部分timers部分

講完了結構我們繼續來講Mode是什么。
我對Mode的理解就是”行為模式“,就像我們說到上學這個行為模式,它就應該包含起床,出門,去學校,上課,午休等等。但是,如果上學這個行為模式什么都不包含,那么即使我們進行上學這個行為,我們也一直睡在床上什么都不會做。就像剛才注釋掉addPort那一行代碼得到的結果一樣,RunLoop在kCFRunLoopDefaultMode下run了,但是因為該Mode下所有東西都為null(不包含任何內容),所以RunLoop什么都沒做又退出來了,然后線程就結束任務最后銷毀。之所以要有Mode的存在是為了讓RunLoop在不同的”行為模式“之下執行不同的”動作“互不影響。比如執行上學這個行為模式就不能進行娛樂這個行為模式下的游戲這個動作。RunLoop同一時間只能運行在一種Mode下,當前運行的這個Mode叫currentMode。(這里也許比較抽象,在下面timer部分會有實例結合實例分析。)

一般我們常用的Mode有三種

1.kCFRunLoopDefaultMode(CFRunLoop)/NSDefaultRunLoopMode(NSRunLoop)
默認模式,在RunLoop沒有指定Mode的時候,默認就跑在DefaultMode下。一般情況下App都是運行在這個mode下的

2.(CFStringRef)UITrackingRunLoopMode(CFRunLoop)/UITrackingRunLoopMode(NSRunLoop)
一般作用于ScrollView滾動的時候的模式,保證滑動的時候不受其他事件影響。

3.kCFRunLoopCommonModes(CFRunLoop)/NSRunLoopCommonModes(NSRunLoop)
這個并不是某種具體的Mode,而是一種模式組合,在主線程中默認包含了NSDefaultRunLoopMode和 UITrackingRunLoopMode。子線程中只包含NSDefaultRunLoopMode。
注意:
①在選擇RunLoop的runMode時不可以填這種模式否則會導致RunLoop運行不成功。
②在添加事件源的時候填寫這個模式就相當于向組合中所有包含的Mode中注冊了這個事件源。
③你也可以通過調用CFRunLoopAddCommonMode()方法將自定義Mode放到 kCFRunLoopCommonModes組合。

講完了”行為模式“,現在我們來講”行為模式“包含的具體”動作“。
Source是什么?
source就是輸入源事件,分為source0和source1這兩種。

1.source0:諸如UIEvent(觸摸,滑動等),performSelector這種需要手動觸發的操作。
2.source1:處理系統內核的mach_msg事件(系統內部的端口事件)。諸如喚醒RunLoop或者讓RunLoop進入休眠節省資源等。
一般來說日常開發中我們需要關注的是source0,source1只需要了解。
之所以說source0更重要是因為日常開發中,我們需要對常駐線程進行操作的事件大多都是source0,稍后的實驗會講到。

Timer是什么?
Timer即為定時源事件。通俗來講就是我們很熟悉的NSTimer,其實NSTimer定時器的觸發正是基于RunLoop運行的,所以使用NSTimer之前必須注冊到RunLoop,但是RunLoop為了節省資源并不會在非常準確的時間點調用定時器,如果一個任務執行時間較長,那么當錯過一個時間點后只能等到下一個時間點執行,并不會延后執行(NSTimer提供了一個tolerance屬性用于設置寬容度,如果確實想要使用NSTimer并且希望盡可能的準確,則可以設置此屬性)。

Observer是什么?
它相當于消息循環中的一個監聽器,隨時通知外部當前RunLoop的運行狀態。NSRunLoop沒有相關方法,只能通過CFRunLoop相關方法創建

    // 創建observer
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {

        NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);

    });

    // 添加觀察者:監聽RunLoop的狀態
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

由于它與這一問的關系并不大所以暫時不做過多闡述,希望進一步了解Observer可以查看文末的文檔或者RunLoop入門學習補充資料(3.Observer)。

重點:它不能作為讓RunLoop正常運行的條件,只有Observer的RunLoop也是無法正常運行的。

上面的 Source/Timer/Observer 被統稱為 mode item,一個item可以被同時加入多個mode。但一個item被重復加入同一個mode時是不會有效果的。如果一個mode中一個item 都沒有(只有Observer也不行),則 RunLoop 會直接退出,不進入循環。

對比剛才的打印日志,再結合剛才講到的RunLoop結構內容,我們不妨做個猜測。RunLoop能正常運行的條件就是,至少要包含一個Mode(RunLoop默認就包含DefaultMode),并且該Mode下需要有至少一個的事件源(Timer/Source)。事實上經過NSRunLoop封裝后,只可以往mode中添加兩類事件源:NSPort(對應的是source1)和NSTimer(Timer源放在后面講)。接下來我們還是用實驗來加強理解。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *subThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    subThread.name = @"subThread";
    [subThread start];
    
}

- (void)subThreadTodo
{
    NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);
    //獲取當前子線程的RunLoop
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    //給RunLoop添加一個事件源,注意添加的Mode
    //關于這里的[NSMachPort port]我的理解是,給RunLoop添加了一個占位事件源,告訴RunLoop有事可做,讓RunLoop運行起來。
    //但是暫時這個事件源不會有具體的動作,而是要等RunLoop跑起來過后等有消息傳遞了才會有具體動作。
    [runLoop addPort:[NSMachPort port] forMode:UITrackingRunLoopMode];

    [runLoop run];
    NSLog(@"%@----執行子線程任務結束",[NSThread currentThread]);
}

又到了愉快的猜謎時間,是否像我們猜測的那樣,只要有Mode,并且Mode包含事件源RunLoop就可以正常運行了呢?

<NSThread: 0x60800006d1c0>{number = 1, name = main}----開辟子線程
<MyThread: 0x600000071800>{number = 3, name = subThread}----開始執行子線程任務
<MyThread: 0x600000071800>{number = 3, name = subThread}----執行子線程任務結束
subThread線程被釋放了

最后跟我們想的不一樣,線程釋放了,RunLoop沒有成功啟用。原因就出在[runLoop run];上面。
這句的意思是,在NSDefaultRunLoopMode下運行RunLoop。而我們添加的事件源是在另外一個Mode下,NSDefaultRunLoopMode仍然空空如也,所以RunLoop也就直接退出了。所以我們還要加一個條件,RunLoop正常運行的條件是:1.有Mode。2.Mode有事件源。3.運行在有事件源的Mode下。

⑤除了[runLoop run]還有那些方法啟動RunLoop?

NSRunLoop中總共包裝了3個方法供我們使用

1.- (void)run;

除非希望子線程永遠存在,否則不建議使用,因為這個接口會導致Run Loop永久性的運行NSDefaultRunLoopMode模式,即使使用 CFRunLoopStop(runloopRef);也無法停止RunLoop的運行,那么這個子線程也就無法停止,只能永久運行下去。

2.- (void)runUntilDate:(NSDate *)limitDate;

比上面的接口好點,有個超時時間,可以控制每次RunLoop的運行時間,也是運行在NSDefaultRunLoopMode模式。這個方法運行RunLoop一段時間會退出給你檢查運行條件的機會,如果需要可以再次運行RunLoop。注意CFRunLoopStop(runloopRef);仍然無法停止RunLoop的運行,因此最好自己設置一個合理的RunLoop運行時間。比如

while (!Stop){

    [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];

}
3.- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;

有一個超時時間限制,而且可以設置運行模式
這個接口在非Timer事件觸發、顯式的用CFRunLoopStop停止RunLoop或者到達limitDate后會退出返回。如果僅是Timer事件觸發并不會讓RunLoop退出返回,但是如果是PerfromSelector事件或者其他Input Source事件觸發處理后,RunLoop會退出返回YES。同樣可以像上面那樣用while包起來使用。

(6)初體驗結論

①.RunLoop是寄生于線程的消息循環機制,它能保證線程存活,而不是線性執行完任務就消亡。
②.RunLoop與線程是一一對應的,每個線程只有唯一與之對應的一個RunLoop。我們不能創建RunLoop,只能在當前線程當中獲取線程對應的RunLoop(主線程RunLoop除外)。
③.子線程默認沒有RunLoop,需要我們去主動開啟,但是主線程是自動開啟了RunLoop的。
④.RunLoop想要正常啟用需要運行在添加了事件源的Mode下。
⑤.RunLoop有三種啟動方式run、runUntilDate:(NSDate *)limitDaterunMode:(NSString *)mode beforeDate:(NSDate *)limitDate。第一種無條件永遠運行RunLoop并且無法停止,線程永遠存在。第二種會在時間到后退出RunLoop,同樣無法主動停止RunLoop。前兩種都是在NSDefaultRunLoopMode模式下運行。第三種可以選定運行模式,并且在時間到后或者觸發了非Timer的事件后退出。

2.保持線程的存活后,讓線程在我們需要的時候響應消息。

前面講到了幾個在某個線程內響應某方法的方法,現在我們就來講講這幾個方法的具體含義

performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:
在主線程中響應指定Selector。這兩個方法給你提供了選項來阻斷當前線程(不是執行Selector的線程而是調用上述方法的線程)直到selector被執行完畢。

performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:
在某個子線程(NSThread對像)中響應指定Selector。這兩個方法同樣給你提供了選項來阻斷當前線程直到Selector被執行完畢。

performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:
在當前線程中執行Selector,并附加了延遲選項。多個排隊的Selector會按照順序一個一個的執行。

其實,這幾個方法都是向線程中的RunLoop發送了消息,然后RunLoop接收到了消息就喚醒線程,去做對應的事情。所以想要正常使用這幾個方法,響應selector的線程必須開啟了RunLoop。慣例用例子來感受。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    //subThread用weak聲明,用weak聲明,用weak聲明
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];
    
}
//子線程執行的內容
- (void)subThreadTodo
{
   NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);

   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];

   [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
   NSLog(@"%@----執行子線程任務結束",[NSThread currentThread]);
}
//我們希望放在子線程中執行的任務
- (void)wantTodo{
    //斷點2
    NSLog(@"當前線程:%@執行任務處理數據", [NSThread currentThread]);
    
}
//屏幕點擊事件
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    //斷點1
    //在子線程中去響應wantTodo方法
    [self performSelector:@selector(wantTodo) onThread:self.subThread withObject:nil waitUntilDone:NO];

運行之前我們先把Xcode左邊側欄中選到顯示CPU、Memory使用情況那一頁,把最下面的按鈕中第一個按鈕選中狀態取消掉。(否則看不見RunLoop的堆棧信息。)

4.png

然后我們運行程序,先暫時不做任何操作。

<NSThread: 0x604000068c40>{number = 1, name = main}----開辟子線程
<MyThread: 0x60c000261a40>{number = 3, name = subThread}----開始執行子線程任務

子線程開啟,RunLoop正常運行,似乎與剛才沒有任何不同。然后我們點擊屏幕任何一個地方。斷點1觸發,我們來查看左側的堆棧。

5.png

前面提到過UIEvent事件屬于source0,從這里的堆棧就可以得到印證。我們在主線程中觸發了touchesBegan,然后主線程的RunLoop就開始響應source0事件源,然后去調用對應的方法。我們放過斷點繼續查看。

6.png

同樣是前面提到的,performSelector也是source0依然可以從堆棧得到印證。放過斷點1后調用了performSelector,然后subThread的RunLoop開始響應source0事件源,然后去調用對應的方法,所以來到了斷點2。放過斷點2查看結果,整個流程結束,打印日志如下。

<NSThread: 0x60c000074f40>{number = 1, name = main}----開辟子線程
<MyThread: 0x604000073f40>{number = 3, name = subThread}----開始執行子線程任務
當前線程:<MyThread: 0x604000073f40>{number = 3, name = subThread}執行任務處理數據
<MyThread: 0x604000073f40>{number = 3, name = subThread}----執行子線程任務結束
subThread線程被釋放了

最后子線程任務結束然后被釋放是因為之前提到的,runMode:(NSString *)mode beforeDate:(NSDate *)limitDate這種啟動RunLoop的方式有一個特性,那就是這個接口在非Timer事件觸發(此處是達成了這個條件)、顯式的用CFRunLoopStop停止RunLoop或者到達limitDate后會退出。而例子當中也沒有用while把RunLoop包圍起來,所以RunLoop退出后子線程完成了任務最后退出了。
前面兩種方法的使用大概就如同這個例子,大同小異。而第三種afterDelay的與前兩種不同,并不是屬于source0的,而是屬于Timer源放在后面來講。

看了剛才的堆棧信息可能會有疑問,標注出來的部分中,最長的那一串是什么,是干嘛的?為啥在執行發送給RunLoop的消息對應的事件之前,總要調用這么一長串?

其實RunLoop進行回調時,一般都是通過一個很長的函數(call out)調用出去(無論是Observer的狀態通知還是Timer、Source的處理),而系統在回調時通常使用如下幾個函數進行回調(換句話說你的代碼其實最終都是通過下面幾個函數來負責調用的)

 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__();

上面幾個函數之所以那么長,我估計官方是想讓我們觀名知意,看名字就可以猜出作用。但是同樣的,剛接觸RunLoop的時候過多的接觸這些深層次的東西反而會覺得找不到方向。我覺得入門還是先從表面一些的東西入手比較好。等有一些比較全面的了解以后想要深入理解了再來看這部分,現在有個印象知道這個概念就好。想要深入的話可以查看文末的文檔或者RunLoop入門學習補充資料(4.RunLoop回調函數觸發邏輯)。

3.讓線程定時執行某任務(Timer)

說到timer估計大家都不陌生,日常開發中我們經常都會用到。可能很多人聽說RunLoop還是在使用NSTimer的時候。NSTimer有如下幾個創建方式:

 + (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 ;//iOS10以后新加的方法
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti invocation:(NSInvocation *)invocation repeats:(BOOL)yesOrNo;
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block ;//iOS10以后新加的方法

<1>初識NSTimer遇到的坑

剛接觸Timer的時候,很多人(包括我)肯定都踩過一個坑,那就是創建了Timer卻沒有啟動,百思不得其解,然后才知道還要把Timer加到一個叫RunLoop的東西里面才能正常運行。就像下面一樣:

- (void)viewDidLoad {
    [super viewDidLoad];

    NSTimer *timer = [NSTimer timerWithTimeInterval:1 target:self selector:@selector(wantTodo) userInfo:nil repeats:YES];
    //timerWith開頭的方法創建的Timer如果不加下面一句無法運行。
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

當時我們可能不理解為什么,只知道必須要這么做才能正常啟動Timer。但是現在我們可以知道原因了。其實NSTimer定時器的觸發正是基于RunLoop運行的,所以使用NSTimer之前必須注冊到RunLoop。同時我們也應該知道Timer并不是嚴格的按照設定的時間點來觸發的,RunLoop為了節省資源并不會在非常準確的時間點調用定時器,如果一個任務執行時間較長,那么當錯過一個時間點后只能等到下一個時間點執行,并不會延后執行。(NSTimer提供了一個tolerance屬性用于設置寬容度,如果確實想要使用NSTimer并且希望盡可能的準確,則可以設置此屬性)
注意:GCD的timer與NStimer不是一個東西。他倆中只有NSTimer是與RunLoop相關的。關于GCDTimer與NStimer對比放在了RunLoop入門學習補充資料(5.GCDTimer與NStimer對比)

但是凡事都有例外,似乎scheduedTimerWith開頭的方法創建的NSTimer就不需要添加到RunLoop中就可以運行。事實上,這一系列方法的真實邏輯是,創建一個定時器并自動添加到當前線程RunLoop的NSDefaultRunLoopMode中。在聲明一次,不添加到RunLoop中的NSTimer是無法正常工作的

<2>使用NSTimer遇到的坑

不管是跟著網上說的[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];后發現Timer能正常使用,還是自己摸索發現直接用scheduedTimerWith創建的Timer直接就生效。用Timer的時間長了總有一天突然發現,為啥我的Timer運行的好好的突然就時好時壞了。于是找半天原因,發現是在進行Scrollview的滾動操作時Timer不進行響應,滑動結束后timer又恢復正常了。發現現象了但是,為啥???抓半天頭發然后網上搜資料,然后我們就發現又回到了RunLoop的Mode這個點上。以前我們不懂為什么,現在對RunLoop有一定了解了,我們不妨來分析一下以便加深理解。

1.在之前講Mode的時候提到過,RunLoop每次只能運行在一個Mode下,其意義是讓不同Mode中的item互不影響。

2.NSTimer是一個Timer源(item),在上面哪個例子中不管是`[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];`還是`scheduedTimerWith`我們都是把Timer加到了主線程RunLoop的NSDefaultRunLoopMode中。一般情況下主線程RunLoop就運行在NSDefaultRunLoopMode下,所以定時器正常運行。

3.當Scrollview開始滑動時,主線程RunLoop自動切換了當前運行的Mode(currentMode),變成了UITrackingRunLoopMode。所以現在RunLoop要處理的就是UITrackingRunLoopMode中item。

4.我們的timer是添加在NSDefaultRunLoopMode中的,并沒有添加到UITrackingRunLoopMode中。即我們的timer不是UITrackingRunLoopMode中的item。

5.本著不同Mode中的item互不影響的原則,RunLoop也就不會處理非當前Mode的item,所以定時器就不會響應。

6.當Scrollview滑動結束,主線程RunLoop自動切換了當前運行的Mode(currentMode),變成了NSDefaultRunLoopMode。我們的Timer是NSDefaultRunLoopMode的item,所以RunLoop會處理它,所以又正常響應了。

7.如果想Timer在兩種Mode中都得到響應怎么辦?前面提到過,一個item可以被同時加入多個mode。讓Timer同時成為兩種Mode的item就可以了(分別添加或者直接加到commonMode中),這樣不管RunLoop處于什么Mode,timer都是當前Mode的item,都會得到處理。

<3>NSTimer導致的ViewController無法釋放問題

https://www.cnblogs.com/kenshincui/p/6823841.html 在這篇文檔的NSTimer部分提到,用iOS10之前的(非Block的方法)方法創建NSTimer會因為設置target為self導致Timer對ViewController有一個強引用,最后結果就是ViewController無法釋放。這一部分因為篇幅太長而且與RunLoop本身關系不是那么緊密所以不在這部分展開,感興趣的可以看下??偟膩碚ftimer更推薦使用GCDTimer。(對比參考RunLoop入門學習補充資料5.GCDTimer與NStimer對比)

<4>performSelecter:afterDelay:

前面在講performSelecter方法時提到過,這個方法與其他兩種方法不同,不同在哪,我們來驗證下。由于這個方法是作用于當前線程的,所以為了在RunLoop比較干凈純粹的子線程中響應這個方法會比較繞,不過一開始與之前的例子都沒什么太大區別,區別在于wantTodo函數部分。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];

}
    
- (void)subThreadTodo
{
   NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);

   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];

   [runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
   NSLog(@"%@----執行子線程任務結束",[NSThread currentThread]);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    [self performSelector:@selector(wantTodo) onThread:self.subThread withObject:nil waitUntilDone:NO];
    
}

- (void)wantTodo{
    //斷點1
    //1.這個方法是作用于當前線程,現在在子線程中調用這個函數,所以會作用于子線程的RunLoop
    //self這個位置只要是繼承自NSObject的對象都能填,但是他要有后面SEL的方法,否則會崩潰。(直白的說這個方法就是在當前線程中延遲調用某個對象的某個方法。)
    [self performSelector:@selector(afterDelayTodo) withObject:nil afterDelay:0];
    
}

- (void)afterDelayTodo{
    //斷點2
    NSLog(@"當前線程:%@執行任務處理數據", [NSThread currentThread]);
}

跟之前perform的例子中一樣的操作,到斷點1的時候結果與之前也沒什么不同,我們放過斷點1,來到斷點2.

7.png

可以看到,跟之前調用其他performSelecter時的DoSource0等等一系列堆棧不一樣了,全部變成了Timer相關。實際上,當調用 performSelecter:afterDelay: 后,其內部會創建一個 Timer 并添加到當前線程的 RunLoop 中,所以這個方法是屬于Timer源的。關于這個方法有個比較經典的用法,很多文檔都說過。

當tableview的cell上有需要從網絡獲取的圖片的時候,異步線程會去加載圖片,加載完成后主線程就會設置cell的圖片,有可能會造成卡頓??梢宰屧O置圖片的任務在CFRunLoopDefaultMode下進行,當滾動tableView的時候,RunLoop是在 UITrackingRunLoopMode 下進行,不去設置圖片,而是當停止的時候,再去設置圖片。(這個場景的核心還是利用不同Mode的切換的思想,可以拓展其他地方)

[self.myImageView performSelector:@selector(setImage:)
                           withObject:[UIImage imageNamed:@""]
                           afterDelay:0
                              inModes:@[NSDefaultRunLoopMode]];

當然不是說這種方法就一定好,畢竟他在滑動的時候不會顯示圖片,萬一你的需求跟這剛好相反呢,而且現在SDWebImage處理的已經很好了,已經很少有人用這種方法了。但是這個利用Mode切換的思想可以借鑒,萬一其他地方用上就很合適呢。

4.監聽Observer達到一些目的

這個就比現在我們講的更進一階,因為它涉及到的包括RunLoop的運行邏輯還有一些其他你想實現的功能的拓展。暫時我們只知道有這個用法就行,如果現在來鉆研可能就迷失了。
目前知道的比較有名的有:
1.sunnyxx的UITableView+FDTemplateLayoutCell利用Observer在界面空閑狀態下計算出UITableViewCell的高度并進行緩存。
2.FaceBook的AsyncDisplayKit
共同之處在于,通過合理利用RunLoop機制,將很多不是必須在主線程中執行的操作放在子線程中實現,然后在合適的時機同步到主線程中,這樣可以節省在主線程執行操作的時間,避免卡頓。

關于RunLoop的釋放問題(RunLoop帶autoreleasepool的正確寫法)

<1>分析

Timer和Source以及一些回調block等等,都需要占用一部分存儲空間,所以要釋放掉,如果不釋放掉,就會一直積累,占用的內存也就越來越大。
在主線程中
1.當RunLoop開啟時,會自動創建一個自動釋放池。
2.當RunLoop在休息之前會釋放掉自動釋放池的東西。
3.然后重新創建一個新的空的自動釋放池。
4.當RunLoop被喚醒重新開始跑圈時,Timer,Source等新的事件就會放到新的自動釋放池中。
5.重復2-4。

所以主線程中,有關RunLoop的釋放問題不需要我們關心。

注意:這里說的是主線程(關于子線程的autoreleasepool是否需要手動創建還有個研究過程,因為網上眾說紛紜,有的說不需要創建有的說需要。)這部分的資料也比較少,總結了有限的資料加上自己的一些理解我認為RunLoop正確的寫法應該是下面這樣的,如果不對希望指正。

1.Sunnyxx 孫源大神在《黑幕背后的Autorelease》中提到"在沒有手加Autorelease Pool的情況下,Autorelease對象是在當前的runloop迭代結束時釋放的,而它能夠釋放的原因是系統在每個runloop迭代中都加入了自動釋放池Push和Pop(這里說的是主線程)"加上之前提到的"1.當RunLoop開啟時,會自動創建一個自動釋放池。"我們可以得到一個結論:系統自動管理的autoreleasepool,或者說系統自己管理的Autorelease對象的自動釋放的實現依賴于RunLoop

2.只有主線程的RunLoop是自動開啟了的,子線程中,RunLoop是需要我們手動獲?。ɑ蛘哒f手動激活)的,就更不可能自動創建了autoreleasepool(自動管理對象的釋放)。所以子線程的autoreleasepool需要我們手動創建。(這一點可以參考《iOS中autoreleasepool的理解和使用》中對蘋果文檔的翻譯:"你生成了一個輔助線程。
一旦線程開始執行你必須自己創建自動釋放池。否則,應用將泄漏對象。"
)

3.結論:NSThread和NSOperationQueue開辟子線程需要手動創建autoreleasepool。GCD開辟子線程不需要手動創建autoreleasepool,因為GCD的每個隊列都會自行創建autoreleasepool。(參考自:《關于iOS子線程上的autorelease對象釋放問題?》

所以得出以下兩個寫法

<2>需要用while循環控制的RunLoop

    @autoreleasepool {
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        while (!self.isNeedStopRunLoop) {
            
            //這里RunLoop不需要添加autoreleasepool
            //每個RunLoop內部都會自動管理autoreleasepool
            //事件源等一些autorelease對象會在RunLoop的迭代中自動釋放。

                [runLoop runMode:NSDefaultRunLoopMode
                  beforeDate:[NSDate distantFuture]];
            //[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];

        }
        
    }

<3>不需要用while循環控制的RunLoop

    @autoreleasepool {
    
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
        //[runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
        
    }

<4>關于主線程中autoreleasepool的題外話

主線程中一般不需要我們手動添加autoreleasepool,但是,如果你希望某個對象或者變量盡快釋放的時候我們也可以手動添加。比如下面這種情況:

1.很長的循環(while 同理)
    int lagerNum = 1024 * 1024 * 2 ;
    for(int i = 0 ; i < lagerNum; i++)
    {
        NSString *str = [NSString stringWithFormat:@"Hello"];
        str = [str uppercaseString];
        str = [NSString stringByAppendingFormat:@"-%@",@"World!"];
    }

"在沒有手加Autorelease Pool的情況下,Autorelease對象是在當前的runloop迭代結束時釋放的,而它能夠釋放的原因是系統在每個runloop迭代中都加入了自動釋放池Push和Pop。按照我們以前的想法,每次循環的大括號結束后,當前的變量就會釋放??墒乾F在我們了解的自動釋放的相關知識后就知道其實要等到一次主線程的RunLoop迭代以后才會一起釋放這些變量。而如果還來不及等到RunLoop迭代結束去釋放變量,這期間就積累了足夠多的變量,就會導致內存警告或者崩潰。所以我們可以手動添加autoreleasepool,讓對象盡快釋放。

1.改
    int lagerNum = 1024 * 1024 * 2 ;
    for(int i = 0 ; i < lagerNum; i++)
    {
      @autoreleasepool{
           NSString *str = [NSString stringWithFormat:@"Hello"];
           str = [str uppercaseString];
           str = [NSString stringByAppendingFormat:@"-%@",@"World!"];
       }
    }

關于子線程RunLoop切換Mode的思考

學習RunLoop查看文檔的過程中,很多地方都會講到RunLoop的Mode切換,就像之前提到的主線程RunLoop中defaultMode和UITrackingRunLoopMode的切換。同時關于這一部分說的最多的也是"同一時間只能運行一個Mode,如果想切換Mode需要退出當前Mode重新設定運行Mode"。

但是,關于如何切換,我沒有找到文檔有提及(也許是我看的文檔還是太少,如果知道有相關文檔的大大麻煩給個鏈接),就像RunLoop的Mode切換是自動進行的一樣。事實上主線程的RunLoop的Mode切換確實是自動的,不需要我們來管理的。那么子線程中呢?或許對大神們來說這個問題不是問題,但是作為一個小白我還是想搞清楚這個問題。雖然暫時不清楚是不是子線程中根本不存在Mode切換的問題還是不存在這種用法,但是研究一下總是好的,萬一哪天用上了呢。或者這是不可行的,以后別人提起你可以明確的說不行。

本文最大干貨,子線程中模擬主線程DefaultMode與TrackingMode的切換,長代碼預警

@interface ViewController ()<UITextViewDelegate>

@property (nonatomic, weak)NSThread *subThread;//子線程

@property (nonatomic, weak)NSRunLoopMode runLoopMode;//想設置的RunLoop的Mode

@property (nonatomic, assign)BOOL isNeedRunLoopStop;//控制是否需要停止RunLoop

@property (weak, nonatomic) IBOutlet UITextView *myTextView;//只要是Scrollview及其子類都行

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.myTextView.delegate = self;
    
    self.isNeedRunLoopStop = NO;
    
    NSLog(@"%@----開辟子線程",[NSThread currentThread]);
    
    NSThread *tmpThread = [[MyThread alloc] initWithTarget:self selector:@selector(subThreadTodo) object:nil];
    
    self.subThread = tmpThread;
    self.subThread.name = @"subThread";
    [self.subThread start];
    
}
    
- (void)subThreadTodo
{
   NSLog(@"%@----開始執行子線程任務",[NSThread currentThread]);

    @autoreleasepool{
        
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        
        //NSDefaultRunLoopMode下暫時什么都不干,只是為了讓RunLoop能在該模式下運行添加了一個source1
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        
        NSTimer *timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerTodo) userInfo:nil repeats:YES];
        
        //給UITrackingRunLoopMode添加一個timer,為了等下切換到該模式的時候能看到效果。
        //提示:子線程RunLoop如果不給UITrackingRunLoopMode添加item就沒有這個Mode,可以看前面初體驗疑問的截圖。
        //但是,NSDefaultRunLoopMode是無論如何都存在的,就算你不給他添加item,他也只是內容為空而已。
        [runLoop addTimer:timer forMode:UITrackingRunLoopMode];
        
        self.runLoopMode = NSDefaultRunLoopMode;
        
        //這一句先保持注釋狀態,之后再根據下面文章的提示取消注釋看效果。
        //CFRunLoopAddCommonMode(CFRunLoopGetCurrent(), (CFStringRef)UITrackingRunLoopMode);
        
        while (!self.isNeedRunLoopStop) {//用while來控制RunLoop的運行與否

            //讓RunLoop在我們希望的Mode下運行
            [runLoop runMode:self.runLoopMode beforeDate:[NSDate distantFuture]];
            
        }
        
    }
    
    
}


- (void)changeSubThreadRunLoopMode:(NSRunLoopMode)mode{

    //改變我們希望RunLoop運行的Mode的方法
    //到時候用[performSelector:onThread:withObject:waitUntilDone:]來調用
    //結合[runMode:beforeDate:]觸發非Timer的事件源會退出RunLoop的特性
    //再結合上面While的寫法,就退出了之前的RunLoop并讓RunLoop以我們希望的Mode重新Run。

    //斷點3
    NSLog(@"當前線程:%@ RunLoop即將將Mode改變成:%@\n", [NSThread currentThread], mode);
    
    self.runLoopMode = mode;

}

- (void)timerTodo{
    //上面的Timer執行的函數,只是為了等下切換的mode后有打印好觀察。
    NSLog(@"Timer啟動啦,當前RunLoopMode:%@\n", [[NSRunLoop currentRunLoop] currentMode]);

}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    if (self.runLoopMode != UITrackingRunLoopMode) {
        //如果有滑動事件,并且RunLoop的Mode不為UITrackingRunLoopMode
        //就改變Mode并退出當前RunLoop然后讓RunLoop以更改后的Mode重新Run
        //加if是為了避免重復操作,切換RunLoopMode只需要一次
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:UITrackingRunLoopMode waitUntilDone:NO];
    }
    
}

- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    //拖拽結束會調用這個方法,如果還有拖拽后的滑動動畫就不做操作
    if (!decelerate) {
        //如果沒有后續動畫了就切換Mode為NSDefaultRunLoopMode
        if (self.runLoopMode != NSDefaultRunLoopMode) {
            //斷點1
            [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO];
        }
    }

}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    
    //拖拽后的后續滑動動畫結束(如果有才會到這,沒有就不會到這個函數里面)
    //也就是說上面那個函數如果切換了Mode就不會走這里,否則說明需要在這里切換Mode
    //切換Mode為NSDefaultRunLoopMode
    if (self.runLoopMode != NSDefaultRunLoopMode) {
        //斷點2
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO];
    }

}

@end

好了代碼看完了,激動人心的時刻到了,我們來看效果。

<NSThread: 0x600000065bc0>{number = 1, name = main}----開辟子線程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----開始執行子線程任務

程序啟動以后,打印如上,沒什么好說的,一切正常,接下來才是重點。來讓我們拖拽textView。

<NSThread: 0x600000065bc0>{number = 1, name = main}----開辟子線程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----開始執行子線程任務
當前線程:<MyThread: 0x60c00007ec80>{number = 3, name = subThread} RunLoop即將將Mode改變成:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode

成功了?。?!依照我們所想的Mode切換成了UITrackingRunLoopMode。好就差最后一步了,我們松手看看是什么情況。

<NSThread: 0x600000065bc0>{number = 1, name = main}----開辟子線程
<MyThread: 0x60c00007ec80>{number = 3, name = subThread}----開始執行子線程任務
當前線程:<MyThread: 0x60c00007ec80>{number = 3, name = subThread} RunLoop即將將Mode改變成:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode

可以看到即使我們松手,Timer依舊在執行,說明Mode切換失敗了??墒俏覀兊倪壿嬍菍Φ陌。缓笪叶喾N查找原因。最后發現,切換成UITrackingRunLoopMode后我們的執行邏輯是沒錯的,都是可以進斷點1和斷點2的地方。但是,雖然執行到了斷點1和斷點2,卻不會走到斷點3的位置去。說明performSelector的方法根本沒被執行,所以切換Mode失敗了。

折騰半天,我曾一度以為子線程中切換Mode是不現實的,因為剛好在這個時候看到一個文檔說UITrackingRunLoopMode在子線程RunLoop中無效。我以為UITrackingRunLoopMode下不會響應performSelector方法。但是轉念一想,主線程RunLoop又是怎么樣的呢?于是我做了一個小測試。

對上面的代碼稍作改動,看看主線程在UITrackingRunLoopMode下會不會響應performSelector方法。

- (void)changeSubThreadRunLoopMode:(NSRunLoopMode)mode{

    NSLog(@"當前線程:%@ RunLoop即將將Mode改變成:%@\n", [NSThread currentThread], mode);

    NSLog(@"%@", [NSRunLoop currentRunLoop]);
    
}

- (void)scrollViewDidScroll:(UIScrollView *)scrollView{
    
    if (self.runLoopMode != UITrackingRunLoopMode) {
        //改變成UITrackingRunLoopMode后打印一次日志
        [self performSelectorOnMainThread:@selector(changeSubThreadRunLoopMode:) withObject:UITrackingRunLoopMode waitUntilDone:NO];
    }
    
}

打印結果很長我只截圖了比較關鍵的部分并標注。

8.jpeg
9.png
10.png
11.png
12.png

從日志搜索結果和與響應perform方法之前的日志對比(對比請自行進行,太長了)綜合來看,圖中標識的perform事件源就是我們在UITrackingRunLoopMode下調用的那個perform方法。事實證明UITrackingRunLoopMode下也是可以響應perform方法,我們之前的實驗失敗并不是UITrackingRunLoopMode的鍋

那么是怎么回事呢?原因就出在第一張 截圖的第二塊標識部分。主線程的NSRunLoopCommonModes默認是包含UITrackingRunLoopModeNSDefaultRunLoopMode子線程的NSRunLoopCommonModes默認是只包含,只包含,只包含,重要的事情說三次,只包含NSDefaultRunLoopMode的。而從后面截圖標識部分來看,performSelector:onThread:withObject:waitUntilDone:,其實就是向RunLoop中所有標識為CommonMode的Mode添加一個source0。

這也就是為什么主線程在UITrackingRunLoopMode下可以響應perform方法而子線程卻不行。因為這個source0在子線程中根本沒有被添加到UITrackingRunLoopMode下,也就不會做出對應的響應。

知道為什么就簡單了,我們只需在子線程中把UITrackingRunLoopMode標識為CommonMode就可以了。前面提到過只有CFRunLoop有相關方法可以添加CommonMode,那么我們只需要添加下面這一行代碼就行了(也就是把剛才例子中注釋的部分取消注釋)。

CFRunLoopAddCommonMode(CFRunLoopGetCurrent(), (CFStringRef)UITrackingRunLoopMode);

接下來我們再看看效果

<NSThread: 0x604000065500>{number = 1, name = main}----開辟子線程
<MyThread: 0x60400026e600>{number = 3, name = subThread}----開始執行子線程任務
當前線程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即將將Mode改變成:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
當前線程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即將將Mode改變成:kCFRunLoopDefaultMode
當前線程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即將將Mode改變成:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
Timer啟動啦,當前RunLoopMode:UITrackingRunLoopMode
當前線程:<MyThread: 0x60400026e600>{number = 3, name = subThread} RunLoop即將將Mode改變成:kCFRunLoopDefaultMode

最后就實現了類似主線程的mode切換效果。拖拽滑動期間是UITrackingRunLoopMode,會觸發timer的內容打印日志。
滑動停止以后就切換成了kCFRunLoopDefaultMode,不在打印timer的日志。

到這一步mode切換的實驗就結束了,雖然我仍然沒想到啥時候會用上,但至少我們知道了子線程中的Mode切換是可行的,并且搞清楚了與RunLoop相關的那幾個perform方法其實是向所有注冊了CommonMode的Mode添加source0而不是向某一個特定Mode添加。如果我這個實驗有錯誤請大大們指出。

更正:

performSelectorOnMainThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:

這兩個方法,是向所有注冊了CommonMode的Mode添加source0

performSelectorOnMainThread:withObject:waitUntilDone:modes:
performSelector:onThread:withObject:waitUntilDone:modes:
performSelector:withObject:afterDelay:inModes:

這三個方法是給特定modes(參數是包含至少一個mode的數組)添加source0(前兩個)或者timer(afterDelay)。

performSelector:withObject:afterDelay:

這個方法是向NSDefaultRunLoopMode添加timer源

更正過后,我們就有兩種方式實現這個子線程切換mode的實驗。

1.跟之前寫的實驗邏輯一樣不需要改變。

2.不使用CFRunLoopAddCommonMode添加UITrackingRunLoopMode,而是做出如下改動。

//因為在這個實驗中觸發下面兩個代理的時候子線程RunLoop是處于UITrackingRunLoopMode
//而子線程CommonMode默認只有NSDefaultRunLoopMode
//所以調用performSelector:onThread:withObject:waitUntilDone:時,在UITrackingRunLoopMode下無法響應(理由可以看實驗的分析過程)
//調用performSelector:onThread:withObject:waitUntilDone:modes:指定響應的mode為UITrackingRunLoopMode即可解決問題
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate{
    
    //拖拽結束會調用這個方法,如果還有拖拽后的滑動動畫就不做操作
    if (!decelerate) {
        //如果沒有后續動畫了就切換Mode為NSDefaultRunLoopMode
        if (self.runLoopMode != NSDefaultRunLoopMode) {
            //斷點1
            [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO modes:@[UITrackingRunLoopMode]];
        }
    }

}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    
    //拖拽后的后續滑動動畫結束(如果有才會到這,沒有就不會到這個函數里面)
    //也就是說上面那個函數如果切換了Mode就不會走這里,否則說明需要在這里切換Mode
    //切換Mode為NSDefaultRunLoopMode
    if (self.runLoopMode != NSDefaultRunLoopMode) {
        //斷點2
        [self performSelector:@selector(changeSubThreadRunLoopMode:) onThread:self.subThread withObject:NSDefaultRunLoopMode waitUntilDone:NO modes:@[UITrackingRunLoopMode]];
    }

}

參考文檔

<1>RunLoop

1.iOS刨根問底-深入理解RunLoop Kenshin Cui大神的文章 講的很全但是概念也挺多的,一開始可能會懵但是多看幾次會有不少收獲。里面關于NSTimer部分可以著重看下,里面提到了ViewController無法釋放的問題
2.深入理解RunLoop里面關于Mode和實現功能部分講的很細
3.iOS線下分享《RunLoop》 by 孫源 sunnyxx 也是個大神,但是不太推薦作為入門直接去看,視頻不管是反復看某一段還是理解都有不便,適合有一定自己理解或者了解了去做個印證或者補充。
4.iOS-RunLoop充滿靈性的死循環相對比較適合新手看一些,里面也有不少例子,也有關于GCDTimer的例子
5.【iOS程序啟動與運轉】- RunLoop個人小結不光講了RunLoop還講了像是App啟動的詳細過程這種擴展。
6.RunLoop 總結:RunLoop的應用場景(一)里面也提到了很多很好的文檔,也有大量例子講解。
7.NSRunLoop原理詳解——不再有盲點講的也很不錯,適合入門看,里面講到了RunLoop的嵌套。
8.iOS開發 底層拋析運行循環—— RunLoop
9.iOS中RunLoop機制的探索
10.RunLoop 原理和核心機制同樣很不錯,沒有過多概念,多數是例子和自己的理解混合著將。第一次在RunLoop的文章看到@autoreleasepool的具體代碼也是在這篇文章。
11.[iOS]淺談NSRunloop工作原理和相關應用
12.學習 RunLoop (二)主要是一些關于RunLoop的實例。里面關于自動釋放池釋放的時間和RunLoop的關系這一部分講的挺詳細的。里面講到這里的自動釋放池指的是主線程的自動釋放池,我們看不見它的創建和銷毀(后面就講了系統自己創建的自動釋放池與RunLoop的關系)。自己手動創建@autoreleasepool {}是根據代碼塊來的,出了這個代碼塊就釋放了。
13.避免使用 GCD Global隊列創建Runloop常駐線程這一篇暫時還沒研究,應該也是需要注意的,留個存檔以后研究。

<2>autoreleasepool

1.自動釋放池什么時候創建,什么時候銷毀?
2.iOS中autoreleasepool的理解和使用
3.黑幕背后的Autorelease sunnyxx大神的,講了底層原理。
4.關于iOS子線程上的autorelease對象釋放問題?

還有一些其他已經找不到的記錄的參考文檔無法貼出,在這里向作者們致以誠摯的歉意,因為文中可能會有引用但是沒有說出處。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 1 Runloop機制原理 深入理解RunLoop http://www.cocoachina.com/ios/2...
    Kevin_Junbaozi閱讀 4,093評論 4 30
  • Runloop 是和線程緊密相關的一個基礎組件,是很多線程有關功能的幕后功臣。盡管在平常使用中幾乎不太會直接用到,...
    jackyshan閱讀 9,893評論 10 75
  • iOS刨根問底-深入理解RunLoop 概述 RunLoop作為iOS中一個基礎組件和線程有著千絲萬縷的關系,同時...
    reallychao閱讀 830評論 0 6
  • 最近看了很多RunLoop的文章,看完很懵逼,決心整理一下,文章中大部分內容都是引用大神們的,但好歹對自己有個交代...
    小涼介閱讀 6,760評論 12 79
  • runtime 和 runloop 作為一個程序員進階是必須的,也是非常重要的, 在面試過程中是經常會被問到的, ...
    SOI閱讀 21,862評論 3 63