肆:RunLoop在系統中的使用
在我們開發中使用的很多API都依賴的RunLoop來實現的,比如我們熟悉的perform selector方法,比如我們熟悉的Timer等等。
Cocoa Perform Selector
以下是Swift中NSObject中提供的perform selector方法簇:
```swift / 在指定執行緒執行方法: 主執行緒或者其他執行緒 / open func performSelector(onMainThread aSelector: Selector, with arg: Any?, waitUntilDone wait: Bool, modes array: [String]?)
open func performSelector(onMainThread aSelector: Selector, with arg: Any?, waitUntilDone wait: Bool)
@available(iOS 2.0, *) open func perform(_ aSelector: Selector, on thr: Thread, with arg: Any?, waitUntilDone wait: Bool, modes array: [String]?)
@available(iOS 2.0, *) open func perform(_ aSelector: Selector, on thr: Thread, with arg: Any?, waitUntilDone wait: Bool)
@available(iOS 2.0, *) open func performSelector(inBackground aSelector: Selector, with arg: Any?)
/ 延遲時間執行方法 / open func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval, inModes modes: [RunLoop.Mode])
open func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval) ```
NSObjct有一些方法可以在其它的執行緒上執行方法。從這些方法本身,其實我們可以大概猜測出它們和RunLoop的關係:如delay和時間有關,onThread和執行緒間通訊有關,值得一提的是如果想要perform呼叫的方法執行,那麼目標執行緒必須有一個已經啟用的RunLoop,否則aSelector引數對應的方法是不會執行的。而RunLoop會在一次迴圈中一次性處理完所有入佇列的perform的selector,而不是一次迴圈處理一個selector。
延時執行
在以上的方法簇中有兩個延時的方法:
```swift func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval, inModes modes: [RunLoop.Mode])
func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval) ```
這個方法會在當前執行緒的runLoop中設定一個timer,通過timer的callback來呼叫這個selector。這個timer被加入到預設的mode中(CFDefaultRunLoopMode),當然也可以手動指定timer被加入的mode。當這個timer被觸發時,這個執行緒就會從runloop的訊息佇列中取出對應的方法並執行,但是前提是這個runloop執行的mode正好是timer加入的mode,否則的話timer就會等待,直到runloop運行了指定的mode。
比如在ViewController中寫一個5秒延時的方法,並將此timer加入到defaultMode中:
swift
self.perform(#selector(hahah), with: nil, afterDelay: 5.0, inModes: [.default])
在控制檯斷點輸出如下,可以很明確的看到,就是在Timer觸發之後(CFEUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION),在回撥中呼叫這個具體的方法:#selector(hahah)
主執行緒執行
而在指定主執行緒中執行的perform方法中performSelector(onMainThread aSelector: Selector),很多文章都說這個也是設定一個Timer,但是經過測試發現並不是如此,並不是設定一個Timer來喚醒runloop,而是系統註冊來一個source0事件,並手動來喚醒runloop。
```swift @objc func hahah() { self.performSelector(onMainThread: #selector(testPerformMainThread), with: nil, waitUntilDone: false) }
@objc func testPerformMainThread() { NSLog("I want to see the world.") } ```
通過我們實際測試程式碼可以看出這裡RunLoop被喚醒之後執行了source0的回撥,然後呼叫了#selector(hahah)方法。
其他執行緒執行
這裡就不做標註了,因為和上圖是一樣的,只不過這裡注意thread需要去建立一個runloop保活,不然是沒辦法在一個沒有執行RunLoop的執行緒上去perform selector的。此處,也是通過source0來呼叫具體的方法(這裡的方法我沒有改名:testPerformMainThread,希望不會引起歧義)
以上的這些Cocoa Perform Selector Sources根據蘋果文件的描述,它們不像基於Port的Source(即source1):一個perform selector source會在執行完它的selector之後,從runloop中被移除。
Timer
Timer
在Swift中我們使用的是Timer型別,而在Objective-C中是NSTimer型別,它們的底層都是CFRunLoopTimerRef。網上的部分文章說Timer會提前註冊好時間點,然後一個一個的去執行,其實這個是不對的,它只會註冊下一次時間點,RunLoop被Timer喚醒之後,執行完回撥之中的方法,又會繼續註冊下一個時間點。
我們可以從兩個地方的原始碼看,其一是CFRunLoopAddTimer方法,在這個方法中有一個方法的呼叫順序,它在新增完Timer之後會呼叫__CFArmNextTimerInMode
方法。
```c CFRunLoopAddTimer -> _CFRepositionTimerInMode(rlm, rlt, false)
-> __CFArmNextTimerInMode(rlm, rlt->_runLoop) ```
另一個地方是__CFRunLoopRun方法,在Runloop在被Timer喚醒之後會呼叫到__CFRunLoopDoTimers
方法, 它的方法鏈為:__CFRunLoopDoTimers -> __CFRunLoopDoTimer —> __CFArmNextTimerInMode
也就是說最後還是會呼叫到__CFArmNextTimerInMode
方法。
```c Bool __CFRunLoopRun() { ··· if (livePort == rlm->_timerPort) { CFRUNLOOP_WAKEUP_FOR_TIMER();
if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
// Re-arm the next timer
__CFArmNextTimerInMode(rlm, rl);
}
}
···
} ```
那麼這個Timer執行的核心就在__CFArmNextTimerInMode
中了:
```c static void __CFArmNextTimerInMode(CFRunLoopModeRef rlm, CFRunLoopRef rl) { uint64_t nextHardDeadline = UINT64_MAX; uint64_t nextSoftDeadline = UINT64_MAX;
if (rlm->_timers) {
// 1.設定每一個timer的下一次到期時間
for (...) {
}
// 2.判斷下一次時間
// - 如果時間是合理的
if (nextSoftDeadline < UINT64_MAX
&& (nextHardDeadline != rlm -> _timerHardDeadline
|| nextSoftDealline != rlm -> _timerSoftDeadline)) {
// 3、到點了給_timerPort傳送訊息
if (rlm->_timerPort) {
mk_timer_arm(rlm->_timerPort, __CFUInt64ToAbsoluteTime(nextSoftDeadline));
}
// - 如果時間是無限:那麼就取消timer
} else if (nextSoftDeadline == UINT64_MAX) {
if (rlm->_mkTimerArmed && rlm->_timerPort) {
AbsoluteTime dummy;
mk_timer_cancel(rlm->_timerPort, &dummy);
rlm->_mkTimerArmed = false;
}
}
rlm->_timerHardDeadline = nextHardDeadline;
rlm->_timerSoftDeadline = nextSoftDeadline;
}
} ```
上面的程式碼註釋已經比較詳細了,就是在時間到了之後通過mk_timer_arm
方法來給timerPort傳送訊息,那麼如果因為滑動螢幕的時候切換了RunLoop執行的Mode呢?
核心程式碼是mk_timer_arm(rlm->_timerPort, __CFUInt64ToAbsoluteTime(nextSoftDeadline));
到點之後依然會給timerPort傳送訊息,這個訊息會存在timerPort的訊息佇列中!在RunLoop切換回timer所在的Mode之後,當執行到__CFRunLoopServiceMachPort
方法的時候,就會接收到這個timerPort的訊息佇列中的訊息,從而處理Timer的回撥事件。這也是為什麼切換Mode之後,timer的回撥會立馬執行一次的原因。
CADisplayLink
CADispalyLink 提供了幾個基本的API,從中我們可以很直白的看出它和RunLoop是直接相關聯的。
```swift open func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
open func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode) ```
CADispalyLink和Timer在某些方面是有相似之處的,在建立完之後也需要將其加入到RunLoop的Mode中。我們可以設定display link的幀速率(preferredFramesPerSecond),幀速率也決定了一秒之內系統呼叫了target的這個方法多少次。然而實際上display link的幀速率是會受到裝置的最大重新整理率制約的。
比如說裝置的最大重新整理率是每秒60幀,我們設定的preferredFramesPerSecond如果比這個值大,那麼display link的幀速率也只能是60,不能超過裝置的螢幕最大重新整理率。
接下來我們要看一看display link是如何喚醒runloop的:
```swift func createDisplayLink() { let link = CADisplayLink.init(target: self, selector: #selector(step)) link.preferredFramesPerSecond = 1 link.add(to: RunLoop.main, forMode: .default) }
@objc func step(displaylink: CADisplayLink) { print(displaylink.targetTimestamp) } ```
通過打斷點的方法棧中可知:RunLoop是被CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION,也就是被source1所喚醒的,然後最後呼叫到target的step方法。很直白,也很簡單。因為它是硬體裝置的螢幕重新整理排程後臺管理程式通過IPC通訊,向當前前臺程序的mach port 傳送訊息喚醒了RunLoop。
DispatchSourceTimer
這個比較特殊,一開始的時候我也以為它是和RunLoop有關係的,後來根據我自己測試,RunLoop已經處於休眠狀態了,然而它還是會定時觸發callback,基於此我在opensource.apple.com中查看了libdispatch的原始碼,dispatch_source_timer是由GCD管理的定時器,並不是由RunLoop管理的,所以它其實適合RunLoop無關的。
GCD
GCD和RunLoop是處於同一層級的,從開原始碼的資料夾就可窺一二,其中RunLoop原始碼在開源的CF程式碼中,GCD原始碼在開源的libdispatch原始碼中。但是它們有一個很特別的關聯:GCD主佇列的任務派發是通過Runloop來實現的,這裡在原始碼中有很明確的顯示:
c
// 在RunLoop被喚醒的原始碼中
if (livePort == dispatchPort) {
...
CFRUNLOOP_WAKEUP_FOR_DISPATCH();
...
__CFRUNLOOP_IS_SERVECING_THE_MAIN_DISPATCH_QUEUE__(msg)
...
}
GCD派發到主佇列中的任務會喚醒RunLoop,但是其它任務佇列中的任務並不會和RunLoop進行互動。舉例:我們知道DispatchSourceTimer和RunLoop是無關的,所以可以使用DispatchSourceTimer寫一個延時任務來執行GCD的主佇列派發:
```swift func createSourceTimer() { sourceTimer = DispatchSource.makeTimerSource() sourceTimer?.schedule(deadline: .now(), repeating: 10.0, leeway: .nanoseconds(1)) sourceTimer?.setEventHandler { DispatchQueue.main.async { NSLog("我想知道我是誰?") } }
sourceTimer?.activate()
} ```
從方法棧可知,時間到了之後,會給dispatchPort埠傳送訊息,而這個埠接受訊息之後就會喚醒RunLoop,然後就會執行__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
函式,最後執行派發到主佇列中的任務。但是要強調的是,僅限於主佇列的任務派發,而dispatch到其它執行緒的任務是通過libDispatch來處理的。
事件響應
一個硬體事件被iOS系統接受之後,一定會被系統處理然後再分發給應該處理該事件的程序,即當前正在前臺的程序。
SpringBoard
我們開啟iPhone可以看到許多不同App的icon,並且左右滑動,可以切換不同的頁面,其實這是通過SpringBoard來管理的,它提供了所有App的應用啟動服務,Icon的管理,狀態列的控制等等,它本質上就是iOS上的桌面程式,同時它是有BundleID的: com.apple.springboard,這一點正好可以驗證它就是一個桌面程式。
但是在iOS6之後,SpringBoard的部分方法被分離到在BackBoardd中。BackBoardd是一個後臺駐留程式,承擔了以前SpringBoard的部分工作。它的主要目的是處理來自硬體的資訊,比如觸控事件,按鈕事件,加速度計資訊。它通過BackBoardServices.framework來和SpringBoard通訊。BackBoard勾連繫統的IOKit以及使用者程序(即應用程式),它也管理著應用程式的啟動、暫停和結束。
觸控事件
以觸控事件為例:在螢幕被觸控之後(硬體事件),系統通過IOKit.framework處理該事件,IOKit將這個觸控事件封裝為IOHIDEvent物件,然後BackBoard呼叫當前CAWindowDisplayServer的-contextIdAtPosition 方法來得到touch事件應該要發往何處的contextID,這個contextID決定了哪個程序來接受這個touch事件。
如果前臺並沒有應用程式的話,那麼就會通過mach port(IPC通訊)將事件分發給SpringBoard來處理,這就意味著使用者是操作的是iPhone的桌面,比如使用者點選一個應用圖示,它將啟動這個應用。如果前臺有應用程式的話,BackBoard得到contextID之後,會將這個事件通過mach port(IPC通訊)分發給前臺的這個應用程式
前臺應用在接受到mach port 傳遞來的事件之後,它會喚醒主執行緒的RunLoop,觸發Source1回撥,Source1回撥會呼叫__IOHIDEventSystemClientQueueCallback方法,這個方法會將事件交給source0來處理,source0將會呼叫__eventFetcherSourceCallback方法,在這個方法內部會呼叫__processEventQueue方法,在這個方法內部會對IOHIDEvent進行處理,將其轉化為UIEevent物件,然後呼叫__dispatchPreprocessedEventQueue分發給UIApplication去尋找相應的響應檢視。
介面更新
在對介面進行操作的時候,比如改變了UI的Frame,或者改變了UIView/CALayer的層次時,或者手動呼叫了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法後,這個UIView/CALayer就會被標記為待處理,並被提交到一個全域性的容器中去。
Apple註冊了一個註冊了一個 Observer 監聽 BeforeWaiting(即將進入休眠) 和 Exit (即將退出Loop) 事件,回撥去執行一個函式:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv
。這個函式裡會遍歷所有待處理的 UIView/CALayer 以執行實際的繪製和調整,並更新 UI 介面。這個函式內部得方法棧如下:
c
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
QuartzCore:CA::Transaction::observer_callback:
CA::Transaction::commit();
CA::Context::commit_transaction(CA::Transaction*, double, double*);
CA::Layer::layout_and_display_if_needed(CA::Transaction*);
CA::Layer::layout_if_needed();
[CALayer layoutSublayers];
[UIView layoutSubviews];
CA::Layer::display_if_needed();
[CALayer display];
[UIView drawRect];
對於改變頁面的Frame確實是上述所示,那麼對於動畫也是在BeforeWaiting的時候才去commit_transition嗎?答案是肯定的。在滑動一個UITableView的過程中,通過控制檯可以看到,也是在每一次被Observer監聽到喚醒之後,才去呼叫重新整理UI的方法:
參考
3、https://developpaper.com/ios-event-handling-look-at-me-thats-enough/
- 我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿。