多線程之RunLoop

iOS多線程之RunLoop

RunLoop

文章目錄

  1. RunLoop簡介
    1.1 什麼是RunLoop?
    1.2 RunLoop和線程
    1.3 默認情況下主線程的RunLoop原理
  2. RunLoop相關類
    2.1 CFRunLoopRef
    2.2 CFRunLoopModeRef
    2.3 CFRunLoopTimerRef
    2.4 CFRunLoopSourceRef
    2.5 CFRunLoopObserverRef
  3. RunLoop原理
  4. RunLoop實戰應用
    4.1 NSTimer的使用
    4.2 ImageView推遲顯示
    4.3 後臺常駐線程(很常用)

文中Demo地址:YSC-RunLoopDemo

1. RunLoop簡介

1.1 什麼是RunLoop?

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

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

1.2 RunLoop和線程

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

  1. 一條線程對應一個RunLoop對象,每條線程都有唯一一個與之對應的RunLoop對象。
  2. 我們只能在當前線程中操作當前線程的RunLoop,而不能去操作其他線程的RunLoop。
  3. RunLoop對象在第一次獲取RunLoop時創建,銷燬則是在線程結束的時候。
  4. 主線程的RunLoop對象系統自動幫助我們創建好了(原理如下),而子線程的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個類的關係圖(來源:http://blog.ibireme.com/2015/05/18/runloop/)。


RunLoop相關類關係圖.png

接着來講解這5個類的相互關係(來源:http://blog.ibireme.com/2015/05/18/runloop/),這篇文章總結的特別好,就拿來參考一下,有興趣的朋友可以去看看,寫的很好。

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


    添加Button.png
  3. 然後運行程序,並點擊按鈕。

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


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


    函數調用棧

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

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


打印結果

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

3. RunLoop原理

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

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


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
  2. 在項目中拖入一張圖片,比如下圖。


    tupian.jpg
  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]];
    }
  4. 運行程序,點擊一下屏幕,然後拖動UIText View,拖動4秒以上,發現過了4秒之後,UIImageView還沒有顯示圖片,當我們鬆開的時候,則顯示圖片,效果如下:

UIImageView延遲顯示效果.gif

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

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

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

那麼怎麼做呢?

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

具體實現過程如下:

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


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


轉載自:http://www.jianshu.com/p/d260d18dd551

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章