iOS 應用的啓動流程和優化詳解

一、應用啓動流程

1、整體過程

(1)解析 Info.plist

(2)Mach-O(可執行文件)加載

Mach-O

Header 頭部,包含可以執行的 CPU 架構,比如 x86,arm64

Load commands 加載命令,包含文件的組織架構和在虛擬內存中的佈局方式

Data,數據,包含 load commands 中需要的各個段 (segment) 的數據,每一個 Segment 都得大小是 Page 的整數倍。

Virtual Memory

虛擬內存是在物理內存上建立的一個邏輯地址空間,它向上(應用)提供了一個連續的邏輯地址空間,向下隱藏了物理內存的細節。
虛擬內存使得邏輯地址可以沒有實際的物理地址,也可以讓多個邏輯地址對應到一個物理地址。
虛擬內存被劃分爲一個個大小相同的Page(64位系統上是16KB),提高管理和讀寫的效率。 Page又分爲只讀和讀寫的Page。
複製代碼

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

Page fault

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

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

(3)程序執行

2、主要階段:

分爲兩個階段,pre-main 階段和 main() 階段。程序啓動到 main 函數執行前是 pre-main 階段;在執行 main 函數後,調用 AppDelegate 中的 -application:didFinishLaunchingWithOptions:方法完成初始化,並展示首頁,這是 main() 階段,或者叫做 main() 之後階段。

(1)pre-main 階段:
(2)main() 階段:

二、獲取啓動流程的時間消耗

1、pre-main 階段

對於 pre-main 階段,Apple 提供了一種測量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 將環境變量 DYLD_PRINT_STATISTICS 設爲 1 。之後控制檯會輸出類似內容,我們可以清晰的看到每個耗時:

 從上面可以看出時間區域主要分爲下面幾個部分:

ASLR(Address Space Layout Randomization),地址空間佈局隨機化。在 ASLR 技術出現之前,程序都是在固定的地址加載的,這樣 hacker 可以知道程序裏面某個函數的具體地址,植入某些惡意代碼,修改函數的地址等,帶來了很多的危險性。ASLR 就是爲了解決這個的,程序每次啓動後地址都會隨機變化,這樣程序裏所有的代碼地址都需要需要重新對進行計算修復才能正常訪問。rebasing 這一步主要就是調整鏡像內部指針的指向。

Binding:將指針指向鏡像外部的內容。

承接上一過程進行初始化(load)。如果我們代碼裏面使用了 clang 的__attribute__((constructor))構造方法,這裏會調用到。

2、main() 階段

測量 main() 函數開始執行到 didFinishLaunchingWithOptions 執行結束的時間,簡單的方法:直接插入代碼。(也可以使用其他工具)

extern CFAbsoluteTime startTime;

double launchTime = (CFAbsoluteTimeGetCurrent()-StartTime);

三、改善 APP 的啓動

建議應用的啓動時間控制在 400ms 之下,並且在 20s 內啓動,否則系統會 kill app。優化 APP 的啓動時間,需要就是分別優化 pre-main 和 main 的時間。

1、改善啓動時 pre-main 階段

(1)加載 Dylib

載入動態庫,這個過程中,會去裝載 app 使用的動態庫,而每一個動態庫有它自己的依賴關係,所以會消耗時間去查找和讀取。對於 Apple 提供的的系統動態庫,做了高度的優化。而對於開發者定義導入的動態庫,則需要在花費更多的時間。Apple 官方建議儘量少的使用自定義的動態庫,或者考慮合併多個動態庫,其中一個建議是當大於 6 個的時候,則需要考慮合併它們。

(2)Rebase/Binding

減少 App 的 Objective-C 類, 分類和 Selector 的個數。這樣做主要是爲了加快程序的整個動態鏈接, 在進行動態庫的重定位和綁定 (Rebase/binding) 過程中減少指針修正的使用,加快程序機器碼的生成;

(3)Objc setup

大部分 ObjC 初始化工作已經在 Rebase/Bind 階段做完了,這一步 dyld 會註冊所有聲明過的 ObjC 類,將分類插入到類的方法列表裏,再檢查每個 selector 的唯一性。

在這一步倒沒什麼優化可做的,Rebase/Bind 階段優化好了,這一步的耗時也會減少。

(4)Initializers

到了這一階段,dyld 開始運行程序的初始化函數,調用每個 Objc 類和分類的 + load 方法,調用 C/C++ 中的構造器函數 (用 attribute((constructor)) 修飾的函數),和創建非基本類型的 C++ 靜態全局變量。Initializers 階段執行完後,dyld 開始調用 main()函數。

在這一步,我們可以做的優化有:

 2、main() 階段的優化

(1)核心點:didFinishLaunchingWithOptions 方法

  這一階段的優化主要是減少 didFinishLaunchingWithOptions 方法裏的工作,在 didFinishLaunchingWithOptions 方法裏我們經常會進行:

由於歷史原因,這裏的代碼容易變得比較龐大,啓動耗時難以控制。

(2)優化點:

  滿足業務需要的前提下,didFinishLaunchingWithOptions 在主線程裏做的事情越少越好。在這一步,我們可以做的優化有:

1、+load

(1)+load方法是一定會在 runtime 中被調用的。只要類被添加到 runtime 中了,就會調用+load方法,即只要是在Compile Sources中出現的文件總是會被裝載,與這個類是否被用到無關,因此+load方法總是在 main 函數之前調用

(2)+load方法不會覆蓋。也就是說,如果子類實現了+load方法,那麼會先調用父類的+load方法(無需手動調用 super),然後又去執行子類的+load方法。

(3)+load 方法只會調用一次。

(4)+load 方法執行順序是:類 -> 子類 -> 分類。而不同分類之間的執行順序不一定,依據在Compile Sources中出現的順序 (先編譯,則先調用,列表中在下方的爲 “先”)

(5)+load 方法是函數指針調用,即遍歷類中的方法列表,直接根據函數地址調用。如果子類沒有實現 + load 方法,子類也不會自動調用父類的 + load 方法。

2、+initialize

(1)+initialize方法是在類或它的子類收到第一條消息之前被調用的,這裏所指的消息包括實例方法和類方法的調用。因此+initialize方法總是在 main 函數之後調用

(2)+initialize方法只會調用一次。

(3)+initialize方法實際上是一種惰性調用,如果一個類一直沒被用到,那它的+initialize方法也不會被調用,這一點有利於節約資源。

(4)+initialize方法會覆蓋。如果子類實現了+initialize方法,就不會執行父類的了,直接執行子類本身的。如果分類實現了+initialize方法,也不會再執行主類的。

(5)+initialize方法的執行覆蓋順序是:分類 -> 子類 -> 類。且只會有一個+initialize方法被執行

(6)+initialize方法是發送消息(objc_msgSend()),如果子類沒有實現+initialize方法,也會自動調用其父類的+initialize方法。

3、兩者的異同

(1)相同點

  1. load 和 initialize 會被自動調用,不能手動調用它們。
  2. 子類實現了 load 和 initialize 的話,會隱式調用父類的 load 和 initialize 方法。
  3. load 和 initialize 方法內部使用了鎖,因此它們是線程安全的。

(2)不同點

  1. 調用順序不同,以 main 函數爲分界,+load方法在 main 函數之前執行,+initialize在 main 函數之後執行。
  2. 子類中沒有實現+load方法的話,子類不會調用父類的+load方法;而子類如果沒有實現+initialize方法的話,也會自動調用父類的+initialize方法。
  3. +load方法是在類被裝在進來的時候就會調用,+initialize在第一次給某個類發送消息時調用(比如實例化一個對象),並且只會調用一次,是懶加載模式,如果這個類一直沒有使用,就不回調用到+initialize方法。

4、使用場景

(1)+load一般是用來交換方法Method Swizzle,由於它是線程安全的,而且一定會調用且只會調用一次,通常在使用 UrlRouter 的時候註冊類的時候也在+load方法中註冊。
(2)+initialize方法主要用來對一些不方便在編譯期初始化的對象進行賦值,或者說對一些靜態常量進行初始化操作。

Mach-O 啓動過程

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

大致的過程如下:

加載dyld到App進程
加載動態庫(包括所依賴的所有動態庫)
Rebase
Bind
初始化Objective C Runtime
其它的初始化代碼
複製代碼

加載動態庫

dyld 會首先讀取 mach-o 文件的 Header 和 load commands。 接着就知道了這個可執行文件依賴的動態庫。例如加載動態庫 A 到內存,接着檢查 A 所依賴的動態庫,就這樣的遞歸加載,直到所有的動態庫加載完畢。通常一個 App 所依賴的動態庫在 100-400 個左右,其中大多數都是系統的動態庫,它們會被緩存到 dyld shared cache,這樣讀取的效率會很高。 

Rebase && Bind

裏先來講講爲什麼要 Rebase? 

 有兩種主要的技術來保證應用的安全:ASLR 和 Code Sign。

 ASLR 的全稱是 Address space layout randomization,翻譯過來就是 “地址空間佈局隨機化”。App 被啓動的時候,程序會被影射到邏輯的地址空間,這個邏輯的地址空間有一個起始地址,而 ASLR 技術使得這個起始地址是隨機的。如果是固定的,那麼黑客很容易就可以由起始地址 + 偏移量找到函數的地址。 

 Code Sign 相信大多數開發者都知曉,這裏要提一點的是,在進行 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 修正外部指針指向 

之所以需要 Rebase,是因爲剛剛提到的 ASLR 使得地址隨機化,導致起始地址不固定,另外由於 Code Sign,導致不能直接修改 Image。Rebase 的時候只需要增加對應的偏移量即可。待 Rebase 的數據都存放在__LINKEDIT 中。 可以通過 MachOView 查看:Dynamic Loader Info -> Rebase Info 

Rebase 解決了內部的符號引用問題,而外部的符號引用則是由 Bind 解決。在解決 Bind 的時候,是根據字符串匹配的方式查找符號表,所以這個過程相對於 Rebase 來說是略慢的。 同樣,也可以通過 xcrun dyldinfo 來查看 Bind 的信息,比如我們查看 bind 信息中,包含 UITableView 的部分:

Objective C 

 Objective C 是動態語言,所以在執行 main 函數之前,需要把類的信息註冊到一個全局的 Table 中。同時,Objective C 支持 Category,在初始化的時候,也會把 Category 中的方法註冊到對應的類中,同時會唯一 Selector,這也是爲什麼當你的 Cagegory 實現了類中同名的方法後,類中的方法會被覆蓋。 

 另外,由於 iOS 開發時基於 Cocoa Touch 的,所以絕大多數的類起始都是系統類,所以大多數的 Runtime 初始化起始在 Rebase 和 Bind 中已經完成。

 Initializers 接下來就是必要的初始化部分了,主要包括幾部分: 

 +load 方法。 

C/C++ 靜態初始化對象和標記爲__attribute__(constructor) 的方法 

這裏要提一點的就是,+load 方法已經被棄用了,如果你用 Swift 開發,你會發現根本無法去寫這樣一個方法,官方的建議是實用 initialize。區別就是,load 是在類裝載的時候執行,而 initialize 是在類第一次收到 message 前調用。

 dyld3 

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

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

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

這樣,在應用啓動的時候,就可以直接從緩存中讀取數據,加快加載速度

啓動時間

冷啓動 VS 熱啓動

如果你剛剛啓動過App,這時候App的啓動所需要的數據仍然在緩存中,再次啓動的時候稱爲熱啓動。如果設備剛剛重啓,然後啓動App,這時候稱爲冷啓動
複製代碼

啓動時間在小於 400ms 是最佳的,因爲從點擊圖標到顯示 Launch Screen,到 Launch Screen 消失這段時間是 400ms。啓動時間不可以大於 20s,否則會被系統殺掉。

 在 Xcode 中,可以通過設置環境變量來查看 App 的啓動時間,DYLD_PRINT_STATISTICS 和 DYLD_PRINT_STATISTICS_DETAILS。

Total pre-main time:  43.00 milliseconds (100.0%)
         dylib loading time:  19.01 milliseconds (44.2%)
        rebase/binding time:   1.77 milliseconds (4.1%)
            ObjC setup time:   3.98 milliseconds (9.2%)
           initializer time:  18.17 milliseconds (42.2%)
           slowest intializers :
             libSystem.B.dylib :   2.56 milliseconds (5.9%)
   libBacktraceRecording.dylib :   3.00 milliseconds (6.9%)
    libMainThreadChecker.dylib :   8.26 milliseconds (19.2%)
                       ModelIO :   1.37 milliseconds (3.1%)
複製代碼

對於這個 libMainThreadChecker.dylib 估計很多同學會有點陌生,這是 XCode 9 新增的動態庫,用來做主線成檢查的。

優化啓動時間

啓動時間這個名詞,不同的人有不同的定義。在我看來,

啓動時間是用戶點擊App圖標,到第一個界面展示的時間。
複製代碼

以 main 函數作爲分水嶺,啓動時間其實包括了兩部分:main 函數之前和 main 函數到第一個界面的viewDidAppear:。所以,優化也是從兩個方面進行的,個人建議優先優化後者,因爲絕大多數 App 的瓶頸在自己的代碼裏。

Main 函數之後 我們首先來分析下,從 main 函數開始執行,到你的第一個界面顯示,這期間一般會做哪些事情。 執行 AppDelegate 的代理方法,主要是 didFinishLaunchingWithOptions 初始化 Window,初始化基礎的 ViewController 結構 (一般是 UINavigationController+UITabViewController) 獲取數據 (Local DB/Network),展示給用戶。 

UIViewController

延遲初始化那些不必要的UIViewController

在啓動的時候只需要初始化首頁頭條頁面即可。像 “要聞”,“我的” 等頁面,則延遲加載,即啓動的時候只是一個 UIViewController 作爲佔位符給 TabController,等到用戶點擊了再去進行真正的數據和視圖的初始化工作。

AppDelegate

通常我們會在 AppDelegate 的代理方法裏進行初始化工作,主要包括了兩個方法:

優化這些初始化的核心思想就是:

能延遲初始化的儘量延遲初始化,不能延遲初始化的儘量放到後臺初始化。
複製代碼

這些工作主要可以分爲幾類:

對於didFinishLaunchingWithOptions的代碼,建議按照以下的方式進行劃分:

@interface AppDelegate ()
//業務方需要的生命週期回調
@property (strong, nonatomic) NSArray<id<UIApplicationDelegate>> * eventQueues;
//主框架負責的生命週期回調
@property (strong, nonatomic) id<UIApplicationDelegate> basicDelegate;
@end
複製代碼

然後,你會得到一個非常乾淨的 AppDelegate 文件:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    for (id<UIApplicationDelegate> delegate in self.eventQueues) {
        [delegate application:application didFinishLaunchingWithOptions:launchOptions];
    }
    return [self.basicDelegate application:application didFinishLaunchingWithOptions:launchOptions];
}
複製代碼

由於對這些初始化進行了分組,在開發期就可以很容易的控制每一個業務的初始化時間:

CFTimeInterval startTime = CACurrentMediaTime();
//執行方法
CFTimeInterval endTime = CACurrentMediaTime();
複製代碼

用 Time Profiler 找到元兇 Time Profiler 在分析時間佔用上非常強大。實用的時候注意三點 

 在打包模式下分析(一般是 Release), 這樣和線上環境一樣。

 記得開啓 dsym,不然無法查看到具體的函數調用堆棧 

分析性能差的設備,對於支持 iOS 8 的,一般分析 iphone 4s 或者 iphone 5。 一個典型的分析界面如下: 

幾點要注意:

  1. 分析啓動時間,一般只關心主線程
  2. 選擇 Hide System Libraries 和 Invert Call Tree,這樣我們能專注於自己的代碼
  3. 右側可以看到詳細的調用堆棧信息

在某一行上雙擊,我們可以進入到代碼預覽界面,去看看實際每一行佔用了多少時間:

小結 

 不同的 App 在啓動的時候做的事情往往不同,但是優化起來的核心思想無非就兩個: 能延遲執行的就延遲執行。比如 SDK 的初始化,界面的創建。 不能延遲執行的,儘量放到後臺執行。比如數據讀取,原始 JSON 數據轉對象,日誌發送。 

Main 函數之前

 Main 函數之前是 iOS 系統的工作,所以這部分的優化往往更具有通用性。

 dylibs 啓動的第一步是加載動態庫,加載系統的動態庫使很快的,因爲可以緩存,而加載內嵌的動態庫速度較慢。所以,提高這一步的效率的關鍵是:減少動態庫的數量。

 合併動態庫,比如公司內部由私有 Pod 建立了如下動態庫:XXTableView, XXHUD, XXLabel,強烈建議合併成一個 XXUIKit 來提高加載速度。 

Rebase & Bind & Objective C Runtime 

 Rebase 和 Bind 都是爲了解決指針引用的問題。對於 Objective C 開發來說,主要的時間消耗在 Class/Method 的符號加載上,所以常見的優化方案是:

 減少__DATA 段中的指針數量。 

合併 Category 和功能類似的類。比如:UIView+Frame,UIView+AutoLayout… 合併爲一個

 刪除無用的方法和類。

 多用 Swift Structs,因爲 Swfit Structs 是靜態分發的。感興趣的同學可以看看我之前這篇文章:《Swift 進階之內存模型和方法調度》 

Initializers 

 通常,我們會在 + load 方法中進行 method-swizzling,這也是 Nshipster 推薦的方式。 

 用 initialize 替代 load。不少同學喜歡用 method-swizzling 來實現 AOP 去做日誌統計等內容,強烈建議改爲在 initialize 進行初始化。 

減少__atribute__((constructor)) 的使用,而是在第一次訪問的時候才用 dispatch_once 等方式初始化。 

不要創建線程 

使用 Swfit 重寫代碼。 

Objective C 支持 Category,在初始化的時候,也會把 Category 中的方法註冊到對應的類中,同時會唯一 Selector,這也是爲什麼當你的 Cagegory 實現了類中同名的方法後,類中的方法會被覆蓋。” category 的的確會實現類似覆蓋原同名方法的功能,但是實現上不是覆蓋,而是將 category 的方法放到原方法的前面,methodlist 中就有了兩個同名的方法,第一個方法是 category 方法,而第二個方法是原明發。這樣在命中方法的時候,就會命中這個 category 方法。 系統的實現方法是這樣的

or (uint32_t m = 0;             (scanForCustomRR || scanForCustomAWZ)  &&  m < mlist>count;             m++)        {            SEL sel = method_list_nth(mlist, m)->name;            if (scanForCustomRR  &&  isRRSelector(sel)) {                cls->setHasCustomRR();                scanForCustomRR = false;            } else if (scanForCustomAWZ  &&  isAWZSelector(sel)) {                cls->setHasCustomAWZ();                scanForCustomAWZ = false;            }        }         // Fill method list array        newLists[newCount++] = mlist;    // Copy old methods to the method list array    for (i = 0; i < oldCount; i++) {        newLists[newCount++] = oldLists[i];}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6951591401528229895