前言
APP的啟動(dòng)優(yōu)化,對(duì)開發(fā)者來說是一個(gè)永無止境的過程。開發(fā)者們?cè)谧非蟾斓穆飞?,?shí)現(xiàn)了一次又一次的突破(這里也包括Apple團(tuán)隊(duì)對(duì)操作系統(tǒng)的優(yōu)化);而且啟動(dòng)優(yōu)化也是面試經(jīng)常會(huì)問到的問題。本篇文章我們就一起探索下在iOS APP中啟動(dòng)優(yōu)化的那些事兒。
通常我們?cè)谧鰞?yōu)化的時(shí)候會(huì)將優(yōu)化以main()函數(shù)為界分為2個(gè)部分,即main之前的pre-main階段 和 main()之后。這里我們就具體的看下:
啟動(dòng)性能檢測(cè)main()之前
我們?cè)谇懊娴奈恼陆榻B過,在APP啟動(dòng)到main函數(shù)執(zhí)行的這個(gè)階段,是由dyld來操作的,即_dyld_start->dyldbootstrap::start()->dyld::_main()
->dyld::initializeMainExecutable()->ImageLoader->ImageLoaderMachO->
libSystem_initializer()->libdispatch_init()->_objc_init()->
_dyld_objc_notify_register()->map_images->load_images->main() 的大致流程。而且這個(gè)階段我們也無法介入,所在這里只能借助dyld的一些配置參數(shù)來獲取一些信息。這里我們通過一個(gè)DYLD_PRINT_STATISTICS配置來獲取pre-main階段的耗時(shí)統(tǒng)計(jì)。
在Edit Schemes -> Run -> Arguments -> Environment Variables中添加配置:

配置完成之后,運(yùn)行我們的程序(冷啟動(dòng)),然后看控制臺(tái)輸出:
Total pre-main time: 5.8 seconds (100.0%)
dylib loading time: 169.23 milliseconds (2.9%)
rebase/binding time: 312.46 milliseconds (5.3%)
ObjC setup time: 229.00 milliseconds (3.9%)
initializer time: 5.1 seconds (87.7%)
slowest intializers :
libSystem.B.dylib : 6.01 milliseconds (0.1%)
TestApp : 5.0 seconds (85.9%) 主程序耗時(shí)
這里可以看到,輸出了在pre-main階段的總的耗時(shí)數(shù),同時(shí)列舉出了每個(gè)環(huán)節(jié)的具體耗時(shí)情況,以及最耗時(shí)操作項(xiàng)。接下來,對(duì)每一項(xiàng)做一個(gè)簡(jiǎn)單的介紹和一些優(yōu)化建議:
dylib loading time: 動(dòng)態(tài)庫(kù)加載耗時(shí)(169.23ms)。關(guān)于動(dòng)態(tài)庫(kù)的加載,這個(gè)是不可避免的,我們能做的就是減少動(dòng)態(tài)庫(kù)的引用,官方的建議的是動(dòng)態(tài)庫(kù)的使用應(yīng)該在6個(gè)以內(nèi),所以這里就引入了一個(gè)動(dòng)態(tài)庫(kù)合并的概念(后面的文章會(huì)詳細(xì)介紹),通過合并動(dòng)態(tài)庫(kù),從而減少在pre-main時(shí)的加載時(shí)間。
rebase/binding: 偏移修正/符號(hào)綁定。這個(gè)過程由操作系統(tǒng)完成。(ASLR安全機(jī)制,在二進(jìn)制文件頭部添加隨機(jī)值)/
ObjC setup: OC類注冊(cè)。這也就意味著項(xiàng)目中OC類越多,這里消耗的時(shí)間也就會(huì)增加。
initializer: 這個(gè)階段指的是+ (void)load,C++構(gòu)造函數(shù)等初始化操作。 這里可以看到用時(shí)5.1 seconds,是所有項(xiàng)做高的。這里是因?yàn)槲以陧?xiàng)目里面隨便的一個(gè)類里實(shí)現(xiàn)了+(void)load函數(shù),并模擬了一個(gè)耗時(shí)操作。所以這里的優(yōu)化比較明確:1. 能不使用+load就盡量不要使用,可以將load內(nèi)部邏輯推遲到initialize時(shí);2. 使用到了load,就盡量不要在內(nèi)部執(zhí)行耗時(shí)操作;3. 如果混編了C++代碼,要盡量減少構(gòu)造函數(shù)中的耗時(shí)操作
slowest intializers: 啟動(dòng)時(shí)用時(shí)最慢的文件,這個(gè)可以看到耗時(shí)最多的是TestApp項(xiàng)目本身,這里主要是由于那個(gè)模擬的耗時(shí)操作導(dǎo)致。
啟動(dòng)性能檢測(cè)main()之后
在main()函數(shù)之后的優(yōu)化就因項(xiàng)目不同而異了,大致有這么幾個(gè)核心:
- 業(yè)務(wù)邏輯:這里主要指APP從啟動(dòng)到首頁(yè)呈現(xiàn)的階段。盡量減少與該階段無關(guān)且沒有必要的初始化代碼操作,把這部分代碼以懶加載的方式處理。
-
刪除無用代碼:這里是隨著業(yè)務(wù)的發(fā)展,APP不斷的迭代更新,會(huì)產(chǎn)生很多的的下架業(yè)務(wù),從而堆積了很多的無用代碼,這些代碼會(huì)增加
ObjC setup的耗時(shí),所以要清理掉。 - 多線程操作:在啟動(dòng)時(shí),將一些必要的非UI業(yè)務(wù)且需要初始化操作的任務(wù)放在子線程中,這樣可以在APP啟動(dòng)的時(shí)候,發(fā)揮CPU的最大性能。
-
啟動(dòng)頁(yè)面:首要呈現(xiàn)的畫面,盡量減少使用
.xib或storyBoard來實(shí)現(xiàn),因?yàn)樗鼈冃枰馕龀纱a,會(huì)造成耗時(shí)。 - 業(yè)務(wù)具體優(yōu)化:提升代碼質(zhì)量,采用最合理的實(shí)現(xiàn)方式等。
如果說上面的優(yōu)化,都是具體項(xiàng)目?jī)?nèi)部代碼的優(yōu)化,而且都是些常規(guī)的操作,那接下來就開始裝逼了,近年來比較有名的項(xiàng)目層面的優(yōu)化——二進(jìn)制重排。在第一次聽到這個(gè)概念的時(shí)候,覺的非常的牛逼(無知嘛),但實(shí)際了解后發(fā)現(xiàn),這本不是一個(gè)新的概念,而是蘋果(Xcode)本身就提供給我們的能力,操作也非常的簡(jiǎn)單,由于不了解,所以被忽視。在實(shí)際操作前,我們需要弄清楚的是,
- 什么是二進(jìn)制排列?
- 二進(jìn)制重排是如何做到優(yōu)化的,或者說它優(yōu)化的是什么?
為了弄清楚這兩個(gè)問題,我們就需要先了解一下計(jì)算機(jī)內(nèi)存的發(fā)展演變過程,帶著問題,繼續(xù)閱讀下文:
虛擬內(nèi)存與物理內(nèi)存
物理內(nèi)存指通過物理內(nèi)存條而獲得的內(nèi)存空間。
在早期的操作系統(tǒng)沒有虛擬內(nèi)存的概念,而是直接操作物理內(nèi)存,當(dāng)應(yīng)用程序啟動(dòng)后,會(huì)全部載入物理內(nèi)存中。

這樣就導(dǎo)致了2個(gè)非常嚴(yán)重的問題:
1.內(nèi)存不夠用:由于應(yīng)用程序會(huì)直接全部載入到物理內(nèi)存,當(dāng)多個(gè)應(yīng)該啟動(dòng)時(shí),出現(xiàn)了內(nèi)存使用緊張問題;同時(shí)隨著軟件行業(yè)的快速發(fā)展,軟件越來越大,當(dāng)前的邏輯無法滿足需求。
2.安全問題:由于是直接載入到物理內(nèi)存,所以黑客可以通過內(nèi)存地址直接訪問到應(yīng)用程序,并做任意更改;而且可以通過地址偏移訪問到其他應(yīng)用程序;造成了嚴(yán)重的安全問題。
為了解決上面的問題,就出面了虛擬內(nèi)存。虛擬內(nèi)存是計(jì)算機(jī)系統(tǒng)內(nèi)存管理的一種技術(shù)。它使得應(yīng)用程序認(rèn)為它擁有連續(xù)的可用的內(nèi)存(一個(gè)連續(xù)完整的地址空間),而實(shí)際上,它通常是被分隔成多個(gè)物理內(nèi)存碎片,還有部分暫時(shí)存儲(chǔ)在外部磁盤存儲(chǔ)器上,在需要時(shí)進(jìn)行數(shù)據(jù)交換。在當(dāng)下的操作系統(tǒng)中,被普遍應(yīng)用。

操作系統(tǒng)會(huì)給每一個(gè)進(jìn)程分配獨(dú)立的虛擬內(nèi)存空間(大小為4G,并不會(huì)滿載),將二進(jìn)制文件進(jìn)行碎片化切割,而切割的每一個(gè)碎片稱為一個(gè)頁(yè)表,頁(yè)表大小在MacOS的大小為4kb,在iOS下是16kb。頁(yè)表實(shí)際上是虛擬地址->物理地址的映射表,通過MMU技術(shù)實(shí)現(xiàn)查詢,最終找到對(duì)應(yīng)的物理地址。
此時(shí),由于物理內(nèi)存只存在多個(gè)進(jìn)程的非連續(xù)碎片,使得物理內(nèi)存空間得到了釋放;每個(gè)APP進(jìn)程都有了獨(dú)立的虛擬內(nèi)存,進(jìn)程與進(jìn)程之間隔離且獨(dú)立,互不干擾,安全方面提升;同時(shí)開發(fā)者只能訪問虛擬內(nèi)存地址,而無法操作物理地址,黑客攻擊難度加大。
還要注意一個(gè)點(diǎn)是:為了防止攻擊者能可靠地跳轉(zhuǎn)到內(nèi)存的特定位置來利用函數(shù),在二進(jìn)制文件加載到虛擬內(nèi)存時(shí),采用ASLR技術(shù),在二進(jìn)制的頭部添加了一個(gè)隨機(jī)地址偏移。這也就對(duì)應(yīng)上了在pre-main階段的rebase(偏移修正)操作。
這跟二進(jìn)制重排有什么關(guān)系吶?
當(dāng)CPU訪問頁(yè)表數(shù)據(jù)時(shí),首先訪問虛擬內(nèi)存頁(yè)表地址,通過MMU 讀取到物理地址,如果存在頁(yè)表,讀取物理內(nèi)存上的數(shù)據(jù);物理內(nèi)存頁(yè)表不存在時(shí),會(huì)將虛擬內(nèi)存對(duì)應(yīng)的頁(yè)表載入到物理內(nèi)存,然后讀取物理內(nèi)存上的數(shù)據(jù);當(dāng)物理內(nèi)存滿載時(shí),此時(shí)如果需要載入新的頁(yè)表,操作系統(tǒng)會(huì)通過一些算法來找到物理內(nèi)存中不活躍的頁(yè)表數(shù)據(jù),讓新的頁(yè)表直接覆蓋掉(比如我們打開多個(gè)APP,并將APP退入到后臺(tái),長(zhǎng)時(shí)間不打開后,再次從后臺(tái)喚起時(shí),需要重新加載)。
這里重點(diǎn)說一下物理內(nèi)存頁(yè)表不存在時(shí)的情況,此時(shí)會(huì)觸發(fā)一個(gè)缺頁(yè)異常/缺頁(yè)中斷(PageFault),然后會(huì)將對(duì)應(yīng)的虛擬內(nèi)存中的頁(yè)表載入到物理內(nèi)存。一個(gè)PageFault是會(huì)耗時(shí)的,只不過這個(gè)耗時(shí)非常短,用戶感知不到。
在我們的應(yīng)用中,什么時(shí)候會(huì)大量的出現(xiàn)PageFault異常吶?
毫無疑問是APP剛啟動(dòng)的時(shí)候,初始化時(shí),物理內(nèi)存上還沒有相關(guān)的頁(yè)表,需要大量的載入,同時(shí)拋出大量的PageFault,大量的PageFault堆積導(dǎo)致了用戶可感知的耗時(shí)。
而這里的頁(yè)表載入到物理內(nèi)存是存在資源浪費(fèi)的。比如APP啟動(dòng)到首頁(yè)呈現(xiàn)的過程中調(diào)用了func0,test1,load2等方法,但這3個(gè)方法分別存在Page0,Page3,Page5,而為了達(dá)到目的,需要將這3個(gè)頁(yè)表都載入到內(nèi)存中,也就是3x16kb 的數(shù)據(jù)量,而我訪問這3個(gè)方法的數(shù)據(jù)量一共還沒有一個(gè)頁(yè)表的大小,如果能將這3個(gè)方法放在同一個(gè)頁(yè)表內(nèi),不就不需要其他頁(yè)表的載入了嗎?這里就引出了我們二進(jìn)制重排,將一些相關(guān)性的數(shù)據(jù)放在同一個(gè)頁(yè)表中(默認(rèn)放在最前面的頁(yè)表中),這樣就能減少多個(gè)頁(yè)表的載入,從而減少PageFault的出現(xiàn)次數(shù),啟動(dòng)耗時(shí)自然也就減少了。
接下來,我們通過一個(gè)簡(jiǎn)單的Demo 來感受一下:
@implementation ViewController
- (void)loadOtherObject
{
TestObject *objc = [TestObject alloc];
[objc toDoSomething];
}
- (void)func1
{
NSLog(@"func1");
}
- (void)viewDidLoad
{
[super viewDidLoad];
NSLog(@"viewDidLoad");
[self func1];
[self loadOtherObject];
}
void func0(){
NSLog(@"func0");
}
+ (void)load
{
NSLog(@"我是load");
func0();
}
@end
上面代碼的執(zhí)行順序:
load->func0->viewDidLoad->func1->loadOtherObject->toDoSomething
但是代碼的排列順序是什么樣的吶?查看代碼排列順序:
Target->Build Settings->Link Map->Write Link Map File = YES

command+build后,會(huì)在指定的Path to Link Map File的位置生成一個(gè).txt文件,打開文件:
從這里可以看出代碼的排列順序是跟代碼書寫順序是一致的,頁(yè)表的拆分也會(huì)按照這個(gè)順序來分割,這也就導(dǎo)致了在
ViewController中的用到的-[TestObject toDoSomething] 跟其它函數(shù)的調(diào)用會(huì)分布在不同的頁(yè)表中,而二進(jìn)制重排的目的就上將有關(guān)聯(lián)性的零散分布在多個(gè)頁(yè)表中的數(shù)據(jù)整合到一個(gè)頁(yè)表內(nèi),來減少多個(gè)頁(yè)表的載入耗時(shí),提升APP啟動(dòng)效率。
這里的代碼排列順序?yàn)槭裁词窍?code>LoginView,TestObject,LoginViewModel,SecneDelegate.....的順序吶?這是由Build Phases下的Compile Source決定的:

這里要注意的是二進(jìn)制重排并不受這里順序的影響。
PageFault調(diào)試
通過Xcode的Instruments工具來直觀的看下當(dāng)APP啟動(dòng)時(shí)會(huì)拋出多少PageFault異常數(shù)量。
通過command+control+I喚起Instruments,然后選擇System Trace

點(diǎn)擊運(yùn)行,當(dāng)程序起來后,結(jié)束運(yùn)行,等待生成報(bào)告,

這里需要切換到APP的
MainThread,然后選擇Summary:Virtual Memory,之后可以看到列表第一行File Backed Page In,Count值為836,這里表示APP啟動(dòng)時(shí)發(fā)生了836次缺頁(yè)異常/缺頁(yè)中斷(PageFault)。我們的優(yōu)化目標(biāo)就是降低這個(gè)Count的值。
二進(jìn)制重排
上面大篇幅的文字,都是為二進(jìn)制重排做鋪墊,同時(shí)也回答了上面提到的2個(gè)問題。接下來,我們就實(shí)際的操作一下二進(jìn)制重排,上文中我也提到過,Xcode本身就給我們提供了這個(gè)能力,我們只需要按照步驟操作就行了。
二進(jìn)制文件 本質(zhì)上是由 鏈接器(LLVM-ld) 生成的,而鏈接器本身自帶一個(gè)參數(shù)Order file,這個(gè)參數(shù)就是為了滿足開發(fā)者自定義符號(hào)排列順序而存在的。
在apple的庫(kù)源碼項(xiàng)目中也會(huì)看到這么一個(gè).order文件的存在,它也是通過二進(jìn)制重排的方式做了一些優(yōu)化:


到此,我們首先要在我們項(xiàng)目的根目錄創(chuàng)建一個(gè).order文件,然后在Xcode中配置Order File,最后編輯.order文件:

接下來編輯TestApp.order文件,在上的Link-Map中已經(jīng)看到過代碼排列順序,
而且可以看到啟動(dòng)時(shí)使用到的代碼都在后面:
# Address Size File Name
0x100004ED4 0x00000028 [ 1] +[LoginView load]
0x100004EFC 0x00000014 [ 1] +[LoginView initialize]
0x100004F10 0x00000114 [ 1] -[LoginView initWithFrame:]
0x100005024 0x00000014 [ 1] -[LoginView loadUI]
0x100005038 0x00000074 [ 1] -[LoginView loginClick]
0x1000050AC 0x0000002C [ 1] -[LoginView viewModel]
0x1000050D8 0x00000048 [ 1] -[LoginView setViewModel:]
0x100005120 0x00000044 [ 1] -[LoginView .cxx_destruct]
0x100005164 0x0000002C [ 2] -[TestObject toDoSomething]
0x100005190 0x00000080 [ 3] -[LoginViewModel toLoginWithPhone:pass:]
0x100005210 0x000000B4 [ 4] -[SceneDelegate scene:willConnectToSession:options:]
0x1000052C4 0x0000004C [ 4] -[SceneDelegate sceneDidDisconnect:]
0x100005310 0x0000004C [ 4] -[SceneDelegate sceneDidBecomeActive:]
0x10000535C 0x0000004C [ 4] -[SceneDelegate sceneWillResignActive:]
0x1000053A8 0x0000004C [ 4] -[SceneDelegate sceneWillEnterForeground:]
0x1000053F4 0x0000004C [ 4] -[SceneDelegate sceneDidEnterBackground:]
0x100005440 0x0000002C [ 4] -[SceneDelegate window]
0x10000546C 0x00000048 [ 4] -[SceneDelegate setWindow:]
0x1000054B4 0x00000044 [ 4] -[SceneDelegate .cxx_destruct]
0x1000054F8 0x000000AC [ 5] _main
0x1000055A4 0x00000088 [ 6] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x10000562C 0x00000108 [ 6] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100005734 0x00000080 [ 6] -[AppDelegate application:didDiscardSceneSessions:]
0x1000057B4 0x00000064 [ 7] -[ViewController loadOtherObject]
0x100005818 0x0000002C [ 7] -[ViewController func1]
0x100005844 0x00000088 [ 7] -[ViewController viewDidLoad]
0x1000058CC 0x0000001C [ 7] _func0
0x1000058E8 0x00000030 [ 7] +[ViewController load]
接下來我想要修改這個(gè)排列順序?yàn)?
# Address Size File Name
0x1000058E8 0x00000030 [ 7] +[ViewController load]
0x1000058CC 0x0000001C [ 7] _func0
0x1000054F8 0x000000AC [ 5] _main
0x100005844 0x00000088 [ 7] -[ViewController viewDidLoad]
0x100005818 0x0000002C [ 7] -[ViewController func1]
0x1000057B4 0x00000064 [ 7] -[ViewController loadOtherObject]
0x100005164 0x0000002C [ 2] -[TestObject toDoSomething]
·····其他符號(hào)````````
編輯TestApp.order,在文件中添加我們想要的符號(hào)順序:

然后
command + B編譯一下,然后查看LinkMap.txt的變化情況:
可以看到的是,代碼的排列順序按照了
order文件的順序排列,并且將他們都放在了開頭。到此,我們就完成了一次牛逼的二進(jìn)制重排(是不是非常的簡(jiǎn)單)。
細(xì)節(jié):
a. 在order文件中指定的符號(hào),Link Map會(huì)按照order配置的順序放在最前面;沒有指定的符號(hào),會(huì)按照原來的方式排列;
b. 在order指定一個(gè)不存在的方法符號(hào),編譯時(shí)不會(huì)發(fā)生錯(cuò)誤的,會(huì)自動(dòng)忽略掉;問題
該如何寫出一個(gè)完整的Order File文件,或者說如何獲取啟動(dòng)時(shí)加載的所有方法符號(hào),啟動(dòng)結(jié)束的界限在哪里?