RunLoop與事件響應

[TOC]html

RunLoop與事件響應

2020-03-22編程

在上一篇《調試iOS用戶交互事件響應流程》中,調試了 iOS 事件響應的完整過程,可是隻涉及了事件在 UIKit 的視圖層級之間的傳遞的應用層的實現細節,具體到事件在哪裏生成,如何分發到 UIKit 層的底層流程則未有說起。本文嘗試從 RunLoop 入手,探索事件響應的底層流程。安全

1、XNU內核和Mach

在進入正題以前,先聊聊 XNU 內核。首先 Mac OS X 和 iOS 都是基於 Darwin 系統開發而來。Darwin 系統是 Mac OS X 的核心操做系統部分,而 Darwin 系統的內核就是 XNU(X is Not Unix)。bash

1.1 XNU內核架構

XNU 內核是混合架構內核,它以 Mach 微內核爲核心,在上層添加了 BSD 和 I/O Kit 等必要的系統組件。從蘋果官方文檔 [NextPrevious Kernel Architecture Overview] 搬運如下三張 Mac OS X 的內核架構圖(iOS 內核架構也差很少如此)。網絡

OS X architecture

Darwin and OS X

OS X kernel architecture

Mach 是 XNU 內核中的內核。Mach 提供的是 CPU 管理、內存管理、任務調度等最底層的功能,爲操做系統層的組件提供了基於 mach message 的通訊基礎架構。Mach 更具體的功能是進程(機間)通信(IPC)、遠程過程調用(RPC)、對稱多處理調度(SMP)、虛擬內存管理、分頁支持、模塊化架構、時鐘管理等最基本的操做系統功能。數據結構

BSD(Berkly Software Distribution)實現了全部現代操做系統所包含的核心功能,包括文件系統、網絡通信系統、內存管理系統、用戶管理系統等等。BSD 屬於內核環境的一部分,可是因爲它對外提供了豐富的應用層 API,所以它表現出來有點遊離於內核以外而處於應用層。NKEs(Network Kernel Extensions)詳見《OSX與iOS內核編程》可用於監聽網絡流量、修改網絡流量、接收來自驅動層的異步通知等等。架構

I/O Kit 則是對外設 Driver 進行了面向對象封裝,除了負責處理來自 I/O 設備的信號外,還提供了豐富的 KPI 用於 I/O 設備驅動開發。一般 iOS 開發不多涉及到驅動開發,主要緣由是 iOS 操做系統的核心代碼不是開源的,而 iOS 在硬件權限的管理上也至關謹慎,所以不會在 iOS 上開發如此底層的接口。對於涉及硬件交互的內容,iOS 一般是提供相關應用開發框架給開發者調用;Mac OS X 則開放了 I/O Kit 專門用於外設驅動層的開發。併發

總之,XNU 內核組成對外表現爲 Mach + BSD + I/OKit,BSD 層創建於 I/O Kit 層之上,Mach 內核做爲核心貫穿於兩層之中。Mach 在任務調度和底層消息通訊中佔據核心地位。NSRunLoop的 Source1 就是經過 mach message 來喚醒 RunLoop 的。app

1.2 mach_msg

在程序調試過程當中,常常須要暫停程序運行下斷點,程序暫停就是經過發送mach_msg消息實現的。從下圖的調用棧能夠發現,mach_msg中調用了mach_msg_trap。當 App 接收到mach_msg_trap時,其中的syscall指令觸發系統調用,應用從用戶態轉入內核態。框架

進入內核態意味着應用獲取了訪問系統資源的最高特權,包括 CPU 調度、寄存器讀寫、內存訪問、虛擬內存管理、外圍設備訪問、跨進程訪問等等。而這些任務在用戶態下是沒法完成的。此時收到mach_msg的當前線程暫停手中的任務,保存當前線程的上下文,等待系統調用完成。收到msg_msg消息喚醒後,線程才從新投入運行。

也許也正是由於 Mach 如此強大,並且創建在極少許的 API 的基礎上而又具有很強的靈活性,因此 XNU 內核的設計者纔在 Mach 以外套了一層 BSD 加以控制,以提供更加具體且統一的操做系統內核開發的規範。

在使用 Profile >> System Trace 工具跟蹤應用的 CPU 使用狀況時會發現,靜止的應用大部分時間是處在 sytem call 狀態下(以下圖紅色條帶區域),主線程則是 Blocked 阻塞狀態(以下圖灰色條帶區域),這是由於在 iOS 沒有接收到用戶事件、沒有須要正在運行的邏輯時,系統調用了mach_msg進入了內核態並阻塞了主線程,此時主線程 RunLoop 處於睡眠狀態。這就是 iOS 保證 CPU 可以大部分時間下低功耗運行的緣由。僅當系統接收到須要零星須要處理的事件時(如圖藍色條帶區域),才從內核態轉回用戶態處理事件,固然處理事件過程當中若是要調度系統資源還會切到內核態,例如NSLog函數調用時,就會阻塞線程,進入 I/O 過程輸出日誌,完成後才返回用戶態。

2、RunLoop調試

本節大量參照了蘋果官方文檔對 RunLoop 的描述,並結合 lldb 調試 RunLoop 的數據結構以及工做流程,是本文的核心章節。

2.1 RunLoop簡介

一般狀況下,線程在執行完指定任務後就馬上銷燬。但有些狀況,開發者但願線程可以常駐,並在空閒時進入等待任務的狀態。此時就須要用到 RunLoop 實現線程保活。

下圖是 RunLoop 的一個不徹底的狀態轉換圖,能夠比較直觀地展現 RunLoop 大體工做流程,右邊紅色標記部分就是 RunLoop 處理邏輯的主體,明顯是一個「喚醒->處理消息->睡眠」的循環迭代過程,輸入源能夠看做是 RunLoop 接收消息的地方。

再參考來自官方文檔的定義。RunLoop 用來監控任務的輸入源(input sources),並在輸入源準備就緒後,對其進行調度控制。常見的輸入源包括:用戶輸入設備、網絡鏈接、時鐘、延遲觸發事件、異步回調等等。RunLoop 能夠監控的輸入源種類有三種:Sources、Timers、Observers,它們有回調函數(callback)。RunLoop 接收到某輸入源的觸發事件時,會執行該輸入源的回調函數。監控輸入源以前,須要將其添加到 RunLoop。再也不須要監控時,則將其從 RunLoop 移除,回調函數就不會再觸發。

注意:RunLoop 是調用 input sources 的回調函數是同步調用,並非異步,也就說若是回調函數的處理過程若是特別耗時,會直接影響到其餘 input sources 事件響應的時效性。

Sources、Timers、Observers 都須要與一個或多個 Mode(run loop mode)關聯。Mode 界定了 RunLoop 在運行之時,所監控的輸入源(事件)的範圍。運行 RunLoop 前,須要指定 RunLoop 所要進入的 Mode;開始運行後,RunLoop 只處理 Mode 中包含的監控對象的觸發事件。加之 RunLoop 能夠重複運行,所以能夠控制 RunLoop 在適當的時間點,進入適當的 Mode,以處理適當的事件。

總結 RunLoop、Sources、Mode 的關係以下圖所示:

2.1.1 Input Sources

輸入源是根據觸發事件的類型分類的。其中,Sources 的觸發事件是外部消息(信號);Timer 的觸發事件是時鐘信號;Observer 的觸發事件是 RunLoop 狀態變動。其實,輸入源觸發事件時,發送的消息都很是簡單,能夠理解爲一個脈衝信號1,它只是給輸入源打上待處理標記,這樣 RunLoop 在被喚醒時就能查詢當前 Mode 的輸入源中哪些須要處理,須要處理的則觸發其回調函數。

Source0和Source1

Sources 根據消息種類分爲 Source0 和 Source1。

Source0 是應用自行管理的輸入源。應用選擇在適當的時機調用CFRunLoopSourceSignal來告訴 RunLoop 有個 Source0 須要處理。譬如,在線程 A 上完成準備工做後,給線程 B 的 RunLoop 中的 Source0 發送信號,觸發(並非立刻)Source0 回調函數中的主任務邏輯開始執行。CFSocket就是經過 Source0 實現的。

Source1 是 RunLoop 和內核共同管理的輸入源。Source1 須要關聯一個 mach port,並經過 mach port 發送觸發事件信號,從而告訴 RunLoop 有個 Source1 須要處理。當 mach port 收到 mach 消息時,內核會自動給 Source1 發送信號,mach 消息的內容也會一併發送給 Source1,做爲觸發 Source1 回調函數觸發的上下文(參數)。CFMachPortCFMessagePort就是經過 Source1 實現的。

單個 Source 能夠同時註冊到多個 RunLoop 或 Mode 中,當 Source 事件觸發時,不管哪一個 RunLoop 率先接收到消息,都會觸發 Source 的回調函數。單個 Source 添加到多個 RunLoop 能夠應用於 處理離散數據集(數據間不存在關聯性)的「worker」線程池管理,譬如消息隊列的「生產者-消費者」模型,當任務到達時,會自動隨機觸發一條線程接收數據並進行處理。

總結 Source0 和 Source1 的主要區別以下:

  • 事件發送方式不一樣,Source0 是經過CFRunLoopSourceSignal發送事件信號,Source1 是經過 mach port 發送事件消息;
  • 事件的複雜度不一樣,Source0 的事件是不附帶上下文的(至關於簡單的1信號),Source1 的事件是附帶上下文(有消息內容)的;
  • Source1 比 Source0 多了個 mach port 成員;

Note: A run loop source can be registered in multiple run loops and run loop modes at the same time. When the source is signaled, whichever run loop that happens to detect the signal first will fire the source.

Timer

Timer 是一種預設好事件觸發時間點的 RunLoop 輸入源。既能夠設置 Timer 只觸發一次,也能夠設置以指定的時間間隔重複觸發。重複觸發的 Timer 能夠手動觸發 Timer 的下一次事件。CFRunLoopTimerNSTimer是 toll-free bridged 的。

Timer 並非實時的,它的觸發是創建在,RunLoop 正在運行 Timer 所在 Mode 的前提上。當 到達 Timer 的預設觸發時間點時,若 RunLoop 此時正運行於其餘 Mode,或者 RunLoop 正在處理某個複雜的回調,RunLoop 的當前迭代則會跳過該 Timer 觸發事件,直到 RunLoop 下次迭代到來再檢查 Timer 並觸發事件。

Timer 輸入源的本質,是根據時鐘信號,在 RunLoop 中註冊觸發時間點,RunLoop 喚醒並進入迭代時,會檢查 Timer 是否到達觸發時間點,若到達則調用 Timer 的回調函數。Timer 的註冊時間點始終是按照 Timer 初始化時所指定的觸發時間策略排布的。譬如一個在2020-02-02 12:00:00開始,每 5s 循環觸發的 Timer,其2020-02-02 12:00:05觸發事件被推遲到2020-02-02 12:00:06觸發了,那麼 Timer 的下個觸發時間點仍然是2020-02-02 12:00:10,而不是在延遲的觸發時間點基礎上再加 5s。另外,若 Timer 延遲時間內跳過了多個觸發時間點,則 RunLoop 在下個觸發時間點檢查 Timer 時,僅僅會觸發一次 Timer 回調函數。

須要注意,Timer 只能被添加到一個 RunLoop 中,可是 Timer 能夠被添加到一個 RunLoop 的多個 Modes 中。

Note: A timer can be registered to only one run loop at a time, although it can be in multiple modes within that run loop.

Observer

前面介紹的輸入源中,Source0 的事件來自手動觸發信號,Source1 的時間來自內核的 mach ports,Timer 的事件來自內核經過 mach port 發送的時鐘信號,Observer 的事件則是來自 RunLoop 自己的狀態變動。

RunLoop 的狀態用CFRunLoopActivity類型表示,包括

  • kCFRunLoopEntry
  • kCFRunLoopBeforeTimers
  • kCFRunLoopBeforeSources
  • kCFRunLoopBeforeWaiting
  • kCFRunLoopAfterWaiting
  • kCFRunLoopExit
  • kCFRunLoopAllActivities(全部狀態的集合)。

構建 RunLoop Observer 時須要指定它所觀察的目標 RunLoop 狀態,狀態是位域能夠經過CFRunLoopActivity的「按位與」運算指定 Observer 觀察多種目標狀態。當 Observer 所觀察的 RunLoop 狀態發生相應變動時,RunLoop 觸發 Observer 的回調函數。

須要注意,Observer 只能被添加到一個 RunLoop 中,可是 Observer 能夠被添加到一個 RunLoop 的多個 Modes 中。

Note: A run loop observer can be registered in only one run loop at a time, although it can be added to multiple run loop modes within that run loop.

2.1.2 Modes

前面提到 Modes 爲 RunLoop 的運行過程須要處理的輸入源劃定範圍。缺省狀況下都會指定 RunLoop 進入默認 RunLoop Mode(kCFRunLoopDefaultMode)。默認 RunLoop Mode 是用於在應用(線程)空閒時處理輸入源的事件。但 RunLoop Mode 的種類毫不僅限於此,開發者甚至能夠新建自定義的 Mode。Mode 之間是經過 mode name 字符串來區分的。Core Foundation 公開的 mode 只有:

  • kCFRunLoopDefaultMode
  • kCFRunLoopCommonModes

Foundation 公開的 mode 卻是更多:

  • NSDefaultRunLoopMode
  • NSRunLoopCommonModes
  • NSEventTrackingRunLoopMode
  • NSModalPanelRunLoopMode
  • UITrackingRunLoopMode
Common Modes

Core Foundation 還定義了一個特殊的 Mode,common modes(kCFRunLoopCommonModes),用於將 Sources、Timers、Observers 輸入源同時關聯到多個 Mode。每一個 RunLoop 都會有本身設定的 common modes 集合,可是默認 mode 一定是其中一個。Common modes 用集合數據類型(哈希表)保存。開發者可使用CFRunLoopAddCommonMode將某個 Mode 指定爲 common mode。

舉個例子。當把NSTimer添加到主線程 RunLoop 的NSDefaultRunLoopModeTimer 只與默認 mode 關聯。用戶一直滾動界面時,NSTimer註冊的 selector 是不會觸發的。由於用戶滾動界面時主線程 RunLoop 會進入UITrackingRunLoopMode,其中並無 Timer 這個輸入源,所以 Timer 的事件就不會觸發。其中一種解決方式是,將NSTimer添加到主線程 RunLoop 的NSRunLoopCommonModes

爲調試將 Timer 添加到 default mode 和添加到 common modes 有什麼區別,使用如下一段代碼進行調試。並在NSLog(@"");打上斷點,而後運行。

CFRunLoopTimerRef defaultTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 1, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in default mode tick: %d", tick++);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), defaultTimer, kCFRunLoopDefaultMode);
    
CFRunLoopTimerRef commonTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 2, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in common modes tick:%d", tick++);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), commonTimer, kCFRunLoopCommonModes);

//讓 RunLoop 持有 Timer 便可
CFRelease(defaultTimer);
CFRelease(commonTimer);

NSLog(@"");
複製代碼

程序陷入斷點後,輸入如下紅框的 lldb 命令打印兩個 timer 以及當前 RunLoop 對象。以下圖所示,RunLoop 對象信息太多,使用 Command+F 快捷鍵在調試日誌中搜索兩個 Timer 對象的內存地址。

首先搜索添加到 default mode 的defaultTimer,發現defaultTimer只被添加到kCFRunLoopDefaultMode中,以下圖所示

而後搜索添加到 common modes 的commonTimer,發現commonTimer被添加到了三個地方,分別是:

  • common modes item

  • UITrackingRunLoopMode

  • kCFRunLoopDefaultMode

此時再回頭看剛開調試,沒有提到的藍色方框框中的內容,其含義是當前 RunLoop 的 common modes 包含兩個kCFRunLoopDefaultModeUITrackingRunLoopMode。這意味着當把 input source 添加到 RunLoop 的kCFRunLoopCommonModes時,input source 同時會被添加到 RunLoop 的 common modes 包含的全部 modes 中,同時也將其添加到 RunLoop 的 common items 中進行備案。重點是,這樣一來,把 Timer 添加到kCFRunLoopCommonModes,則標記爲 common mode 的UITrackingRunLoopMode也會添加該 Timer。這就是爲何,即便滾動頁面時 RunLoop 運行在UITrackingRunLoopMode下,也能觸發該 Timer 的事件的緣由。而添加到kCFRunLoopDefaultMode的 Timer 不觸發則是由於,它只被添加到了kCFRunLoopDefaultMode中。

能夠進一步嘗試搜索 common items 中任意一個 input source,在調試窗口日誌中都會命中多個結果。

Note: Once a mode is added to the set of common modes, it cannot be removed.

2.2 RunLoop與線程

RunLoop 和線程(Thread)是一一對應的關係,默認狀況下線程是沒有 RunLoop 的(主線程除外),也就是說線程執行完任務後就能夠直接銷燬。且 Cocoa 也沒有提供建立 RunLoop 的 API,僅能經過CFRunLoopGetMain()CFRunLoopGetCurrent()獲取,當獲取時檢測到線程未建立 RunLoop 實例,則系統自動爲其建立 RunLoop。

RunLoop 公開的接口有兩套,NSRunLoopCFRunLoop二者之間能夠 toll-free bridging 轉換。CFRunLoop代碼是開源的。須要注意,NSRunLoop不是線程安全的,Apple Documentation 中有如下一條 Warning 聲明不能在 RunLoop 的線程以外的線程上,調用該 RunLoop 的方法。

Warning: The NSRunLoop class is generally not considered to be thread-safe and its methods should only be called within the context of the current thread. You should never try to call the methods of an NSRunLoop object running in a different thread, as doing so might cause unexpected results.

2.3 RunLoop的API

RunLoop 公佈的 API 有兩套NSRunLoopCFRunLoop,後者的 API 更加完備,所以本章只介紹CFRunLoop的 API。上面貼出摘自的 Apple Documentation 的 Warning 意思是NSRunLoop不是線程安全的,不能在NSRunLoop所在線程外調用NSRunLoop的方法(不能在NSRunLoop線程外調用其performSelector:XXX接口彷佛會讓NSRunLoop的這套接口變得有點雞肋)。本章對CFRunLoop的公開 API 作了一個簡單的分類,大部分從接口就能夠知道其用途,所以只註釋其中一部分 API。

2.3.1 RunLoop操做API

運行RunLoop
CFRunLoopRunResult CFRunLoopRunInMode(CFRunLoopMode mode, CFTimeInterval seconds, Boolean returnAfterSourceHandled)
複製代碼

CFRunLoopRunInMode用於以指定的 mode 運行 RunLoop。CFRunLoopRunInMode能夠遞歸地調用,即開發者能夠在 RunLoop 內的任何一個回調函數中調用CFRunLoopRunInMode從而在 RunLoop 所在線程的調用棧上造成層次嵌套的 RunLoop 激活形態。意思就是,在 RunLoop 的回調函數內,開發者能夠按需自由調用CFRunLoopRunInMode切換 RunLoop Mode,並且基本不會產生反作用。

  • seconds參數表示當次 RunLoop 運行的時間長度,若是seconds指定爲0,則 RunLoop 只會處理其中一個 input source 的事件(若是處理的剛好是 source0,則存在額外再多處理一個事件的可能(TODO)),此時不管開發者指定怎樣的returnAfterSourceHandled都是無濟於事的。

  • returnAfterSourceHandled用於指定 RunLoop 執行完 source 後是否當即退出。若是是NO,則 source 執行完畢後,仍要等到seconds時間點到達時才退出。

  • 返回 RunLoop 退出的緣由。

    • kCFRunLoopRunFinished:RunLoop 中已經沒有 input source;
    • kCFRunLoopRunStoped:RunLoop 被CFRunLoopStop函數終止;
    • kCFRunLoopRunTimedOutseconds計時到時,超時退出;
    • kCFRunLoopRunHandledSource:已完成一個 input source 的處理。該返回值只會在returnAfterSourceHandled參數爲true時纔會出現。

CFRunLoopRun是在 default mode 下運行 RunLoop。

Note: You must not specify the kCFRunLoopCommonModes constant for the mode parameter. Run loops always run in a specific mode. You specify the common modes only when configuring a run-loop observer and only in situations where you want that observer to run in more than one mode.

喚醒RunLoop

CFRunLoopWakeUp用於喚醒 RunLoop。當 input source 未事件觸發時,RunLoop 處於睡眠狀態,在它超時退出或被顯式喚醒以前,RunLoop 都會一直維持在睡眠狀態。當修改 RunLoop 時,譬如添加了 input source,必須喚醒 RunLoop 讓它處理該修改操做。當向 Source0 發送信號,並但願 RunLoop 能馬上處理時,能夠調用CFRunLoopWakeUp當即喚醒 RunLoop。

停止RunLoop

CFRunLoopStop用於停止 RunLoop 當前運行,並將控制權交還給當初調用CFRunLoopRunCFRunLoopRunInMode激活 RunLoop 本次運行的函數。若是該函數是 RunLoop 的某個回調函數,也就是CFRunLoopRunInMode嵌套,則只會停止 最內層的那次CFRunLoopRunInMode調用 所激活的運行循環。

RunLoop的等待狀態

若 RunLoop 的輸入源中沒有須要處理的事件,則 RunLoop 會進入睡眠狀態,直到 RunLoop 被CFRunLoopWakeUp顯式喚醒,或者被 mach_port 消息喚醒。CFRunLoopIsWaiting能夠用於查詢 RunLoop 是否處於睡眠狀態,RunLoop 正在處理事件或者 RunLoop 還未開始運行,該函數都返回false。注意該函數只用於查詢外部線程的 RunLoop 狀態,由於若是查詢當前 RunLoop 狀態只會返回false

2.4 RunLoop的流程

我的能想到的探索 RunLoop 的流程有兩種方式,分別是 lldb 調試和源代碼解讀。前者比較直觀,就先從它入手。

2.4.1 LLDB調試RunLoop流程

Source0調試

仍然沿用《調試iOS用戶交互事件響應流程》的簡單 Demo,可是屏蔽其中的全部定製的hitTest:withEvent:nextRespondertouchesBegan:withEvent:touchesBegan:withEvent:代碼。由於調試不須要看這些打印內容。而後在didClickBtnFront:點擊「點我前Button」的點擊事件回調中種下一個斷點。點擊「點我前Button」程序打斷。

使用bt命令查看調用棧以下圖所示,提取出與 RunLoop 相關的調用爲下圖紅框框中的內容。原來 iOS 的用戶交互事件是在GSEventRunModal中調用CFRunLoopRunSpecific函數運行了某個CFRunLoop對象。當點擊事件觸發時喚醒了 RunLoop,RunLoop 經過__CFRunLoopDoSource0函數調用__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__開始運行某個 Source0 的回調函數。後面就是事件響應流程了。

然而這「某個CFRunLoop對象」具體是哪一個RunLoop,具體在哪一個 mode 下運行的 RunLoop,且這「某個 Source0」具體是哪一個 Source0。這些細節都暫時不得而知。

首先嚐試扒一扒CFRunLoop的細節。首先從調用棧中找到調用CFRunLoopRunSpecific的棧幀,這裏是 16 號棧幀,frame select 16進入該棧幀。而後打印調用CFRunLoopRunSpecific前賦值的寄存器$rbx,結果是kCFRunLoopDefaultMode,原來是以默認 mode 運行 RunLoop 的。

繼續進到 15 號幀看看會不會有什麼意外收穫。打印到r13寄存器,喲吼,還真敢有。這裏由看到了熟悉的kCFRunLoopDefaultMode的面孔,並且還找到一個「形跡可疑」回調函數名爲__handleEventQueue的 Source0,注意調用棧第 10 幀剛好是__handleEventQueueInternal這就是咱們要找的 Source0 了。

其實更大的驚喜在後頭。打印r15寄存器。嗯?這不就是咱們要找的 RunLoop 君麼。並且它還包含了前面打印出來的kCFRunLoopDefaultMode君。再po CFRunLoopGetMain()打印一下主線程 RunLoop 能夠確認該 RunLoop 其實就是主線程 RunLoop

以上就是 Source0 的觸發流程以下:

->CFRunLoopRunXXX ->__CFRunLoopRun ->__CFRunLoopDoSources0 ->__CFRunLoopDoSource0 ->__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__

可是其中彷佛少了添加 source 和添加 mode 的細節,這應該是應用初始化時須要完成的操做,並且基本就是調用CFRunLoop相應的 API 實現,所以不調試該部份內容。

那麼究竟是誰向 Source0 發送的觸發信號呢?接下來就揪出這個「幕後黑手」。從前面對 Source0 的介紹已知,使用CFRunLoopSourceSignal發送觸發信號。首先將前面下的斷點用breakpoint delete全刪掉,而後breakpoint set -n CFRunLoopSourceSignalCFRunLoopSourceSignal函數下個全局斷點。準備就緒,點擊「點我前Button」。接下來斷點命中了不少次,每次命中都bt瞄一眼調用棧,發現前幾回命中都是與事件觸發相關。

原來事件是經過UIEventFetcher_receiveHIDEventInternal方法觸發的,從函數名能夠知道,它是用來接收從 IOHID(I/O Hardware Interface Device) 層發送來的用戶交互事件的。接下來在斷點第一次命中時調試用戶事件究竟是從何而來。frame select 1進入第 1 幀,打印關鍵寄存器數據,能夠推斷出用戶交互事件是底層經過IOHIDEventSystemClientHIDServiceClient發送而來。

那麼,底層發送而來的事件是怎樣的形式呢?咱們再試探性地打印寄存器內容。試到rbx寄存器時,發現了一個很像事件的「東西」,看起來像是表示一次 touch 事件,進一步po [$rdx class]打印其類型是HIDEvent。看來這就是從 IOHID 層發送上來的用戶觸摸事件。

想必是HIDEvent 經過UIEventFetcher接收,並轉化爲UIEvent發送到 UIKitCore 框架進行處理。另外須要注意,從上面調試過程當中的調用棧所屬線程爲 Thread 6 能夠判定,上面收集HIDEvent的線程並非主線程。也就是說收集來自 IOHID 層的HIDEvent和處理UIEvent事件是在不一樣的線程,並且後者纔是在主線程。

UIEventFetcher還不是最終 boss,再回頭看本次的調用棧,從中發現了__CFRunLoopDoSource1,Source1 是經過發送 mach port 消息觸發的,原來這隱藏的幕後黑手居然是內核!

最後的問題,是誰喚醒了主線程處理 Source0 仍是說根本不須要?爲驗證這個問題,再下一個CFRunLoopWakeUp的全局斷點,發現點擊按鈕後,確實有觸發CFRunLoopWakeUp喚醒 RunLoop,那麼這個 RunLoop 具體是哪一個 RunLoop 呢?咱們再試探性的打印寄存器內容,發現rdi寄存器裏面保存的剛好是一個 RunLoop 對象,以下圖所示。經過po CFRunLoopGetMain()打印主線程 RunLoop 對象後能夠確認,這裏喚醒的正是主線程 RunLoop

至此,主線程經過 Source0 接收並觸發UIEvent的流程就能夠串聯起來了。

Source1調試

緊接上一節的進度,繼續探索 Source1 接收來自底層的HIDEvent的流程。陷入斷點時,用bt命令查看調用棧,可見 Source1 的觸發流程以下。其中__CFMachPortPerform__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__調用用來觸發 mach port 對應的 Source1 的回調事件的函數。RunLoop 的某次運行迭代,若沒有檢測到待處理的 mach port 消息,則不會觸發__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__

->CFRunLoopRunXXX ->__CFRunLoopRun ->__CFRunLoopDoSource1 ->__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ ->__CFMachPortPerform

關注到第 3 號和第 5 號棧幀。試探性地打印寄存器內容,能夠查看到接收 IOHID 事件的NSMachPort對象,以及其對應的 Source1 內容。Source1 的回調是__IOHIDEventSystemClientQueueCallback,對應上面的調用棧中的第 2 號棧幀,由__CFMachPortPerform觸發。

關於 IOHID 事件消息如何發送到 mach port,經過sendPortsendBeforeDatereceivePort斷點是截獲不到該過程的,估計其實現是直接調用了內核的 mach port 消息發送 API 實現的。不過該部分過程比較明顯,這裏就不繼續調試了。只須要知道,若是是自定義的 Source1 輸入源,須要給輸入源指定NSMachPort對象,消息發送接收經過sendPortsendBeforeDatereceivePort API 實現便可。

猜測:關於爲什麼嘗試了各類斷點都沒有捕捉到 mach port 消息的發送動做,極可能是由於該消息是從系統的另一個進程發送過來的,其中最可能就是 SpringBoard,做爲 iOS 的桌面 APP,SpringBoard 率先處理來自加速計事件處理橫豎屏切換、接收鎖屏鍵音量鍵等事件本是理所應當的。另外,從 iOS 6.0 開始,蘋果引入了 BackBoard 分擔了 SpringBoard 的部分功能,例如,處理來自光傳感器的信號調整屏幕亮度、桌面 APP 圖標的點擊及長按事件。BackBoard 和 SpringBoard 同樣,也是一個 Daemon 進程。

Timer調試

爲調試 Timer,在 Demo 中增長一句使用CFRunLoopTimer的代碼,實際上隨便寫一個NSTimer也能夠,由於前面提到過二者是 toll-free bridged 的。

//調試Timer
CFRunLoopTimerRef defaultTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 1, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in default mode tick: %d", tick);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), defaultTimer, kCFRunLoopDefaultMode);

//不要忘了手動 Release CF 資源,此時 Timer 會被 RunLoop 持有,所以添加完能夠直接釋放
CFRelease(defaultTimer);
複製代碼

NSLog除打上斷點,運行很少久 Demo 程序就會陷入斷點,此時bt查看調用棧,能夠看到其調用 timer source 的觸發過程也是至關簡單的,其流程以下:

->CFRunLoopRunXXX ->__CFRunLoopRun ->__CFRunLoopDoTimers ->__CFRunLoopDoTimer ->__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__

前文提到過 Timer 的本質是在 RunLoop 中註冊時間點。翻了 RunLoop 源代碼,發現該時間的參照標準是來自內核的uint64_t mach_absolute_time(void)函數。時間點註冊則是間接調用了dispatch_time。看來 不管是NSTimer仍是CFRunLoopTimer定時器,本質都是經過 GCD Timer 實現的

CF_PRIVATE dispatch_time_t __CFTSRToDispatchTime(uint64_t tsr) {
    uint64_t tsrInNanoseconds = __CFTSRToNanoseconds(tsr);
    if (tsrInNanoseconds > INT64_MAX - 1) tsrInNanoseconds = INT64_MAX - 1;
    return dispatch_time(1, (int64_t)tsrInNanoseconds);
}
複製代碼
Observer調試

用如下代碼調試CFRunLoopObserver。在NSLog處打上斷點,運行程序很快就會陷入斷點。Observer 的觸發流程以下:

->CFRunLoopRunXXX ->__CFRunLoopRun ->__CFRunLoopDoObservers ->__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__

CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAfterWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
    NSLog(@"");
});
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
CFRelease(observer);
複製代碼

Autorelease pool 是和 RunLoop 有十分密切的聯繫的。用戶點擊界面上的按鈕時,主線程就會從阻塞狀態轉向運行狀態(不考慮就緒中間態),主線程 RunLoop 也會觸發kCFRunLoopAfterWaiting狀態變動。同理,APP 靜止時,主線程 RunLoop 就會進入kCFRunLoopBeforeWaiting。此時,RunLoop 會調用一次objc_autoreleasePoolPop清理 autorelease pool,緊接着調用objc_autoreleasePoolPush新建 autorelease pool,併發送mach_msg消息進入內核態,主線程進入阻塞狀態。

2.4.2 解讀RunLoop源代碼

文章仍是太長,再寫下去就太太太太長了。這裏直接安利 Ibireme 的[深刻理解RunLoop]吧。他的博文對 RunLoop 關鍵代碼提取至關精煉。這裏借用一張 Ibireme 文章裏面總結的很是好的 RunLoop 處理 Input Sources 的流程圖。

3、RunLoop與事件響應

原本是打算把本文寫成《調試iOS用戶交互事件響應流程》續集,標題原定《事件響應與RunLoop》寫着寫着(實際上是邊寫邊學)發現,RunLoop 漸漸「喧賓奪主」了,既然如此,因而將計就計,換了個順序,讓 RunLoop 作了「大哥」。

得益於第二部分調試 RunLoop 時,已經使用了事件響應做爲例子調試了 RunLoop 的各類 input source 事件的響應邏輯,這裏能夠直接整理出 iOS 經過 RunLoop 處理用戶事件的流程:

4、總結

  • RunLoop 是線程保活的方式,與線程是一一對應的關係;
  • RunLoop 中包含了若干 mode,mode 中包含了若干輸入源,mode 的含義是當 RunLoop 在 mode 狀態下執行是,只響應 mode 中的輸入源。RunLoop 能夠嵌套運行,即在輸入源的回調函數調用CFRunLoopRunXXX,使 RunLoop 能夠在各類 mode 之間自由切換;
  • Common modes 是一種特殊的 mode,將 mode 標記爲 common 意味着會將 RunLoop 中的 common mode items 同步到該 mode 中;
  • 輸入源都包含一個回調函數,用戶處理接收事件,事件處理邏輯則在回調函數中;
  • 輸入源包括 Sources、Timers、Observers,Sources 有兩種,Source0 和 Source1;
  • 經過CFRunLoopSourceSignal向 Source0 發送事件信號,若想 RunLoop 當即處理事件則調用CFRunLoopWakeUp喚醒 RunLoop;
  • Source1 與特定的 mach port 關聯,經過向 mach port 發送 mach port 消息觸發 Source1 事件;
  • Timer 的本質是在 RunLoop 中註冊時間點,在時間點到達時觸發 Timer 的回調函數,CFRunLoopTimer本質是經過 GCD Timer 實現的;
  • Observer 能夠觀察 RunLoop 的狀態變動,觸發 Observer 的回調函數;
  • 用戶交互事件首先在 IOHID 層生成 HIDEvent,而後向事件處理線程的 Source1 的 mach port 發送 HIDEvent 消息,Source1 的回調函數將事件轉化爲 UIEvent 並篩選須要處理的事件推入待處理事件隊列,向主線程的事件處理 Source0 發送信號,並喚醒主線程,主線程檢查到事件處理 Source0 有待處理信號後,觸發 Source0 的回調函數,從待處理事件隊列中提取 UIEvent,最後進入 hit-test 等 UIEvent 事件響應流程。
相關文章
相關標籤/搜索