你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發綜合 >> 深入理解RunLoop

深入理解RunLoop

編輯:IOS開發綜合

RunLoop是iOS開發中非常底層的一個概念,我們來看看runloop的實現原理,然後結合實例講解下runloop的應用場景,來幫助大家更深刻的理解runloop。

runloop概念

什麼是runloop呢?從字面意思來看就是運行循環,就是一個線程不斷地持續運行,來接受事件處理。

我們知道,線程在創建完成之後,執行完畢任務就會消亡,如下所示:

如果我們想讓線程不死,可以一直接受事件處理,那麼實現方式如下:

上面就是一個簡版的runloop實現方式。

RunLoop 實際上就是一個對象,這個對象管理了其需要處理的事件和消息,並提供了一個入口函數來執行上面 Event Loop 的邏輯。線程執行了這個函數後,就會一直處於這個函數內部 "接受消息->等待->處理" 的循環中,直到這個循環結束(比如傳入 quit 的消息),函數返回。

大概有如下幾個優點:

保持程序的持續運行處理App中的各種事件(比如觸摸事件、定時器事件、Selector事件)節省CPU資源,提高程序性能:該做事時做事,該休息時休息

我們打開一個app,不管你用不用,app都不會死(除非人為殺死或者被系統殺死),但是只要你一點擊或者觸摸,app馬上就能夠響應你的操作,這就是runloop在背後起作用。

因為在程序入口處,就開啟了一個runloop,如下所示:


runloop對象

iOS中有2套API來訪問和使用RunLoop。

Foundation中的NSRunLoopCore Foundation中的CFRunLoopRef

NSRunLoop和CFRunLoopRef都代表著RunLoop對象

NSRunLoop是基於CFRunLoopRef的一層OC包裝,所以要了解RunLoop內部結構,需要多研究CFRunLoopRef層面的API(Core Foundation層面)


獲取runloop對象

Foundation:

[NSRunLoop currentRunLoop]; // 獲得當前線程的RunLoop對象
[NSRunLoop mainRunLoop]; // 獲得主線程的RunLoop對象

Core Foundation:

CFRunLoopGetCurrent(); // 獲得當前線程的RunLoop對象
CFRunLoopGetMain(); // 獲得主線程的RunLoop對象

RunLoop與線程

每條線程都有唯一的一個與之對應的RunLoop對象

主線程的RunLoop已經自動創建好了,子線程的RunLoop需要主動創建

線程剛創建時並沒有 RunLoop,如果你不主動獲取,那它一直都不會有。RunLoop 的創建是發生在第一次獲取時,RunLoop 的銷毀是發生在線程結束時。你只能在一個線程的內部獲取其 RunLoop(主線程除外)


RunLoop的五個類

CFRunLoopRefCFRunLoopModeRefCFRunLoopSourceRefCFRunLoopTimerRefCFRunLoopObserverRef

這五個類的關系如下圖所示:

雖然runloop包含了五個類,但是公開的類只有圖中的三個。

CFRunLoopSourceRef類

CFRunLoopSourceRef是事件源(輸入源),比如外部的觸摸,點擊事件和系統內部進程間的通信等。

按照官方文檔,Source的分類:

Port-Based SourcesCustom Input SourcesCocoa Perform Selector Sources

按照函數調用棧,Source的分類:

Source0:非基於Port的。只包含了一個回調(函數指針),它並不能主動觸發事件。使用時,你需要先調用 CFRunLoopSourceSignal(source),將這個 Source 標記為待處理,然後手動調用 CFRunLoopWakeUp(runloop) 來喚醒 RunLoop,讓其處理這個事件。Source1:基於Port的,通過內核和其他線程通信,接收、分發系統事件。這種 Source 能主動喚醒 RunLoop 的線程。後面講到的創建常駐線程就是在線程中添加一個NSport來實現的。

CFRunLoopTimerRef類

CFRunLoopTimerRef是基於時間的觸發器CFRunLoopTimerRef基本上說的就是NSTimer,它受RunLoop的Mode影響GCD的定時器不受RunLoop的Mode影響當其加入到 RunLoop 時,RunLoop會注冊對應的時間點,當時間點到時,RunLoop會被喚醒以執行那個回調

CFRunLoopObserverRef類

每個 Observer 都包含了一個回調(函數指針),當 RunLoop 的狀態發生變化時,觀察者就能通過回調接受到這個變化。可以觀測的時間點有以下幾個

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 = 0x0FFFFFFU, // 包含上面所有狀態

};

CFRunLoopModeRef

從上圖可以看到一個runloop可以包含多個model,每個model都是獨立的,而且runloop只能選擇一個model運行,也就是currentModel。如果需要切換 Mode,只能退出 Loop,再重新指定一個 Mode 進入。這樣做主要是為了分隔開不同組的 Source/Timer/Observer,讓其互不影響。

系統默認注冊了5個Mode:

NSDefaultRunLoopMode:App的默認Mode,通常主線程是在這個Mode下運行

UITrackingRunLoopMode:界面跟蹤 Mode,用於 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響

UIInitializationRunLoopMode: 在剛啟動 App 時第進入的第一個 Mode,啟動完成後就不再使用

GSEventReceiveRunLoopMode: 接受系統事件的內部 Mode,通常用不到

NSRunLoopCommonModes: 這是一個占位用的Mode,不是一種真正的Mode

這裡重點說一下最後一個commonmodel

一個 Mode 可以將自己標記為"Common"屬性(通過將其 ModeName 添加到 RunLoop 的 "commonModes" 中)。每當 RunLoop 的內容發生變化時,RunLoop 都會自動將 _commonModeItems 裡的 Source/Observer/Timer 同步到具有 "Common" 標記的所有Mode裡。

應用場景舉例:主線程的 RunLoop 裡有兩個預置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。這兩個 Mode 都已經被標記為"Common"屬性。DefaultMode 是 App 平時所處的狀態,TrackingRunLoopMode 是追蹤 ScrollView 滑動時的狀態。當你創建一個 Timer 並加到 DefaultMode 時,Timer 會得到重復回調,但此時滑動一個TableView時,RunLoop 會將 mode 切換為 TrackingRunLoopMode,這時 Timer 就不會被回調,並且也不會影響到滑動操作。

有時你需要一個 Timer,在兩個 Mode 中都能得到回調,一種辦法就是將這個 Timer 分別加入這兩個 Mode。還有一種方式,就是將 Timer 加入到commonMode 中。那麼所有被標記為commonMode的mode(defaultMode和TrackingMode)都會執行該timer。這樣你在滑動界面的時候也能夠調用timer,下面會有實例講解。


RunLoop 的內部邏輯

直接看圖,分別是蘋果的官方解釋和他人整理的:

\
image \
image

下面具體解釋下該流程:

\
image

Apple使用RunLoop實現的功能

1、AutoreleasePool

自動釋放池的創建和釋放,銷毀的時機如下所示

kCFRunLoopEntry; // 進入runloop之前,創建一個自動釋放池kCFRunLoopBeforeWaiting; // 休眠之前,銷毀自動釋放池,創建一個新的自動釋放池kCFRunLoopExit; // 退出runloop之前,銷毀自動釋放池

2、事件響應

蘋果注冊了一個 Source1 (基於 mach port 的) 用來接收系統事件,

當一個硬件事件(觸摸/鎖屏/搖晃等)發生後,首先由 IOKit.framework 生成一個 IOHIDEvent 事件並由 SpringBoard 接收。SpringBoard 只接收按鍵(鎖屏/靜音等),觸摸,加速,接近傳感器等幾種 Event,隨後用 mach port 轉發給需要的App進程。隨後蘋果注冊的那個 Source1 就會觸發回調,並調用 _UIApplicationHandleEventQueue() 進行應用內部的分發。

_UIApplicationHandleEventQueue() 會把 IOHIDEvent 處理並包裝成 UIEvent 進行處理或分發,其中包括識別 UIGesture/處理屏幕旋轉/發送給 UIWindow 等。通常事件比如 UIButton 點擊、touchesBegin/Move/End/Cancel 事件都是在這個回調中完成的。

3、手勢識別

當上面的 _UIApplicationHandleEventQueue() 識別了一個手勢時,其首先會調用 Cancel 將當前的 touchesBegin/Move/End 系列回調打斷。隨後系統將對應的 UIGestureRecognizer 標記為待處理。

蘋果注冊了一個 Observer 監測 BeforeWaiting (Loop即將進入休眠) 事件,這個Observer的回調函數是 _UIGestureRecognizerUpdateObserver(),其內部會獲取所有剛被標記為待處理的 GestureRecognizer,並執行GestureRecognizer的回調。

當有 UIGestureRecognizer 的變化(創建/銷毀/狀態改變)時,這個回調都會進行相應處理。

4、界面更新

當在操作 UI 時,比如改變了 Frame、更新了 UIView/CALayer 的層次時,或者手動調用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法後,這個 UIView/CALayer 就被標記為待處理,並被提交到一個全局的容器去。

蘋果注冊了一個 Observer 監聽 BeforeWaiting(即將進入休眠) 和 Exit (即將退出Loop) 事件,回調去執行一個很長的函數:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。這個函數裡會遍歷所有待處理的 UIView/CAlayer 以執行實際的繪制和調整,並更新 UI 界面。

5、定時器

NSTimer 其實就是 CFRunLoopTimerRef,他們之間是 toll-free bridged 的。一個 NSTimer 注冊到 RunLoop 後,RunLoop 會為其重復的時間點注冊好事件。例如 10:00, 10:10, 10:20 這幾個時間點。RunLoop為了節省資源,並不會在非常准確的時間點回調這個Timer。Timer 有個屬性叫做 Tolerance (寬容度),標示了當時間點到後,容許有多少最大誤差。

如果某個時間點被錯過了,例如執行了一個很長的任務,則那個時間點的回調也會跳過去,不會延後執行。就比如等公交,如果 10:10 時我忙著玩手機錯過了那個點的公交,那我只能等 10:20 這一趟了。

CADisplayLink 是一個和屏幕刷新率一致的定時器(但實際實現原理更復雜,和 NSTimer 並不一樣,其內部實際是操作了一個 Source)。如果在兩次屏幕刷新之間執行了一個長任務,那其中就會有一幀被跳過去(和 NSTimer 相似),造成界面卡頓的感覺。在快速滑動TableView時,即使一幀的卡頓也會讓用戶有所察覺。Facebook 開源的 AsyncDisplayLink 就是為了解決界面卡頓的問題,其內部也用到了 RunLoop

ps:

上面講解的都是runloop的一些基本概念

然後加上自己的總結。這篇博客算是目前看到的關於runloop講解最好的一篇博文了,裡面還講到了其他runloop的底層實現原理,大家有興趣可以自己去看看。


RunLoop實踐

1、滾動scrollview導致定時器失效

在界面上有一個UIscrollview控件(tableview,collectionview等),如果此時還有一個定時器在執行一個事件,你會發現當你滾動scrollview的時候,定時器會失效。

主線程中添加NSTimer

- (void)viewDidLoad {
    [super viewDidLoad];
    [self timer];
}

//下面兩種添加定時器的方法效果相同,都是在主線程中添加定時器
- (void)timer1
{
    NSTimer *timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopDefaultModes];
}

- (void)timer2
{
    [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];
}

子線程中添加Timer

- (void)viewDidLoad {
    [super viewDidLoad];

    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(timer) object:nil];
    [self.thread start];
}

- (void)timer
{
    [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(test) userInfo:nil repeats:YES];
    //啟動當前線程的runloop
    [[NSRunLoop currentRunLoop] run];
}

原因:

因為當你滾動textview的時候,runloop會進入UITrackingRunLoopMode 模式,而定時器運行在defaultMode下面,系統一次只能處理一種模式的runloop,所以導致defaultMode下的定時器失效。

解決辦法1:

把定時器的runloop的model改為NSRunLoopCommonModes 模式,這個模式是一種占位mode,並不是真正可以運行的mode,它是用來標記一個mode的。默認情況下default和tracking這兩種mode 都會被標記上NSRunLoopCommonModes 標簽。

改變定時器的mode為commonmodel,可以讓定時器運行在defaultMode和trackingModel兩種模式下,不會出現滾動scrollview導致定時器失效的故障

    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

解決辦法2:

使用GCD創建定時器,GCD創建的定時器不會受runloop的影響

    // 獲得隊列
    dispatch_queue_t queue = dispatch_get_main_queue();

    // 創建一個定時器(dispatch_source_t本質還是個OC對象)
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);

    // 設置定時器的各種屬性(幾時開始任務,每隔多長時間執行一次)
    // GCD的時間參數,一般是納秒(1秒 == 10的9次方納秒)
    // 比當前時間晚1秒開始執行
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));

    //每隔一秒執行一次
    uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
    dispatch_source_set_timer(self.timer, start, interval, 0);

    // 設置回調
    dispatch_source_set_event_handler(self.timer, ^{
        NSLog(@"------------%@", [NSThread currentThread]);

    });

    // 啟動定時器
    dispatch_resume(self.timer);

2、圖片下載

來看一個需求:

由於圖片渲染到屏幕需要消耗較多資源,為了提高用戶體驗,當用戶滾動tableview的時候,只在後台下載圖片,但是不顯示圖片,當用戶停下來的時候才顯示圖片。

實現代碼

- (void)viewDidLoad {
    [super viewDidLoad];

    self.thread = [[XMGThread alloc] initWithTarget:self selector:@selector(run) object:nil];
    [self.thread start];
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self performSelector:@selector(useImageView) onThread:self.thread withObject:nil waitUntilDone:NO];
}

- (void)useImageView
{
    // 只在NSDefaultRunLoopMode模式下顯示圖片
    [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"placeholder"] afterDelay:3.0 inModes:@[NSDefaultRunLoopMode]];
}

分析:

上面的代碼可以達到如下效果:

用戶點擊屏幕,在主線程中,三秒之後顯示圖片

但是當用戶點擊屏幕之後,如果此時用戶又開始滾動textview,那麼就算過了三秒,圖片也不會顯示出來,當用戶停止了滾動,才會顯示圖片。

這是因為限定了方法setImage只能在NSDefaultRunLoopMode 模式下使用。而滾動textview的時候,程序運行在tracking模式下面,所以方法setImage不會執行。


3、常駐線程

需求:

需要創建一個在後台一直存在的程序,來做一些需要頻繁處理的任務。比如檢測網絡狀態等。

默認情況一個線程創建出來,運行完要做的事情,線程就會消亡。而程序啟動的時候,就創建的主線程已經加入到runloop,所以主線程不會消亡。

這個時候我們就需要把自己創建的線程加到runloop中來,就可以實現線程常駐後台。

實現代碼1、添加NSPort:

 (void)viewDidLoad {
    [super viewDidLoad];

    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
    [self.thread start];
}

- (void)run
{
    NSLog(@"----------run----%@", [NSThread currentThread]);
    @autoreleasepool{
    /*如果不加這句,會發現runloop創建出來就掛了,因為runloop如果沒有CFRunLoopSourceRef事件源輸入或者定時器,就會立馬消亡。
      下面的方法給runloop添加一個NSport,就是添加一個事件源,也可以添加一個定時器,或者observer,讓runloop不會掛掉*/
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSDefaultRunLoopMode];

    // 方法1 ,2,3實現的效果相同,讓runloop無限期運行下去
    [[NSRunLoop currentRunLoop] run];
   }


    // 方法2
    [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];

    // 方法3
    [[NSRunLoop currentRunLoop] runUntilDate:[NSDate distantFuture]];

    NSLog(@"---------");
}

- (void)test
{
    NSLog(@"----------test----%@", [NSThread currentThread]);
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self performSelector:@selector(test) onThread:self.thread withObject:nil waitUntilDone:NO];
}

實現代碼2、添加NSTimer:

- (void)viewDidLoad {
    [super viewDidLoad];

    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
    [self.thread start];
}
- (void)run
{
    [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(test) userInfo:nil repeats:YES];

    [[NSRunLoop currentRunLoop] run];
}

分析:

如果沒有實現添加NSPort或者NSTimer,會發現執行完run方法,線程就會消亡,後續再執行touchbegan方法無效。

我們必須保證線程不消亡,才可以在後台接受時間處理

RunLoop 啟動前內部必須要有至少一個 Timer/Observer/Source,所以在 [runLoop run] 之前先創建了一個新的 NSMachPort 添加進去了。通常情況下,調用者需要持有這個 NSMachPort (mach_port) 並在外部線程通過這個 port 發送消息到 loop 內;但此處添加 port 只是為了讓 RunLoop 不至於退出,並沒有用於實際的發送消息。

可以發現執行完了run方法,這個時候再點擊屏幕,可以不斷執行test方法,因為線程self.thread一直常駐後台,等待事件加入其中,然後執行。


4、在所有UI相應操作之前處理任務

比如我們點擊了一個按鈕,在ui關聯的事件開始執行之前,我們需要執行一些其他任務,可以在observer中實現

代碼如下:

- (IBAction)btnClick:(id)sender {
    NSLog(@"btnClick----------");
}
- (void)viewDidLoad {
    [super viewDidLoad];

    [self observer];
}
- (void)observer
{
        // 創建observer,參數kCFRunLoopAllActivities表示監聽所有狀態
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
    });
    // 添加觀察者:監聽RunLoop的狀態
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);

    // 釋放Observer
    CFRelease(observer);
}

假設我們想實現cell的高度緩存計算,因為“計算cell的預緩存高度”的任務需要在最無感知的時刻進行,所以應該同時滿足:

RunLoop 處於“空閒”狀態 Mode當這一次 RunLoop 迭代處理完成了所有事件,馬上要休眠時
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
    // TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);
在其中的 TODO 位置,就可以開始任務的收集和分發了,當然,不能忘記適時的移除這個 observer
  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved