RunLoop學習筆記

參考

深入理解RunLoop

深入研究 Runloop 與線程保活

RunLoop分享by孫源

RunLoop的概念

RunLoop是一個機制,讓線程能隨時處理事件但并不退出。這種機制實現的關鍵點在于:如何管理事件/消息,如何讓線程在沒有處理消息時休眠以避免資源占用、在有消息到來時立刻被喚醒。

iOS提供了兩個這樣的對象:NSRunLoopCFRunLoopRef。

  • CFRunLoopRef 是在 CoreFoundation 框架內的,它提供了純 C 函數的 API,所有這些 API 都是線程安全的。
  • NSRunLoop 是基于 CFRunLoopRef 的封裝,提供了面向對象的 API,但是這些 API 不是線程安全的。

Runloop和線程之間的關系

線程和 RunLoop 之間是一一對應的,其關系是保存在一個全局的 Dictionary 里。線程剛創建時并沒有RunLoop,如果你不主動獲取,那它一直都不會有。RunLoop 的創建是發生在第一次獲取時,RunLoop 的銷毀是發生在線程結束時。你只能在一個線程的內部獲取其 RunLoop(主線程除外)。主線程的RunLoop是一直運行的,RunLoop在執行完任務后會進入休眠,等待下一次啟動。

RunLoop的組成

  • Timer
    • 理解的Timer
  • SourceRunLoop數據源的抽象類protocol)
    • Source0:處理App內部時間,App自己負責觸發(UIEvent、CFSocket
    • Source1:由RunLoopmach內核管理,由mach-port驅動
  • Observer
    • 許多機制都由Observer來觸發
      • 例如CAAnimation,在afterwaiting收集完所有animation后才執行動畫

RunLoop的Mode

  • NSDefaultRunLoopModekCFRunLoopDefaultMode):App的默認 Mode,通常主線程是在這個 Mode 下運行的
  • UITrackingRunLoopMode:界面跟蹤 Mode,用于 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響
  • UIInitializationRunLoopMode:在剛啟動 App 時第進入的第一個 Mode,啟動完成后就不再使用(iOS不公開提供)
  • NSRunLoopCommonModeskCFRunLoopCommonModes):Mode集合(iOS不公開提供)
  • GSEventReceiveRunLoopMode: 接受系統事件的內部 Mode,通常用不到

RunLoop只能運行在一個mode下,如果要換mode,當前的loop也需要停下重啟成新的。

例如:ScrollView滾動過程中NSDefaultRunLoopModekCFRunLoopDefaultMode)的mode會切換到UITrackingRunLoopMode來保證ScrollView的流暢滑動,如果我們把一個NSTimer對象以NSDefaultRunLoopModekCFRunLoopDefaultMode)添加到主運行循環中的時候, ScrollView滾動過程中會因為mode的切換,而導致NSTimer將不再被調度,解決方案是將timer添加到NSRunLoopCommonModeskCFRunLoopCommonModes)中或者另起線程避免mode切換來解決。

RunLoop內部邏輯

RunLoop 內部的大致邏輯

RunLoop 內部是一個 do-while 循環。當你調用 CFRunLoopRun()時,線程就會一直停留在這個循環里;直到超時或被手動停止,該函數才會返回。

RunLoop的底層實現

RunLoop 的核心是基于 mach port 的

iOS的內核是Mach,在 Mach 中,所有的東西都是通過自己的對象實現的,進程、線程和虛擬內存都被稱為"對象"。和其他架構不同, Mach 的對象間不能直接調用,只能通過消息傳遞的方式實現對象間的通信。"消息"是 Mach 中最基礎的概念,消息在兩個端口 (port) 之間傳遞,這就是 MachIPC (進程間通信) 的核心。

為了實現消息的發送和接收,mach_msg() 函數實際上是調用了一個 Mach 陷阱 (trap),即函數mach_msg_trap(),陷阱這個概念在 Mach 中等同于系統調用。當你在用戶態調用 mach_msg_trap() 時會觸發陷阱機制,切換到內核態;內核態中內核實現的 mach_msg()函數會完成實際的工作。

RunLoop調用這個函數去接收消息,如果沒有別人發送 port 消息過來,內核會將線程置于等待狀態。例如你在模擬器里跑起一個 iOS 的 App,然后在 App 靜止時點擊暫停,你會看到主線程調用棧是停留在mach_msg_trap()這個地方。

iOS利用RunLoop實現的功能

  • AutoreleasePool

    App啟動后,蘋果在主線程 RunLoop 里注冊了兩個 Observer,其回調都是 _wrapRunLoopWithAutoreleasePoolHandler()。

    第一個 Observer 監視的事件是 Entry(即將進入Loop),其回調內會調用 _objc_autoreleasePoolPush() 創建自動釋放池。其 order 是-2147483647,優先級最高,保證創建釋放池發生在其他所有回調之前。

    第二個 Observer 監視了兩個事件:BeforeWaiting(準備進入休眠) 時調用_objc_autoreleasePoolPop()_objc_autoreleasePoolPush()釋放舊的池并創建新池;Exit(即將退出Loop) 時調用 _objc_autoreleasePoolPop() 來釋放自動釋放池。這個 Observerorder 是 2147483647,優先級最低,保證其釋放池子發生在其他所有回調之后。

    在主線程執行的代碼,通常是寫在諸如事件回調、Timer回調內的。這些回調會被 RunLoop 創建好的 AutoreleasePool環繞著,所以不會出現內存泄漏,開發者也不必顯示創建 Pool 了。

  • 事件響應

    蘋果注冊了一個 Source1 (基于 mach port的) 用來接收系統事件,其回調函數為 __IOHIDEventSystemClientQueueCallback()。

    當一個硬件事件(觸摸/鎖屏/搖晃等)發生后,首先由 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/CALayersetNeedsLayout/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

  • PerformSelecter

    當調用NSObjectperformSelecter:afterDelay:后,實際上其內部會創建一個Timer并添加到當前線程的RunLoop中。所以如果當前線程沒有 RunLoop,則這個方法會失效。

    當調用 performSelector:onThread:時,實際上其會創建一個Timer加到對應的線程去,同樣的,如果對應線程沒有 RunLoop 該方法也會失效。

RunLoop常見應用

  • 使用RunLoopModetableview滑動優化
    • 通過不同的mode的切換,實現滑動時暫停加載圖片等,停止滑動時加載
  • NSTimer計時任務
  • autorelease pool
    • RunLoop維護
  • 卡頓檢測
    • 利用Observer記錄主線程RunLoop休眠的時間
    • 利用Observer記錄主線程RunLoop喚醒的時間
    • 計算這個(喚醒時間 - 休眠時間)的值,將其與正常的時間比較,判斷當前是否會掉幀
  • Crash的程序回光返照
    • 接收到CrashSignal后手動重啟RunLoop
  • 異步Test Case
    • sleep前驗證

用到的框架

  • AFNetworking用于維護線程

    AFNetworking是基于NSURLConnection構建的,為了在后臺也能接受回調,會創建一個線程,線程中添加一個RunLoop。由于沒有調用RunLoop的停止方法,所以RunLoop不會退出。

  • AsyncDisplayKit

    ASDK 創建了一個名為 ASDisplayNode的對象,并在內部封裝了UIView/CALayer,它具有和 UIView/CALayer 相似的屬性,例如 frame、backgroundColor等。所有這些屬性都可以在后臺線程更改,開發者可以只通過 Node來操作其內部的 UIView/CALayer,這樣就可以將排版和繪制放入了后臺線程。
    并在主線程的RunLoop中添加一個Observer,監聽RunLoop進入休眠和退出的回調事件,收到回調后,遍歷執行隊列中的任務。

  • YYAsyncLayer

    • 實現原理如下:
      • 正常情況下:假設一次RunLoop需要處理50張圖片
      • 使用YYAsyncLayer的情況:一次RunLoop處理1張圖片,利用50個RunLoop去處理50張圖片
        • 注意:在不計算休眠時間的情況下,50個RunLoop處理時間 = 1次RunLoop處理50張圖片的時間

有關RunLoop的問題

  • RunLoop與線程的關系
    • 一對一,一個線程可以有一個RunLoop,也可以沒有
    • 主線程的RunLoop已經自動創建好了,子線程的RunLoop需要主動創建
    • RunLoop在第一次獲取時創建,在線程結束時銷毀
  • RunLoop只是個死循環嗎?
    • 不是,RunLoop是個有時間限制的循環
  • 使用while(true)RunLoop哪個好?
    • RunLoop,因為RunLoop可以在不需要使用的時候休眠,節省CPU資源,而while(true)則一直處于CPU活躍狀態
  • 為什么我們主線程需要有RunLoop
    • 保持線程存活,接受事件
    • 為了管理AutoreleasePool
  • [NSRunLoop currentRunLoop]實際上做了什么
    • [NSRunLoop currentRunLoop]實則為一個懶加載的方法。它會遍歷一張全局靜態的數據表,該數據表以線程PID為Key,以與該線程綁定的RunLoopValue。該表創建的時候會首先對當前線程(主線程)的PID放入一個RunLoop
  • RunLoopautorelease pool的關系
    • 對于每一個Runloop, 系統會隱式創建一個Autorelease pool,這樣所有的release pool會構成一個象CallStack一樣的一個棧式結構,對象會自動被放入棧頂的AutoreleasePool中,在每一個Runloop結束時,當前棧頂的Autorelease pool會被銷毀,這樣這個pool里的每個Object會被release。
    • 兩次pop兩次push,均利用Observer實現
      • 進入后push
      • 睡眠前pop
      • 睡眠后push
      • 離開前pop
  • GCDdispatch_get_main()是如何實現的
    • 當調用dispatch_async(dispatch_get_main_queue(), block) 時,libDispatch會向主線程的RunLoop發送消息,RunLoop會被喚醒,并從消息中取得這個block,并在回調\__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__里執行這個block。但這個邏輯僅限于 利用GCDblock分發到主線程,分發到其他線程仍然是由libDispatch處理的。
    • GCD有自己的線程池,當需要使用到線程的時候隨機找一個線程來跑,但是主線程是唯一的,使用RunLoop的主線程
  • 如何切換Mode?為什么要這樣做?
    • 先離開,重新進入后切換Mode
    • 這樣是為了保證Mode里面的Timer、SourcesObserver互不影響
    • 延伸:在主線程Mode切換的時候,RunLoop這一次離開與下一次進入之前有一段間隔,這段間隔會對我們的應用有影響嗎(比如會丟事件嗎)?
      • 不會有影響,因為我們會把在這期間收到的事件都放在一個隊列中,等待下一次RunLoop進入的時候,RunLoop根據該隊列進行處理
  • 使用Timer要注意什么
    • 注意使用內存管理[timer invalidate];及設nil
    • 使用addCommonMode / addUITrackingMode保證精準度
  • CommonModes本質是什么
    • CommonModes是一個標識,CFRunLoopAddCommonMode等于給某個Mode打標識。
    • 這里有個概念叫CommonModes:一個Mode可以將自己標記為Common屬性(通過將其 ModeName添加到 RunLoopcommonModes 中)。每當RunLoop 的內容發生變化時,RunLoop都會自動將 _commonModeItems 里的 Source/Observer/Timer 同步到具有Common標記的所有Mode里。
  • NSThread在沒有RunLoop的情況下,執行完入口函數,會被立刻關閉嗎?
    • 不會立刻關閉,會在執行完后,過段時間被清理
    • 延伸:既然如此,為什么把主線程的RunLoop關閉后,應用會崩潰?
      • 應用保證了主線程一定要有RunLoop,沒有RunLoop則崩,與上面問題沒有關系
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容