iOS老司機的RunLoop原理探究及實用Tips

語言: CN / TW / HK

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

前言

  • iOS中的RunLoop除了面試中跟面試官的探討, 在實際開發中就沒用了嗎? 初入iOS開發大門時, 可能很多人都會有這個疑惑.
  • 誠然, 日常的iOS開發中, RunLoop的直接使用頻率確實相對不高, 但是一旦深入理解RunLoop的原理和機制, 我們就會發現, iOS開發中的方方面面都包含著RunLoop的影子.
  • RunLoop的資料結構設計和機制也體現著iOS作業系統兼顧效能和耗電的使用者態核心態切換的精妙.
  • 下面就RunLoop的底層資料結構原理及應用, 跟各位同仁聊一聊自己的淺見, 拋磚引玉.
  • 文章純手打, 拋磚引玉, 如有錯誤還請評論區指正, 先行謝過了:)

1. RunLoop的概念和資料結構

1.1 RunLoop的概念

  • 有事做的時候做事,沒事做的時候休息
  • 通過內部維護的事件迴圈來對事件/訊息進行管理的一個 物件
  • 沒有訊息需要處理時, 休眠以避免資源佔用
    • 使用者態到核心態切換
  • 有訊息需要處理時, 立刻被喚醒
    • 核心態到使用者態切換 image.png

RunLoop機制官方圖.png

1.2 RunLoop的資料結構

  • NSRunLoop是CFRunLoop的封裝, 提供了面向物件的API image.png

1.3 RunLoop模式有哪些?

  • 常用的3個Mode:
  • NSDefaultRunLoopMode, 預設的模式, 有事件響應的時候, 會阻塞舊事件
  • NSRunLoopCommonModes, 普通模式, 不會影響任何事件
  • UITrackingRunLoopMode, 只能是有事件的時候才會響應的模式
  • App剛啟動的時候會執行一次的模式
  • 系統檢測App各種事件的模式
  • 蘋果官方文件對5個Mode的介紹: ```

System Run Loop Modes

NSRunLoopCommonModes

A pseudo-mode that includes one or more other run loop modes.

NSDefaultRunLoopMode

The mode set to handle input sources other than connection objects.

NSEventTrackingRunLoopMode

The mode set when tracking events modally, such as a mouse-dragging loop.

NSModalPanelRunLoopMode

The mode set when waiting for input from a modal panel, such as a save or open panel.

UITrackingRunLoopMode

The mode set while tracking in controls takes place. ```

1.4 關於RunLoop的5個類

  1. CFRunLoopRef: 代表RunLoop的物件
  2. CFRunLoopModeRef: 代表RunLoop的執行模式
  3. CFRunLoopSourceRef: 就是RunLoop模型圖中提到的輸入源(事件源)
  4. CFRunLoopTimerRef: 就是RunLoop模型圖中提到的定時源
  5. CFRunLoopObserverRef: 觀察者, 能夠監聽RunLoop的狀態改變.
  6. 一個RunLoop物件中包含若干個執行模式.每一個執行模式下又包含若干個輸入源、定時源、觀察者.
    • 每次RunLoop啟動時, 只能指定其中一個執行模式, 這個執行模式被稱作當前執行模式CurrentMode.
    • 如果需要切換執行模式, 只能退出當前Loop, 再重新指定一個執行模式進入.
    • 這樣做主要是為了分隔開不同組的輸入源、定時源、觀察者, 讓其互不影響. image.png

1.5 CFRunLoopSourceRef

  • CFRunLoopSourceRef是事件源, 有兩種分類方法.
  • 按照官方文件來分類
    • Port-Based Sources (基於埠)
    • Custom Input Sources (自定義)
    • Cocoa Perform Selector Sources
  • 按照函式呼叫棧來分類
    • Source0: 非基於Port
    • Source1: 基於Port, 通過核心和其他執行緒通訊, 接收、分發系統事件

1.6 RunLoop的基本執行原理

  • 原本系統就有一個RunLoop在檢測App內的事件, 當輸入源有執行操作的時候, 系統的RunLoop會監聽輸入源的狀態, 進而在系統內部做一些對應的操作. 處理完事件後, 會自動回到睡眠狀態, 等待下一次被喚醒.

image.png - 在每次執行開啟RunLoop的時候, 所線上程的RunLoop會自動處理之前未處理的事件, 並且通知相關的觀察者. 1. 通知觀察者RunLoop已經啟動 2. 通知觀察者即將要開始定時器 3. 通知觀察者任何即將啟動的非基於埠的源Source0 4. 啟動任何準備好的非基於埠的源Source0 5. 如果基於埠的源Source1準備好並處於等待狀態, 立即啟動, 並進入步驟9 6. 通知觀察者執行緒進入休眠狀態 7. 將執行緒置於休眠直到下面任一種事件發生: - - 某一事件到達基於埠的源Source1 - - 定時器啟動 - - RunLoop設定的時間已經超時 - - RunLoop被顯示喚醒 8. 通知觀察者執行緒將被喚醒 9. 處理未處理的事件 - - 如果使用者定義的定時器啟動, 處理定時器事件並重啟RunLoop, 進入步驟2 - - 如果輸入源啟動, 傳遞相應的訊息 - - 如果RunLoop被顯示喚醒而且時間還沒超時, 重啟RunLoop. 進入步驟2 10. 通知觀察者RunLoop結束.

image.png

2. RunLoop在iOS中的落地使用細節

2.1 RunLoop和執行緒的關係

  • 在預設情況下, 執行緒執行完之後就會退出, 就不能再繼續任務了. 這時我們需要採用一種方式來讓執行緒能夠不斷地處理任務, 並不退出. 所以, 我們就有了RunLoop.
  • 一條執行緒對應一個RunLoop物件, 每條執行緒都有唯一一個與之對應的RunLoop物件.
  • RunLoop並不保證執行緒安全. 我們只能在當前執行緒內部操作當前執行緒的RunLoop物件, 而不能在當前執行緒內部去操作其他執行緒的RunLoop物件方法.
  • RunLoop物件在第一次獲取RunLoop時建立, 銷燬則是線上程結束的時候.
  • 主執行緒的RunLoop物件系統自動幫助我們建立好了(UIApplicationMain函式), 子執行緒的RunLoop物件需要我們主動建立和維護. ```

import

import "AppDelegate.h"

int main(int argc, char * argv[]) {     @autoreleasepool {         return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));     } } ```

2.1.1 RunLoop與常駐執行緒

  • 常駐執行緒
    • 指的就是那些不會停止,一直存在於記憶體中的執行緒。
  • 後臺常駐執行緒測試程式碼: ```
  • (void)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"); }

  • (void)touchesBegan:(NSSet )touches withEvent:(UIEvent )event { // 利用performSelector,在self.thread的執行緒中呼叫run2方法執行任務 [self performSelector:@selector(run2) onThread:self.thread withObject:nil waitUntilDone:NO]; }

  • (void)run2 { NSLog(@"----run2-----"); } ```

2.1.2 AFN2.0 和3.0的主要區別--去除常駐執行緒

  • AFN3.0去除了所有NSURLConnection請求的API
  • AFN3.0使用NSURLSession代替AFN2.0的常駐執行緒

2.1.2.1 AFN2.X常駐線分析

  • 常駐執行緒
    • 指的就是那些不會停止,一直存在於記憶體中的執行緒。
    • AFNetworking 2.0 專門建立了一個執行緒來接收 NSOperationQueue 的回撥,這個執行緒其實就是一個常駐執行緒。 ```
  • (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { // 先用 NSThread 建立了一個執行緒 [[NSThread currentThread] setName:@"AFNetworking"]; // 使用 run 方法新增 runloop NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } ``` image.png
  • 雖然說,在一個 App 裡網路請求這個動作的佔比很高,但也有很多不需要網路的場景,所以執行緒一直常駐在記憶體中,也是不合理的。

  • 在請求完成後我們需要對資料進行一些處理, 如果我們在主執行緒中處理就會導致UI卡頓

  • 這時我們就需要一個子執行緒來處理事件和網路請求的回撥. 但是子執行緒在處理完事件後就會自動結束生命週期,
    • 這時後面的一些網路請求的回撥我們就無法接收了,
    • 所以我們就需要開啟子執行緒的RunLoop使執行緒常駐來保活執行緒.

2.1.2.2 AFN3.X不在常駐執行緒的分析

  • AFNetworking 在 3.0 版本時,使用蘋果公司新推出的 NSURLSession 替換了 NSURLConnection,從而避免了常駐執行緒這個坑。
    • NSURLSession 可以指定回撥 NSOperationQueue,這樣請求就不需要讓執行緒一直常駐在記憶體裡去等待回調了。 self.operationQueue = [[NSOperationQueue alloc] init]; self.operationQueue.maxConcurrentOperationCount = 1; self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
    • NSURLSession 發起的請求,可以指定回撥的 delegateQueue,不再需要在當前執行緒進行代理方法的回撥。所以說,NSURLSession 解決了 NSURLConnection 的執行緒回撥問題。
  • AFNetworking 2.0 使用常駐執行緒也是無奈之舉,一旦有方案能夠替代常駐執行緒,它就會毫不猶豫地廢棄常駐執行緒。

  • 在AFN3.X中使用的是NSURLSession進行封裝,

    • 對比NSURLConnection, NSURLSession不需要再當前的執行緒等待網路回撥,
    • 而是可以讓開發者自己設定需要回調的佇列.
  • 在AFN3.X中使用了NSOperationQueue管理網路,
    • 並設定self.operationQueue.maxConcurrentOperationCount = 1;,保證了最大的併發數為1,
    • 也就是說讓網路請求序列執行. 避免了多執行緒環境下的資源搶奪問題.
    • AFNetworking 2.0 專門建立了一個執行緒來接收 NSOperationQueue 的回撥,這個執行緒其實就是一個常駐執行緒。 ```
  • (void)networkRequestThreadEntryPoint:(id)__unused object { @autoreleasepool { // 先用 NSThread 建立了一個執行緒 [[NSThread currentThread] setName:@"AFNetworking"]; // 使用 run 方法新增 runloop NSRunLoop *runLoop = [NSRunLoop currentRunLoop]; [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; [runLoop run]; } } ```

image.png

  • 雖然說,在一個 App 裡網路請求這個動作的佔比很高,但也有很多不需要網路的場景,所以執行緒一直常駐在記憶體中,也是不合理的。
  • AFNetworking 在 3.0 版本時,使用蘋果公司新推出的 NSURLSession 替換了 NSURLConnection,從而避免了常駐執行緒這個坑。
    • NSURLSession 可以指定回撥 NSOperationQueue,這樣請求就不需要讓執行緒一直常駐在記憶體裡去等待回調了。 self.operationQueue = [[NSOperationQueue alloc] init]; self.operationQueue.maxConcurrentOperationCount = 1; self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
    • NSURLSession 發起的請求,可以指定回撥的 delegateQueue,不再需要在當前執行緒進行代理方法的回撥。所以說,NSURLSession 解決了 NSURLConnection 的執行緒回撥問題。
  • AFNetworking 2.0 使用常駐執行緒也是無奈之舉,一旦有方案能夠替代常駐執行緒,它就會毫不猶豫地廢棄常駐執行緒。

2.2 NSTimer與RunLoop

2.2.1 NSTimer中的scheduledTimerWithTimeInterval方法和RunLoop的關係.

``` NSTimer *timer1 = [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES];

/ 上面這句程式碼呼叫了scheduledTimer返回的定時器, NSTimer會自動加入到RunLoop的NSDefaultRunLoop模式下, 相當於下面兩句程式碼. / NSTimer timer = [NSTimer timerWithTimeInterval:2.0 target:self selector:@selector(run) userInfo:nil repeats:YES]; [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];

/* 因為預設已經添加了NSDefaultRunLoopMode, 所以只給timer1添加了UITrackingRunLoopMode後, 效果跟添加了NSRunLoopCommonModes一致, 拖動也不影響定時器 / [[NSRunLoop currentRunLoop] addTimer:timer1 forMode:UITrackingRunLoopMode];

// 開發中推薦使用 NSTimer *timer = [NSTimer timerWithTimeInterval:duration target:self selector:@selector(cs_toastTimerDidFinish:) userInfo:toast repeats:NO]; [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; ```

2.2.2 為什麼說NSTimer不準確

  • NSTimer的觸發時間到的時候, runloop如果在阻塞狀態, 觸發時間就會推遲到下一個runloop週期
  • 可利用GCD優化 ``` NSTimeInterval interval = 1.0; _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));

dispatch_source_set_timer(_timer, dispatch_walltime(NULL, 0), interval * NSEC_PER_SEC, 0);

dispatch_source_ser_evernt_handler(_timer, ^{ NSLog(@"GCD timer test"); });

dispatch_resume(_timer); ```

2.3 RunLoop使用的其他小Tips

  1. NSTimer不被手勢操作影響
  2. 滑動tableviewcell中的ImageView推遲顯示 [self.imageView performSelector:@selector(setImage:) withObject:[UIImage imageNamed:@"tupian"] afterDelay:4.0 inModes:@[NSDefaultRunLoopMode]];

3. 如何用RunLoop原理去監控卡頓

  • 戴銘老師的RunLoop示意圖 image.png
  • 卡頓跟FPS關係不大, 24幀的動畫也是流暢的
  • 通過監控RunLoop的狀態, 就能夠發現呼叫方法是否執行時間過長, 從而判斷出是否會出現卡頓.

image.png 1. 要想監聽 RunLoop,你就首先需要建立一個 CFRunLoopObserverContext 觀察者,程式碼如下: CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context); 2. 將建立好的觀察者 runLoopObserver 新增到主執行緒 RunLoop 的 common 模式下觀察。然後,建立一個持續的子執行緒專門用來監控主執行緒的 RunLoop 狀態。 3. 一旦發現進入睡眠前的 kCFRunLoopBeforeSources 狀態,或者喚醒後的狀態 kCFRunLoopAfterWaiting,在設定的時間閾值內一直沒有變化,即可判定為卡頓。 4. 接下來,我們就可以通過三方庫PLCrashReporter dump 出堆疊的資訊,從而進一步分析出具體是哪個方法的執行時間過長。

發文不易, 喜歡點讚的人更有好運氣👍 :), 定期更新+關注不迷路~

ps:歡迎加入筆者18年建立的研究iOS稽核及前沿技術的三千人扣群:662339934,坑位有限,備註“掘金網友”可被群管通過~

本文正在參加「金石計劃 . 瓜分6萬現金大獎」