參考
RunLoop的概念
RunLoop
是一個機制,讓線程能隨時處理事件但并不退出。這種機制實現的關鍵點在于:如何管理事件/消息,如何讓線程在沒有處理消息時休眠以避免資源占用、在有消息到來時立刻被喚醒。
iOS提供了兩個這樣的對象:NSRunLoop
和 CFRunLoopRef
。
-
CFRunLoopRef
是在CoreFoundation
框架內的,它提供了純 C 函數的 API,所有這些 API 都是線程安全的。 -
NSRunLoop
是基于CFRunLoopRef
的封裝,提供了面向對象的 API,但是這些 API 不是線程安全的。
Runloop和線程之間的關系
線程和 RunLoop
之間是一一對應的,其關系是保存在一個全局的 Dictionary
里。線程剛創建時并沒有RunLoop
,如果你不主動獲取,那它一直都不會有。RunLoop
的創建是發生在第一次獲取時,RunLoop
的銷毀是發生在線程結束時。你只能在一個線程的內部獲取其 RunLoop
(主線程除外)。主線程的RunLoop
是一直運行的,RunLoop
在執行完任務后會進入休眠,等待下一次啟動。
RunLoop的組成
-
Timer
- 理解的
Timer
- 理解的
-
Source
(RunLoop
數據源的抽象類protocol)-
Source0
:處理App內部時間,App自己負責觸發(UIEvent
、CFSocket
) -
Source1
:由RunLoop
和mach
內核管理,由mach-port
驅動
-
-
Observer
- 許多機制都由
Observer
來觸發- 例如
CAAnimation
,在afterwaiting
收集完所有animation
后才執行動畫
- 例如
- 許多機制都由
RunLoop的Mode
-
NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
):App的默認 Mode,通常主線程是在這個 Mode 下運行的 -
UITrackingRunLoopMode
:界面跟蹤 Mode,用于ScrollView
追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響 -
UIInitializationRunLoopMode
:在剛啟動 App 時第進入的第一個 Mode,啟動完成后就不再使用(iOS不公開提供) -
NSRunLoopCommonModes
(kCFRunLoopCommonModes
):Mode集合(iOS不公開提供) -
GSEventReceiveRunLoopMode
: 接受系統事件的內部 Mode,通常用不到
RunLoop
只能運行在一個mode下,如果要換mode,當前的loop也需要停下重啟成新的。
例如:ScrollView
滾動過程中NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
)的mode會切換到UITrackingRunLoopMode
來保證ScrollView
的流暢滑動,如果我們把一個NSTimer
對象以NSDefaultRunLoopMode
(kCFRunLoopDefaultMode
)添加到主運行循環中的時候, ScrollView
滾動過程中會因為mode的切換,而導致NSTimer將不再被調度,解決方案是將timer添加到NSRunLoopCommonModes
(kCFRunLoopCommonModes
)中或者另起線程避免mode切換來解決。
RunLoop內部邏輯
RunLoop
內部是一個 do-while 循環。當你調用 CFRunLoopRun()
時,線程就會一直停留在這個循環里;直到超時或被手動停止,該函數才會返回。
RunLoop的底層實現
RunLoop 的核心是基于 mach port 的
iOS的內核是Mach
,在 Mach
中,所有的東西都是通過自己的對象實現的,進程、線程和虛擬內存都被稱為"對象"。和其他架構不同, Mach 的對象間不能直接調用,只能通過消息傳遞的方式實現對象間的通信。"消息"是 Mach
中最基礎的概念,消息在兩個端口 (port) 之間傳遞,這就是 Mach
的IPC
(進程間通信) 的核心。
為了實現消息的發送和接收,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()
來釋放自動釋放池。這個Observer
的order
是 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
/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
。 -
PerformSelecter
當調用
NSObject
的performSelecter:afterDelay:
后,實際上其內部會創建一個Timer
并添加到當前線程的RunLoop
中。所以如果當前線程沒有RunLoop
,則這個方法會失效。當調用
performSelector:onThread:
時,實際上其會創建一個Timer
加到對應的線程去,同樣的,如果對應線程沒有RunLoop
該方法也會失效。
RunLoop常見應用
- 使用
RunLoop
的Mode
做tableview
滑動優化- 通過不同的
mode
的切換,實現滑動時暫停加載圖片等,停止滑動時加載
- 通過不同的
-
NSTimer
計時任務 -
autorelease pool
- 由
RunLoop
維護
- 由
- 卡頓檢測
- 利用
Observer
記錄主線程RunLoop
休眠的時間 - 利用
Observer
記錄主線程RunLoop
喚醒的時間 - 計算這個(喚醒時間 - 休眠時間)的值,將其與正常的時間比較,判斷當前是否會掉幀
- 利用
- 讓
Crash
的程序回光返照- 接收到
Crash
的Signal
后手動重啟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張圖片的時間
- 注意:在不計算休眠時間的情況下,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,以與該線程綁定的RunLoop
為Value
。該表創建的時候會首先對當前線程(主線程)的PID放入一個RunLoop
-
-
RunLoop
與autorelease pool
的關系- 對于每一個
Runloop
, 系統會隱式創建一個Autorelease pool
,這樣所有的release pool
會構成一個象CallStack
一樣的一個棧式結構,對象會自動被放入棧頂的AutoreleasePool
中,在每一個Runloop
結束時,當前棧頂的Autorelease pool
會被銷毀,這樣這個pool
里的每個Object
會被release
。 - 兩次
pop
兩次push
,均利用Observer
實現- 進入后
push
- 睡眠前
pop
- 睡眠后
push
- 離開前
pop
- 進入后
- 對于每一個
-
GCD
的dispatch_get_main()
是如何實現的- 當調用
dispatch_async(dispatch_get_main_queue(), block)
時,libDispatch
會向主線程的RunLoop
發送消息,RunLoop
會被喚醒,并從消息中取得這個block
,并在回調\__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
里執行這個block
。但這個邏輯僅限于 利用GCD
將block
分發到主線程,分發到其他線程仍然是由libDispatch
處理的。 -
GCD
有自己的線程池,當需要使用到線程的時候隨機找一個線程來跑,但是主線程是唯一的,使用RunLoop
的主線程
- 當調用
- 如何切換
Mode
?為什么要這樣做?- 先離開,重新進入后切換
Mode
- 這樣是為了保證
Mode
里面的Timer
、Sources
、Observer
互不影響 - 延伸:在主線程
Mode
切換的時候,RunLoop
這一次離開與下一次進入之前有一段間隔,這段間隔會對我們的應用有影響嗎(比如會丟事件嗎)?- 不會有影響,因為我們會把在這期間收到的事件都放在一個隊列中,等待下一次
RunLoop
進入的時候,RunLoop
根據該隊列進行處理
- 不會有影響,因為我們會把在這期間收到的事件都放在一個隊列中,等待下一次
- 先離開,重新進入后切換
- 使用
Timer
要注意什么- 注意使用內存管理
[timer invalidate];
及設nil
- 使用
addCommonMode
/addUITrackingMode
保證精準度
- 注意使用內存管理
-
CommonModes
本質是什么-
CommonModes
是一個標識,CFRunLoopAddCommonMode
等于給某個Mode
打標識。 - 這里有個概念叫
CommonModes
:一個Mode
可以將自己標記為Common
屬性(通過將其ModeName
添加到RunLoop
的commonModes
中)。每當RunLoop
的內容發生變化時,RunLoop
都會自動將_commonModeItems
里的Source
/Observer
/Timer
同步到具有Common
標記的所有Mode里。
-
-
NSThread
在沒有RunLoop
的情況下,執行完入口函數,會被立刻關閉嗎?- 不會立刻關閉,會在執行完后,過段時間被清理
- 延伸:既然如此,為什么把主線程的
RunLoop
關閉后,應用會崩潰?- 應用保證了主線程一定要有
RunLoop
,沒有RunLoop
則崩,與上面問題沒有關系
- 應用保證了主線程一定要有