boxuegu

深入理解 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。

一個典型的分析界面如下:

幾點要注意:

  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重寫代碼。

參考資料

發表我的評論

取消評論
表情 插代碼

Hi,您需要填寫昵稱和郵箱!

  • 必填項
  • 必填項
22选5今晚开奖公告