iOS App啟動過程的探究

探究App的啟動過程,有助于我們優化App的啟動時間,從main函數之前和main函數之后兩個階段進行分析一下。

1. 分析前的準備

1.1 dyld的介紹

dyld的全稱是dynamic loader,它的作用是加載一個進程所需要的image(映像),dyld是蘋果的動態鏈接器,動態鏈接庫的加載過程主要由dyld來完成。

  1. 系統先加載解析App的可執行文件(Mach-O文件),從里面獲取dyld的路徑
  2. 然后加載dyld,dyld去初始化運行環境,開啟緩存策略,加載程序相關依賴庫(其中也包含我們的可執行文件),并對這些庫進行鏈接
  3. 最后調用每個依賴庫的初始化方法(在這一步,runtime被初始化)
  4. 當所有依賴庫初始化完成后,輪到最后一位(程序可執行文件)進行初始化,在這時runtime會對項目中所有類進行類結構初始化,然后調用所有的load方法。
  5. 最后dyld返回main函數地址,main函數被調用,我們便來到了熟悉的程序入口
1.2 dyld共享庫緩存

當你構建一個真正的程序時,將會鏈接各種各樣的庫。他們又會依賴一些framework和動態庫,因此需要加載的動態庫會非常多,而對于相互依賴的符號就更多了,可能會有上千個符號需要解析處理,這樣將耗費很長的時間。
為了縮短這個處理過程所花費時間,OS X和iOS上的動態鏈接器使用了共享緩存。
對于每一種架構,操作系統都有一個單獨的文件,文件中包含了絕大多數的動態庫,這些庫都已經鏈接為一個文件,并且已經處理好了他們之間的符號關系。當加載一個Mach-O文件(一個可執行文件或者一個庫)時,動態鏈接器首先會檢查共享緩存看看是否已經存在,如果存在那么就直接從共享緩存中拿出來使用。每一個進程都把這個共享緩存映射到了自己的地址空間中。這個方法大大優化了OS X和iOS上程序的啟動時間。比如手機開機后,連續兩次啟動同一個APP的pre-main實際時間的差值比較大, 因為第一次啟動的時候,會把App使用到的系統動態庫加入緩存,第二次直接從緩存中取就行了。

1.3 ASLR(Address Space Layout Randomization)

地址空間布局隨機化,鏡像會在隨機的地址上加載。
傳統方式下,進程每次啟動采用的都是固定可預見的方法,這意味著一個給定的程序在給定的架構上的進程初始虛擬內存都是基本一致的,而且在進程正常運行的生命周期中,內存中的地址分布具有非常強的可預測性,這給了黑客很大的施展空間(代碼注入,重寫內存)

如果采用ASLR,進程每次啟動,地址空間都會被簡單的隨機化,但是只是偏移,不是攪亂。大體布局、程序文本、數據和庫是一樣的,但是具體的地址都不同了,可以阻擋黑客對地址的猜測。

1.4 代碼簽名

可能我們認為Xcode會把整個文件都做加密hash并用做數字簽名。其實為了在運行時驗證Mach-O文件的簽名,并不是每次重復讀入整個文件,而是把每頁內容都生成一個單獨的加密散列值,存儲在__LINKEDIT中,這使得文件每頁的內容都能及時被校驗并確保不被篡改。

1.5 虛擬內存virtual memory

虛擬內存是在物理內存上建立的一個邏輯地址空間,它向上(應用)提供了一個連續的邏輯地址空間,向下隱藏了物理內存的細節。

虛擬內存使得邏輯地址可以沒有實際的物理地址,也可以讓多個邏輯地址對應到一個物理地址。
虛擬內存被劃分為一個個大小相同的page(64位系統上是16KB),提高管理和讀寫的效率,Page又分為只讀和讀寫的Page。

虛擬內存是建立在物理內存和進程之間的中間層。在iOS上,當內存不足時,會嘗試釋放那些只讀的Page,因為只讀的Page在下次被訪問的時候,可以再從磁盤讀取,如果沒有可用內存,會通知在后臺的App(也就是在這個時候收到了memory warning),如果在這之后仍然沒有可用內存,則會殺死在后臺的APP。

1.6 Page fault

在應用執行的時候,它被分配的邏輯地址空間都是可以訪問的,當應用訪問一個邏輯Page,而在對應的物理內存中并不存在的時候,這時候就發生了一次Page fault,當Page fault發生的時候,會中斷當前的程序,在物理內存中尋找一個可用的Page,然后從磁盤中讀取數據到物理內存,接著繼續執行當前程序。

1.7 Dirty Page & Clean Page
  • 如果一個Page可以從磁盤上重新生成,那么這個Page稱為Clear Page
  • 如果一個Page包含了進程相關信息,那么這個Page稱為Dirty Page
    像代碼段這種只讀的Page就是Clean Page,而數據段(_DATA)這種讀寫的Page,當寫數據發生的時候,會觸發CO(copy on write),也就是寫時復制,Page會被標記成Dirty,同時會被復制。

2. App啟動過程


  1. 解析Info.plist
    加載相關信息,例如閃屏
    沙箱建立、權限檢查
  2. Mach-O加載 (Mach-O這里不再介紹)
    如果是胖二進制文件,尋找適合當前CPU架構的部分
    加載所有依賴的Mach-O文件(遞歸調用Mach-O加載的方法)
    定位內部、外部指針引用,例如字符串、函數等
    加載類擴展(Category)中的方法
    C++靜態對象加載、調用ObjC的 +load 函數
    執行聲明為attribute((constructor))的C函數
  3. 程序執行
    調用main()
    調用UIApplicationMain()
    調用applicationWillFinishLaunching

從上面的順序中也可以看出來,attribute((constructor))的函數調用會在+load函數之后調用。

換成另一個說法就是:

App開始啟動后,系統首先加載可執行文件(自身App的所有.o文件的集合),然后加載動態鏈接器dyld(用于加載動態鏈接庫的庫)。dyld從當前可執行文件的依賴開始,遞歸加載所有依賴的動態鏈接庫。
動態鏈接庫包括:iOS中用到的所有系統framework,加載OC runtime方法的libobjc,系統級別的libSystem,例如libdispatch(GCD)和libsystem_blocks(Block)

啟動過程圖

使用dyld2啟動應用的過程如圖:


image.png

大致的過程如下:

1.加載dyld到App進程
2.加載動態庫(包括所依賴的所有動態庫)
3.Rebase
4.Bind
5.初始化Objective-C Runtime
6.其它的初始化代碼

2.1 可執行文件的內核流程

如圖,當啟動一個應用程序時,系統最后會根據你的行為調用兩個函數,fork和execve:

fork功能創建一個進程;
execve功能加載和運行程序

這里有多個不同的功能,比如execl、execv、exect,每個功能提供了不同傳參和環境變量的方法到程序中。在OSX中,每個這些其他的exec路徑最終調用了內核路徑execve。

image.png
  1. 執行exec系統調用,一般都是這樣,用fork()函數新建一個進程,然后讓進程去執行exec調用。我們知道,在fork()建立新進程之后,父進程與子進程共享代碼段(TEXT),但數據空間(DATA)是分開的,但父進程會把自己數據空間的內容copy到子進程中去,還有上下文也會copy到子進程中去。

  2. 為了提高效率,采用一種寫時copy的策略,即創建子進程的時候,并不copy父進程的地址空間,父子進程擁有共同的地址空間,只有當子進程需要寫入數據(如向緩沖區寫入數據),這時候會復制地址空間,復制緩沖區到子進程中去。從而父子進程擁有獨立的地址空間。而對于fork()之后執行exec之前,這種策略能夠很好的提高效率,如果一開始就copy,那么exec之后,子進程(可以說父進程也可以說是子進程,因為兩個進程的數據此時是一樣的)的數據會被放棄,被新的進程所代替。

2.2 App啟動流程的節點

iOS應用的啟動可分為per-main階段和main兩個階段,所以App總啟動時間 = pre-main耗時 + main耗時

image.png
階段 pre-main main
流程 系統dylib(動態鏈接庫)和自身App可執行文件的加載 main方法執行之后到AppDelegate類中的didFinishLaunchingWithOptions方法執行結束前這段時間,主要是構建第一個界面,并完成渲染展示
  • pre-main


    image.png
  • main


    image.png
2.3 dyld加載過程

dyld加載過程主要包含以下幾個步驟:

2.3.1 Load dylibs image 加載動態庫

dyld會首先讀取Mach-O文件的Header和Load Commands,接著就知道了這個可執行文件依賴的動態庫。例如加載動態庫A到內存,接著檢查A所依賴的動態庫,就這樣的遞歸加載,直到所有的動態庫加載完畢。通常一個App所依賴的動態庫在100-400個左右,其中大多數都是系統的動態庫,它們會被緩存到dyld shared cache(動態共享庫緩存)。
所以應用所依賴的dylib文件,可能會再依賴其他dylib,所以dyld所需要加載的是動態庫列表一個遞歸依賴的集合。
針對這一步驟的優化有:

1. 減少非系統庫的依賴
2. 合并非系統庫
2.3.2 Rebase/Bind image

為什么要有這一步呢?
我們知道有兩種主要的技術來保證應用的安全:ASLR和Code Sign,這里再次介紹一下:

  1. ASLR
    地址空間布局隨機化,App被啟動的時候,程序會被影射到邏輯的地址空間,這個邏輯的地址空間有一個起始地址,而ASLR技術使得這個起始地址是隨機的。如果是固定的,那么黑客很容易就可以由起始地址+偏移量找到函數的地址。
  2. 在進行Code sign的時候,加密哈希不是針對于整個文件,而是針對于每一個Page的。這就保證了在dyld進行加載的時候,可以對每一個page進行獨立的驗證。

Mach-O中有很多符號,有指向當前Mach-O的,也有指向其他dylib的,比如printf。那么,運行時,代碼如何找到printf的地址呢?

Mach-O中采用了PIC技術,全稱是Position Independ Code。當你的程序要調用printf的時候,會先在__DATA中建立一個指針指向printf,再通過這個指針實現間接調用。dyld這時候就需要做一些fix-up工作,幫助應用程序找到這些符號的實際地址。主要包括兩部分:

Rebase 修正內部(指向當前Mach-O文件)的指針指向
Bind 修正外部指針指向
image.png

Rebase步驟先進行,需要把鏡像讀入內存,并以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。之所以要Rebase,是因為剛提到的ASLR使得地址隨機化,導致起始地址不固定,另外由于Code Sign,導致不能直接修改Image。Rebase的時候,只需要增加對應的偏移量即可,待Rebase的數據都放在__LINKEDIT中。

Rebase解決了內部的符號引用問題,Bind在其后進行解決外部的符號引用,由于要查詢符號表,來指向跨鏡像的資源,加上在Rebase階段,鏡像已被讀入和加密驗證,所以這一步在于CPU計算。
優化該階段的關鍵在于減少_DATA segment中的指針數量。我們可以優化的點有:

1. 減少Objc類數量,減少selector數量
2. 減少C++虛函數數量
2.3.3 Objc setup

Objc setup主要是在objc_init完成的,objc_init是在libsystem中的一個initialize方法libsystem_initializer中初始化了libdispatch,然后libdispatch_init調用了_os_object_int,最終調用了_objc_init。


image.png
void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();

    _dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}

runtime在_objc_init向dyld綁定了3個回調函數,分別是map_2_images, load_images, unmap_image

image.png
  1. dyld在binding操作結束之后,會發出dyld_image_state_bound通知,然后與之綁定的回調函數map_2_images就會被調用,它主要做以下幾件事來完成Objc Setup:
  • 讀取二進制文件的DATA段內容,找到與objc相關的信息
  • 注冊Objc類
  • 確保selector的唯一性
  • 讀取protocol以及category的信息
  1. load_images函數作用就是調用Objc的load方法,它監聽dyld_image_state_dependents_initialize通知
  2. upmap_image可以理解為map_2_images的逆向操作
2.3.4 initializers

以上三步屬于靜態調整,都是在修改__DATA segment中的內容,而這里開始動態調整,開始在堆和棧中寫入內容。
主要工作是:

  • objc的+load()函數
  • C++的構造函數屬性函數 形如attribute((constructor)) void DoSomeInitializationWork()
  • 非基本類型的C++靜態全局變量的創建(通常是類或結構體)比如一個全局靜態結構體的創建,如果在構造函數中有繁重的工作,那么會拖慢啟動速度

Objc的load函數和C++的靜態構造器采用由底向上的方式執行,來保證每個執行的方法,都可以找到所依賴的動態庫:

  • dyld開始將程序二進制文件初始化
  • 交由ImageLoader讀取image,其中包含了我們的類、方法等各種符號
  • 由于runtime向dyld綁定了回調,當image加載到內存后,dyld會通知runtime進行處理
  • runtime接手后調用mapimages做解析和處理,接下來loadimages中調用callloadmethods方法,遍歷所有加載進來的Class,按繼承層級依次調用Class的+load方法和其Category的+load方法

整個事件由dyld主導,完成運行環境的初始化后,配合ImageLoader將二進制文件按格式加載到內存,動態鏈接依賴庫,并由runtime負責加載成objc定義的結構,所有初始化工作結束后,dyld調用真正的main函數

2.4 dyld3

上文的講解是dyld2的加載方式。而最新的是dyld3加載方式略有不同:

image.png

dyld2是純粹的in-process,也就是在程序進程內執行的,也就意味著只有當應用程序被啟動的時候,dyld2才能開始執行任務。

dyld3則是部分out-of-process,部分in-process。圖中,虛線之上的部分是out-of-process的,在App下載安裝和版本更新的時候回去執行,out-of-process會做如下事情:

  • 分析Mach-o Headers
  • 分析依賴的動態庫
  • 查找需要Rebase & Bind之類的符號
  • 把上述結果寫入緩存
    這樣,在應用啟動的時候,就可以直接從緩存中讀取數據,加快加載速度。

3. 優化步驟

3.1 pre-main階段優化
  1. 刪除無用代碼(未被調用的靜態變量、類和方法)
  2. 抽象重復代碼
2.1 可能一個類有不同的分類,導致方法重復,這樣會增加App體積,增加啟動時間
2.2 抽離相同功能的代碼
  1. +load方法中做的事情延遲到+initialize中,或者在+load中做的事情不宜花費過多的時間
load是在啟動的時候調用,而initalize是在類首次被使用的時候調用,不過當你把load中的邏輯移到initialize中時候,一定要注意initialize的重復調用問題
  1. 減少不必要的framework,優化已有的framework
3.2 main階段優化

這一階段的時間主要是指:main函數開始到第一個界面渲染完成這段時間,優化出發點就是減少從main函數開始到第一個界面出現的時間,可以從兩方面入手:

  • didFinishLaunchingWithOptions
    一般情況下,app在didFinishLaunchingWithOptions這個函數中會做以下工作:
日志、統計
配置APP運行環境
第三方SDK繼承 ...

如果這個工作里面有的功能可能是不必要的,有的可以采用懶加載的方法,那么可以進行優化。

  • 首次啟動渲染的頁面優化
  1. 不使用xib或者storyboard,直接使用代碼
  2. 對于viewDidLoad以及viewWillAppear方法中盡量不做,少做,晚做,或者采用異步的方式
  3. 當首頁邏輯比較復雜的時候,建議通過instruments的Time Profiler分析耗時瓶頸
  • 寫代碼注意
  1. 版本迭代過程中,如果業務變化,導致代碼變化,一般情況下需要把舊代碼&舊資源刪了
  2. 盡量抽象重復的代碼,重構代碼,采用合理的設計模式
  3. 在寫啟動相關業務模塊時注意延遲加載或者懶加載
  4. 類和方法名不要太長:iOS每個類和方法名都在__cstring段里都存了相應的字符串值,所以類和方法名的長短,對于可執行文件大小是有影響的,原因還是OC的動態特性,因為需要通過類/方法名反射找到這個類/方法進行調用,OC對象模型會把類/方法名字符串都保存下來。

先大概了解一下,以便以后隨時翻閱。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容

  • 一. 先給出一個結構圖,大致了解一下內部的結構: 主要結構分成三個部分: Header部分:保存了該文件的一些基本...
    ldzSpace閱讀 2,455評論 1 2
  • Mach-O類型的文件 Mach-O是一種文件的格式; 是iOS/Mac OS上存儲程序以及庫的標準格式Mach ...
    其字德安閱讀 5,427評論 0 13
  • 上一篇博客介紹了mach_header相關內容,Mach-O文件介紹之mach_header。這篇博客主要介紹Ma...
    Tomychen閱讀 2,391評論 0 7
  • 一天的工作終于告一段落,利用這閑暇的時間翻開了簡書,思緒不由得回到了早上~~ 早上坐在昌吉回瑪納斯...
    x博文閱讀 77評論 0 1
  • 前幾天,有位久攻經濟學不下的朋友跟我吐苦水: 學了這么久經濟學,投入了不少時間和金錢,最后發現效果還是不盡如人意。...
    北魚學堂閱讀 355評論 0 0