一. 前言
隨著互聯網的高速發展,用戶對手機應用的要求越來越高,應用啟動時間作為一項重要的參考指標,直接影響著用戶的使用體驗。QQ閱讀App的啟動流程包含了大量的業務模塊,并且涉及了很多第三方庫的初始化,這勢必會增加應用的啟動時間,因此非常有必要對App的啟動進行優化。
二. Mach-O文件
在優化之前,先來了解下什么是Mach-O文件。UNIX標準制定了一個通用的可移植的二進制格式文件,叫ELF,然而OSX卻維護了一個自己獨有的二進制格式:Mach-Object(Mach-O)
1. Mach-O文件類型
對于OSX和iOS來說,Mach-O是其可執行文件的格式,主要包括以下幾種文件類型:
Executable:應用的主要二進制
Dylib:動態鏈接庫
Bundle:不能被鏈接,只能在運行時使用dlopen加載
Image:包含Executable、Dylib和Bundle
Framework:包含Dylib、資源文件和頭文件的文件夾
2. Mach-O鏡像文件格式
Mach-O鏡像文件主要包含以下3部分:
Mach64 Header
Load Commands
Section64
看一個真實的可執行文件的格式:
幾乎所有的Mach-O文件都包含3個段:_TEXT、_DATA和_LINKEDIT
__TEXT 包含 Mach header,被執行的代碼和只讀常量(如C 字符串)。只讀可執行(r-x)。
__DATA 包含全局變量,靜態變量等??勺x寫(rw-)。
__LINKEDIT 包含了加載程序的『元數據』,比如函數的名稱和地址。只讀(r–)
3. 通用二進制(胖二進制)
通用二進制格式由多種架構的Mach-O文件合并而成,通過Fat Header來記錄不同架構在文件中的偏移量,QQReader的可執行文件就是一個胖二進制:
lingyun@sairyouifus-MBP Desktop$ file QQReaderUI
QQReaderUI: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
QQReaderUI (for architecture armv7): Mach-O executable arm_v7
QQReaderUI (for architecture arm64): Mach-O 64-bit executable arm64
三. iOS應用啟動流程
1. 可執行文件的內核流程
如圖,當啟動一個應用程序時,系統最后會根據你的行為調用兩個函數,fork和execve。fork功能創建一個進程;execve功能加載和運行程序。這里有多個不同的功能,比如execl,execv和exect,每個功能提供了不同傳參和環境變量的方法到程序中。在OSX中,每個這些其他的exec路徑最終調用了內核路徑execve。
1.執行exec系統調用,一般都是這樣,用fork()函數新建立一個進程,然后讓進程去執行exec調用。我們知道,在fork()建立新進程之后,父進各與子進程共享代碼段,但數據空間是分開的,但父進程會把自己數據空間的內容copy到子進程中去,還有上下文也會copy到子進程中去。
2.為了提高效率,采用一種寫時copy的策略,即創建子進程的時候,并不copy父進程的地址空間,父子進程擁有共同的地址空間,只有當子進程需要寫入數據時(如向緩沖區寫入數據),這時候會復制地址空間,復制緩沖區到子進程中去。從而父子進程擁有獨立的地址空間。而對于fork()之后執行exec后,這種策略能夠很好的提高效率,如果一開始就copy,那么exec之后,子進程的數據會被放棄,被新的進程所代替c
2. App啟動流程的關鍵節點
根據Apple官方的《WWDC Optimizing App Startup Time》,iOS應用的啟動可分為pre-main階段和main兩個階段,所以App總啟動時間 = pre-main耗時 + main耗時
- pre-main:系統dylib(動態鏈接庫)和自身App可執行文件的加載 。
- main:main方法執行之后到AppDelegate類中的didFinishLaunchingWithOptions方法執行結束前這段時間,主要是構建第一個界面,并完成渲染展示 。
盜用兩張經典的圖:
main:
3. QQReader啟動的pre-main耗時測量
添加DYLD_PRINT_STATISTICS選項
測量結果
冷啟動時間:
熱啟動時間:
四. 動態鏈接庫dyld
1. 什么是dyld?
動態鏈接庫的加載過程主要由dyld來完成,dyld是蘋果的動態鏈接器
系統先讀取App的可執行文件(Mach-O文件),從里面獲得dyld的路徑,然后加載dyld,dyld去初始化運行環境,開啟緩存策略,加載程序相關依賴庫(其中也包含我們的可執行文件),并對這些庫進行鏈接,最后調用每個依賴庫的初始化方法,在這一步,runtime被初始化。當所有依賴庫的初始化后,輪到最后一位(程序可執行文件)進行初始化,在這時runtime會對項目中所有類進行類結構初始化,然后調用所有的load方法。最后dyld返回main函數地址,main函數被調用,我們便來到了熟悉的程序入口。
2. dyld共享庫緩存?
當你構建一個真正的程序時,將會鏈接各種各樣的庫。它們又會依賴其他一些framework和動態庫。需要加載的動態庫會非常多。而對于相互依賴的符號就更多了??赡軐猩锨€符號需要解析處理,這將花費很長的時間
為了縮短這個處理過程所花費時間,OS X 和 iOS 上的動態鏈接器使用了共享緩存,OS X的共享緩存位于/private/var/db/dyld/
,iOS的則在/System/Library/Caches/com.apple.dyle/
。
對于每一種架構,操作系統都有一個單獨的文件,文件中包含了絕大多數的動態庫,這些庫都已經鏈接為一個文件,并且已經處理好了它們之間的符號關系。當加載一個 Mach-O 文件 (一個可執行文件或者一個庫) 時,動態鏈接器首先會檢查共享緩存看看是否存在其中,如果存在,那么就直接從共享緩存中拿出來使用。每一個進程都把這個共享緩存映射到了自己的地址空間中。這個方法大大優化了 OS X 和 iOS 上程序的啟動時間
3. dyld加載過程
dyld加載過程主要包含以下幾個步驟:
(1) Load dylibs image
在每個動態庫的加載過程中, dyld需要:
- 分析所依賴的動態庫
- 找到動態庫的mach-o文件
- 打開文件
- 驗證文件
- 在系統核心注冊文件簽名
- 對動態庫的每一個segment調用mmap()
通常的,一個App需要加載很多個dylibs, 但是其中的系統庫被優化,可以很快的加載。應用所依賴的dylib文件可能會再依賴其他 dylib,所以dyld所需要加載的是動態庫列表一個遞歸依賴的集合。
針對這一步驟的優化有:
- 減少非系統庫的依賴
- 合并非系統庫
來看一下QQReader依賴的共享動態庫
輸入命令:otool -L QQReaderUI
(2) Rebase/Bind image
由于ASLR(address space layout randomization)的存在,可執行文件和動態鏈接庫在虛擬內存中的加載地址每次啟動都不固定,所以需要這2步來修復鏡像中的資源指針,來指向正確的地址。 rebase修復的是指向當前鏡像內部的資源指針; 而bind指向的是鏡像外部的資源指針。
rebase步驟先進行,需要把鏡像讀入內存,并以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其后進行,由于要查詢符號表,來指向跨鏡像的資源,加上在rebase階段,鏡像已被讀入和加密驗證,所以這一步的瓶頸在于CPU計算。
優化該階段的關鍵在于減少__DATA segment中的指針數量。我們可以優化的點有:
- 減少Objc類數量, 減少selector數量
- 減少C++虛函數數量
這里主要解決幾個疑惑:
1.ASLR(地址空間布局隨機化)
傳統方式下,進程每次啟動采用的都是固定可預見的方式,這意味著一個給定的程序在給定的架構上的進程初始虛擬內存都是基本一致的,而且在進程正常運行的生命周期中,內存中的地址分布具有非常強的可預測性,這給了黑客很大的施展空間(代碼注入,重寫內存);
如果采用ASLR,進程每次啟動,地址空間都會被簡單地隨機化,但是只是偏移,不是攪亂。大體布局——程序文本、數據和庫是一樣的,但是具體的地址都不同了,可以阻擋黑客對地址的猜測 。
2.rebase:針對mach-o在加載到內存中不是固定的首地址 這一現象做數據修正的過程;
3.binding就是將這個二進制調用的外部符號進行綁定的過程。比如我們objc代碼中需要使用到NSObject, 即符號OBJC_CLASS$_NSObject,但是這個符號又不在我們的二進制中,在系統庫 Foundation.framework中,因此就需要binding這個操作將對應關系綁定到一起;
4.lazyBinding就是在加載動態庫的時候不會立即binding, 當時當第一次調用這個方法的時候再實施binding。 做到的方法也很簡單: 通過dyld_stub_binder這個符號來做。lazyBinding的方法第一次會調用到dyld_stub_binder, 然后dyld_stub_binder負責找到真實的方法,并且將地址bind到樁上,下一次就不用再bind了。
(3) Objc setup
Objc setup
主要是在objc_init
完成的,objc_init
是在libsystem
中的一個initialize
方法libsystem_initializer
中初始化了libdispatch
,然后libdispatch_init
調用了_os_object_int
, 最終調用了_objc_init
。
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
- 1.dyld在
binding
操作結束之后,會發出dyld_image_state_bound
通知,然后與之綁定的回調函數map_2_images
就會被調用,它主要做以下幾件事來完成Objc Setup
:
(1). 讀取二進制文件的 DATA 段內容,找到與 objc 相關的信息
(2). 注冊 Objc 類
(3). 確保 selector 的唯一性
(4). 讀取 protocol 以及 category 的信息- 2.
load_images
函數作用就是調用Objc的load
方法,它監聽dyld_image_state_dependents_initialize
通知
unmap_image
可以理解為map_2_images
的逆向操作
(4) initializers
以上三步屬于靜態調整,都是在修改__DATA segment中的內容,而這里則開始動態調整,開始在堆和棧中寫入內容。 工作主要有:
- Objc的+load()函數
- C++的構造函數屬性函數 形如attribute((constructor)) void >DoSomeInitializationWork()
- 非基本類型的C++靜態全局變量的創建(通常是類或結構體)(non-trivial initializer) 比如一個全局靜態結構體的構建,如果在構造函數中有繁重的工作,那么會拖慢啟動速度
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函數
五. pre-main階段優化
1. 刪除無用代碼(未被調用的靜態變量、類和方法)
可以使用AppCode對工程進行掃描,刪除以下無用代碼
1.未使用的本地變量;
2.未使用的參數;
3.未使用的值;
2. 抽象重復代碼
1.在iOS代碼中可能會為同一個類寫很多分類方法,由于參與開發同學較多,可能會導致方法重復,但是實際上運行起來只能有一個分類的方法被調用,這取決于哪個分類后被加載,然而編譯的二進制代碼中,兩個方法應該是都存在的,這不僅會增加app體積,也會增加啟動時間,所以應該杜絕這樣的重復問題;
2.有很多地方可能是名字不同,但是函數的功能相同,這個不容易被發現,需要大家在寫代碼的過程中注意;
3.又或者兩個函數名字比較接近,里面有很多相似的代碼,這種情況下可以進行相同的代碼的提取。
3. +load方法中做的事情延遲到+initialize中,或者在+load中做的事情不宜花費過多時間
因為load是在啟動的時候調用,而initialize是在類首次被使用的時候調用,不過當你把load中的邏輯移到initialize中時候,一定要注意initialize的重復調用問題。
4. 減少不必要的framework,或者優化已有的framework
例如QQReaderUI linkmap分析,看下那部分文件或者第三方庫占了較大的空間,從而給我們優化提供一定的方向
Path: /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Products/Debug-iphoneos/QQReaderUI.app/QQReaderUI
Arch: arm64
Object files:
[0] linker synthesized
[ 1] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX1.o
[ 2] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX2.o
[ 3] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX3.o
[ 4] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX4.o
...
用WMLinkMapAnalyzer
分析下linkmap文件
這個文件可以讓你了解整個APP編譯后的情況,也許從中可以發現一些異常,還可以用這個文件計算靜態鏈接庫在項目里占的大小,有時候我們在項目里鏈了很多第三方庫,導致APP體積變大很多,我們想確切知道每個庫占用了多大空間,可以給我們優化提供方向。LinkMap里有了每個目標文件每個方法每個數據的占用大小數據,所以只要寫個腳本,就可以統計出每個.o最后的大小,屬于一個.a靜態鏈接庫的.o加起來,就是這個庫在APP里占用的空間大小
各模塊體積大小,從大到小排列:
Core1(xxxx1.o) 256.00M
Core2(xxxx2.o) 208.00M
Core3(xxxx3.o) 64.00M
Core4(xxxx4.o) 20.41M
...
然后就可以根據分析結果決定具體優化模塊了
六. main階段優化
這一個階段的時間主要是指:main函數開始到第一個界面渲染完成這段時間,優化出發點就是減少從main函數開始到第一個界面出現的時間,可以從兩方面入手:
1. didFinishLaunchingWithOptions
一般情況下,app在didFinishLaunchingWithOptions
這個函數中會做以下工作:
- 1、日志、統計
- 2、配置 APP 運行需要的環境
- 3、第三方SDK集成
- …
如果這些工作里面有的可能是不必要的,有的可以采用懶加載的方式,那么久有必要進行優化。QQReader在didFinishLaunchingWithOptions
有將近30多個啟動模塊,其中耗時最多的前6個模塊耗時占比將近86%,對此,我們對這主要的6個模塊進行逐個分析,比如字體加載模塊、打點上報模塊等采用懶加載的方式進行優化。
2. 首次啟動渲染的頁面優化
- 1、不使用xib或者storyboard,直接使用代碼;
- 2、對于
viewDidLoad
以及viewWillAppear
方法中盡量去嘗試少做,晚做,不做,或者采用異步的方式去做;- 3、當首頁邏輯比較復雜的時候,建議CD冷卻放大招:通過
instruments
的Time Profiler
分析耗時瓶頸。
3. 寫代碼注意事項
- 1、在版本迭代過程中,如果業務發生變化,導致相應的代碼也發生變化,一般情況下我們需要把對應的舊代碼和舊資源刪除掉(舊資源會增加App體積,舊代碼會增加執行文件的大小,進而增加Objc類數量或者selector數量);
- 2、盡量抽象重復,時時重構代碼,一方面減少執行文件大小,另一方面方便維護。盡可能地復用UI,在添加某個功能時,先去查查我們的代碼中是否已經實現了該功能,減少重復。多重構代碼,使用繼承或者組合等技術減少代碼量;
- 3、在寫與啟動相關的業務模塊時尤其要注意,看哪些邏輯可以延遲加載或者懶加載;
- 4、類和方法名不要太長:iOS每個類和方法名都在
__cstring
段里都存了相應的字符串值,所以類和方法名的長短也是對可執行文件大小是有影響的,原因還是object-c的動態特性,因為需要通過類/方法名反射找到這個類/方法進行調用,object-c對象模型會把類/方法名字符串都保存下來。