啟動流程
- 首先加載info.plist文件中的配置進(jìn)行解析
- 創(chuàng)建沙盒, (iOS8之后會每次生成一個新的沙盒, 參考模擬器運(yùn)行時的沙盒路徑)
- 加載Mach-O可執(zhí)行文件,讀取dyld路徑兵運(yùn)行dyld動態(tài)鏈接器。runtime就是在這個時候被初始化的, 同時還會加載c函數(shù), Category以及C++靜態(tài)函數(shù), OC的+load方法, 最后dyld返回main函數(shù)地址, main函數(shù)被調(diào)用.
+load以及+initialize
load方法:
當(dāng)類被引用進(jìn)項(xiàng)目的時候就會執(zhí)行l(wèi)oad函數(shù)(在main函數(shù)開始執(zhí)行之前) 與這個類是否被用到無關(guān), 每個類的load函數(shù)只會自動調(diào)用一次. 由于load函數(shù)是系統(tǒng)自動加載的 不需要[super load], 否則會導(dǎo)致父類的load方法重復(fù)調(diào)用
注意:
load調(diào)用時機(jī)比較早,當(dāng)load調(diào)用時,其他類可能還沒加載完成,運(yùn)行環(huán)境不安全. load方法是線程安全的,它使用了鎖,我們應(yīng)該避免線程阻塞在load方法
load方法加載順序:
- 一個類的+load方法在其父類的+load方法后調(diào)用
- 一個Category的+load方法在被其擴(kuò)展的類自由+load方法后調(diào)用.
當(dāng)有多個類別(Category)都實(shí)現(xiàn)了load方法, 這幾個load方法都會執(zhí)行, 但執(zhí)行順序不確定(其執(zhí)行順序與類別在Compile Sources中出現(xiàn)的順序一致)
initialize方法:
該方法在類或者子類的第一個方法被調(diào)用前調(diào)用, 即使類文件被引用進(jìn)項(xiàng)目, 但是沒有使用, initialize不會被調(diào)用
initialize與load方法相同為系統(tǒng)自動調(diào)用, 無需[super initialize]
initialize方法調(diào)用順序:
- 父類的initialize方法會比子類的initialize方法先執(zhí)行
- 當(dāng)子類未實(shí)現(xiàn)initialize方法時, 會調(diào)用父類initialize方法.
子類實(shí)現(xiàn)initialize方法時, 會覆蓋父類initialize方法. - 當(dāng)有多個Category都實(shí)現(xiàn)了initialize方法, 會覆蓋類中的方法,
只執(zhí)行一個(會執(zhí)行Compile Sources列表中最后一個Category的initialize方法)
main函數(shù)
main函數(shù)是iOS程序的入口, 返回值為int, 死循環(huán)并不會返回.

UIApplicationMain:
該方法會初始化一個UIApplication實(shí)例以及他的代理
@param argc 參數(shù)個數(shù)
@param argv 參數(shù)
@param principalClassName 根據(jù)該參數(shù)初始化一個UIApplication或其子類的對象并開始接收事件(傳入nil, 意味使用默認(rèn)的UIApplication)
@param delegateClassName 該參數(shù)指定AppDelegate類作為委托, delegate對象主要用于監(jiān)聽, 類似于生命周期的回調(diào)函數(shù)
@return 返回值為int, 但是并不會返回(runloop), 會一直在內(nèi)存中 直到程序終止
在swift工程中并沒有main函數(shù), 但是會發(fā)現(xiàn)Appdelegate.swift文件中有一句@UIApplicationMain, 這個標(biāo)簽的作用就是將標(biāo)注的類作為委托, 創(chuàng)建一個UIApplication并啟動整個程序. 如果我們想要使用UIApplication的子類可以直接刪除這個標(biāo)簽, 并在工程中新建一個main.swift文件

Appdelegate
app啟動完成
如果由通知打開, launchOptions對應(yīng)的key有值, iOS10之后UNUserNotificationCenterDelegate中的didReceiveNotificationResponse方法也能響應(yīng)
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
NSLog(@"%s", __func__);
return YES;
}
程序由后臺轉(zhuǎn)入前臺
前臺是指app為當(dāng)前手機(jī)展示. app首次啟動時不會調(diào)用該方法
Swift:
func applicationWillEnterForeground(_ application: UIApplication) {
// 本地通知key: UIApplication.willEnterForegroundNotification
}
OC:
- (void)applicationWillEnterForeground:(UIApplication *)application {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
// 本地通知key: `UIApplicationWillEnterForegroundNotification`
NSLog(@"%s", __func__);
}
程序進(jìn)入活躍狀態(tài)
該方法app首次進(jìn)入就會調(diào)用, 由后臺轉(zhuǎn)入前臺, 也會在applicationWillEnterForeground方法之后調(diào)用
Swift:
func applicationDidBecomeActive(_ application: UIApplication) {
// 本地通知key: UIApplication.didBecomeActiveNotification
}
OC:
- (void)applicationDidBecomeActive:(UIApplication *)application {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// 本地通知key: UIApplicationDidBecomeActiveNotification
NSLog(@"%s", __func__);
}
程序進(jìn)入非活躍狀態(tài)
比如有電話進(jìn)來或者鎖屏等情況, 此時應(yīng)用會先進(jìn)入非活躍狀態(tài), 也有可能是程序即將進(jìn)入后臺(進(jìn)入后臺前會先調(diào)用)
Swift:
func applicationWillResignActive(_ application: UIApplication) {
// 本地通知key: UIApplication.willResignActiveNotification
}
OC:
- (void)applicationWillResignActive:(UIApplication *)application {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
// 本地通知key: UIApplicationWillResignActiveNotification
NSLog(@"%s", __func__);
}
程序進(jìn)入后臺
Swift:
func applicationDidEnterBackground(_ application: UIApplication) {
// 本地通知key: UIApplication.didEnterBackgroundNotification
}
OC:
- (void)applicationDidEnterBackground:(UIApplication *)application {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
// 本地通知key: UIApplicationDidEnterBackgroundNotification
NSLog(@"%s", __func__);
}
當(dāng)程序進(jìn)入后臺,很快便會就如掛起狀態(tài),在掛起狀態(tài)下,無法執(zhí)行任何代碼。等到系統(tǒng)內(nèi)存告急時會被殺死,如果有未完成的任務(wù),可以在該方法下申請延時180s執(zhí)行代碼.
__block UIBackgroundTaskIdentifier backTaskId;
backTaskId = [application beginBackgroundTaskWithExpirationHandler:^{
NSLog(@"backgroundTask reaches 0");
[application endBackgroundTask:backTaskId];
backTaskId = UIBackgroundTaskInvalid;
}];
程序即將退出
Swift:
func applicationWillTerminate(_ application: UIApplication) {
// 程序即將退出
// 本地通知key:UIApplication.willTerminateNotification
}
OC:
- (void)applicationWillTerminate:(UIApplication *)application {
// 本地通知key: UIApplicationWillTerminateNotification
NSLog(@"%s", __func__);
}
參考文獻(xiàn):
iOS App生命周期理解
啟動流程
什么是App的啟動?
通俗來講,就是從用戶點(diǎn)擊App圖標(biāo)開始,到看到第一個頁面的時間間隔。
細(xì)分來看,App的啟動分兩種:冷啟動和熱啟動。
- 冷啟動:App啟動前,內(nèi)核沒有為它分配相應(yīng)的進(jìn)程。
- 熱啟動:App冷啟動后,用戶將App退出至后臺,再進(jìn)入的過程。
熱啟動沒什么好說的,能做的通用的事情非常少。App從后臺回來直接進(jìn)入AppDelegate生命周期函數(shù),剩下的就是App自身的業(yè)務(wù)邏輯,如果要優(yōu)化,也僅是業(yè)務(wù)上的優(yōu)化,恢復(fù)數(shù)據(jù),做一些同步。所以本篇我們只展開講冷啟動(后文將用啟動代替冷啟動)的流程和優(yōu)化。BTW: 筆者建議先從熱啟動開始優(yōu)化,畢竟大部分的性能瓶頸都在自家代碼里。
App啟動步驟概覽
從大方向講,App啟動步驟分為三個階段:
從系統(tǒng)exec()調(diào)用到我們App的main()為止 (main函數(shù)之前)
main()執(zhí)行之后
首屏渲染結(jié)束(至第一個界面的viewDidAppear:,nib loading)
整個流程如下:

概括來講:內(nèi)核先將我們的App加載進(jìn)內(nèi)存,之后加載一個“中間件”: dynamic loader(簡稱:dyld)。之后dyld會負(fù)責(zé)分析App的Mach-O文件以加載所需的dynamic libraries。之后利用Rebasing和Binding修正內(nèi)部和外部指針指向。最后加載runtime組件,runtime組件加載好后就會向需要初始化的object發(fā)送消息,開始初始化。 至此,main函數(shù)之前的步驟結(jié)束。之后的流程就是我們耳熟能詳?shù)纳芷诹耍珹pp開始在AppDelegate里面初始化我們自定義的服務(wù)以及渲染首屏等。
Crash Course (名詞速成班)
相信你看到這里或多或少已經(jīng)對其中的一些名詞感到陌生甚至”討厭”了。這里面可能除了runtime,其他的你都沒接觸過。所以,這一小節(jié)我們針對上述流程涉及到的名詞加以解釋,并幫助大家擴(kuò)充下底層知識,更好的理解main函數(shù)之前發(fā)生的事情。
內(nèi)核
內(nèi)核是操作系統(tǒng)的核心。iOS和OS X使用的都是XNU內(nèi)核。在這里,我們不需要知道XNU內(nèi)核的詳細(xì)架構(gòu),只需要知道它的功能即可,例如:提供基礎(chǔ)服務(wù),像線程,進(jìn)程管理,IPC(進(jìn)程間通信),文件系統(tǒng)等等。

上圖闡述了內(nèi)核是如何加載我們的App到進(jìn)程中的。在這幅圖里有兩個關(guān)鍵點(diǎn):
PAGEZERO是怎么回事?
為什么我們的App起始位置是不確定的?
這就要涉及到下一個知識點(diǎn):ASLR。
ASLR(地址空間布局隨機(jī)化) + Code Sign
簡單來說,就是當(dāng)應(yīng)用映射到邏輯地址空間的時候,利用ASLR技術(shù),可以使得應(yīng)用的起始地址總是隨機(jī)的,以避免黑客通過起始地址+偏移量找到函數(shù)的地址。ASLR和Code Sign是iOS兩種主要的安全技術(shù)。相信大家對Code Sign并不陌生,在底層上進(jìn)行code sign的時候,加密哈希是針對Mach-O的每一個page,這就保證了dyld在加載Mach-O的時候可以確保單個page不被篡改。
那PAGEZERO的作用又是什么呢?當(dāng)系統(tǒng)利用ASLR分配了隨機(jī)地址后,從0到該地址的整個區(qū)間會被標(biāo)記為不可訪問,意味著不可讀,不可寫,不可被執(zhí)行。這個區(qū)域的大小蘋果給出了官方的解釋:
針對32位進(jìn)程,至少4KB
針對64位進(jìn)程,至少4GB
這塊區(qū)域可以幫助我們捕獲任意的空指針和指針截斷。(例如:64位指針轉(zhuǎn)32位的時候)
虛擬內(nèi)存
理解了虛擬內(nèi)存能夠更好地幫助我們理解iOS內(nèi)部機(jī)制。首先,現(xiàn)有的操作系統(tǒng)大都使用邏輯地址和物理地址這兩個概念。邏輯地址可以理解為是虛擬地址,為的是”欺騙App”;但經(jīng)過硬件和軟件的配合,邏輯地址可以被映射到實(shí)實(shí)在在的物理地址上。
邏輯內(nèi)存是被分頁的,就像一整塊蛋糕被分成多個小塊一樣。然后通過頁表,映射到物理內(nèi)存。物理內(nèi)存是被分為很多幀的,和邏輯內(nèi)存的頁相對應(yīng)。(頁面和幀的對應(yīng)關(guān)系主要是通過頁表來保存)
總的來說,邏輯地址空間(虛擬內(nèi)存)大大提高了CPU的使用效率,使得多個程序可以被同時、按需加載進(jìn)內(nèi)存。
iOS中,每一個進(jìn)程都是一個邏輯地址空間,并且同時映射到物理內(nèi)存上。這種映射不只是”一對一”關(guān)系,還可以是“一對0,多對一”。 當(dāng)邏輯內(nèi)存地址在物理內(nèi)存上沒有對應(yīng)的地址時,就會發(fā)生page fault錯誤。這時候內(nèi)核就會停止當(dāng)前線程,分配一塊物理內(nèi)存給當(dāng)前的邏輯地址;如果我們有兩個進(jìn)程運(yùn)行在不同的邏輯地址空間,它們是可以同時映射到同一物理內(nèi)存的,這時候就需要它們share部分的RAM了。
那么問題來了,既然兩個進(jìn)程的某些邏輯地址空間可以同時映射到相同的物理地址,那么如果它們一方需要修改該地址內(nèi)容的話,該如何是好呢?這就需要介紹下iOS的Copy-On-Write(COW)機(jī)制了。
當(dāng)一個進(jìn)程試圖向DATA page寫入數(shù)據(jù)時,內(nèi)核會立刻創(chuàng)建一個拷貝,并映射到另一個物理RAM
至于什么是DATA page, 我們稍后在Mach-O章節(jié)介紹。
當(dāng)Copy-On-Write發(fā)生的時候,會產(chǎn)生dirty page, 與之相對的則是clean page。Clean page是那種內(nèi)核可以之后從磁盤恢復(fù)的拷貝;而dirty page則包含了進(jìn)程信息,無法被其他進(jìn)程重用。

圖中RAM 3所在的page就是一個dirty page。
在dyld章節(jié)我會再次提到clean page和dirty page,著重講解dyld是如何通過修改Mach-O的__Data段,從而產(chǎn)生dirty page。
Mach-O
Mach-O,全稱是Mac object file format, 是一種文件類型。哪些文件是Mach-O呢?
- Exectuable: 例如我們App bundle下的二進(jìn)制文件
- Dylib: 動態(tài)庫,好比Windows下得DLL
- Bundle: 指的是無法被鏈接,只能使用dlopen加載的動態(tài)庫,例如Mac平臺下的plug-ins
- Image: 以上三個
- Framework: 包含資源文件、頭文件等的dylib
段(Segments)
Apple的Mach-O文件可以分為三大部分:

- Header: 頭部, 包含可執(zhí)行的cpu架構(gòu),文件類型等,例如arm64,x86;MH_EXECUTE。(如果合并過架構(gòu),則會是Fat Header)
- Load commands: 加載命令,包含文件的組織架構(gòu)和在虛擬內(nèi)存中的布局方式
- Data: 數(shù)據(jù),包含load commands中需要的各個段(segment)的數(shù)據(jù),每一個Segment的大小都是Page的整數(shù)倍。
Data部分示意圖:

上例中,TEXT段大小是3頁,DATA和LINKEDIT各是一頁。Page的大小取決于硬件的架構(gòu),例如在arm64
架構(gòu)下,每頁是16KB;其余架構(gòu)下每頁是4KB。
Data部分包含哪些segment呢?絕大多數(shù)mach-o包括以下三段:
- __TEXT: 代碼段,只讀,包括函數(shù),和只讀的常量,例如C字符串,上圖中類似__TEXT, __text的都是代碼段
- __DATA: 數(shù)據(jù)段,讀寫,包括可讀寫的全局變量, 靜態(tài)變量等,上圖類似中的__DATA, __data都是數(shù)據(jù)段
- __LINKEDIT: 如何加載程序, 包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息。
下圖是我個人項(xiàng)目NetworkTransport的Mach-O文件布局: 從圖中可以清晰的看到__TEXT和__DATA. 那__LINKEDIT去哪里了呢?據(jù)我觀察__LINKEDIT似乎被隱藏了,但是其存儲的元數(shù)據(jù)卻是可見的,例如下圖中筆者選中的Dynamic Loader Info一欄,Rebase的信息一覽無余的展現(xiàn)在我們面前。

并且,當(dāng)你展開Load Commands選項(xiàng)的時候,就會發(fā)現(xiàn)__LINKEDIT的段布局信息。

部分(Sections)
Section是段(Segment)中的子區(qū)域, 每個section包含的內(nèi)容不同,大小也沒有限制。例如在__TEXT segment里, text section包含的是可執(zhí)行的機(jī)器碼; cstring section包含C類常量字符串。值得注意的是,這里面的常量是沒有重復(fù)的,原因是靜態(tài)連接器在做構(gòu)建的時候,合并了有相同值的常量。

PIC(Position Independ Code, code-gen)
在dyld拼接不同的dylibs的時候,dylibA也需要知道如何調(diào)用dylibB。但是由于每頁都被簽名的原因,dyld是無法直接去修改指令的。這時候code-gen,也就是動態(tài)PIC會在__DATA段為dylibA創(chuàng)建一個指針,指向dylibB的某個地址。比方說:dylibA想調(diào)用dylibB的sayHello方法,code-gen會先在Mach-O的__DATA段中建立一個指針指向sayHello,再通過這個指針實(shí)現(xiàn)間接調(diào)用。
dyld (dynamic loader/linker)
加載dylibs
dyld是iOS平臺上的二進(jìn)制加載器或者說動態(tài)鏈接器,也是內(nèi)核的得力”小助手”。它負(fù)責(zé)將程序依賴的各種動態(tài)庫加載到進(jìn)程。同時也肩負(fù)著修復(fù)內(nèi)部和外部指針指向的責(zé)任(傳送門,dyld開源代碼)。下面我們就來看看dyld是如何幫助內(nèi)核完成加載工作的。

從上圖可以看到,ALSR將dyld也加載到了進(jìn)程中一個隨機(jī)地址,此時的dyld和App享有同樣的權(quán)限。其實(shí)從加載完dyld之后,內(nèi)核要做的事情就結(jié)束了,之后的所有步驟都由dyld完成。首先,dyld會讀取Mach-O文件中的指令(Load commands),去將其依賴的各個動態(tài)庫映射到當(dāng)前的邏輯地址空間,如果該動態(tài)庫還依賴其他動態(tài)庫,比方說下圖的A庫依賴C庫,dyld會遞歸的將沒加載過的dylib都加載到當(dāng)前進(jìn)程中(具體由ImageLoader完成),直到所有的動態(tài)庫加載完畢。Apple官方稱,一個App通常會加載1到400個dylibs! 不過幸運(yùn)的是,這其中大部分是系統(tǒng)的dylibs,Apple在操作系統(tǒng)啟動的時候,也已經(jīng)幫我們提前計算和緩存了這些dylibs的信息,這樣dyld加載它們的時候就會快很多。

Dirty & Clean Page
我們在上文提到: 當(dāng)Copy-On-Write發(fā)生時,該page會被標(biāo)記為dirty,同時會被復(fù)制。下面我們通過一個實(shí)例來進(jìn)一步理解虛擬內(nèi)存和Mach-O布局,以及dyld是如何產(chǎn)生出dirty page的。

上圖展示的是兩個不同的進(jìn)程共享同一個dylib的使用場景。我們從左到右看,左邊的進(jìn)程1先加載了該動態(tài)庫,通過讀取Load Commands, dyld知道要先加載__TEXT段(可讀,可執(zhí)行),上圖__TEXT段大小為3個page,但是dyld只會先將第一個page映射到物理RAM。之后讀取__DATA段信息(可讀可寫),在Mach-O章節(jié)中,我們已經(jīng)知道,__DATA段存儲了全局變量,而大部分的全局變量又都被初始化為0,所以在第一次被加載的時候,虛擬內(nèi)存技術(shù)會將這些全局變量直接分配到一些page上(上圖是3個page),不從磁盤中讀取。接著dyld加載__LINKEDIT段,并將其映射到物理RAM2。當(dāng)dyld加載__LINKEDIT(只讀)段時,會被告知需要做一些”修正”以便dylib可以被順利運(yùn)行,這時,dyld就需要向__DATA段寫入一些數(shù)據(jù)了。當(dāng)寫入發(fā)生時,該page對應(yīng)的物理RAM就包含了當(dāng)前進(jìn)程信息,變成了dirty page。最終,這個dylib占用了8個page,包含2個clean page,一個dirty page(其余還未被映射)。
這時,如果有第二個進(jìn)程同時引用了這個dylib,那么會發(fā)生什么呢?第一步同樣是加載__TEXT段,不同的是,這次內(nèi)核會發(fā)現(xiàn)在物理內(nèi)存的某一處,已經(jīng)加載過對應(yīng)的__TEXT段,所以這次只是簡單地把映射重定向下,不做任何IO操作。__LINKEDIT也是如此,區(qū)別就是這次變快了許多。當(dāng)內(nèi)核在尋找__DATA段的時候,它先會去檢查是否還存在干凈的副本,如果有,則直接映射;如果沒有則需要重新從磁盤中讀取,讀取后dyld做修正,所以這個page也會變?yōu)閐irty page。當(dāng)dyld完成了修正過程后,__LINKEDIT也就不被再需要,內(nèi)核可以在適當(dāng)?shù)臅r候釋放其映射的物理RAM。
總結(jié)下: 當(dāng)兩個進(jìn)程共享一個dylib時,使用虛擬內(nèi)存技術(shù)映射物理RAM,把原本16個臟頁面變成了兩個臟頁面和一個干凈的共享頁面。但上例是針對Apple自己提供的動態(tài)庫,如果是我們自己寫的cocoatouch framework,不排除當(dāng)兩個進(jìn)程共用一個framework時,可以共享某些page。例如兩個App都依賴Alamofire網(wǎng)絡(luò)庫時,一個App先行加載其Mach-O文件到物理內(nèi)存;當(dāng)另一個App加載Alamofire時,直接映射即可。
Rebase & Binding
這個步驟就是上文說到的”修正”步驟, 同時也用到了上文提到的PIC技術(shù)。Reabse是指修正內(nèi)部指針指向; Binding是指修正外部指針指向。如下圖所示: 指向_malloc和_free的指針是修正后的外部指針指向。而指向__TEXT的指針則是被修復(fù)后的內(nèi)部指針指向。

這個步驟發(fā)生的原因我們上文提到過: ASLR。由于起始地址的偏移,所有_DATA段內(nèi)指向Mach-O文件內(nèi)部的指針都需要增加這個偏移量, 并且這些指針的信息可以在__LINKEDIT段中找到。既然Rebase需要寫入數(shù)據(jù)到__DATA段,那也就意味著Copy-On-Write勢必會發(fā)生,dirty page的創(chuàng)建,IO的開銷也無法避免。
Binding是__DATA段對外部符號的引用。不過和Rebase不同的是,binding是靠字符串的匹配來查找符號表的,雖說沒有多少IO,但是計算多,比Rebase慢。
使用如下命令可查看所有Resabe和Binding的修復(fù):
xcrun dyldinfo -rebase -bind -lazy_bind NetworkTransport.app/NetworkTransport
Objc Runtime
簡介
Objective-C runtime是Objc這款語言的運(yùn)行時函數(shù)庫(傳送門,Runtime開源代碼),負(fù)責(zé)支持我們?nèi)粘S玫降母鞣N動態(tài)特性,例如Target-Action,Associated Objects,Method Swizzling等。當(dāng)然,功能覺不僅限于此,運(yùn)行時庫更像是一個橋階層,幫助Objc和其他語言更好地協(xié)同工作。
Objc運(yùn)行時庫有很多的DATA數(shù)據(jù)結(jié)構(gòu),這些大都是系統(tǒng)類,這些系統(tǒng)類就會有很多的指針,例如指向其方法和超類。幾乎所有的這些指針的修正都會在上一步完成。不過Objc runtime還需要做如下一些事情:
- 為每一個類生成一張函數(shù)表: 在Objc里我們可以使用字符串來初始化一個類,原理就是該類擁有函數(shù)表。
- 將分類(Category)里定義的擴(kuò)展添加到函數(shù)表里: 如果分類override的原類的方法,則運(yùn)行時會將其添加到函數(shù)表上方,調(diào)用時先用Category中定義的方法。
- 確保選擇器的唯一性: 和上一個類似,Objc是依靠Selector的,所以確保其唯一性就保證了調(diào)用的正確性。
Load vs Initialize
在Objc時代,NSObject中有兩個很特殊的方法: +load 和 +initialize,它們被用于類的初始化。
+load
當(dāng)類或者是Category被添加到Objc Runtime時,+load方法即被調(diào)用。一個很典型的例子就是Method Swizzling,由于Apple”自底向上”的初始化策略,當(dāng)我們想替換系統(tǒng)的某個實(shí)現(xiàn)時,一般都會在自定義的類中重寫+load方法,實(shí)現(xiàn)相關(guān)代碼,已達(dá)到替換的目的。
- 父類+load方法 先于 子類+load方法執(zhí)行;
- 主類+load方法 先于 Category的+load方法執(zhí)行;
- 不同類+load方法調(diào)用順序不確定。
下面我們來看看部分和+load方法相關(guān)的Objc runtime的源碼,以加深對+load的理解。首先是文件objc-runtime-new.mm 中的 prepare_load_methods(header_info *hi) 函數(shù):
void prepare_load_methods(const headerType *mhdr)
{
size_t count, i;
runtimeLock.assertLocked();
classref_t *classlist = _getObjc2NonlazyClassList(mhdr, &count);
for (i = 0; i < count; i++) {
schedule_class_load(remapClass(classlist[i]));
}
category_t **categorylist = _getObjc2NonlazyCategoryList(mhdr, &count);
for (i = 0; i < count; i++) {
category_t *cat = categorylist[i];
Class cls = remapClass(cat->cls);
if (!cls) continue; // category for ignored weak-linked class
realizeClass(cls);
assert(cls->ISA()->isRealized());
add_category_to_loadable_list(cat);
}
}
這個函數(shù)是將那些實(shí)現(xiàn)了+load方法的類和Category找出并實(shí)現(xiàn)(realized), 之后將其加入對應(yīng)的loadable列表。其中 _getObjc2NonlazyClassList和 _getObjc2NonlazyCategoryList兩個方法就是找出這樣的類和Category。Non lazy意味著它們實(shí)現(xiàn)了+load方法,與之對應(yīng)的則是lazy class,它們沒有實(shí)現(xiàn)+load方法,所以不會在App啟動的時候初始化,而是在收到第一次消息時初始化,可謂名副其實(shí)的懶加載。Mach-O的non lazy類可以在__DATA, __objc_nlclslist部分看到。
static void schedule_class_load(Class cls)
{
if (!cls) return;
assert(cls->isRealized()); // _read_images should realize
if (cls->data()->flags & RW_LOADED) return;
// Ensure superclass-first ordering
schedule_class_load(cls->superclass);
add_class_to_loadable_list(cls);
cls->setInfo(RW_LOADED);
}
prepare_load_methods中還調(diào)用到了schedule_class_load方法,在該方法里第9行我們可以看到: 函數(shù)對傳入?yún)?shù)的父類進(jìn)行了遞歸調(diào)用,以確保父類優(yōu)先的順序。
當(dāng)類和Category準(zhǔn)備好后,Objc runtime就可以對它們的+load方法調(diào)用了。打開文件objc-loadmethod.m,找到其中的call_load_methods函數(shù)。
void call_load_methods(void)
{
static bool loading = NO;
bool more_categories;
loadMethodLock.assertLocked();
// Re-entrant calls do nothing; the outermost call will finish the job.
if (loading) return;
loading = YES;
void *pool = objc_autoreleasePoolPush();
do {
// 1. Repeatedly call class +loads until there aren't any more
while (loadable_classes_used > 0) {
call_class_loads();
}
// 2. Call category +loads ONCE
more_categories = call_category_loads();
// 3. Run more +loads if there are classes OR more untried categories
} while (loadable_classes_used > 0 || more_categories);
objc_autoreleasePoolPop(pool);
loading = NO;
}
這個函數(shù)有兩點(diǎn)值得注意:
- 第12行和第26行的auto release pool操作,這意味著我們在自定義+load方法是不需要自己添加autorelease的block,Objc runtime幫我們處理了。
- 第17行和第21行,類的+load先于Category調(diào)用。
我們來看下call_class_loads的實(shí)現(xiàn),call_category_loads方法和它異曲同工,就不詳細(xì)介紹了。
static void call_class_loads(void)
{
int i;
// Detach current loadable list.
struct loadable_class *classes = loadable_classes;
int used = loadable_classes_used;
loadable_classes = nil;
loadable_classes_allocated = 0;
loadable_classes_used = 0;
// Call all +loads for the detached list.
for (i = 0; i < used; i++) {
Class cls = classes[i].cls;
load_method_t load_method = (load_method_t)classes[i].method;
if (!cls) continue;
if (PrintLoading) {
_objc_inform("LOAD: +[%s load]\n", cls->nameForLogging());
}
(*load_method)(cls, SEL_load);
}
// Destroy the detached list.
if (classes) free(classes);
}
最關(guān)鍵的在第21行: (*load_method)(cls, SEL_load)。這意味著+load方法的調(diào)用不是我們熟知的 objc_msgSend(消息機(jī)制),而是直接使用其內(nèi)存地址的方式調(diào)用。這也意味著,父類、子類和分類中的+load方法的實(shí)現(xiàn)是被區(qū)別對待的。如果子類實(shí)現(xiàn)+load方法而父類沒有實(shí)現(xiàn),則父類中的+load方法不會被調(diào)用;如果主類和分類都實(shí)現(xiàn)了+load方法,則兩個都會被調(diào)用,不過Category的+load方法會后調(diào),這也為我們實(shí)現(xiàn)Swizzling提供了契機(jī)。
+initialize
+load方法在Swift中已經(jīng)被廢棄, 官方推薦使用+initialize來完成之前在+load中完成的事情。+initialize方法會在類或其子類收到第一條消息(方法調(diào)用)前調(diào)用。屬于懶加載,節(jié)省系統(tǒng)資源,避免浪費(fèi)。
Swift3.1廢棄了該方法, 不過可以用Objc的Category做該Swift類的擴(kuò)展,依舊可以使用該函數(shù);純Swift環(huán)境下也有替代方法,會在之后的文章中介紹。
打開objc-runtime-new.mm文件,找到lookUpImpOrForward方法:
IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver)
{
...
if (initialize && !cls->isInitialized()) {
runtimeLock.unlock();
_class_initialize (_class_getNonMetaClass(cls, inst));
runtimeLock.lock();
// If sel == initialize, _class_initialize will send +initialize and
// then the messenger will send +initialize again after this
// procedure finishes. Of course, if this is not being called
// from the messenger then it won't happen. 2778172
}
...
}
當(dāng)調(diào)用某個類的方法時,Objc runtime會使用這個函數(shù)去查找或者轉(zhuǎn)發(fā)該消息。如果該類沒有被初始化,則調(diào)用 _class_initialize方法來初始化。
void _class_initialize(Class cls)
{
assert(!cls->isMetaClass());
Class supercls;
bool reallyInitialize = NO;
// Make sure super is done initializing BEFORE beginning to initialize cls.
// See note about deadlock above.
supercls = cls->superclass;
if (supercls && !supercls->isInitialized()) {
_class_initialize(supercls);
}
...
#if __OBJC2__
@try
#endif
{
callInitialize(cls);
if (PrintInitializing) {
_objc_inform("INITIALIZE: thread %p: finished +[%s initialize]",
pthread_self(), cls->nameForLogging());
}
}
...
}
第12行的遞歸調(diào)用保證了父類先于子類初始化;第21行調(diào)用callInitialize,代碼如下:
void callInitialize(Class cls)
{
((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
asm("");
}
可以看出+initialize方法的調(diào)用和普通函數(shù)調(diào)用一樣,走的都是發(fā)送消息的流程。也就是說,無論如何,父類的+initialize都會被調(diào)用,而且如果Category實(shí)現(xiàn)了+initialize方法,則會”覆蓋”掉主類的+initialize方法(Category的實(shí)現(xiàn)在函數(shù)表中優(yōu)先于主類)。
而且還帶來一個問題: 如果子類沒有實(shí)現(xiàn)+initialize方法,那么父類的+initialize方法會被調(diào)用多次。如果想確保自己的+initialize方法只執(zhí)行一次的話,有兩種方式:
- 判斷當(dāng)前Class是否是該類
- 使用dispatch once token(和初始化一個Singleton一樣)
BTW, 無論是+load還是+initialize,都不需要在自己的實(shí)現(xiàn)里調(diào)用super load或者是super initialize。
Main函數(shù)的調(diào)用
dyld調(diào)用UIApplicationMain()。
總結(jié)下: 內(nèi)核加載App和dyld到進(jìn)程中,dyld加載所有依賴的dylibs,修正所有DATA page,之后Objc runtime初始化所有類,最后調(diào)用main函數(shù)。
如何優(yōu)化App啟動時間
熬過了理論部分,現(xiàn)在我們來看看如何將其應(yīng)用到實(shí)際開發(fā)中,來提升我們App的啟動速度。
Apple已有的優(yōu)化
Apple的dyld 3(目前使用版本)與之前的dyld 2相比有了顯著的優(yōu)化。dyld 3由三個部分構(gòu)成:
- 進(jìn)程無關(guān)的Mach-O解析器
- 進(jìn)程相關(guān)的啟動回調(diào)引擎
- 啟動回調(diào)緩存服務(wù)
啟動回調(diào)(Launch Closure)是dyld 3引進(jìn)的一個新概念,它是指啟動你App所有的必要信息。比方說: 你的App用到哪些動態(tài)庫,指針偏移量,代碼簽名位置等等。
在dyld 2時代,所有我們之前提到的啟動步驟都是發(fā)生在內(nèi)核分配給你的進(jìn)程中的(in-process); 而在dyld 3中,關(guān)于Mach-O文件的解析發(fā)生在App第一次安裝或者是之后的更新過程中。解析過后,關(guān)于App啟動的信息會被存到磁盤的某處,供App啟動時使用。這兩步大大提升了App加載的速度,系統(tǒng)庫是有共享緩存的,所以加載速度不會慢,但我們自己App中也會有很多依賴庫,每次根據(jù)@rpath查找,映射也是很耗時的工作,而且從App下載之后,這些依賴庫也不會再變了,把它們放到out of process中實(shí)為明智之舉。

上圖中虛線以上部分就是App第一次下載或更新時dyld會做的事情。虛線以下就是當(dāng)App加載到進(jìn)程中后dyld會做的事情, 除了需要從緩存中讀取和驗(yàn)證信息之外,其他步驟都是一樣的。
Apple為什么要這樣做?除了我們已經(jīng)提到的性能優(yōu)化,還有兩個主要原因: 安全和可靠性。先說可靠性: 將這些步驟移出進(jìn)程意味著dyld的大部分工作就像是一個普通的守護(hù)程序,Apple的工程師可以用標(biāo)準(zhǔn)工具去測試它,提升可靠性;至于安全性,Apple認(rèn)為最容易被攻擊的步驟是解析Mach-O頭部和查詢依賴庫。攻擊者可以搞亂App的@rpath路徑,或者替換為其它library來完成@rpath confusion attacks。 所以Apple將其放入守護(hù)進(jìn)程中加載。除此之外,符號表查找是另一項(xiàng)耗時工作,也完全可以放在進(jìn)程外執(zhí)行。因?yàn)槌歉萝浖托薷囊蕾噹?,否則該依賴庫的符號表的內(nèi)部偏移量是不會變的。
項(xiàng)目優(yōu)化
在優(yōu)化之前,我們需要知道如何測量啟動時間,為什么啟動時間會慢以及多長時間的啟動時間是可被接受的。
推薦的啟動時間
Apple推薦的啟動時間是400ms, 當(dāng)然最好是在App支持的最低配設(shè)備上達(dá)到這個標(biāo)準(zhǔn)。最長不要超過20s,否則系統(tǒng)會直接kill掉這個App。
如何測量啟動時間
Apple提供一個內(nèi)置的環(huán)境變量來記錄App啟動時間(pre-main): DYLD_PRINT_STATISTICS。 具體配置方法如下:

之后啟動App,在console中就可看到如下信息:

值得一提的是: dyld很智能,它會自動扣除Debugger的插入時間,所以不用擔(dān)心Dev版本時間與發(fā)布版本不同。
優(yōu)化啟動時間
基于上文的console信息,我們的優(yōu)化可以從4個方面來開始:
第一: 減少動態(tài)庫的依賴。在iOS平臺下,盡量減少項(xiàng)目所依賴的動態(tài)庫; 如果無法減少,嘗試將不同動態(tài)庫合并。在項(xiàng)目開發(fā)階段,有些程序員偏愛創(chuàng)建CocoaTouch Framework,覺得分離出業(yè)務(wù)邏輯以及依賴資源對開發(fā)和以后維護(hù)都有好處。但是,當(dāng)這樣做的時候,也需要考慮下App的啟動時間以及性能,是否值得這樣做。Apple的WWDC2016(Session 406)給出了一個例子: 一個項(xiàng)目依賴26個動態(tài)庫,dylibs加載時間是240毫秒; 當(dāng)將其合并成2個動態(tài)庫時,加載時間變?yōu)?0毫秒,可以說性能顯著提高。
第二: 減少指針的使用。從上圖中我們可以發(fā)現(xiàn), rebase和binding的時間占據(jù)了最多的時間消耗。也就是說dyld修復(fù)指針指向花費(fèi)了300多毫秒。從上文我們知道,dyld修復(fù)的指針都位于__DATA段,所以我們需要做的很簡單,減少項(xiàng)目中指針的使用。如何減少? 如果你的項(xiàng)目使用純Objc開發(fā),那就要適當(dāng)減少類的個數(shù),根據(jù)WWDC2016(Session 406),Apple工程師的說法,項(xiàng)目中包含100,1000個類沒什么太大的overhead,但要是5000到20000以上,則加載時間會多700到800毫秒。如果工程使用Swift居多,甚至是純Swift,那么Apple建議能使用struct則使用struct,因?yàn)閟truct是值類型,不會引入指針(使用偏移量)。
第三: 優(yōu)化Objc建立時間。這一步包含四個步驟:
- 注冊類
- 更新類實(shí)例變量偏移(例如SDK更新)
- 注冊Category
- 選擇器的唯一性
這一步其實(shí)不用我們來優(yōu)化,因?yàn)榇蟛糠值墓ぷ鰽pple已經(jīng)幫我們做了,比方說ivar的偏移,這些會在rebase & binding步驟完成。
第四: 初始化。在iOS平臺下,如果項(xiàng)目使用Objc編寫,盡量少使用+load方法,如果非要使用, 替換為+initialize,延遲加載。如果項(xiàng)目已經(jīng)使用Swift編寫,那就沒什么優(yōu)化的了,Apple暗地里幫我們調(diào)用了他們自己的initializer(dispatch_once), 確保Swift class不會被初始化多次。
不過Apple給的啟動時間的信息還是太少,除了前三步,最后一步其實(shí)是可以度量的?,F(xiàn)在的iOS App很多都在使用Cocoapods或是Carthage加載第三方依賴庫。如果我們想獲得每一個這樣的依賴庫初始化耗時時間,該怎么做呢?簡單說就是Swizzling +load方法加上Instrument Static Initializers工具來追蹤時間消耗,具體步驟可以參考這篇文章: 如何精確度量 iOS App 的啟動時間.
參考文獻(xiàn):
iOS App啟動的奧秘