前言
當(dāng)我們的應(yīng)用程序非常龐大的時(shí),打開我們的App感覺非???,啟動(dòng)比較緩慢,非常影響用戶的體驗(yàn),那么如何才能使我們的App啟動(dòng)比較流暢,給用戶很好的體驗(yàn),這篇文章將給大家?guī)鞟pp啟動(dòng)優(yōu)化相關(guān)的知識(shí)。
1 App啟動(dòng)流程分析
App的啟動(dòng)我們一般分為兩個(gè)部分:main函數(shù)之前即pre-main和main函數(shù)之后
1.1 pre-main階段流程
我們通過DYLD監(jiān)測(cè)一下pre-main的時(shí)間消耗,我們?cè)趚code中設(shè)置一個(gè)參數(shù),如圖

我們啟動(dòng)App,看下輸出結(jié)果,如下圖

這時(shí)顯示了在pre-main階段流程的耗時(shí),這是一個(gè)空工程,是在模擬器運(yùn)行,所以時(shí)間不準(zhǔn),真機(jī)一個(gè)工程大概在400ms左右。
- dylib loading 加載動(dòng)態(tài)庫的時(shí)間
- rebase/binding 重定向/綁定的時(shí)間
- ObjC setup OC類的注冊(cè)時(shí)間
- initializer 注冊(cè)方法的時(shí)間(load,構(gòu)造函數(shù)的耗時(shí))
這是pre-main的基本流程
1.2 dylib動(dòng)態(tài)的加載
dylib的加載的耗時(shí)是必然的,系統(tǒng)的動(dòng)態(tài)庫是已經(jīng)載入共享緩存空間,系統(tǒng)動(dòng)態(tài)庫已經(jīng)做了高速的優(yōu)化, 但是我們自定義的動(dòng)態(tài)庫不一樣,所以蘋果的建議不要大于6個(gè)動(dòng)態(tài)庫,如果大于6個(gè),盡量合并。
1.3 ObjC setup
因?yàn)槲覀兊腛C是動(dòng)態(tài)語言,OC類的注冊(cè)
- 讀取Mach-o的data字段,找到OC類的相關(guān)信息
- 注冊(cè)O(shè)C類,OC的runtime需要維護(hù)映射表即SEL/IMP的映射以及類名與類的全局表,當(dāng)加載Mach-o的時(shí)候,這些所有的類都要注冊(cè)到全局表中,除些之外,還有類別,協(xié)議信息要插入到方法列表中,這是必然的損耗,所以這里的優(yōu)化減少OC的類的定義、刪除無用的OC的類文件(只要這個(gè)類存在即使沒有用到,也會(huì)造成時(shí)間損耗)
1.4 initializer
在load方法以及構(gòu)造函數(shù)中,盡量不要做延遲加載的事情,把消耗的任務(wù)放在子線程去,以減少主線程的開銷,數(shù)據(jù)可以緩存。
以上幾點(diǎn)的優(yōu)化都比較簡(jiǎn)單,下理我們來介紹rebase/binding 重定向/綁定,再介紹之前,我們先來講來虛擬內(nèi)存相關(guān)的知識(shí)
2 虛擬內(nèi)存介紹
2.1 虛擬地址的概念
操作系統(tǒng)在早期加載應(yīng)用程序時(shí),直接把應(yīng)用程序載入到物理內(nèi)存,這時(shí)應(yīng)用程序的地址就是真實(shí)在物理內(nèi)存條的地址,這么做什么有問題呢?
- 導(dǎo)致內(nèi)存不夠用:應(yīng)用直接被加載物理內(nèi)存中,如果加載很多應(yīng)用,會(huì)報(bào)內(nèi)存不足,這個(gè)時(shí)候把之前的應(yīng)用殺掉,才可以訪問
- 不安全,原因:比如游戲外掛,可以直接訪問物理內(nèi)存,定位到代碼的相關(guān)內(nèi)存,可直接修改。
為了解決內(nèi)存不足的問題,這個(gè)時(shí)候虛擬內(nèi)存。
加載到內(nèi)存的應(yīng)用,用戶一般不會(huì)使用應(yīng)用的所有功能,這也說明完全加載到內(nèi)存的應(yīng)用,有一塊內(nèi)存有可能沒有用到,這就導(dǎo)致內(nèi)存的浪費(fèi)。
為了解決這些問題,就用懶加載的方式,把應(yīng)用分成一塊一塊,當(dāng)我們啟動(dòng)應(yīng)用程序時(shí),啟動(dòng)時(shí)需要加載的代碼載入到內(nèi)存中,當(dāng)用到新的功能時(shí),再加載這塊內(nèi)存,這就是懶加載。
但是這個(gè)時(shí)候有一個(gè)問題,會(huì)造成我們的代碼不連續(xù),程序訪問會(huì)變得很復(fù)雜,每次要重新計(jì)算地址。
虛擬內(nèi)存表的出現(xiàn)解決了這個(gè)問題,存儲(chǔ)應(yīng)用程序與真實(shí)物理內(nèi)存之間的映射關(guān)系。

這張圖很好的說明了虛擬內(nèi)存表與物理內(nèi)存之前的映射關(guān)系。
為了提高效率和性能,這個(gè)時(shí)候出現(xiàn)了Page(分頁)加載,目前在iOS中一頁的大小是16k,Mac(PAGESIZE命令)上是4k。
這時(shí)解決內(nèi)存不足的問題,同時(shí)也解決相對(duì)安全,因?yàn)檫@個(gè)時(shí)候游戲外掛是不可能直接訪問到物理內(nèi)存,只能訪問虛擬內(nèi)存,再通過MMU翻譯,訪問物理內(nèi)存,這個(gè)時(shí)候游戲外掛只能訪問到自己的進(jìn)程內(nèi)存空間,進(jìn)程之間的安全隔離。
2.2 內(nèi)存分頁的原理
我們分析下應(yīng)用程序是怎么加載到內(nèi)存中的。
虛擬內(nèi)存表是4G的大小
我們看下圖

在這張圖中:
- 進(jìn)程1的虛擬頁表中,P1,P3,P5啟動(dòng)時(shí)加載到內(nèi)存中,當(dāng)用戶在操作過程中,當(dāng)需要用到P2的數(shù)據(jù),發(fā)現(xiàn)P2未加載到內(nèi)存,操作系統(tǒng)會(huì)發(fā)出缺頁異常(缺頁中斷),這個(gè)時(shí)候CPU要執(zhí)行代碼會(huì)中斷掉,操作系統(tǒng)會(huì)把P2的數(shù)據(jù)加載到物理內(nèi)存中,哪里有空閑位置就插入到這里,一般來說,手機(jī)啟動(dòng)后一段時(shí)間,基本沒有空閑位置,操作系統(tǒng)會(huì)通過頁面置換算法覆蓋掉不活躍的內(nèi)存
- PAGEZERO,當(dāng)我們?cè)L問到大于或小于我們代碼空間時(shí),會(huì)指向空,在真實(shí)物理內(nèi)存中就是一個(gè)小小的標(biāo)記,做到進(jìn)程之間的隔絕。
- 我們能輸入最大的地址是8G,不可能超過8G,我們的內(nèi)存是用8個(gè)字節(jié)表示一個(gè)地址。
- 我們能訪問最大是8G,也就是從0x0000010000000(4G)開始訪問,但是前面4G是不能訪問的,是為了隔離32位,64位程序要兼容32位,為了區(qū)分64位與32位,所以64位都是從1開始訪問。
- 系統(tǒng)為了給進(jìn)程之間合法的通訊,就提供的專用的接口,通過kernel發(fā)信號(hào)。
3 PageFault調(diào)試&啟動(dòng)優(yōu)化的原理
3.1 CPU的32位和64位的概念
CPU的32位和64位指的是CPU上的一個(gè)部件,叫數(shù)據(jù)總線。
CPU上有很多針腳,主板有一排一排的線,這是導(dǎo)線,每根線,只有1和0兩種狀態(tài)組成,8根線一次通信表示1個(gè)字節(jié),32位是4字節(jié),所在32位系統(tǒng)中,地址都是4G以內(nèi),這是數(shù)據(jù)總線。
32位和64位指的就是CPU的吞吐量,一次放電能讀或者寫多大的數(shù)據(jù)。
在64位中,一個(gè)內(nèi)存地址占用8字節(jié),在面向?qū)ο笳Z言中,對(duì)象的傳遞也是8字節(jié)(指針),這樣最高效。
3.2 binding/綁定
為什么會(huì)有綁定的過程?
在內(nèi)部文件要訪問外部的函數(shù)時(shí),我們是通過內(nèi)部的符號(hào)綁定之后去訪問,所以綁定的耗時(shí)是必然有的。
要想減少這部分的耗時(shí)只能減少去外部函數(shù)的訪問,但是這里的綁定是懶加載的綁定方式,所以減少這部分耗時(shí)也不會(huì)有什么效果
3.3 rebase/重定向
當(dāng)我們虛擬內(nèi)存出現(xiàn)之后,虛擬內(nèi)存都是從0開始,這樣只要計(jì)算出偏移地址就可以進(jìn)行訪問,造成相對(duì)的不安全的,為了解決這個(gè)問題,就引入了ASLR技術(shù),這樣每次生成的虛擬內(nèi)存表從一個(gè)隨機(jī)值開始,每次都不一樣,這樣每次啟動(dòng)應(yīng)用,起始地址都不一樣,就無法直接通過計(jì)算文件偏移地址訪問。
但是ASLR的出現(xiàn),內(nèi)部的文件都要通過這個(gè)ASLR計(jì)算偏移后才能訪問。
我們的代碼在編譯后在Mach-o中已經(jīng)確定好地址,如圖

這里的offset就是代碼在文件的偏移量,它是固定的,由于ASLR技術(shù),每次執(zhí)行的時(shí)候,就是ASLR+offset才能找到對(duì)應(yīng)的函數(shù)和方法,這個(gè)過程就是rebase/重定向。
3.4 二進(jìn)制重排
二進(jìn)制重排可以對(duì)我們的啟動(dòng)時(shí)間優(yōu)化,這是為什么呢,我們來分析下。
上文中, 我們講過當(dāng)我們的代碼訪問到?jīng)]有被載入內(nèi)存的Page數(shù)據(jù)時(shí),這個(gè)時(shí)候會(huì)發(fā)生PageFault即缺頁異常/缺頁中斷。
發(fā)生一次PageFault,耗時(shí)雖然是毫秒級(jí)別的,如果同時(shí)發(fā)生的PageFault非常多時(shí),這個(gè)時(shí)間加起來就會(huì)很長。
什么時(shí)候會(huì)出現(xiàn)大量的缺頁異常?
答案肯定在啟動(dòng)的時(shí)候,準(zhǔn)確點(diǎn)叫冷啟動(dòng)
我們先調(diào)試PageFault,我們測(cè)下啟動(dòng)時(shí)間和PageFault數(shù)據(jù),如圖

PageFault圖

這里的File Backed Page in就是PageFault,占用1.25秒,總耗是1.27秒,PageFault占用大量的時(shí)間。
如何優(yōu)化這個(gè)PageFault的時(shí)間。
我們?cè)贐uild Setting 搜下Write Link Map File,如下所示

我們編譯后,打開我們編譯的App文件,如圖所示

打開Demo-LinkMap-normal-x86_64.txt文件,如下所示
# Symbols:
# Address Size File Name
0x100001E90 0x00000030 [ 2] +[AppDelegate load]
0x100001EC0 0x00000080 [ 2] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100001F40 0x00000120 [ 2] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100002060 0x00000070 [ 2] -[AppDelegate application:didDiscardSceneSessions:]
0x1000020D0 0x00000030 [ 3] +[ViewController load]
0x100002100 0x00000039 [ 3] -[ViewController viewDidLoad]
0x100002140 0x0000008E [ 4] _main
0x1000021D0 0x000000B0 [ 5] -[SceneDelegate scene:willConnectToSession:options:]
0x100002280 0x00000040 [ 5] -[SceneDelegate sceneDidDisconnect:]
0x1000022C0 0x00000040 [ 5] -[SceneDelegate sceneDidBecomeActive:]
0x100002300 0x00000040 [ 5] -[SceneDelegate sceneWillResignActive:]
0x100002340 0x00000040 [ 5] -[SceneDelegate sceneWillEnterForeground:]
0x100002380 0x00000040 [ 5] -[SceneDelegate sceneDidEnterBackground:]
0x1000023C0 0x00000020 [ 5] -[SceneDelegate window]
0x1000023E0 0x00000040 [ 5] -[SceneDelegate setWindow:]
這里是所有方法代碼實(shí)現(xiàn)的排列的順序。
這里的Address+ASLR就是在方法在虛擬內(nèi)存的地址。
Address相當(dāng)于Page的順序,Name方法的編譯的順序
我們調(diào)整下文件順序,如圖
# Symbols:
# Address Size File Name
0x100001E90 0x00000030 [ 2] +[ViewController load]
0x100001EC0 0x00000039 [ 2] -[ViewController viewDidLoad]
0x100001F00 0x0000008E [ 3] _main
0x100001F90 0x00000030 [ 4] +[AppDelegate load]
0x100001FC0 0x00000080 [ 4] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100002040 0x00000120 [ 4] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100002160 0x00000070 [ 4] -[AppDelegate application:didDiscardSceneSessions:]
0x1000021D0 0x000000B0 [ 5] -[SceneDelegate scene:willConnectToSession:options:]
0x100002280 0x00000040 [ 5] -[SceneDelegate sceneDidDisconnect:]
0x1000022C0 0x00000040 [ 5] -[SceneDelegate sceneDidBecomeActive:]
0x100002300 0x00000040 [ 5] -[SceneDelegate sceneWillResignActive:]
0x100002340 0x00000040 [ 5] -[SceneDelegate sceneWillEnterForeground:]
0x100002380 0x00000040 [ 5] -[SceneDelegate sceneDidEnterBackground:]
0x1000023C0 0x00000020 [ 5] -[SceneDelegate window]
這個(gè)時(shí)候發(fā)現(xiàn)順序變了,證實(shí)了我們剛才說的。
這里應(yīng)該怎么優(yōu)化?
當(dāng)我們的應(yīng)用程序的時(shí)候,加載很多PAGE,如果某一頁只有一個(gè)方法被調(diào)用,這一頁也是需要加載到內(nèi)存,這樣就會(huì)浪費(fèi)內(nèi)存,這個(gè)時(shí)候,我們可以把程序啟動(dòng)要加載的方法排列到最前面,這樣就大量減少PageFault的次數(shù),這就需要用到二進(jìn)制重排技術(shù)。
3.5 二進(jìn)制重排使用
我們?cè)趏bjc源碼文件中,發(fā)現(xiàn)一個(gè)libobjc.order文件,打開如下所示:
__objc_init
_environ_init
_tls_init
_lock_init
_recursive_mutex_init
_exception_init
_map_images
_map_images_nolock
__getObjcImageInfo
__hasObjcContents
__objc_appendHeader
這里顯示的都是一個(gè)個(gè)的符號(hào)名稱,這個(gè)order是給編譯器用到,當(dāng)編譯讀取到這個(gè)oder文件時(shí),會(huì)按照這里的順序?qū)ΧM(jìn)制進(jìn)行排序。
我們新建一個(gè)order文件,放在根目下,然后編輯,如下所示
-[SceneDelegate sceneWillResignActive:]
-[SceneDelegate sceneWillEnterForeground:]
-[SceneDelegate sceneDidEnterBackground:]
-[SceneDelegate window]
_main
+[ViewController load]
+[AppDelegate load]
我們?cè)?em>Build Settings,搜索Order File

配置我們自定義order的文件路徑。
編譯后 ,我們?cè)倏聪耹ink-map.txt文件,如下所示
# Address Size File Name
0x100001E90 0x00000040 [ 5] -[SceneDelegate sceneWillResignActive:]
0x100001ED0 0x00000040 [ 5] -[SceneDelegate sceneWillEnterForeground:]
0x100001F10 0x00000040 [ 5] -[SceneDelegate sceneDidEnterBackground:]
0x100001F50 0x00000020 [ 5] -[SceneDelegate window]
0x100001F70 0x0000008E [ 2] _main
0x100002000 0x00000030 [ 3] +[ViewController load]
0x100002030 0x00000030 [ 4] +[AppDelegate load]
0x100002060 0x00000039 [ 3] -[ViewController viewDidLoad]
這個(gè)順序就是按照我們自己編輯的順序排列的,如果符號(hào)不存在的話,會(huì)被去掉。
但是這里有一個(gè)問題
我們做的是啟動(dòng)的優(yōu)化,就需要知道啟動(dòng)要調(diào)用的方法,方法非常多,方法還有嵌套,這個(gè)時(shí)候通過手動(dòng)去編寫這個(gè)順序,是非常復(fù)雜的,如果我們通過HOOK這個(gè)objc_msgSend方法可以攔截到OC的方法,但是C函數(shù),Block是無法通過這個(gè)HOOK掉的,這里留一個(gè)伏筆,我們將會(huì)在后續(xù)文章來解決這些問題。
總結(jié)
這篇文章介紹App啟動(dòng)的流程、虛擬地址、虛擬內(nèi)存,PageFault的原理以及二進(jìn)制重排的原理的知識(shí),這篇文章讓本人重新鞏固了不少知識(shí),也希望能讓大家有所收獲。