深入理解 iOS App 的啟動過程
前言
啟動時間是衡量應用品質的重要指標。
本文首先會從原理上出發,講解iOS系統是如何啟動App的,然後從main函式之前和main函式之後兩個角度去分析如何優化啟動時間。
準備知識
Mach-O
哪些名詞指的是Mach-o
- Executable 可執行檔案
- Dylib 動態庫
- Bundle 無法被連線的動態庫,只能通過dlopen()載入
- Image 指的是Executable,Dylib或者Bundle的一種,文中會多次使用Image這個名詞。
- Framework 動態庫和對應的標頭檔案和資原始檔的集合
Apple出品的作業系統的可執行檔案格式幾乎都是mach-o,iOS當然也不例外。
mach-o可以大致的分為三部分:
- Header 頭部,包含可以執行的CPU架構,比如x86,arm64
- Load commands 載入命令,包含檔案的組織架構和在虛擬記憶體中的佈局方式
- Data,資料,包含load commands中需要的各個段(segment)的資料,每一個Segment都得大小是Page的整數倍。
我們用MachOView開啟Demo工程的可以執行檔案,來驗證下mach-o的檔案佈局:
圖中分析的mach-o檔案來源於PullToRefreshKit,這是一個純Swift的編寫的工程。
那麼Data部分又包含哪些segment呢?絕大多數mach-o包括以下三個段(支援使用者自定義Segment,但是很少使用)
- __TEXT 程式碼段,只讀,包括函式,和只讀的字串,上圖中類似__TEXT,__text的都是程式碼段
- __DATA 資料段,讀寫,包括可讀寫的全域性變數等,上圖類似中的__DATA,__data都是資料段
- __LINKEDIT __LINKEDIT包含了方法和變數的後設資料(位置,偏移量),以及程式碼簽名等資訊。
關於mach-o更多細節,可以看看文件:《Mac OS X ABI Mach-O File Format Reference》。
dyld
dyld的全稱是dynamic loader,它的作用是載入一個程式所需要的image,dyld是開源的。
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,同時會被複制。
想要了解更多細節,可以閱讀文件:Memory Usage Performance Guidelines
啟動過程
使用dyld2啟動應用的過程如圖:
大致的過程如下:
載入dyld到App程式 載入動態庫(包括所依賴的所有動態庫) Rebase Bind 初始化Objective C Runtime 其它的初始化程式碼
載入動態庫
dyld會首先讀取mach-o檔案的Header和load commands。 接著就知道了這個可執行檔案依賴的動態庫。例如載入動態庫A到記憶體,接著檢查A所依賴的動態庫,就這樣的遞迴載入,直到所有的動態庫載入完畢。通常一個App所依賴的動態庫在100-400個左右,其中大多數都是系統的動態庫,它們會被快取到dyld shared cache,這樣讀取的效率會很高。
檢視mach-o檔案所依賴的動態庫,可以通過MachOView的圖形化介面(展開Load Command就能看到),也可以通過命令列otool。
192:Desktop Leo$ otool -L demo demo: @rpath/PullToRefreshKit.framework/PullToRefreshKit (compatibility version 1.0.0, current version 1.0.0) /System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 1444.12.0) /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0) @rpath/libswiftCore.dylib (compatibility version 1.0.0, current version 900.0.65) @rpath/libswiftCoreAudio.dylib (compatibility version 1.0.0, current version 900.0.65) //...
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
也可以通過命令列:
192:Desktop Leo$ xcrun dyldinfo -bind demo bind information: segment section address type addend dylib symbol __DATA __got 0x10003C038 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC4LeftC9textLabelSo7UILabelCvWvd __DATA __got 0x10003C040 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC5RightC9textLabelSo7UILabelCvWvd __DATA __got 0x10003C048 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC6FooterC9textLabelSo7UILabelCvWvd __DATA __got 0x10003C050 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC6HeaderC7spinnerSo23UIActivityIndicatorViewCvWvd //...
Rebase解決了內部的符號引用問題,而外部的符號引用則是由Bind解決。在解決Bind的時候,是根據字串匹配的方式查詢符號表,所以這個過程相對於Rebase來說是略慢的。
同樣,也可以通過xcrun dyldinfo來檢視Bind的資訊,比如我們檢視bind資訊中,包含UITableView的部分:
192:Desktop Leo$ xcrun dyldinfo -bind demo | grep UITableView __DATA __objc_classrefs 0x100041940 pointer 0 UIKit _OBJC_CLASS_$_UITableView __DATA __objc_classrefs 0x1000418B0 pointer 0 UIKit _OBJC_CLASS_$_UITableViewCell __DATA __objc_data 0x100041AC0 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController __DATA __objc_data 0x100041BE8 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController __DATA __objc_data 0x100042348 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController __DATA __objc_data 0x100042718 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController __DATA __data 0x100042998 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController __DATA __data 0x100042A28 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController __DATA __data 0x100042F10 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController __DATA __data 0x1000431A8 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController
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會做如下事情:
- 分析Mach-o Headers
- 分析依賴的動態庫
- 查詢需要Rebase & Bind之類的符號
- 把上述結果寫入快取
這樣,在應用啟動的時候,就可以直接從快取中讀取資料,加快載入速度。
啟動時間
冷啟動 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
- applicationDidBecomeActive
優化這些初始化的核心思想就是:
能延遲初始化的儘量延遲初始化,不能延遲初始化的儘量放到後臺初始化。
這些工作主要可以分為幾類:
- 三方SDK初始化,比如Crash統計; 像分享之類的,可以等到第一次呼叫再出初始化。
- 初始化某些基礎服務,比如WatchDog,遠端引數。
- 啟動相關日誌,日誌往往涉及到DB操作,一定要放到後臺去做
- 業務方初始化,這個交由每個業務自己去控制初始化時間。
對於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。
一個典型的分析介面如下:
幾點要注意:
- 分析啟動時間,一般只關心主執行緒
- 選擇Hide System Libraries和Invert Call Tree,這樣我們能專注於自己的程式碼
- 右側可以看到詳細的呼叫堆疊資訊
在某一行上雙擊,我們可以進入到程式碼預覽介面,去看看實際每一行佔用了多少時間:
小結
不同的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重寫程式碼。
參考資料
- WWDC 2016: Optimizing App Startup Time
- WWDC 2017: App Startup Time: Past, Present, and Future
相關文章
- iOS App啟動過程iOSAPP
- app的啟動過程(三)APP
- Android App啟動過程AndroidAPP
- App 啟動過程(含 Activity 啟動過程) | 安卓 offer 收割基APP安卓
- Android啟動過程剖析-深入淺出Android
- 筆記-iOS應用程式的啟動過程筆記iOS
- 理解 Android 程式啟動之全過程Android
- APP爬蟲-某APP iOS版逆向過程APP爬蟲iOS
- Angular的啟動過程Angular
- main的啟動過程AI
- Android小知識-ActivityManagerService詳解(APP啟動過程)AndroidAPP
- Service啟動過程
- SpringBoot啟動過程Spring Boot
- Windows 啟動過程Windows
- 透過 Chrome 深入理解瀏覽器導航過程Chrome瀏覽器
- 根Activity元件的啟動過程元件
- Spring啟動過程(一)Spring
- Linux 啟動過程分析Linux
- SpringBoot 系列-啟動過程Spring Boot
- jmeter 啟動過程剖析JMeter
- Liferay 啟動過程分析
- Spring Boot 啟動過程Spring Boot
- 深入理解JVM(③)虛擬機器的類載入過程JVM虛擬機
- 2.深入一點理解C源程式的編譯過程編譯
- 深入理解 iOS Rendering ProcessiOS
- iOS-APP的啟動流程和生命週期iOSAPP
- 如何實現 iOS App 的冷啟動優化iOSAPP優化
- iOS App Icon和啟動圖尺寸配置iOSAPP
- 走近原始碼:Redis的啟動過程原始碼Redis
- 作業系統啟動的過程作業系統
- Cypress 本身啟動過程的除錯除錯
- Linux的啟動過程及init程式Linux
- 從mixin機制理解Flutter App啟動FlutterAPP
- DUBBO服務啟動過程
- Linux系統啟動過程Linux
- Linux核心Kernel啟動過程Linux
- HDFS啟動過程+安全模式模式
- 計算機啟動過程計算機