RunLoop是iOS開發中非常底層的一個概念,我們來看看runloop的實現原理,然後結合實例講解下runloop的應用場景,來幫助大家更深刻的理解runloop。
什麼是runloop呢?從字面意思來看就是運行循環,就是一個線程不斷地持續運行,來接受事件處理。
我們知道,線程在創建完成之後,執行完畢任務就會消亡,如下所示:
如果我們想讓線程不死,可以一直接受事件處理,那麼實現方式如下:
上面就是一個簡版的runloop實現方式。
RunLoop 實際上就是一個對象,這個對象管理了其需要處理的事件和消息,並提供了一個入口函數來執行上面 Event Loop 的邏輯。線程執行了這個函數後,就會一直處於這個函數內部 "接受消息->等待->處理" 的循環中,直到這個循環結束(比如傳入 quit 的消息),函數返回。
大概有如下幾個優點:
保持程序的持續運行處理App中的各種事件(比如觸摸事件、定時器事件、Selector事件)節省CPU資源,提高程序性能:該做事時做事,該休息時休息我們打開一個app,不管你用不用,app都不會死(除非人為殺死或者被系統殺死),但是只要你一點擊或者觸摸,app馬上就能夠響應你的操作,這就是runloop在背後起作用。
因為在程序入口處,就開啟了一個runloop,如下所示:
iOS中有2套API來訪問和使用RunLoop。
Foundation中的NSRunLoopCore Foundation中的CFRunLoopRefNSRunLoop和CFRunLoopRef都代表著RunLoop對象
NSRunLoop是基於CFRunLoopRef的一層OC包裝,所以要了解RunLoop內部結構,需要多研究CFRunLoopRef層面的API(Core Foundation層面)
[NSRunLoop currentRunLoop]; // 獲得當前線程的RunLoop對象
[NSRunLoop mainRunLoop]; // 獲得主線程的RunLoop對象
CFRunLoopGetCurrent(); // 獲得當前線程的RunLoop對象
CFRunLoopGetMain(); // 獲得主線程的RunLoop對象
每條線程都有唯一的一個與之對應的RunLoop對象
主線程的RunLoop已經自動創建好了,子線程的RunLoop需要主動創建
線程剛創建時並沒有 RunLoop,如果你不主動獲取,那它一直都不會有。RunLoop 的創建是發生在第一次獲取時,RunLoop 的銷毀是發生在線程結束時。你只能在一個線程的內部獲取其 RunLoop(主線程除外)
這五個類的關系如下圖所示:
雖然runloop包含了五個類,但是公開的類只有圖中的三個。
CFRunLoopSourceRef是事件源(輸入源),比如外部的觸摸,點擊事件和系統內部進程間的通信等。
按照官方文檔,Source的分類:
Port-Based SourcesCustom Input SourcesCocoa Perform Selector Sources按照函數調用棧,Source的分類:
Source0:非基於Port的。只包含了一個回調(函數指針),它並不能主動觸發事件。使用時,你需要先調用 CFRunLoopSourceSignal(source),將這個 Source 標記為待處理,然後手動調用 CFRunLoopWakeUp(runloop) 來喚醒 RunLoop,讓其處理這個事件。Source1:基於Port的,通過內核和其他線程通信,接收、分發系統事件。這種 Source 能主動喚醒 RunLoop 的線程。後面講到的創建常駐線程就是在線程中添加一個NSport來實現的。每個 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, // 包含上面所有狀態
};
從上圖可以看到一個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,下面會有實例講解。
直接看圖,分別是蘋果的官方解釋和他人整理的:
下面具體解釋下該流程:
自動釋放池的創建和釋放,銷毀的時機如下所示
kCFRunLoopEntry; // 進入runloop之前,創建一個自動釋放池kCFRunLoopBeforeWaiting; // 休眠之前,銷毀自動釋放池,創建一個新的自動釋放池kCFRunLoopExit; // 退出runloop之前,銷毀自動釋放池蘋果注冊了一個 Source1 (基於 mach port 的) 用來接收系統事件,
當一個硬件事件(觸摸/鎖屏/搖晃等)發生後,首先由 IOKit.framework 生成一個 IOHIDEvent 事件並由 SpringBoard 接收。SpringBoard 只接收按鍵(鎖屏/靜音等),觸摸,加速,接近傳感器等幾種 Event,隨後用 mach port 轉發給需要的App進程。隨後蘋果注冊的那個 Source1 就會觸發回調,並調用 _UIApplicationHandleEventQueue() 進行應用內部的分發。
_UIApplicationHandleEventQueue() 會把 IOHIDEvent 處理並包裝成 UIEvent 進行處理或分發,其中包括識別 UIGesture/處理屏幕旋轉/發送給 UIWindow 等。通常事件比如 UIButton 點擊、touchesBegin/Move/End/Cancel 事件都是在這個回調中完成的。
當上面的 _UIApplicationHandleEventQueue() 識別了一個手勢時,其首先會調用 Cancel 將當前的 touchesBegin/Move/End 系列回調打斷。隨後系統將對應的 UIGestureRecognizer 標記為待處理。
蘋果注冊了一個 Observer 監測 BeforeWaiting (Loop即將進入休眠) 事件,這個Observer的回調函數是 _UIGestureRecognizerUpdateObserver(),其內部會獲取所有剛被標記為待處理的 GestureRecognizer,並執行GestureRecognizer的回調。
當有 UIGestureRecognizer 的變化(創建/銷毀/狀態改變)時,這個回調都會進行相應處理。
當在操作 UI 時,比如改變了 Frame、更新了 UIView/CALayer 的層次時,或者手動調用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法後,這個 UIView/CALayer 就被標記為待處理,並被提交到一個全局的容器去。
蘋果注冊了一個 Observer 監聽 BeforeWaiting(即將進入休眠) 和 Exit (即將退出Loop) 事件,回調去執行一個很長的函數:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。這個函數裡會遍歷所有待處理的 UIView/CAlayer 以執行實際的繪制和調整,並更新 UI 界面。
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
上面講解的都是runloop的一些基本概念
然後加上自己的總結。這篇博客算是目前看到的關於runloop講解最好的一篇博文了,裡面還講到了其他runloop的底層實現原理,大家有興趣可以自己去看看。
在界面上有一個UIscrollview控件(tableview,collectionview等),如果此時還有一個定時器在執行一個事件,你會發現當你滾動scrollview的時候,定時器會失效。
- (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];
}
- (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下的定時器失效。
把定時器的runloop的model改為NSRunLoopCommonModes 模式,這個模式是一種占位mode,並不是真正可以運行的mode,它是用來標記一個mode的。默認情況下default和tracking這兩種mode 都會被標記上NSRunLoopCommonModes 標簽。
改變定時器的mode為commonmodel,可以讓定時器運行在defaultMode和trackingModel兩種模式下,不會出現滾動scrollview導致定時器失效的故障
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
使用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);
來看一個需求:
由於圖片渲染到屏幕需要消耗較多資源,為了提高用戶體驗,當用戶滾動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不會執行。
需求:
需要創建一個在後台一直存在的程序,來做一些需要頻繁處理的任務。比如檢測網絡狀態等。
默認情況一個線程創建出來,運行完要做的事情,線程就會消亡。而程序啟動的時候,就創建的主線程已經加入到runloop,所以主線程不會消亡。
這個時候我們就需要把自己創建的線程加到runloop中來,就可以實現線程常駐後台。
(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];
}
- (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一直常駐後台,等待事件加入其中,然後執行。
比如我們點擊了一個按鈕,在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